arbret/frontend/app/admin/availability/page.tsx
counterweight 6ff3c0a133
Extract duplicate date formatting functions to shared utilities
- Created frontend/app/utils/date.ts with formatDate, formatTime, formatDateTime, formatDisplayDate
- Created frontend/e2e/helpers/date.ts with formatDateLocal, getTomorrowDateStr
- Updated all frontend pages and e2e tests to use shared utilities
- Removed duplicate date formatting code from 6 files
2025-12-21 17:48:17 +01:00

653 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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";
import { formatDate, formatDisplayDate } from "../../utils/date";
const { slotDurationMinutes, maxAdvanceDays } = constants.booking;
type AvailabilityDay = components["schemas"]["AvailabilityDay"];
type AvailabilityResponse = components["schemas"]["AvailabilityResponse"];
type TimeSlot = components["schemas"]["TimeSlot"];
// 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 () => {
const dateRange = getDateRange();
if (!dateRange.length) return;
try {
const fromDate = formatDate(dateRange[0]);
const toDate = formatDate(dateRange[dateRange.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 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}
data-testid={`day-card-${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 };