diff --git a/src/pages/admin/AdminBookings.module.css b/src/pages/admin/AdminBookings.module.css new file mode 100644 index 0000000..d050b1d --- /dev/null +++ b/src/pages/admin/AdminBookings.module.css @@ -0,0 +1,23 @@ +.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); + vertical-align: top; +} + +.error { + color: #a33; +} diff --git a/src/pages/admin/AdminBookings.test.tsx b/src/pages/admin/AdminBookings.test.tsx new file mode 100644 index 0000000..c39b038 --- /dev/null +++ b/src/pages/admin/AdminBookings.test.tsx @@ -0,0 +1,63 @@ +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, it, expect, vi, beforeEach } from 'vitest' + +const orderMock = vi.fn() +const selectMock = vi.fn() +const eqUpdateMock = vi.fn() +const updateMock = vi.fn() +const fromMock = vi.fn() + +vi.mock('../../lib/supabase', () => ({ + supabase: { from: (...args: unknown[]) => fromMock(...args) }, +})) + +const BOOKING = { + id: 'booking-1', + date: '2026-09-15', + time: '10:00 AM', + contact_name: 'Jane Doe', + contact_phone: '555-0100', + contact_email: 'jane@example.com', + contact_notes: '', + status: 'confirmed', + services: { name: 'Knotless Braids' }, +} + +import AdminBookings from './AdminBookings' + +describe('AdminBookings', () => { + beforeEach(() => { + orderMock.mockReset().mockResolvedValue({ data: [BOOKING], error: null }) + selectMock.mockReset().mockReturnValue({ order: orderMock }) + eqUpdateMock.mockReset().mockResolvedValue({ error: null }) + updateMock.mockReset().mockReturnValue({ eq: eqUpdateMock }) + fromMock.mockReset().mockReturnValue({ select: selectMock, update: updateMock }) + }) + + it('lists bookings with the related service name', async () => { + render() + expect(await screen.findByText('Jane Doe')).toBeInTheDocument() + expect(screen.getByText('Knotless Braids')).toBeInTheDocument() + expect(fromMock).toHaveBeenCalledWith('bookings') + }) + + it('cancels a booking', async () => { + render() + await screen.findByText('Jane Doe') + + await userEvent.click(screen.getByRole('button', { name: /cancel/i })) + + await waitFor(() => expect(updateMock).toHaveBeenCalledWith({ status: 'cancelled' })) + expect(eqUpdateMock).toHaveBeenCalledWith('id', 'booking-1') + }) + + it('marks a booking completed', async () => { + render() + await screen.findByText('Jane Doe') + + await userEvent.click(screen.getByRole('button', { name: /mark completed/i })) + + await waitFor(() => expect(updateMock).toHaveBeenCalledWith({ status: 'completed' })) + }) +}) diff --git a/src/pages/admin/AdminBookings.tsx b/src/pages/admin/AdminBookings.tsx new file mode 100644 index 0000000..3e1e4be --- /dev/null +++ b/src/pages/admin/AdminBookings.tsx @@ -0,0 +1,101 @@ +import { useEffect, useState } from 'react' +import { supabase } from '../../lib/supabase' +import styles from './AdminBookings.module.css' + +interface AdminBooking { + id: string + date: string + time: string + contact_name: string + contact_phone: string + contact_email: string + contact_notes: string + status: 'confirmed' | 'cancelled' | 'completed' + services: { name: string } | null +} + +function AdminBookings() { + const [bookings, setBookings] = useState([]) + const [error, setError] = useState('') + + async function loadBookings() { + const { data, error: fetchError } = await supabase + .from('bookings') + .select('id, date, time, contact_name, contact_phone, contact_email, contact_notes, status, services(name)') + .order('date', { ascending: false }) + if (fetchError) { + setError(fetchError.message) + return + } + setBookings((data ?? []) as unknown as AdminBooking[]) + } + + useEffect(() => { + async function load() { + const { data, error: fetchError } = await supabase + .from('bookings') + .select('id, date, time, contact_name, contact_phone, contact_email, contact_notes, status, services(name)') + .order('date', { ascending: false }) + if (fetchError) { + setError(fetchError.message) + return + } + setBookings((data ?? []) as unknown as AdminBooking[]) + } + void load() + }, []) + + async function setStatus(id: string, status: AdminBooking['status']) { + const { error: updateError } = await supabase.from('bookings').update({ status }).eq('id', id) + if (updateError) { + setError(updateError.message) + return + } + await loadBookings() + } + + return ( +
+

Bookings

+ {error &&

{error}

} + + + + + + + + + + + + + {bookings.map((booking) => ( + + + + + + + + + ))} + +
DateTimeServiceContactStatus
{booking.date}{booking.time}{booking.services?.name} +
{booking.contact_name}
+
{booking.contact_phone}
+
{booking.contact_email}
+ {booking.contact_notes &&
{booking.contact_notes}
} +
{booking.status} + {booking.status === 'confirmed' && ( + <> + + + + )} +
+
+ ) +} + +export default AdminBookings