Phase 2: Admin availability UI with calendar grid, edit modal, and e2e tests
This commit is contained in:
parent
64d2e99d73
commit
f6cf093cb1
5 changed files with 1130 additions and 1 deletions
659
frontend/app/admin/availability/page.tsx
Normal file
659
frontend/app/admin/availability/page.tsx
Normal file
|
|
@ -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<Map<string, TimeSlot[]>>(new Map());
|
||||||
|
const [selectedDate, setSelectedDate] = useState<Date | null>(null);
|
||||||
|
const [editSlots, setEditSlots] = useState<EditSlot[]>([]);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [copySource, setCopySource] = useState<string | null>(null);
|
||||||
|
const [copyTargets, setCopyTargets] = useState<Set<string>>(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<AvailabilityResponse>(
|
||||||
|
`/api/admin/availability?from=${fromDate}&to=${toDate}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const map = new Map<string, TimeSlot[]>();
|
||||||
|
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 (
|
||||||
|
<main style={styles.main}>
|
||||||
|
<div style={styles.loader}>Loading...</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user || !isAuthorized) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main style={styles.main}>
|
||||||
|
<Header currentPage="admin-availability" />
|
||||||
|
|
||||||
|
<div style={styles.content}>
|
||||||
|
<div style={styles.pageContainer}>
|
||||||
|
<div style={styles.headerRow}>
|
||||||
|
<div>
|
||||||
|
<h1 style={styles.pageTitle}>Availability</h1>
|
||||||
|
<p style={styles.pageSubtitle}>
|
||||||
|
Configure your available time slots for the next {maxAdvanceDays} days
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{copySource && (
|
||||||
|
<div style={styles.copyActions}>
|
||||||
|
<span style={styles.copyHint}>
|
||||||
|
Select days to copy to, then click Copy
|
||||||
|
</span>
|
||||||
|
<button onClick={executeCopy} disabled={copyTargets.size === 0 || isCopying} style={styles.copyButton}>
|
||||||
|
{isCopying ? "Copying..." : `Copy to ${copyTargets.size} day(s)`}
|
||||||
|
</button>
|
||||||
|
<button onClick={cancelCopyMode} style={styles.cancelButton}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && !selectedDate && (
|
||||||
|
<div style={styles.errorBanner}>{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={styles.calendar}>
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={dateStr}
|
||||||
|
style={{
|
||||||
|
...styles.dayCard,
|
||||||
|
...(hasSlots ? styles.dayCardActive : {}),
|
||||||
|
...(isSource ? styles.dayCardSource : {}),
|
||||||
|
...(isTarget ? styles.dayCardTarget : {}),
|
||||||
|
...(isWeekend ? styles.dayCardWeekend : {}),
|
||||||
|
}}
|
||||||
|
onClick={() => {
|
||||||
|
if (copySource) {
|
||||||
|
toggleCopyTarget(dateStr);
|
||||||
|
} else {
|
||||||
|
openEditModal(date);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={styles.dayHeader}>
|
||||||
|
<span style={styles.dayName}>{formatDisplayDate(date)}</span>
|
||||||
|
{hasSlots && !copySource && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
startCopyMode(dateStr);
|
||||||
|
}}
|
||||||
|
style={styles.copyFromButton}
|
||||||
|
title="Copy to other days"
|
||||||
|
>
|
||||||
|
⎘
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={styles.slotList}>
|
||||||
|
{slots.length === 0 ? (
|
||||||
|
<span style={styles.noSlots}>No availability</span>
|
||||||
|
) : (
|
||||||
|
slots.map((slot, i) => (
|
||||||
|
<span key={i} style={styles.slotBadge}>
|
||||||
|
{formatSlotTime(slot)}
|
||||||
|
</span>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
{selectedDate && (
|
||||||
|
<div style={styles.modalOverlay} onClick={closeModal}>
|
||||||
|
<div style={styles.modal} onClick={(e) => e.stopPropagation()}>
|
||||||
|
<h2 style={styles.modalTitle}>
|
||||||
|
Edit Availability - {formatDisplayDate(selectedDate)}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{error && <div style={styles.modalError}>{error}</div>}
|
||||||
|
|
||||||
|
<div style={styles.slotsEditor}>
|
||||||
|
{editSlots.map((slot, index) => (
|
||||||
|
<div key={index} style={styles.slotRow}>
|
||||||
|
<select
|
||||||
|
value={slot.start_time}
|
||||||
|
onChange={(e) => updateSlot(index, "start_time", e.target.value)}
|
||||||
|
style={styles.timeSelect}
|
||||||
|
>
|
||||||
|
{TIME_OPTIONS.map((t) => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span style={styles.slotDash}>→</span>
|
||||||
|
<select
|
||||||
|
value={slot.end_time}
|
||||||
|
onChange={(e) => updateSlot(index, "end_time", e.target.value)}
|
||||||
|
style={styles.timeSelect}
|
||||||
|
>
|
||||||
|
{TIME_OPTIONS.map((t) => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={() => removeSlot(index)}
|
||||||
|
style={styles.removeSlotButton}
|
||||||
|
title="Remove slot"
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button onClick={addSlot} style={styles.addSlotButton}>
|
||||||
|
+ Add Time Range
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={styles.modalActions}>
|
||||||
|
<button onClick={clearAvailability} disabled={isSaving} style={styles.clearButton}>
|
||||||
|
Clear All
|
||||||
|
</button>
|
||||||
|
<div style={styles.modalActionsRight}>
|
||||||
|
<button onClick={closeModal} style={styles.cancelButton}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button onClick={saveAvailability} disabled={isSaving} style={styles.saveButton}>
|
||||||
|
{isSaving ? "Saving..." : "Save"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const pageStyles: Record<string, React.CSSProperties> = {
|
||||||
|
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 };
|
||||||
|
|
||||||
|
|
@ -15,6 +15,14 @@ export const Permission = {
|
||||||
VIEW_AUDIT: "view_audit",
|
VIEW_AUDIT: "view_audit",
|
||||||
MANAGE_INVITES: "manage_invites",
|
MANAGE_INVITES: "manage_invites",
|
||||||
VIEW_OWN_INVITES: "view_own_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;
|
} as const;
|
||||||
|
|
||||||
export type PermissionType = typeof Permission[keyof typeof Permission];
|
export type PermissionType = typeof Permission[keyof typeof Permission];
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import constants from "../../../shared/constants.json";
|
||||||
|
|
||||||
const { ADMIN, REGULAR } = constants.roles;
|
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 {
|
interface HeaderProps {
|
||||||
currentPage: PageId;
|
currentPage: PageId;
|
||||||
|
|
@ -31,6 +31,7 @@ const REGULAR_NAV_ITEMS: NavItem[] = [
|
||||||
const ADMIN_NAV_ITEMS: NavItem[] = [
|
const ADMIN_NAV_ITEMS: NavItem[] = [
|
||||||
{ id: "audit", label: "Audit", href: "/audit", adminOnly: true },
|
{ id: "audit", label: "Audit", href: "/audit", adminOnly: true },
|
||||||
{ id: "admin-invites", label: "Invites", href: "/admin/invites", 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) {
|
export function Header({ currentPage }: HeaderProps) {
|
||||||
|
|
|
||||||
|
|
@ -312,6 +312,50 @@ export interface paths {
|
||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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": {
|
"/api/meta/constants": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
@ -346,6 +390,27 @@ export interface components {
|
||||||
/** Email */
|
/** Email */
|
||||||
email: string;
|
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
|
* ConstantsResponse
|
||||||
* @description Response model for shared constants.
|
* @description Response model for shared constants.
|
||||||
|
|
@ -358,6 +423,19 @@ export interface components {
|
||||||
/** Invite Statuses */
|
/** Invite Statuses */
|
||||||
invite_statuses: string[];
|
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
|
* CounterRecordResponse
|
||||||
* @description Response model for a counter audit record.
|
* @description Response model for a counter audit record.
|
||||||
|
|
@ -515,6 +593,19 @@ export interface components {
|
||||||
/** Invite Identifier */
|
/** Invite Identifier */
|
||||||
invite_identifier: string;
|
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
|
* SumRecordResponse
|
||||||
* @description Response model for a sum audit record.
|
* @description Response model for a sum audit record.
|
||||||
|
|
@ -558,6 +649,22 @@ export interface components {
|
||||||
/** Result */
|
/** Result */
|
||||||
result: number;
|
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
|
* UserCredentials
|
||||||
* @description Base model for user email/password.
|
* @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: {
|
get_constants_api_meta_constants_get: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|
|
||||||
254
frontend/e2e/availability.spec.ts
Normal file
254
frontend/e2e/availability.spec.ts
Normal file
|
|
@ -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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue