arbret/frontend/app/admin/availability/page.tsx

551 lines
16 KiB
TypeScript
Raw Normal View History

"use client";
import { useEffect, useState, useCallback } from "react";
import { Permission } from "../../auth-context";
import { adminApi } from "../../api";
import { Header } from "../../components/Header";
import { useRequireAuth } from "../../hooks/useRequireAuth";
import { components } from "../../generated/api";
import constants from "../../../../shared/constants.json";
import {
formatDate,
formatDisplayDate,
getDateRange,
formatTimeString,
isWeekend,
} from "../../utils/date";
import {
layoutStyles,
typographyStyles,
bannerStyles,
buttonStyles,
modalStyles,
} from "../../styles/shared";
const { slotDurationMinutes, maxAdvanceDays, minAdvanceDays } = constants.exchange;
type _AvailabilityDay = components["schemas"]["AvailabilityDay"];
type TimeSlot = components["schemas"]["TimeSlot"];
// 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(minAdvanceDays, maxAdvanceDays);
const fetchAvailability = useCallback(async () => {
const dateRange = getDateRange(minAdvanceDays, maxAdvanceDays);
if (!dateRange.length) return;
try {
const fromDate = formatDate(dateRange[0]);
const toDate = formatDate(dateRange[dateRange.length - 1]);
const data = await adminApi.getAvailability(fromDate, 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: formatTimeString(s.start_time),
end_time: formatTimeString(s.end_time),
}))
: [{ 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 adminApi.updateAvailability({
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 adminApi.updateAvailability({
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 adminApi.copyAvailability({
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 `${formatTimeString(slot.start_time)} - ${formatTimeString(slot.end_time)}`;
};
if (isLoading) {
return (
<main style={layoutStyles.main}>
<div style={layoutStyles.loader}>Loading...</div>
</main>
);
}
if (!user || !isAuthorized) {
return null;
}
return (
<main style={layoutStyles.main}>
<Header currentPage="admin-availability" />
<div style={layoutStyles.contentScrollable}>
<div style={styles.pageContainer}>
<div style={styles.headerRow}>
<div>
<h1 style={typographyStyles.pageTitle}>Availability</h1>
<p style={typographyStyles.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={buttonStyles.accentButton}
>
{isCopying ? "Copying..." : `Copy to ${copyTargets.size} day(s)`}
</button>
<button onClick={cancelCopyMode} style={buttonStyles.secondaryButton}>
Cancel
</button>
</div>
)}
</div>
{error && !selectedDate && <div style={bannerStyles.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 isWeekendDate = isWeekend(date);
return (
<div
key={dateStr}
data-testid={`day-card-${dateStr}`}
style={{
...styles.dayCard,
...(hasSlots ? styles.dayCardActive : {}),
...(isSource ? styles.dayCardSource : {}),
...(isTarget ? styles.dayCardTarget : {}),
...(isWeekendDate ? 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={modalStyles.modalOverlay} onClick={closeModal}>
<div style={modalStyles.modal} onClick={(e) => e.stopPropagation()}>
<h2 style={modalStyles.modalTitle}>
Edit Availability - {formatDisplayDate(selectedDate)}
</h2>
{error && <div style={modalStyles.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={modalStyles.modalActions}>
<button onClick={clearAvailability} disabled={isSaving} style={styles.clearButton}>
Clear All
</button>
<div style={modalStyles.modalActionsRight}>
<button onClick={closeModal} style={buttonStyles.secondaryButton}>
Cancel
</button>
<button
onClick={saveAvailability}
disabled={isSaving}
style={buttonStyles.accentButton}
>
{isSaving ? "Saving..." : "Save"}
</button>
</div>
</div>
</div>
</div>
)}
</main>
);
}
// Page-specific styles
const styles: Record<string, React.CSSProperties> = {
pageContainer: {
maxWidth: "1200px",
margin: "0 auto",
},
headerRow: {
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
marginBottom: "2rem",
flexWrap: "wrap",
gap: "1rem",
},
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)",
},
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)",
border: "1px solid rgba(99, 102, 241, 0.3)",
},
dayCardSource: {
background: "rgba(34, 197, 94, 0.15)",
border: "1px solid rgba(34, 197, 94, 0.5)",
},
dayCardTarget: {
background: "rgba(99, 102, 241, 0.2)",
border: "1px solid 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)",
},
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",
},
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",
},
};