feat: add admin bookings page with status actions

This commit is contained in:
belviskhoremk
2026-08-12 11:45:58 +02:00
parent e86d863773
commit 5bcc5ed6c8
3 changed files with 187 additions and 0 deletions

View File

@@ -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;
}

View File

@@ -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(<AdminBookings />)
expect(await screen.findByText('Jane Doe')).toBeInTheDocument()
expect(screen.getByText('Knotless Braids')).toBeInTheDocument()
expect(fromMock).toHaveBeenCalledWith('bookings')
})
it('cancels a booking', async () => {
render(<AdminBookings />)
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(<AdminBookings />)
await screen.findByText('Jane Doe')
await userEvent.click(screen.getByRole('button', { name: /mark completed/i }))
await waitFor(() => expect(updateMock).toHaveBeenCalledWith({ status: 'completed' }))
})
})

View File

@@ -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<AdminBooking[]>([])
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 (
<div className={styles.page}>
<h1>Bookings</h1>
{error && <p className={styles.error}>{error}</p>}
<table className={styles.table}>
<thead>
<tr>
<th>Date</th>
<th>Time</th>
<th>Service</th>
<th>Contact</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
{bookings.map((booking) => (
<tr key={booking.id}>
<td>{booking.date}</td>
<td>{booking.time}</td>
<td>{booking.services?.name}</td>
<td>
<div>{booking.contact_name}</div>
<div>{booking.contact_phone}</div>
<div>{booking.contact_email}</div>
{booking.contact_notes && <div>{booking.contact_notes}</div>}
</td>
<td>{booking.status}</td>
<td>
{booking.status === 'confirmed' && (
<>
<button type="button" onClick={() => setStatus(booking.id, 'cancelled')}>Cancel</button>
<button type="button" onClick={() => setStatus(booking.id, 'completed')}>Mark completed</button>
</>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
export default AdminBookings