Split remaining toolbox modules by responsibility
All checks were successful
Deploy Sokko G / deploy (push) Successful in 16s
All checks were successful
Deploy Sokko G / deploy (push) Successful in 16s
This commit is contained in:
parent
43a9e791c5
commit
a0d7e80a1a
25 changed files with 2199 additions and 1992 deletions
|
|
@ -1,433 +1,22 @@
|
|||
// Rôle : fournit l'outil calendrier hebdomadaire/mensuel avec événements, récurrences et alertes.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
// Rôle : orchestre l'outil calendrier et délègue l'état métier à son contrôleur.
|
||||
import { CalendarContent } from "./CalendarContent.jsx";
|
||||
import {
|
||||
CALENDAR_ALERT_MODES,
|
||||
CALENDAR_COLORS,
|
||||
WEEK_SLOT_MINUTES,
|
||||
addDays,
|
||||
formatLocalDateKey,
|
||||
getCalendarOccurrences,
|
||||
getLocalDayIndex,
|
||||
getMonthEnd,
|
||||
getMonthGridDays,
|
||||
getMonthStart,
|
||||
getWeekDays,
|
||||
getWeekStart,
|
||||
minutesToTimeValue,
|
||||
parseLocalDateKey,
|
||||
timeValueToMinutes
|
||||
} from "./calendarUtils.js";
|
||||
import { WEEK_SLOT_COUNT, getWeekGridLayout } from "./calendarLayout.js";
|
||||
import {
|
||||
createDefaultDraft,
|
||||
getCalendarRepeatMode,
|
||||
getDateSpanDays,
|
||||
getDraftSelection,
|
||||
normalizeDraftAlertOffsets,
|
||||
normalizeDraftDateKey,
|
||||
normalizeDraftMonthlyRepeatBy
|
||||
} from "./calendarDraft.js";
|
||||
import { CalendarModeChoice } from "./CalendarModeChoice.jsx";
|
||||
import { useCalendarController } from "./useCalendarController.js";
|
||||
|
||||
export function CalendarModule({ toolboxId, moduleId, context, editing = false, setEditing, textExchangeOpen }) {
|
||||
const data = context.normalizeCalendarData(context.getModuleData(toolboxId, moduleId, { initialized: false, viewMode: "week", events: [] }));
|
||||
const textContent = context.moduleText?.calendar || {};
|
||||
const [now, setNow] = useState(() => new Date());
|
||||
const [displayedMonth, setDisplayedMonth] = useState(() => getMonthStart(new Date()));
|
||||
const weekStart = useMemo(() => getWeekStart(new Date(), data.weekStartsOn), [data.weekStartsOn]);
|
||||
const monthStart = useMemo(() => getMonthStart(displayedMonth), [displayedMonth]);
|
||||
const monthEnd = useMemo(() => getMonthEnd(monthStart), [monthStart]);
|
||||
const monthGridDays = useMemo(() => getMonthGridDays(monthStart), [monthStart]);
|
||||
const [selection, setSelection] = useState(null);
|
||||
const [monthSelection, setMonthSelection] = useState(null);
|
||||
const [draft, setDraft] = useState(() => createDefaultDraft());
|
||||
const [activeEventId, setActiveEventId] = useState("");
|
||||
const [activeOccurrenceDate, setActiveOccurrenceDate] = useState("");
|
||||
const [copiedEvent, setCopiedEvent] = useState(null);
|
||||
const [hideEmptyHours, setHideEmptyHours] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const previousEditingRef = useRef(editing);
|
||||
const days = useMemo(() => getWeekDays(weekStart), [weekStart]);
|
||||
const weekEnd = useMemo(() => addDays(weekStart, 7), [weekStart]);
|
||||
const rangeStart = data.viewMode === "month" ? monthStart : weekStart;
|
||||
const rangeEnd = data.viewMode === "month" ? monthEnd : weekEnd;
|
||||
const occurrences = useMemo(() => getCalendarOccurrences(data.events, rangeStart, rangeEnd), [data.events, rangeEnd, rangeStart]);
|
||||
const draftSelection = data.viewMode === "week" ? getDraftSelection(draft) : null;
|
||||
const nowMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
const nowDayIndex = getLocalDayIndex(now);
|
||||
const formOpen = editing || Boolean(activeEventId);
|
||||
const weekGridLayout = useMemo(() => {
|
||||
return getWeekGridLayout(occurrences, data.viewMode === "week" && hideEmptyHours && !formOpen);
|
||||
}, [data.viewMode, formOpen, hideEmptyHours, occurrences]);
|
||||
|
||||
const save = useCallback((nextData) => {
|
||||
context.setModuleData(toolboxId, moduleId, nextData, "calendar");
|
||||
}, [context, moduleId, toolboxId]);
|
||||
|
||||
function initializeWeek() {
|
||||
save({ ...data, initialized: true, viewMode: "week", weekStartsOn: 1, events: [] });
|
||||
}
|
||||
|
||||
function initializeMonth() {
|
||||
save({ ...data, initialized: true, viewMode: "month", weekStartsOn: 1, events: [] });
|
||||
}
|
||||
|
||||
function updateDraft(values) {
|
||||
setDraft((current) => ({ ...current, ...values }));
|
||||
setError("");
|
||||
}
|
||||
|
||||
function updateAlertMode(alertMode) {
|
||||
setDraft((current) => {
|
||||
const currentOffsets = normalizeDraftAlertOffsets(current.alertOffsetMinutes);
|
||||
return {
|
||||
...current,
|
||||
alertMode,
|
||||
alertOffsetMinutes: alertMode === "off" ? [] : currentOffsets.length ? currentOffsets : [10]
|
||||
};
|
||||
});
|
||||
setError("");
|
||||
}
|
||||
|
||||
function toggleAlertOffset(offset) {
|
||||
setDraft((current) => {
|
||||
const currentOffsets = normalizeDraftAlertOffsets(current.alertOffsetMinutes);
|
||||
const nextOffsets = currentOffsets.includes(offset)
|
||||
? currentOffsets.filter((item) => item !== offset)
|
||||
: [...currentOffsets, offset].sort((a, b) => b - a);
|
||||
return {
|
||||
...current,
|
||||
alertMode: nextOffsets.length ? current.alertMode === "off" ? "visible" : current.alertMode : "off",
|
||||
alertOffsetMinutes: nextOffsets
|
||||
};
|
||||
});
|
||||
setError("");
|
||||
}
|
||||
|
||||
const resetDraft = useCallback(() => {
|
||||
setDraft(createDefaultDraft());
|
||||
setSelection(null);
|
||||
setMonthSelection(null);
|
||||
setActiveEventId("");
|
||||
setActiveOccurrenceDate("");
|
||||
setError("");
|
||||
}, []);
|
||||
|
||||
function closeForm() {
|
||||
resetDraft();
|
||||
setEditing?.(false);
|
||||
}
|
||||
|
||||
function applySelection(dayIndex, startSlot, endSlot = startSlot) {
|
||||
const orderedStart = Math.min(startSlot, endSlot);
|
||||
const orderedEnd = Math.max(startSlot, endSlot);
|
||||
updateDraft({
|
||||
id: "",
|
||||
dayIndex,
|
||||
startTime: minutesToTimeValue(orderedStart * WEEK_SLOT_MINUTES),
|
||||
endTime: minutesToTimeValue(Math.min(23 * 60 + 59, (Math.min(WEEK_SLOT_COUNT, orderedEnd + 1)) * WEEK_SLOT_MINUTES))
|
||||
});
|
||||
}
|
||||
|
||||
function applyMonthSelection(startDate, endDate = startDate) {
|
||||
const orderedStart = startDate <= endDate ? startDate : endDate;
|
||||
const orderedEnd = startDate <= endDate ? endDate : startDate;
|
||||
const start = parseLocalDateKey(orderedStart);
|
||||
setActiveEventId("");
|
||||
setActiveOccurrenceDate("");
|
||||
updateDraft({
|
||||
id: "",
|
||||
startDate: orderedStart,
|
||||
endDate: orderedEnd,
|
||||
dayIndex: start ? getLocalDayIndex(start) : 0
|
||||
});
|
||||
}
|
||||
|
||||
function pasteEventAtTarget(target) {
|
||||
if (!copiedEvent) return;
|
||||
const startMinutes = data.viewMode === "month" ? 0 : target.slotIndex >= 0 ? target.slotIndex * WEEK_SLOT_MINUTES : timeValueToMinutes(draft.startTime);
|
||||
const durationMinutes = copiedEvent.endMinutes && copiedEvent.endMinutes > copiedEvent.startMinutes ? copiedEvent.endMinutes - copiedEvent.startMinutes : 0;
|
||||
const nextEvent = {
|
||||
...copiedEvent,
|
||||
id: context.uid("calendar"),
|
||||
startMinutes,
|
||||
repeatable: copiedEvent.repeatable !== false,
|
||||
repeat: copiedEvent.repeatable === false ? "none" : getCalendarRepeatMode(data.viewMode)
|
||||
};
|
||||
if (data.viewMode === "month") {
|
||||
const copiedDaySpan = getDateSpanDays(copiedEvent.startDate, copiedEvent.endDate || copiedEvent.startDate);
|
||||
nextEvent.startDate = target.startDate;
|
||||
nextEvent.endDate = formatLocalDateKey(addDays(parseLocalDateKey(target.startDate), copiedDaySpan));
|
||||
nextEvent.dayIndex = getLocalDayIndex(parseLocalDateKey(target.startDate));
|
||||
nextEvent.monthlyRepeatBy = target.monthlyRepeatBy || copiedEvent.monthlyRepeatBy || "monthDay";
|
||||
nextEvent.repeat = copiedEvent.repeat || "monthly";
|
||||
nextEvent.excludedOccurrences = [];
|
||||
delete nextEvent.monthDay;
|
||||
} else {
|
||||
nextEvent.dayIndex = target.dayIndex;
|
||||
delete nextEvent.monthDay;
|
||||
delete nextEvent.monthlyRepeatBy;
|
||||
}
|
||||
delete nextEvent.endMinutes;
|
||||
if (data.viewMode === "week" && durationMinutes > 0) nextEvent.endMinutes = Math.min(23 * 60 + 59, startMinutes + durationMinutes);
|
||||
save({ ...data, initialized: true, events: [...data.events, nextEvent] });
|
||||
context.notify?.(textContent.pastedTitle || "Événement collé.");
|
||||
}
|
||||
|
||||
function pasteCopiedEventAtDraftSelection() {
|
||||
if (!copiedEvent) return;
|
||||
if (data.viewMode === "month") {
|
||||
if (!parseLocalDateKey(draft.startDate)) return;
|
||||
pasteEventAtTarget({
|
||||
startDate: draft.startDate,
|
||||
monthlyRepeatBy: draft.monthlyRepeatBy || "monthDay",
|
||||
slotIndex: -1
|
||||
});
|
||||
return;
|
||||
}
|
||||
const targetSelection = getDraftSelection(draft);
|
||||
if (targetSelection) pasteEventAtTarget({ dayIndex: targetSelection.dayIndex, slotIndex: targetSelection.startSlot });
|
||||
}
|
||||
|
||||
function handleSlotPointerDown(dayIndex, slotIndex) {
|
||||
setActiveEventId("");
|
||||
setActiveOccurrenceDate("");
|
||||
setSelection({ dayIndex, startSlot: slotIndex, endSlot: slotIndex });
|
||||
applySelection(dayIndex, slotIndex);
|
||||
}
|
||||
|
||||
function handleSlotPointerEnter(event, dayIndex, slotIndex) {
|
||||
if (event.buttons !== 1) return;
|
||||
setSelection((current) => {
|
||||
if (!current || current.dayIndex !== dayIndex) return current;
|
||||
applySelection(dayIndex, current.startSlot, slotIndex);
|
||||
return { ...current, endSlot: slotIndex };
|
||||
});
|
||||
}
|
||||
|
||||
function handleMonthPointerDown(dateKey) {
|
||||
setActiveEventId("");
|
||||
setActiveOccurrenceDate("");
|
||||
setMonthSelection({ startDate: dateKey, endDate: dateKey });
|
||||
applyMonthSelection(dateKey);
|
||||
}
|
||||
|
||||
function handleMonthPointerEnter(event, dateKey) {
|
||||
if (event.buttons !== 1) return;
|
||||
setMonthSelection((current) => {
|
||||
if (!current) return current;
|
||||
applyMonthSelection(current.startDate, dateKey);
|
||||
return { ...current, endDate: dateKey };
|
||||
});
|
||||
}
|
||||
|
||||
function editEvent(event, occurrence = null) {
|
||||
const occurrenceDate = occurrence ? formatLocalDateKey(new Date(occurrence.startMs)) : "";
|
||||
if (occurrenceDate) setDisplayedMonth(getMonthStart(parseLocalDateKey(occurrenceDate)));
|
||||
setDraft({
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
dayIndex: event.dayIndex,
|
||||
startDate: occurrenceDate || event.startDate || formatLocalDateKey(now),
|
||||
endDate: occurrenceDate && event.startDate ? formatLocalDateKey(addDays(parseLocalDateKey(occurrenceDate), getDateSpanDays(event.startDate, event.endDate || event.startDate))) : event.endDate || event.startDate || formatLocalDateKey(now),
|
||||
monthlyRepeatBy: event.monthlyRepeatBy || "monthDay",
|
||||
repeat: event.repeat || "none",
|
||||
startTime: minutesToTimeValue(event.startMinutes),
|
||||
endTime: event.endMinutes ? minutesToTimeValue(event.endMinutes) : "",
|
||||
repeatable: event.repeatable !== false,
|
||||
alertMode: event.alertMode || "off",
|
||||
alertOffsetMinutes: event.alertMode === "off" ? [] : normalizeDraftAlertOffsets(event.alertOffsetMinutes),
|
||||
color: event.color || "violet"
|
||||
});
|
||||
setActiveEventId(event.id);
|
||||
setActiveOccurrenceDate(occurrenceDate);
|
||||
setError("");
|
||||
}
|
||||
|
||||
function deleteEvent(eventId) {
|
||||
save({ ...data, initialized: true, events: data.events.filter((event) => event.id !== eventId) });
|
||||
if (draft.id === eventId || activeEventId === eventId) closeForm();
|
||||
}
|
||||
|
||||
function deleteOccurrence(eventId) {
|
||||
const event = data.events.find((item) => item.id === eventId);
|
||||
if (!event || !activeOccurrenceDate || event.repeat === "none") return;
|
||||
const events = data.events.map((item) => item.id === eventId
|
||||
? { ...item, excludedOccurrences: [...new Set([...(item.excludedOccurrences || []), activeOccurrenceDate])].sort() }
|
||||
: item);
|
||||
save({ ...data, initialized: true, events });
|
||||
closeForm();
|
||||
}
|
||||
|
||||
function copyEvent(event) {
|
||||
setCopiedEvent(event);
|
||||
context.notify?.(textContent.copiedTitle || "Événement copié.");
|
||||
}
|
||||
|
||||
function submitEvent(event) {
|
||||
event.preventDefault();
|
||||
const title = draft.title.trim();
|
||||
const dayIndex = Number(draft.dayIndex);
|
||||
const startDate = normalizeDraftDateKey(draft.startDate);
|
||||
const endDate = normalizeDraftDateKey(draft.endDate || draft.startDate);
|
||||
const startMinutes = data.viewMode === "month" ? 0 : timeValueToMinutes(draft.startTime);
|
||||
const endMinutes = data.viewMode === "month" ? -1 : draft.endTime ? timeValueToMinutes(draft.endTime) : -1;
|
||||
const monthlyRepeatBy = normalizeDraftMonthlyRepeatBy(draft.monthlyRepeatBy);
|
||||
const monthRepeat = monthlyRepeatBy === "monthDay" && draft.repeat === "weekly" ? "none" : ["none", "weekly", "monthly"].includes(draft.repeat) ? draft.repeat : "monthly";
|
||||
const validWeekTarget = data.viewMode === "week" && dayIndex >= 0 && dayIndex <= 6;
|
||||
const validMonthTarget = data.viewMode === "month" && startDate && endDate && endDate >= startDate;
|
||||
if (!title || (!validWeekTarget && !validMonthTarget) || startMinutes < 0) {
|
||||
setError(textContent.requiredError || "Titre, date et heure de début requis.");
|
||||
return;
|
||||
}
|
||||
if (draft.endTime && data.viewMode === "week" && endMinutes <= startMinutes) {
|
||||
setError(textContent.invalidRangeError || "La fin doit être après le début.");
|
||||
return;
|
||||
}
|
||||
const alertOffsets = normalizeDraftAlertOffsets(draft.alertOffsetMinutes);
|
||||
const alertMode = alertOffsets.length && CALENDAR_ALERT_MODES.includes(draft.alertMode) ? draft.alertMode : "off";
|
||||
const nextEvent = {
|
||||
id: draft.id || context.uid("calendar"),
|
||||
title,
|
||||
startMinutes,
|
||||
...(data.viewMode === "week" && draft.endTime ? { endMinutes } : {}),
|
||||
repeatable: draft.repeatable !== false,
|
||||
repeat: data.viewMode === "month" ? monthRepeat : draft.repeatable === false ? "none" : getCalendarRepeatMode(data.viewMode),
|
||||
alertMode,
|
||||
alertOffsetMinutes: alertOffsets.length ? alertOffsets : [10],
|
||||
color: CALENDAR_COLORS.includes(draft.color) ? draft.color : "violet"
|
||||
};
|
||||
if (data.viewMode === "month") {
|
||||
nextEvent.monthlyRepeatBy = monthlyRepeatBy;
|
||||
nextEvent.startDate = startDate;
|
||||
nextEvent.endDate = endDate;
|
||||
nextEvent.dayIndex = getLocalDayIndex(parseLocalDateKey(startDate));
|
||||
nextEvent.excludedOccurrences = data.events.find((item) => item.id === draft.id)?.excludedOccurrences || [];
|
||||
} else {
|
||||
nextEvent.dayIndex = dayIndex;
|
||||
}
|
||||
const events = draft.id
|
||||
? data.events.map((item) => item.id === draft.id ? nextEvent : item)
|
||||
: [...data.events, nextEvent];
|
||||
save({ ...data, initialized: true, events });
|
||||
closeForm();
|
||||
}
|
||||
|
||||
function handleKeyDown(event) {
|
||||
if (!event.ctrlKey && !event.metaKey) return;
|
||||
const target = event.target;
|
||||
if (target?.closest?.("input, textarea, select, [contenteditable='true']")) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === "c") {
|
||||
const activeEvent = data.events.find((item) => item.id === activeEventId);
|
||||
if (!activeEvent) return;
|
||||
event.preventDefault();
|
||||
copyEvent(activeEvent);
|
||||
}
|
||||
if (key === "v") {
|
||||
if (!copiedEvent) return;
|
||||
event.preventDefault();
|
||||
pasteCopiedEventAtDraftSelection();
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const intervalId = window.setInterval(() => setNow(new Date()), 60000);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const previousEditing = previousEditingRef.current;
|
||||
previousEditingRef.current = editing;
|
||||
if (!previousEditing && editing) {
|
||||
setDraft(createDefaultDraft());
|
||||
setActiveEventId("");
|
||||
setSelection(null);
|
||||
setError("");
|
||||
}
|
||||
if (previousEditing && !editing) {
|
||||
resetDraft();
|
||||
}
|
||||
}, [editing, resetDraft]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!data.events.length) return undefined;
|
||||
function pruneExpiredOneShotEvents() {
|
||||
const nowMs = Date.now();
|
||||
const nextEvents = data.events.filter((event) => {
|
||||
if (event.repeatable !== false) return true;
|
||||
const occurrence = getCalendarOccurrences([event], rangeStart, rangeEnd)[0];
|
||||
return !occurrence || occurrence.endMs > nowMs;
|
||||
});
|
||||
if (nextEvents.length === data.events.length) return;
|
||||
save({ ...data, initialized: true, events: nextEvents });
|
||||
if (activeEventId && !nextEvents.some((event) => event.id === activeEventId)) resetDraft();
|
||||
}
|
||||
pruneExpiredOneShotEvents();
|
||||
const intervalId = window.setInterval(pruneExpiredOneShotEvents, 30000);
|
||||
return () => window.clearInterval(intervalId);
|
||||
}, [activeEventId, data, rangeEnd, rangeStart, resetDraft, save]);
|
||||
const controller = useCalendarController({ data, context, toolboxId, moduleId, editing, setEditing, textContent });
|
||||
|
||||
if (!data.initialized) {
|
||||
return (
|
||||
<div className="calendar-module">
|
||||
<section className="calendar-mode-choice" aria-label={textContent.init?.title || "Créer un calendrier"}>
|
||||
<div className="calendar-mode-grid">
|
||||
<button type="button" className="calendar-mode-card" onClick={initializeWeek}>
|
||||
<strong>{textContent.init?.weekly || "Hebdomadaire"}</strong>
|
||||
<span>{textContent.init?.weeklyDescription || "Grille jours par créneaux de 30 minutes."}</span>
|
||||
</button>
|
||||
<button type="button" className="calendar-mode-card" onClick={initializeMonth}>
|
||||
<strong>{textContent.init?.monthly || "Mensuel"}</strong>
|
||||
<span>{textContent.init?.monthlyDescription || "Grille du mois courant."}</span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<CalendarModeChoice
|
||||
textContent={textContent}
|
||||
onInitializeWeek={controller.initializeWeek}
|
||||
onInitializeMonth={controller.initializeMonth}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<CalendarContent
|
||||
data={data}
|
||||
context={context}
|
||||
draft={{ ...draft, alertOffsetMinutes: draft.alertMode === "off" ? [] : normalizeDraftAlertOffsets(draft.alertOffsetMinutes) }}
|
||||
error={error}
|
||||
activeOccurrenceDate={activeOccurrenceDate}
|
||||
monthStart={monthStart}
|
||||
monthGridDays={monthGridDays}
|
||||
occurrences={occurrences}
|
||||
textContent={textContent}
|
||||
activeEventId={activeEventId}
|
||||
formOpen={formOpen}
|
||||
draftSelection={draftSelection}
|
||||
selection={selection}
|
||||
monthSelection={monthSelection}
|
||||
days={days}
|
||||
weekGridLayout={weekGridLayout}
|
||||
now={now}
|
||||
nowDayIndex={nowDayIndex}
|
||||
nowMinutes={nowMinutes}
|
||||
hideEmptyHours={hideEmptyHours}
|
||||
textExchangeOpen={textExchangeOpen}
|
||||
onKeyDown={handleKeyDown}
|
||||
onSubmit={submitEvent}
|
||||
onDraftChange={updateDraft}
|
||||
onAlertModeChange={updateAlertMode}
|
||||
onAlertOffsetToggle={toggleAlertOffset}
|
||||
onDeleteEvent={deleteEvent}
|
||||
onDeleteOccurrence={deleteOccurrence}
|
||||
onClose={closeForm}
|
||||
onDisplayedMonthChange={setDisplayedMonth}
|
||||
onImport={(nextData) => save(nextData)}
|
||||
onHideEmptyHoursToggle={() => setHideEmptyHours((current) => !current)}
|
||||
onMonthPointerDown={handleMonthPointerDown}
|
||||
onMonthPointerEnter={handleMonthPointerEnter}
|
||||
onMonthPointerUp={() => setMonthSelection(null)}
|
||||
onSlotPointerDown={handleSlotPointerDown}
|
||||
onSlotPointerEnter={handleSlotPointerEnter}
|
||||
onSlotPointerUp={() => setSelection(null)}
|
||||
onEditEvent={editEvent}
|
||||
/>
|
||||
);
|
||||
return <CalendarContent {...controller.contentProps} textExchangeOpen={textExchangeOpen} />;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue