feat: add admin bookings page with status actions
This commit is contained in:
23
src/pages/admin/AdminBookings.module.css
Normal file
23
src/pages/admin/AdminBookings.module.css
Normal 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;
|
||||
}
|
||||
63
src/pages/admin/AdminBookings.test.tsx
Normal file
63
src/pages/admin/AdminBookings.test.tsx
Normal 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' }))
|
||||
})
|
||||
})
|
||||
101
src/pages/admin/AdminBookings.tsx
Normal file
101
src/pages/admin/AdminBookings.tsx
Normal 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
|
||||
Reference in New Issue
Block a user