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.
This commit is contained in:
belviskhoremk
2026-08-14 13:19:01 +02:00
parent 396b5cf0e5
commit c5dcfc0f6e
3 changed files with 215 additions and 1 deletions

View File

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

View File

@@ -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(<AdminBookings />)
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()
})
})

View File

@@ -20,9 +20,39 @@ const STATUS_LABELS: Record<AdminBooking['status'], string> = {
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<AdminBooking[]>([])
const [error, setError] = useState('')
const [viewMonth, setViewMonth] = useState(() => startOfMonth(new Date()))
const [selectedDate, setSelectedDate] = useState<string | null>(null)
async function loadBookings() {
const { data, error: fetchError } = await supabase
@@ -60,10 +90,83 @@ function AdminBookings() {
await loadBookings()
}
const countsByDate = new Map<string, number>()
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 (
<div className={styles.page}>
<h1>Réservations</h1>
{error && <p className={styles.error}>{error}</p>}
<div className={styles.calendar}>
<div className={styles.calendarHeader}>
<button
type="button"
aria-label="Mois précédent"
className={styles.buttonSecondary}
onClick={() => setViewMonth((m) => new Date(m.getFullYear(), m.getMonth() - 1, 1))}
>
‹
</button>
<h2 className={styles.calendarTitle}>{MONTH_FORMATTER.format(viewMonth)}</h2>
<button
type="button"
aria-label="Mois suivant"
className={styles.buttonSecondary}
onClick={() => setViewMonth((m) => new Date(m.getFullYear(), m.getMonth() + 1, 1))}
>
›
</button>
</div>
<div className={styles.calendarGrid}>
{DAY_LABELS.map((label) => (
<div key={label} className={styles.calendarDayLabel}>{label}</div>
))}
{weeks.flatMap((week, weekIndex) =>
week.map((date, dayIndex) => {
if (!date) return <div key={`${weekIndex}-${dayIndex}`} className={styles.calendarCellEmpty} />
const iso = toISODate(date)
const count = countsByDate.get(iso) ?? 0
const isSelected = selectedDate === iso
const isToday = iso === todayISO
return (
<button
type="button"
key={iso}
aria-label={iso}
className={[
styles.calendarCell,
isSelected ? styles.calendarCellSelected : '',
isToday ? styles.calendarCellToday : '',
].join(' ')}
onClick={() => setSelectedDate(isSelected ? null : iso)}
>
<span>{date.getDate()}</span>
{count > 0 && <span className={styles.calendarBadge}>{count}</span>}
</button>
)
}),
)}
</div>
</div>
{selectedDate && (
<p>
Réservations du {selectedDate}{' '}
<button type="button" className={styles.buttonSecondary} onClick={() => setSelectedDate(null)}>
Voir toutes les réservations
</button>
</p>
)}
<table className={styles.table}>
<thead>
<tr>
@@ -76,7 +179,7 @@ function AdminBookings() {
</tr>
</thead>
<tbody>
{bookings.map((booking) => (
{visibleBookings.map((booking) => (
<tr key={booking.id}>
<td>{booking.date}</td>
<td>{booking.time}</td>