From c5dcfc0f6ec3cc4b21d260f2406300ea6838bb6f Mon Sep 17 00:00:00 2001 From: belviskhoremk Date: Fri, 14 Aug 2026 13:19:01 +0200 Subject: [PATCH] feat: add a month calendar view to the admin bookings page Shows a badge with the booking count on each day; clicking a date filters the table below to that day's bookings, with a button to clear the filter and see everything again. --- src/pages/admin/AdminBookings.module.css | 91 ++++++++++++++++++++ src/pages/admin/AdminBookings.test.tsx | 20 +++++ src/pages/admin/AdminBookings.tsx | 105 ++++++++++++++++++++++- 3 files changed, 215 insertions(+), 1 deletion(-) diff --git a/src/pages/admin/AdminBookings.module.css b/src/pages/admin/AdminBookings.module.css index e3aca43..dceac08 100644 --- a/src/pages/admin/AdminBookings.module.css +++ b/src/pages/admin/AdminBookings.module.css @@ -22,3 +22,94 @@ .error { composes: error from './adminShared.module.css'; } + +.calendar { + composes: card from './adminShared.module.css'; + padding: var(--admin-space-lg); +} + +.calendarHeader { + display: flex; + align-items: center; + justify-content: center; + gap: var(--admin-space-md); + margin-bottom: var(--admin-space-md); +} + +.calendarTitle { + margin: 0; + min-width: 10rem; + text-align: center; + text-transform: capitalize; +} + +.calendarGrid { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: var(--admin-space-xs); +} + +.calendarDayLabel { + text-align: center; + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: var(--admin-text-muted); + padding-bottom: var(--admin-space-xs); +} + +.calendarCellEmpty { + aspect-ratio: 1; +} + +.calendarCell { + position: relative; + aspect-ratio: 1; + display: flex; + align-items: center; + justify-content: center; + font: inherit; + color: var(--admin-text); + background: var(--admin-bg); + border: 1px solid var(--admin-border); + border-radius: calc(var(--admin-radius) / 2); + cursor: pointer; +} + +.calendarCell:hover { + border-color: var(--admin-primary); +} + +.calendarCellToday { + font-weight: 700; + border-color: var(--admin-primary); +} + +.calendarCellSelected { + background: var(--admin-primary); + color: white; +} + +.calendarBadge { + position: absolute; + bottom: 2px; + right: 2px; + min-width: 1.1rem; + height: 1.1rem; + padding: 0 3px; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.65rem; + font-weight: 700; + line-height: 1; + border-radius: 999px; + background: var(--admin-danger); + color: white; +} + +.calendarCellSelected .calendarBadge { + background: white; + color: var(--admin-primary); +} diff --git a/src/pages/admin/AdminBookings.test.tsx b/src/pages/admin/AdminBookings.test.tsx index f8185c8..98c32ca 100644 --- a/src/pages/admin/AdminBookings.test.tsx +++ b/src/pages/admin/AdminBookings.test.tsx @@ -85,4 +85,24 @@ describe('AdminBookings', () => { expect(window.confirm).toHaveBeenCalled() expect(updateMock).not.toHaveBeenCalled() }) + + it('shows a badge on the booking date and filters the table when clicked', async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }) + vi.setSystemTime(new Date('2026-09-01T12:00:00')) + const user = userEvent.setup({ advanceTimers: vi.advanceTimersByTime }) + render() + await screen.findByText('Jane Doe') + + const dayCell = screen.getByRole('button', { name: '2026-09-15' }) + expect(dayCell).toHaveTextContent('1') + + await user.click(dayCell) + + expect(screen.getByText(/réservations du 2026-09-15/i)).toBeInTheDocument() + expect(screen.getByText('Jane Doe')).toBeInTheDocument() + + await user.click(screen.getByRole('button', { name: /voir toutes les réservations/i })) + expect(screen.queryByText(/réservations du 2026-09-15/i)).not.toBeInTheDocument() + vi.useRealTimers() + }) }) diff --git a/src/pages/admin/AdminBookings.tsx b/src/pages/admin/AdminBookings.tsx index 24031ff..3435594 100644 --- a/src/pages/admin/AdminBookings.tsx +++ b/src/pages/admin/AdminBookings.tsx @@ -20,9 +20,39 @@ const STATUS_LABELS: Record = { completed: 'Terminée', } +const DAY_LABELS = ['Dim', 'Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam'] + +function toISODate(date: Date): string { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` +} + +function startOfMonth(date: Date): Date { + return new Date(date.getFullYear(), date.getMonth(), 1) +} + +function buildMonthGrid(monthStart: Date): (Date | null)[][] { + const firstWeekday = monthStart.getDay() + const daysInMonth = new Date(monthStart.getFullYear(), monthStart.getMonth() + 1, 0).getDate() + const cells: (Date | null)[] = [ + ...Array.from({ length: firstWeekday }, () => null), + ...Array.from({ length: daysInMonth }, (_, i) => new Date(monthStart.getFullYear(), monthStart.getMonth(), i + 1)), + ] + while (cells.length % 7 !== 0) cells.push(null) + const weeks: (Date | null)[][] = [] + for (let i = 0; i < cells.length; i += 7) weeks.push(cells.slice(i, i + 7)) + return weeks +} + +const MONTH_FORMATTER = new Intl.DateTimeFormat('fr-CA', { month: 'long', year: 'numeric' }) + function AdminBookings() { const [bookings, setBookings] = useState([]) const [error, setError] = useState('') + const [viewMonth, setViewMonth] = useState(() => startOfMonth(new Date())) + const [selectedDate, setSelectedDate] = useState(null) async function loadBookings() { const { data, error: fetchError } = await supabase @@ -60,10 +90,83 @@ function AdminBookings() { await loadBookings() } + const countsByDate = new Map() + for (const booking of bookings) { + if (booking.status === 'cancelled') continue + countsByDate.set(booking.date, (countsByDate.get(booking.date) ?? 0) + 1) + } + + const weeks = buildMonthGrid(viewMonth) + const todayISO = toISODate(new Date()) + + const visibleBookings = selectedDate ? bookings.filter((b) => b.date === selectedDate) : bookings + return (

Réservations

{error &&

{error}

} + +
+
+ +

{MONTH_FORMATTER.format(viewMonth)}

+ +
+
+ {DAY_LABELS.map((label) => ( +
{label}
+ ))} + {weeks.flatMap((week, weekIndex) => + week.map((date, dayIndex) => { + if (!date) return
+ const iso = toISODate(date) + const count = countsByDate.get(iso) ?? 0 + const isSelected = selectedDate === iso + const isToday = iso === todayISO + return ( + + ) + }), + )} +
+
+ + {selectedDate && ( +

+ Réservations du {selectedDate}{' '} + +

+ )} + @@ -76,7 +179,7 @@ function AdminBookings() { - {bookings.map((booking) => ( + {visibleBookings.map((booking) => (
{booking.date} {booking.time}