arbret/frontend/app/admin/availability/page.tsx
counterweight e2376855ce
Translate admin pages - Create admin.json files and translate all admin pages
- Create admin.json translation files for es, en, ca with all admin strings
- Update IntlProvider to include admin namespace
- Translate admin/invites/page.tsx - all strings now use translations
- Translate admin/trades/page.tsx - all strings now use translations
- Translate admin/price-history/page.tsx - all strings now use translations
- Translate admin/availability/page.tsx - all strings now use translations
- Add 'saving' key to common.json for all languages
- Fix linting errors: add t to useCallback dependencies
- All admin pages now fully multilingual
2025-12-26 11:49:50 +01:00

555 lines
16 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 { adminApi } from "../../api";
import { Header } from "../../components/Header";
import { useRequireAuth } from "../../hooks/useRequireAuth";
import { useTranslation } from "../../hooks/useTranslation";
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 t = useTranslation("admin");
const tCommon = useTranslation("common");
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 : t("availability.errors.saveFailed"));
} 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 : t("availability.errors.clearFailed"));
} 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 : t("availability.errors.copyFailed"));
} 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}>{tCommon("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}>{t("availability.title")}</h1>
<p style={typographyStyles.pageSubtitle}>
{t("availability.subtitle", { days: maxAdvanceDays })}
</p>
</div>
{copySource && (
<div style={styles.copyActions}>
<span style={styles.copyHint}>{t("availability.copyMode.hint")}</span>
<button
onClick={executeCopy}
disabled={copyTargets.size === 0 || isCopying}
style={buttonStyles.accentButton}
>
{isCopying
? t("availability.copyMode.copying")
: t("availability.copyMode.copyTo", { count: copyTargets.size })}
</button>
<button onClick={cancelCopyMode} style={buttonStyles.secondaryButton}>
{t("availability.copyMode.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={t("availability.copyMode.copyTo", { count: 0 })}
>
📋
</button>
)}
</div>
<div style={styles.slotList}>
{slots.length === 0 ? (
<span style={styles.noSlots}>{t("availability.modal.noAvailability")}</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}>
{t("availability.modal.title")} - {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={t("availability.modal.removeSlot")}
>
×
</button>
</div>
))}
<button onClick={addSlot} style={styles.addSlotButton}>
+ {t("availability.modal.addSlot")}
</button>
</div>
<div style={modalStyles.modalActions}>
<button onClick={clearAvailability} disabled={isSaving} style={styles.clearButton}>
{t("availability.modal.clear")}
</button>
<div style={modalStyles.modalActionsRight}>
<button onClick={closeModal} style={buttonStyles.secondaryButton}>
{t("availability.modal.close")}
</button>
<button
onClick={saveAvailability}
disabled={isSaving}
style={buttonStyles.accentButton}
>
{isSaving ? tCommon("saving") : t("availability.modal.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",
},
};