feat: add admin services CRUD page
This commit is contained in:
44
src/pages/admin/AdminServices.module.css
Normal file
44
src/pages/admin/AdminServices.module.css
Normal 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;
|
||||
}
|
||||
81
src/pages/admin/AdminServices.test.tsx
Normal file
81
src/pages/admin/AdminServices.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
177
src/pages/admin/AdminServices.tsx
Normal file
177
src/pages/admin/AdminServices.tsx
Normal 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
|
||||
Reference in New Issue
Block a user