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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user