feat: add admin services CRUD page

This commit is contained in:
belviskhoremk
2026-08-12 11:37:44 +02:00
parent 4c2c558fca
commit feb4c254e2
3 changed files with 302 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
.page {
display: flex;
flex-direction: column;
gap: var(--space-lg);
}
.table {
width: 100%;
border-collapse: collapse;
background: white;
}
.table th,
.table td {
text-align: left;
padding: var(--space-sm);
border-bottom: 1px solid var(--color-cream-alt);
}
.form {
display: flex;
flex-direction: column;
gap: var(--space-sm);
max-width: 420px;
background: white;
padding: var(--space-md);
}
.form label {
display: flex;
flex-direction: column;
gap: 0.25rem;
color: var(--color-charcoal);
}
.form input,
.form textarea {
padding: var(--space-sm);
border: 1px solid var(--color-gold);
}
.error {
color: #a33;
}

View File

@@ -0,0 +1,81 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { describe, it, expect, vi, beforeEach } from 'vitest'
const selectMock = vi.fn()
const orderMock = vi.fn()
const upsertMock = vi.fn()
const deleteMock = vi.fn()
const eqMock = vi.fn()
const fromMock = vi.fn()
vi.mock('../../lib/supabase', () => ({
supabase: { from: (...args: unknown[]) => fromMock(...args) },
}))
vi.mock('../../lib/storage', () => ({
uploadSalonImage: vi.fn(),
}))
const EXISTING_SERVICE = {
id: 'knotless',
name: 'Knotless Braids',
duration_minutes: 240,
price_from: 150,
description: 'Tension-free braids.',
image: 'styles/knotless.jpg',
}
import AdminServices from './AdminServices'
describe('AdminServices', () => {
beforeEach(() => {
selectMock.mockReset()
orderMock.mockReset()
upsertMock.mockReset()
deleteMock.mockReset()
eqMock.mockReset()
fromMock.mockReset()
orderMock.mockResolvedValue({ data: [EXISTING_SERVICE], error: null })
selectMock.mockReturnValue({ order: orderMock })
upsertMock.mockResolvedValue({ error: null })
eqMock.mockResolvedValue({ error: null })
deleteMock.mockReturnValue({ eq: eqMock })
fromMock.mockReturnValue({ select: selectMock, upsert: upsertMock, delete: deleteMock })
})
it('lists existing services', async () => {
render(<AdminServices />)
expect(await screen.findByText('Knotless Braids')).toBeInTheDocument()
expect(fromMock).toHaveBeenCalledWith('services')
})
it('creates a new service from the form', async () => {
render(<AdminServices />)
await screen.findByText('Knotless Braids')
await userEvent.type(screen.getByLabelText(/^id$/i), 'cornrows')
await userEvent.type(screen.getByLabelText(/^name$/i), 'Cornrows')
await userEvent.type(screen.getByLabelText(/duration/i), '120')
await userEvent.type(screen.getByLabelText(/price from/i), '90')
await userEvent.type(screen.getByLabelText(/^description$/i), 'Classic style.')
await userEvent.click(screen.getByRole('button', { name: /save service/i }))
await waitFor(() =>
expect(upsertMock).toHaveBeenCalledWith(
expect.objectContaining({ id: 'cornrows', name: 'Cornrows', duration_minutes: 120, price_from: 90 }),
),
)
})
it('deletes a service with confirmation', async () => {
vi.spyOn(window, 'confirm').mockReturnValue(true)
render(<AdminServices />)
await screen.findByText('Knotless Braids')
await userEvent.click(screen.getByRole('button', { name: /delete/i }))
expect(deleteMock).toHaveBeenCalled()
expect(eqMock).toHaveBeenCalledWith('id', 'knotless')
})
})

View File

@@ -0,0 +1,177 @@
import { useEffect, useState, type FormEvent } from 'react'
import { supabase } from '../../lib/supabase'
import { uploadSalonImage } from '../../lib/storage'
import styles from './AdminServices.module.css'
interface AdminService {
id: string
name: string
duration_minutes: number
price_from: number
description: string
image: string
}
function emptyForm(): AdminService {
return { id: '', name: '', duration_minutes: 0, price_from: 0, description: '', image: '' }
}
function AdminServices() {
const [servicesList, setServicesList] = useState<AdminService[]>([])
const [form, setForm] = useState<AdminService>(emptyForm())
const [file, setFile] = useState<File | null>(null)
const [error, setError] = useState('')
const [saving, setSaving] = useState(false)
async function loadServices() {
const { data, error: fetchError } = await supabase.from('services').select('*').order('name')
if (fetchError) {
setError(fetchError.message)
return
}
setServicesList((data ?? []) as AdminService[])
}
useEffect(() => {
async function load() {
const { data, error: fetchError } = await supabase.from('services').select('*').order('name')
if (fetchError) {
setError(fetchError.message)
return
}
setServicesList((data ?? []) as AdminService[])
}
void load()
}, [])
async function handleSubmit(event: FormEvent) {
event.preventDefault()
setSaving(true)
setError('')
let image = form.image
if (file) {
try {
image = await uploadSalonImage(file, `styles/${form.id}-${file.name}`)
} catch {
setError('Image upload failed.')
setSaving(false)
return
}
}
const { error: upsertError } = await supabase.from('services').upsert({ ...form, image })
setSaving(false)
if (upsertError) {
setError(upsertError.message)
return
}
setForm(emptyForm())
setFile(null)
await loadServices()
}
async function handleDelete(id: string) {
if (!window.confirm('Delete this service?')) return
const { error: deleteError } = await supabase.from('services').delete().eq('id', id)
if (deleteError) {
setError(deleteError.message)
return
}
await loadServices()
}
return (
<div className={styles.page}>
<h1>Services</h1>
<table className={styles.table}>
<thead>
<tr>
<th>Name</th>
<th>Duration (min)</th>
<th>Price from</th>
<th></th>
</tr>
</thead>
<tbody>
{servicesList.map((service) => (
<tr key={service.id}>
<td>{service.name}</td>
<td>{service.duration_minutes}</td>
<td>${service.price_from}</td>
<td>
<button type="button" onClick={() => setForm(service)}>Edit</button>
<button type="button" onClick={() => handleDelete(service.id)}>Delete</button>
</td>
</tr>
))}
</tbody>
</table>
<form className={styles.form} onSubmit={handleSubmit}>
<h2>{form.id && servicesList.some((s) => s.id === form.id) ? 'Edit service' : 'New service'}</h2>
<label htmlFor="service-id">
Id
<input
id="service-id"
value={form.id}
onChange={(e) => setForm((f) => ({ ...f, id: e.target.value }))}
required
/>
</label>
<label htmlFor="service-name">
Name
<input
id="service-name"
value={form.name}
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
required
/>
</label>
<label htmlFor="service-duration">
Duration minutes
<input
id="service-duration"
type="number"
value={form.duration_minutes}
onChange={(e) => setForm((f) => ({ ...f, duration_minutes: Number(e.target.value) }))}
required
/>
</label>
<label htmlFor="service-price-from">
Price from
<input
id="service-price-from"
type="number"
step="0.01"
value={form.price_from}
onChange={(e) => setForm((f) => ({ ...f, price_from: Number(e.target.value) }))}
required
/>
</label>
<label htmlFor="service-description">
Description
<textarea
id="service-description"
value={form.description}
onChange={(e) => setForm((f) => ({ ...f, description: e.target.value }))}
required
/>
</label>
<label htmlFor="service-image">
Image
<input
id="service-image"
type="file"
accept="image/*"
onChange={(e) => setFile(e.target.files?.[0] ?? null)}
/>
</label>
{error && <p className={styles.error}>{error}</p>}
<button type="submit" disabled={saving}>Save service</button>
</form>
</div>
)
}
export default AdminServices