diff --git a/frontend/app/admin/availability/page.tsx b/frontend/app/admin/availability/page.tsx new file mode 100644 index 0000000..c327caa --- /dev/null +++ b/frontend/app/admin/availability/page.tsx @@ -0,0 +1,659 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { Permission } from "../../auth-context"; +import { api } from "../../api"; +import { sharedStyles } from "../../styles/shared"; +import { Header } from "../../components/Header"; +import { useRequireAuth } from "../../hooks/useRequireAuth"; +import { components } from "../../generated/api"; +import constants from "../../../../shared/constants.json"; + +const { slotDurationMinutes, maxAdvanceDays } = constants.booking; + +type AvailabilityDay = components["schemas"]["AvailabilityDay"]; +type AvailabilityResponse = components["schemas"]["AvailabilityResponse"]; +type TimeSlot = components["schemas"]["TimeSlot"]; + +// Helper to format date as YYYY-MM-DD +function formatDate(d: Date): string { + return d.toISOString().split("T")[0]; +} + +// Helper to get next N days starting from tomorrow +function getDateRange(): Date[] { + const dates: Date[] = []; + const today = new Date(); + for (let i = 1; i <= maxAdvanceDays; i++) { + const d = new Date(today); + d.setDate(today.getDate() + i); + dates.push(d); + } + return dates; +} + +// Generate time options for dropdowns (15-min intervals) +function generateTimeOptions(): string[] { + const options: string[] = []; + for (let h = 0; h < 24; h++) { + for (let m = 0; m < 60; m += slotDurationMinutes) { + const hh = h.toString().padStart(2, "0"); + const mm = m.toString().padStart(2, "0"); + options.push(`${hh}:${mm}`); + } + } + return options; +} + +const TIME_OPTIONS = generateTimeOptions(); + +interface EditSlot { + start_time: string; + end_time: string; +} + +export default function AdminAvailabilityPage() { + const { user, isLoading, isAuthorized } = useRequireAuth({ + requiredPermission: Permission.MANAGE_AVAILABILITY, + fallbackRedirect: "/", + }); + + const [availability, setAvailability] = useState>(new Map()); + const [selectedDate, setSelectedDate] = useState(null); + const [editSlots, setEditSlots] = useState([]); + const [isSaving, setIsSaving] = useState(false); + const [error, setError] = useState(null); + const [copySource, setCopySource] = useState(null); + const [copyTargets, setCopyTargets] = useState>(new Set()); + const [isCopying, setIsCopying] = useState(false); + + const dates = getDateRange(); + + const fetchAvailability = useCallback(async () => { + if (!dates.length) return; + + try { + const fromDate = formatDate(dates[0]); + const toDate = formatDate(dates[dates.length - 1]); + const data = await api.get( + `/api/admin/availability?from=${fromDate}&to=${toDate}` + ); + + const map = new Map(); + for (const day of data.days) { + map.set(day.date, day.slots); + } + setAvailability(map); + } catch (err) { + console.error("Failed to fetch availability:", err); + } + }, []); + + useEffect(() => { + if (user && isAuthorized) { + fetchAvailability(); + } + }, [user, isAuthorized, fetchAvailability]); + + const openEditModal = (date: Date) => { + const dateStr = formatDate(date); + const existingSlots = availability.get(dateStr) || []; + setEditSlots( + existingSlots.length > 0 + ? existingSlots.map((s) => ({ + start_time: s.start_time.slice(0, 5), + end_time: s.end_time.slice(0, 5), + })) + : [{ start_time: "09:00", end_time: "17:00" }] + ); + setSelectedDate(date); + setError(null); + }; + + const closeModal = () => { + setSelectedDate(null); + setEditSlots([]); + setError(null); + }; + + const addSlot = () => { + setEditSlots([...editSlots, { start_time: "09:00", end_time: "17:00" }]); + }; + + const removeSlot = (index: number) => { + setEditSlots(editSlots.filter((_, i) => i !== index)); + }; + + const updateSlot = (index: number, field: "start_time" | "end_time", value: string) => { + const updated = [...editSlots]; + updated[index][field] = value; + setEditSlots(updated); + }; + + const saveAvailability = async () => { + if (!selectedDate) return; + + setIsSaving(true); + setError(null); + + try { + const slots = editSlots.map((s) => ({ + start_time: s.start_time + ":00", + end_time: s.end_time + ":00", + })); + + await api.put("/api/admin/availability", { + date: formatDate(selectedDate), + slots, + }); + + await fetchAvailability(); + closeModal(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to save"); + } finally { + setIsSaving(false); + } + }; + + const clearAvailability = async () => { + if (!selectedDate) return; + + setIsSaving(true); + setError(null); + + try { + await api.put("/api/admin/availability", { + date: formatDate(selectedDate), + slots: [], + }); + + await fetchAvailability(); + closeModal(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to clear"); + } finally { + setIsSaving(false); + } + }; + + const startCopyMode = (dateStr: string) => { + setCopySource(dateStr); + setCopyTargets(new Set()); + }; + + const cancelCopyMode = () => { + setCopySource(null); + setCopyTargets(new Set()); + }; + + const toggleCopyTarget = (dateStr: string) => { + if (dateStr === copySource) return; + const newTargets = new Set(copyTargets); + if (newTargets.has(dateStr)) { + newTargets.delete(dateStr); + } else { + newTargets.add(dateStr); + } + setCopyTargets(newTargets); + }; + + const executeCopy = async () => { + if (!copySource || copyTargets.size === 0) return; + + setIsCopying(true); + setError(null); + + try { + await api.post("/api/admin/availability/copy", { + source_date: copySource, + target_dates: Array.from(copyTargets), + }); + + await fetchAvailability(); + cancelCopyMode(); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to copy"); + } finally { + setIsCopying(false); + } + }; + + const formatDisplayDate = (d: Date): string => { + return d.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }); + }; + + const formatSlotTime = (slot: TimeSlot): string => { + return `${slot.start_time.slice(0, 5)} - ${slot.end_time.slice(0, 5)}`; + }; + + if (isLoading) { + return ( +
+
Loading...
+
+ ); + } + + if (!user || !isAuthorized) { + return null; + } + + return ( +
+
+ +
+
+
+
+

Availability

+

+ Configure your available time slots for the next {maxAdvanceDays} days +

+
+ {copySource && ( +
+ + Select days to copy to, then click Copy + + + +
+ )} +
+ + {error && !selectedDate && ( +
{error}
+ )} + +
+ {dates.map((date) => { + const dateStr = formatDate(date); + const slots = availability.get(dateStr) || []; + const hasSlots = slots.length > 0; + const isSource = copySource === dateStr; + const isTarget = copyTargets.has(dateStr); + const isWeekend = date.getDay() === 0 || date.getDay() === 6; + + return ( +
{ + if (copySource) { + toggleCopyTarget(dateStr); + } else { + openEditModal(date); + } + }} + > +
+ {formatDisplayDate(date)} + {hasSlots && !copySource && ( + + )} +
+
+ {slots.length === 0 ? ( + No availability + ) : ( + slots.map((slot, i) => ( + + {formatSlotTime(slot)} + + )) + )} +
+
+ ); + })} +
+
+
+ + {/* Edit Modal */} + {selectedDate && ( +
+
e.stopPropagation()}> +

+ Edit Availability - {formatDisplayDate(selectedDate)} +

+ + {error &&
{error}
} + +
+ {editSlots.map((slot, index) => ( +
+ + + + +
+ ))} + +
+ +
+ +
+ + +
+
+
+
+ )} +
+ ); +} + +const pageStyles: Record = { + content: { + flex: 1, + padding: "2rem", + overflowY: "auto", + }, + pageContainer: { + maxWidth: "1200px", + margin: "0 auto", + }, + headerRow: { + display: "flex", + justifyContent: "space-between", + alignItems: "flex-start", + marginBottom: "2rem", + flexWrap: "wrap", + gap: "1rem", + }, + pageTitle: { + fontFamily: "'Instrument Serif', Georgia, serif", + fontSize: "2rem", + fontWeight: 400, + color: "#fff", + margin: 0, + }, + pageSubtitle: { + fontFamily: "'DM Sans', system-ui, sans-serif", + color: "rgba(255, 255, 255, 0.5)", + marginTop: "0.5rem", + fontSize: "0.95rem", + }, + copyActions: { + display: "flex", + alignItems: "center", + gap: "1rem", + }, + copyHint: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.85rem", + color: "rgba(255, 255, 255, 0.6)", + }, + copyButton: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.85rem", + padding: "0.5rem 1rem", + background: "rgba(99, 102, 241, 0.3)", + color: "#fff", + border: "1px solid rgba(99, 102, 241, 0.5)", + borderRadius: "8px", + cursor: "pointer", + }, + errorBanner: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.9rem", + padding: "1rem", + background: "rgba(239, 68, 68, 0.15)", + border: "1px solid rgba(239, 68, 68, 0.3)", + borderRadius: "8px", + color: "#f87171", + marginBottom: "1rem", + }, + calendar: { + display: "grid", + gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))", + gap: "1rem", + }, + dayCard: { + background: "rgba(255, 255, 255, 0.03)", + border: "1px solid rgba(255, 255, 255, 0.08)", + borderRadius: "12px", + padding: "1rem", + cursor: "pointer", + transition: "all 0.2s", + }, + dayCardActive: { + background: "rgba(99, 102, 241, 0.1)", + borderColor: "rgba(99, 102, 241, 0.3)", + }, + dayCardSource: { + background: "rgba(34, 197, 94, 0.15)", + borderColor: "rgba(34, 197, 94, 0.5)", + }, + dayCardTarget: { + background: "rgba(99, 102, 241, 0.2)", + borderColor: "rgba(99, 102, 241, 0.6)", + }, + dayCardWeekend: { + opacity: 0.7, + }, + dayHeader: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + marginBottom: "0.5rem", + }, + dayName: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.85rem", + fontWeight: 600, + color: "rgba(255, 255, 255, 0.9)", + }, + copyFromButton: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "1rem", + padding: "0.25rem 0.5rem", + background: "transparent", + color: "rgba(255, 255, 255, 0.5)", + border: "none", + cursor: "pointer", + }, + slotList: { + display: "flex", + flexDirection: "column", + gap: "0.25rem", + }, + noSlots: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.75rem", + color: "rgba(255, 255, 255, 0.3)", + }, + slotBadge: { + fontFamily: "'DM Mono', monospace", + fontSize: "0.7rem", + padding: "0.2rem 0.4rem", + background: "rgba(99, 102, 241, 0.2)", + borderRadius: "4px", + color: "rgba(129, 140, 248, 0.9)", + }, + modalOverlay: { + position: "fixed", + top: 0, + left: 0, + right: 0, + bottom: 0, + background: "rgba(0, 0, 0, 0.7)", + display: "flex", + alignItems: "center", + justifyContent: "center", + zIndex: 1000, + }, + modal: { + background: "#1a1a3e", + border: "1px solid rgba(255, 255, 255, 0.1)", + borderRadius: "16px", + padding: "2rem", + width: "90%", + maxWidth: "400px", + boxShadow: "0 25px 50px -12px rgba(0, 0, 0, 0.5)", + }, + modalTitle: { + fontFamily: "'Instrument Serif', Georgia, serif", + fontSize: "1.5rem", + fontWeight: 400, + color: "#fff", + margin: "0 0 1.5rem 0", + }, + modalError: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.85rem", + padding: "0.75rem", + background: "rgba(239, 68, 68, 0.15)", + border: "1px solid rgba(239, 68, 68, 0.3)", + borderRadius: "8px", + color: "#f87171", + marginBottom: "1rem", + }, + slotsEditor: { + display: "flex", + flexDirection: "column", + gap: "0.75rem", + marginBottom: "1.5rem", + }, + slotRow: { + display: "flex", + alignItems: "center", + gap: "0.5rem", + }, + timeSelect: { + fontFamily: "'DM Mono', monospace", + fontSize: "0.9rem", + padding: "0.5rem", + background: "rgba(255, 255, 255, 0.05)", + border: "1px solid rgba(255, 255, 255, 0.1)", + borderRadius: "6px", + color: "#fff", + cursor: "pointer", + flex: 1, + }, + slotDash: { + color: "rgba(255, 255, 255, 0.4)", + }, + removeSlotButton: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "1.2rem", + width: "32px", + height: "32px", + background: "rgba(239, 68, 68, 0.15)", + color: "rgba(239, 68, 68, 0.9)", + border: "1px solid rgba(239, 68, 68, 0.3)", + borderRadius: "6px", + cursor: "pointer", + display: "flex", + alignItems: "center", + justifyContent: "center", + }, + addSlotButton: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.85rem", + padding: "0.5rem", + background: "transparent", + color: "rgba(99, 102, 241, 0.9)", + border: "1px dashed rgba(99, 102, 241, 0.4)", + borderRadius: "6px", + cursor: "pointer", + }, + modalActions: { + display: "flex", + justifyContent: "space-between", + alignItems: "center", + gap: "1rem", + }, + modalActionsRight: { + display: "flex", + gap: "0.75rem", + }, + clearButton: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.85rem", + padding: "0.6rem 1rem", + background: "rgba(239, 68, 68, 0.15)", + color: "rgba(239, 68, 68, 0.9)", + border: "1px solid rgba(239, 68, 68, 0.3)", + borderRadius: "8px", + cursor: "pointer", + }, + cancelButton: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.85rem", + padding: "0.6rem 1rem", + background: "rgba(255, 255, 255, 0.05)", + color: "rgba(255, 255, 255, 0.7)", + border: "1px solid rgba(255, 255, 255, 0.1)", + borderRadius: "8px", + cursor: "pointer", + }, + saveButton: { + fontFamily: "'DM Sans', system-ui, sans-serif", + fontSize: "0.85rem", + fontWeight: 500, + padding: "0.6rem 1.5rem", + background: "rgba(99, 102, 241, 0.3)", + color: "#fff", + border: "1px solid rgba(99, 102, 241, 0.5)", + borderRadius: "8px", + cursor: "pointer", + }, +}; + +const styles = { ...sharedStyles, ...pageStyles }; + diff --git a/frontend/app/auth-context.tsx b/frontend/app/auth-context.tsx index 8f874a5..5f1589b 100644 --- a/frontend/app/auth-context.tsx +++ b/frontend/app/auth-context.tsx @@ -15,6 +15,14 @@ export const Permission = { VIEW_AUDIT: "view_audit", MANAGE_INVITES: "manage_invites", VIEW_OWN_INVITES: "view_own_invites", + // Booking permissions (regular users) + BOOK_APPOINTMENT: "book_appointment", + VIEW_OWN_APPOINTMENTS: "view_own_appointments", + CANCEL_OWN_APPOINTMENT: "cancel_own_appointment", + // Availability/Appointments permissions (admin) + MANAGE_AVAILABILITY: "manage_availability", + VIEW_ALL_APPOINTMENTS: "view_all_appointments", + CANCEL_ANY_APPOINTMENT: "cancel_any_appointment", } as const; export type PermissionType = typeof Permission[keyof typeof Permission]; diff --git a/frontend/app/components/Header.tsx b/frontend/app/components/Header.tsx index 487d692..94eb844 100644 --- a/frontend/app/components/Header.tsx +++ b/frontend/app/components/Header.tsx @@ -7,7 +7,7 @@ import constants from "../../../shared/constants.json"; const { ADMIN, REGULAR } = constants.roles; -type PageId = "counter" | "sum" | "profile" | "invites" | "audit" | "admin-invites"; +type PageId = "counter" | "sum" | "profile" | "invites" | "audit" | "admin-invites" | "admin-availability"; interface HeaderProps { currentPage: PageId; @@ -31,6 +31,7 @@ const REGULAR_NAV_ITEMS: NavItem[] = [ const ADMIN_NAV_ITEMS: NavItem[] = [ { id: "audit", label: "Audit", href: "/audit", adminOnly: true }, { id: "admin-invites", label: "Invites", href: "/admin/invites", adminOnly: true }, + { id: "admin-availability", label: "Availability", href: "/admin/availability", adminOnly: true }, ]; export function Header({ currentPage }: HeaderProps) { diff --git a/frontend/app/generated/api.ts b/frontend/app/generated/api.ts index 512ed25..ff2e906 100644 --- a/frontend/app/generated/api.ts +++ b/frontend/app/generated/api.ts @@ -312,6 +312,50 @@ export interface paths { patch?: never; trace?: never; }; + "/api/admin/availability": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Availability + * @description Get availability slots for a date range. + */ + get: operations["get_availability_api_admin_availability_get"]; + /** + * Set Availability + * @description Set availability for a specific date. Replaces any existing availability. + */ + put: operations["set_availability_api_admin_availability_put"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/admin/availability/copy": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Copy Availability + * @description Copy availability from one day to multiple target days. + */ + post: operations["copy_availability_api_admin_availability_copy_post"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/meta/constants": { parameters: { query?: never; @@ -346,6 +390,27 @@ export interface components { /** Email */ email: string; }; + /** + * AvailabilityDay + * @description Availability for a single day. + */ + AvailabilityDay: { + /** + * Date + * Format: date + */ + date: string; + /** Slots */ + slots: components["schemas"]["TimeSlot"][]; + }; + /** + * AvailabilityResponse + * @description Response model for availability query. + */ + AvailabilityResponse: { + /** Days */ + days: components["schemas"]["AvailabilityDay"][]; + }; /** * ConstantsResponse * @description Response model for shared constants. @@ -358,6 +423,19 @@ export interface components { /** Invite Statuses */ invite_statuses: string[]; }; + /** + * CopyAvailabilityRequest + * @description Request to copy availability from one day to others. + */ + CopyAvailabilityRequest: { + /** + * Source Date + * Format: date + */ + source_date: string; + /** Target Dates */ + target_dates: string[]; + }; /** * CounterRecordResponse * @description Response model for a counter audit record. @@ -515,6 +593,19 @@ export interface components { /** Invite Identifier */ invite_identifier: string; }; + /** + * SetAvailabilityRequest + * @description Request to set availability for a specific date. + */ + SetAvailabilityRequest: { + /** + * Date + * Format: date + */ + date: string; + /** Slots */ + slots: components["schemas"]["TimeSlot"][]; + }; /** * SumRecordResponse * @description Response model for a sum audit record. @@ -558,6 +649,22 @@ export interface components { /** Result */ result: number; }; + /** + * TimeSlot + * @description A single time slot (start and end time). + */ + TimeSlot: { + /** + * Start Time + * Format: time + */ + start_time: string; + /** + * End Time + * Format: time + */ + end_time: string; + }; /** * UserCredentials * @description Base model for user email/password. @@ -1097,6 +1204,106 @@ export interface operations { }; }; }; + get_availability_api_admin_availability_get: { + parameters: { + query: { + /** @description Start date (inclusive) */ + from: string; + /** @description End date (inclusive) */ + to: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AvailabilityResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + set_availability_api_admin_availability_put: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetAvailabilityRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AvailabilityDay"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + copy_availability_api_admin_availability_copy_post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CopyAvailabilityRequest"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AvailabilityResponse"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_constants_api_meta_constants_get: { parameters: { query?: never; diff --git a/frontend/e2e/availability.spec.ts b/frontend/e2e/availability.spec.ts new file mode 100644 index 0000000..c798968 --- /dev/null +++ b/frontend/e2e/availability.spec.ts @@ -0,0 +1,254 @@ +import { test, expect, Page } from "@playwright/test"; + +/** + * Availability Page E2E Tests + * + * Tests for the admin availability management page. + */ + +const API_URL = process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"; + +function getRequiredEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Required environment variable ${name} is not set.`); + } + return value; +} + +const REGULAR_USER = { + email: getRequiredEnv("DEV_USER_EMAIL"), + password: getRequiredEnv("DEV_USER_PASSWORD"), +}; + +const ADMIN_USER = { + email: getRequiredEnv("DEV_ADMIN_EMAIL"), + password: getRequiredEnv("DEV_ADMIN_PASSWORD"), +}; + +async function clearAuth(page: Page) { + await page.context().clearCookies(); +} + +async function loginUser(page: Page, email: string, password: string) { + await page.goto("/login"); + await page.fill('input[type="email"]', email); + await page.fill('input[type="password"]', password); + await page.click('button[type="submit"]'); + await page.waitForURL((url) => !url.pathname.includes("/login"), { timeout: 10000 }); +} + +// Helper to get tomorrow's date in the format displayed on the page +function getTomorrowDisplay(): string { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + return tomorrow.toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }); +} + +test.describe("Availability Page - Admin Access", () => { + test.beforeEach(async ({ page }) => { + await clearAuth(page); + await loginUser(page, ADMIN_USER.email, ADMIN_USER.password); + }); + + test("admin can access availability page", async ({ page }) => { + await page.goto("/admin/availability"); + + await expect(page).toHaveURL("/admin/availability"); + await expect(page.getByRole("heading", { name: "Availability" })).toBeVisible(); + await expect(page.getByText("Configure your available time slots")).toBeVisible(); + }); + + test("admin sees Availability link in nav", async ({ page }) => { + await page.goto("/audit"); + + const availabilityLink = page.locator('a[href="/admin/availability"]'); + await expect(availabilityLink).toBeVisible(); + }); + + test("availability page shows calendar grid", async ({ page }) => { + await page.goto("/admin/availability"); + + // Should show tomorrow's date in the calendar + const tomorrowText = getTomorrowDisplay(); + await expect(page.getByText(tomorrowText)).toBeVisible(); + + // Should show "No availability" for days without slots + await expect(page.getByText("No availability").first()).toBeVisible(); + }); + + test("can open edit modal by clicking a day", async ({ page }) => { + await page.goto("/admin/availability"); + + // Click on the first day card + const tomorrowText = getTomorrowDisplay(); + await page.getByText(tomorrowText).click(); + + // Modal should appear + await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible(); + await expect(page.getByRole("button", { name: "Save" })).toBeVisible(); + await expect(page.getByRole("button", { name: "Cancel" })).toBeVisible(); + }); + + test("can add availability slot", async ({ page }) => { + await page.goto("/admin/availability"); + + // Click on the first day + const tomorrowText = getTomorrowDisplay(); + await page.getByText(tomorrowText).click(); + + // Wait for modal + await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible(); + + // Click Save (default is 09:00-17:00) + await page.getByRole("button", { name: "Save" }).click(); + + // Wait for modal to close + await expect(page.getByRole("heading", { name: /Edit Availability/ })).not.toBeVisible(); + + // Should now show the slot + await expect(page.getByText("09:00 - 17:00")).toBeVisible(); + }); + + test("can clear availability", async ({ page }) => { + await page.goto("/admin/availability"); + + const tomorrowText = getTomorrowDisplay(); + + // First add availability + await page.getByText(tomorrowText).click(); + await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible(); + await page.getByRole("button", { name: "Save" }).click(); + await expect(page.getByRole("heading", { name: /Edit Availability/ })).not.toBeVisible(); + + // Verify slot exists + await expect(page.getByText("09:00 - 17:00")).toBeVisible(); + + // Now clear it + await page.getByText(tomorrowText).click(); + await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible(); + await page.getByRole("button", { name: "Clear All" }).click(); + + // Wait for modal to close + await expect(page.getByRole("heading", { name: /Edit Availability/ })).not.toBeVisible(); + + // Slot should be gone - verify by checking the time slot is no longer visible + await expect(page.getByText("09:00 - 17:00")).not.toBeVisible(); + }); + + test("can add multiple slots", async ({ page }) => { + await page.goto("/admin/availability"); + + const tomorrowText = getTomorrowDisplay(); + await page.getByText(tomorrowText).click(); + + await expect(page.getByRole("heading", { name: /Edit Availability/ })).toBeVisible(); + + // First slot is 09:00-17:00 by default - change it to morning only + const timeSelects = page.locator("select"); + await timeSelects.nth(1).selectOption("12:00"); // Change first slot end to 12:00 + + // Add another slot for afternoon + await page.getByText("+ Add Time Range").click(); + + // Change second slot times to avoid overlap + await timeSelects.nth(2).selectOption("14:00"); // Second slot start + await timeSelects.nth(3).selectOption("17:00"); // Second slot end + + // Save + await page.getByRole("button", { name: "Save" }).click(); + await expect(page.getByRole("heading", { name: /Edit Availability/ })).not.toBeVisible(); + + // Should see both slots + await expect(page.getByText("09:00 - 12:00")).toBeVisible(); + await expect(page.getByText("14:00 - 17:00")).toBeVisible(); + }); +}); + +test.describe("Availability Page - Access Control", () => { + test("regular user cannot access availability page", async ({ page }) => { + await clearAuth(page); + await loginUser(page, REGULAR_USER.email, REGULAR_USER.password); + + await page.goto("/admin/availability"); + + // Should be redirected (to counter/home for regular users) + await expect(page).not.toHaveURL("/admin/availability"); + }); + + test("regular user does not see Availability link", async ({ page }) => { + await clearAuth(page); + await loginUser(page, REGULAR_USER.email, REGULAR_USER.password); + + await page.goto("/"); + + const availabilityLink = page.locator('a[href="/admin/availability"]'); + await expect(availabilityLink).toHaveCount(0); + }); + + test("unauthenticated user redirected to login", async ({ page }) => { + await clearAuth(page); + + await page.goto("/admin/availability"); + + await expect(page).toHaveURL("/login"); + }); +}); + +test.describe("Availability API", () => { + test("admin can set availability via API", async ({ page, request }) => { + await clearAuth(page); + await loginUser(page, ADMIN_USER.email, ADMIN_USER.password); + + const cookies = await page.context().cookies(); + const authCookie = cookies.find(c => c.name === "auth_token"); + + if (authCookie) { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const dateStr = tomorrow.toISOString().split("T")[0]; + + const response = await request.put(`${API_URL}/api/admin/availability`, { + headers: { + Cookie: `auth_token=${authCookie.value}`, + "Content-Type": "application/json", + }, + data: { + date: dateStr, + slots: [{ start_time: "10:00:00", end_time: "12:00:00" }], + }, + }); + + expect(response.status()).toBe(200); + const data = await response.json(); + expect(data.date).toBe(dateStr); + expect(data.slots).toHaveLength(1); + } + }); + + test("regular user cannot access availability API", async ({ page, request }) => { + await clearAuth(page); + await loginUser(page, REGULAR_USER.email, REGULAR_USER.password); + + const cookies = await page.context().cookies(); + const authCookie = cookies.find(c => c.name === "auth_token"); + + if (authCookie) { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + const dateStr = tomorrow.toISOString().split("T")[0]; + + const response = await request.get( + `${API_URL}/api/admin/availability?from=${dateStr}&to=${dateStr}`, + { + headers: { + Cookie: `auth_token=${authCookie.value}`, + }, + } + ); + + expect(response.status()).toBe(403); + } + }); +}); +