arbret/frontend/app/admin/availability/page.tsx
counterweight 40b193238e
Move TIME_OPTIONS outside component
- TIME_OPTIONS is now computed at module level, not inside component
- generateTimeOptions now accepts slotDurationMinutes as parameter
- Prevents unnecessary recomputation on every render
2025-12-21 17:53:17 +01:00

654 lines
19 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)
// Moved outside component since slotDurationMinutes is a constant
function generateTimeOptions(slotDurationMinutes: number): 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(slotDurationMinutes);
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 };