267 lines
8.2 KiB
TypeScript
267 lines
8.2 KiB
TypeScript
|
|
/**
|
|||
|
|
* Calendar page — month view with toolbar, ICS controls, sharing.
|
|||
|
|
*
|
|||
|
|
* Owns the data fetching (entries + calendars + resources) for the visible
|
|||
|
|
* month and dispatches user actions to the appropriate API client call.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|||
|
|
import { useTranslation } from 'react-i18next';
|
|||
|
|
import { Button } from '@/components/ui/Button';
|
|||
|
|
import { Select } from '@/components/ui/Select';
|
|||
|
|
import { EmptyState } from '@/components/ui/EmptyState';
|
|||
|
|
import { MonthView } from '@/components/calendar/MonthView';
|
|||
|
|
import { AppointmentModal } from '@/components/calendar/AppointmentModal';
|
|||
|
|
import { IcsControls } from '@/components/calendar/IcsControls';
|
|||
|
|
import { SharingSettings } from '@/components/calendar/SharingSettings';
|
|||
|
|
import { TaskDetailPanel } from '@/components/calendar/TaskDetailPanel';
|
|||
|
|
import { useCalendarStore } from '@/stores/calendarStore';
|
|||
|
|
import {
|
|||
|
|
fetchCalendars,
|
|||
|
|
listEntries,
|
|||
|
|
updateEntry,
|
|||
|
|
type Calendar,
|
|||
|
|
type CalendarEntry,
|
|||
|
|
} from '@/api/calendar';
|
|||
|
|
|
|||
|
|
export function CalendarPage() {
|
|||
|
|
const { t } = useTranslation();
|
|||
|
|
const {
|
|||
|
|
visibleMonth,
|
|||
|
|
goToNextMonth,
|
|||
|
|
goToPrevMonth,
|
|||
|
|
goToToday,
|
|||
|
|
activeCalendarId,
|
|||
|
|
setActiveCalendarId,
|
|||
|
|
calendars,
|
|||
|
|
setCalendars,
|
|||
|
|
} = useCalendarStore();
|
|||
|
|
|
|||
|
|
const [entries, setEntries] = useState<CalendarEntry[]>([]);
|
|||
|
|
const [loading, setLoading] = useState(false);
|
|||
|
|
const [error, setError] = useState<string | null>(null);
|
|||
|
|
|
|||
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|||
|
|
const [modalEntry, setModalEntry] = useState<CalendarEntry | null>(null);
|
|||
|
|
const [prefillDate, setPrefillDate] = useState<Date | null>(null);
|
|||
|
|
|
|||
|
|
const [selectedTask, setSelectedTask] = useState<CalendarEntry | null>(null);
|
|||
|
|
|
|||
|
|
// Load calendars once
|
|||
|
|
useEffect(() => {
|
|||
|
|
let cancelled = false;
|
|||
|
|
fetchCalendars()
|
|||
|
|
.then((list) => {
|
|||
|
|
if (cancelled) return;
|
|||
|
|
setCalendars(list);
|
|||
|
|
if (!activeCalendarId && list.length > 0) {
|
|||
|
|
setActiveCalendarId(list[0].id);
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
.catch((e: Error) => {
|
|||
|
|
if (!cancelled) setError(e.message);
|
|||
|
|
});
|
|||
|
|
return () => {
|
|||
|
|
cancelled = true;
|
|||
|
|
};
|
|||
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
// Range covering the visible grid (always 6 weeks = 42 days)
|
|||
|
|
const range = useMemo(() => {
|
|||
|
|
const first = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth(), 1);
|
|||
|
|
const dayOfWeek = (first.getDay() + 6) % 7;
|
|||
|
|
const start = new Date(first);
|
|||
|
|
start.setDate(first.getDate() - dayOfWeek);
|
|||
|
|
start.setHours(0, 0, 0, 0);
|
|||
|
|
const end = new Date(start);
|
|||
|
|
end.setDate(start.getDate() + 42);
|
|||
|
|
return { start, end };
|
|||
|
|
}, [visibleMonth]);
|
|||
|
|
|
|||
|
|
const loadEntries = useCallback(async () => {
|
|||
|
|
setLoading(true);
|
|||
|
|
setError(null);
|
|||
|
|
try {
|
|||
|
|
const list = await listEntries({
|
|||
|
|
start: range.start.toISOString(),
|
|||
|
|
end: range.end.toISOString(),
|
|||
|
|
});
|
|||
|
|
setEntries(list);
|
|||
|
|
} catch (e) {
|
|||
|
|
setError((e as Error).message ?? t('calendar.errorLoad'));
|
|||
|
|
} finally {
|
|||
|
|
setLoading(false);
|
|||
|
|
}
|
|||
|
|
}, [range.start, range.end, t]);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
void loadEntries();
|
|||
|
|
}, [loadEntries]);
|
|||
|
|
|
|||
|
|
const handleCreateAt = (date: Date) => {
|
|||
|
|
setModalEntry(null);
|
|||
|
|
setPrefillDate(date);
|
|||
|
|
setModalOpen(true);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleEditEntry = (entry: CalendarEntry) => {
|
|||
|
|
if (entry.entry_type === 'task') {
|
|||
|
|
setSelectedTask(entry);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
setModalEntry(entry);
|
|||
|
|
setPrefillDate(null);
|
|||
|
|
setModalOpen(true);
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleMoveEntry = async (entry: CalendarEntry, newStart: Date) => {
|
|||
|
|
try {
|
|||
|
|
const oldEnd = entry.end_at ? new Date(entry.end_at) : null;
|
|||
|
|
const oldStart = entry.start_at ? new Date(entry.start_at) : null;
|
|||
|
|
const durationMs = oldStart && oldEnd ? oldEnd.getTime() - oldStart.getTime() : 60 * 60 * 1000;
|
|||
|
|
const newEnd = new Date(newStart.getTime() + durationMs);
|
|||
|
|
const updated = await updateEntry(entry.id, {
|
|||
|
|
start_at: newStart.toISOString(),
|
|||
|
|
end_at: newEnd.toISOString(),
|
|||
|
|
});
|
|||
|
|
setEntries((cur) => cur.map((e) => (e.id === updated.id ? updated : e)));
|
|||
|
|
} catch (e) {
|
|||
|
|
setError((e as Error).message ?? t('calendar.errorSave'));
|
|||
|
|
await loadEntries();
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleSaved = (saved: CalendarEntry) => {
|
|||
|
|
setEntries((cur) => {
|
|||
|
|
const exists = cur.some((e) => e.id === saved.id);
|
|||
|
|
return exists ? cur.map((e) => (e.id === saved.id ? saved : e)) : [...cur, saved];
|
|||
|
|
});
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleDeleted = (entryId: string) => {
|
|||
|
|
setEntries((cur) => cur.filter((e) => e.id !== entryId));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const handleTaskChanged = (updated: CalendarEntry) => {
|
|||
|
|
setSelectedTask(updated);
|
|||
|
|
setEntries((cur) => cur.map((e) => (e.id === updated.id ? updated : e)));
|
|||
|
|
};
|
|||
|
|
|
|||
|
|
const activeCalendar = calendars.find((c) => c.id === activeCalendarId) ?? null;
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div className="p-6 max-w-7xl mx-auto" data-testid="calendar-page">
|
|||
|
|
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
|
|||
|
|
<h1 className="text-2xl font-bold text-secondary-900">{t('calendar.title')}</h1>
|
|||
|
|
<div className="flex items-center gap-2">
|
|||
|
|
<Button
|
|||
|
|
variant="ghost"
|
|||
|
|
size="sm"
|
|||
|
|
onClick={goToPrevMonth}
|
|||
|
|
aria-label={t('calendar.previousMonth')}
|
|||
|
|
data-testid="calendar-prev"
|
|||
|
|
>
|
|||
|
|
‹
|
|||
|
|
</Button>
|
|||
|
|
<Button
|
|||
|
|
variant="secondary"
|
|||
|
|
size="sm"
|
|||
|
|
onClick={goToToday}
|
|||
|
|
data-testid="calendar-today"
|
|||
|
|
>
|
|||
|
|
{t('calendar.today')}
|
|||
|
|
</Button>
|
|||
|
|
<Button
|
|||
|
|
variant="ghost"
|
|||
|
|
size="sm"
|
|||
|
|
onClick={goToNextMonth}
|
|||
|
|
aria-label={t('calendar.nextMonth')}
|
|||
|
|
data-testid="calendar-next"
|
|||
|
|
>
|
|||
|
|
›
|
|||
|
|
</Button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="grid grid-cols-1 lg:grid-cols-[1fr_320px] gap-4">
|
|||
|
|
<div>
|
|||
|
|
{error && (
|
|||
|
|
<div
|
|||
|
|
className="mb-3 text-sm text-danger-700 bg-danger-50 border border-danger-200 rounded-md p-2"
|
|||
|
|
data-testid="calendar-error"
|
|||
|
|
>
|
|||
|
|
{error}
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
{calendars.length === 0 && !loading ? (
|
|||
|
|
<EmptyState
|
|||
|
|
title={t('calendar.noCalendars')}
|
|||
|
|
description={t('calendar.noEntries')}
|
|||
|
|
data-testid="calendar-empty"
|
|||
|
|
/>
|
|||
|
|
) : (
|
|||
|
|
<MonthView
|
|||
|
|
visibleMonth={visibleMonth}
|
|||
|
|
entries={entries}
|
|||
|
|
loading={loading}
|
|||
|
|
onCreateAt={handleCreateAt}
|
|||
|
|
onEditEntry={handleEditEntry}
|
|||
|
|
onMoveEntry={handleMoveEntry}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<aside className="space-y-4">
|
|||
|
|
<div className="bg-white border border-secondary-200 rounded-lg p-4 space-y-3">
|
|||
|
|
<div>
|
|||
|
|
<label className="block text-xs uppercase text-secondary-500 mb-1">
|
|||
|
|
{t('calendar.appointmentCalendar')}
|
|||
|
|
</label>
|
|||
|
|
<Select
|
|||
|
|
value={activeCalendarId ?? ''}
|
|||
|
|
onChange={(e) => setActiveCalendarId(e.target.value || null)}
|
|||
|
|
options={[
|
|||
|
|
...calendars.map((c: Calendar) => ({ value: c.id, label: c.name })),
|
|||
|
|
]}
|
|||
|
|
data-testid="calendar-select"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
<IcsControls
|
|||
|
|
calendar={activeCalendar}
|
|||
|
|
onImported={() => void loadEntries()}
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{activeCalendar && (
|
|||
|
|
<div className="bg-white border border-secondary-200 rounded-lg p-4">
|
|||
|
|
<SharingSettings calendar={activeCalendar} />
|
|||
|
|
</div>
|
|||
|
|
)}
|
|||
|
|
</aside>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<AppointmentModal
|
|||
|
|
open={modalOpen}
|
|||
|
|
onClose={() => setModalOpen(false)}
|
|||
|
|
entry={modalEntry}
|
|||
|
|
prefillDate={prefillDate}
|
|||
|
|
calendars={calendars}
|
|||
|
|
defaultCalendarId={activeCalendarId}
|
|||
|
|
onSaved={handleSaved}
|
|||
|
|
onDeleted={handleDeleted}
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
{selectedTask && (
|
|||
|
|
<TaskDetailPanel
|
|||
|
|
entry={selectedTask}
|
|||
|
|
onClose={() => setSelectedTask(null)}
|
|||
|
|
onChanged={handleTaskChanged}
|
|||
|
|
/>
|
|||
|
|
)}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export default CalendarPage;
|