Files
leocrm/frontend/src/pages/Calendar.tsx
T

703 lines
23 KiB
TypeScript
Raw Normal View History

/**
* Calendar page - 3-column explorer-style layout.
*
* Left: CalendarTree (calendar selection + visibility)
* Middle: Calendar view (Day / Week / Month / Range) with date controls
* Right: CalendarDetail (selected entry details + subtasks)
*
* Uses PluginToolbar for actions, ResizablePanel for desktop layout.
*/
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ResizablePanel } from '@/components/ui/ResizablePanel';
import { ConfirmDialog } from '@/components/ui/ConfirmDialog';
import { useToast } from '@/components/ui/Toast';
import { CalendarTree } from '@/components/calendar/CalendarTree';
import { MonthView } from '@/components/calendar/MonthView';
import { WeekView } from '@/components/calendar/WeekView';
import { DayView } from '@/components/calendar/DayView';
import { RangeView } from '@/components/calendar/RangeView';
import { CalendarDetail } from '@/components/calendar/CalendarDetail';
import { AppointmentModal } from '@/components/calendar/AppointmentModal';
import { IcsControls } from '@/components/calendar/IcsControls';
import { SharingSettings } from '@/components/calendar/SharingSettings';
import { useCalendarStore, type CalendarViewMode } from '@/stores/calendarStore';
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
import { ChevronLeft, ChevronRight, Info, Plus } from 'lucide-react';
import {
fetchCalendars,
createCalendar,
listEntries,
updateEntry,
deleteEntry,
type Calendar,
type CalendarEntry,
} from '@/api/calendar';
export function CalendarPage() {
const { t } = useTranslation();
const toast = useToast();
const {
viewMode,
setViewMode,
visibleMonth,
visibleWeek,
visibleDay,
rangeStart,
rangeEnd,
setRangeStart,
setRangeEnd,
goToNextMonth,
goToPrevMonth,
goToNextWeek,
goToPrevWeek,
goToNextDay,
goToPrevDay,
goToToday,
activeCalendarId,
setActiveCalendarId,
calendars,
setCalendars,
selectedEntry,
setSelectedEntry,
visibleCalendarIds,
toggleCalendarVisibility,
} = useCalendarStore();
const [entries, setEntries] = useState<CalendarEntry[]>([]);
const [loading, setLoading] = useState(false);
const [loadingCalendars, setLoadingCalendars] = useState(true);
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 [showSharing, setShowSharing] = useState(false);
const [showIcs, setShowIcs] = useState(false);
const [deleteTarget, setDeleteTarget] = useState<CalendarEntry | null>(null);
const [showDetails, setShowDetails] = useState(true);
// Mobile view state
const [activeView, setActiveView] = useState<'tree' | 'calendar' | 'details'>('tree');
// Load calendars once
useEffect(() => {
let cancelled = false;
setLoadingCalendars(true);
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);
})
.finally(() => {
if (!cancelled) setLoadingCalendars(false);
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Compute date range based on viewMode
const range = useMemo(() => {
if (viewMode === 'month') {
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 };
}
if (viewMode === 'week') {
const start = new Date(visibleWeek);
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(start.getDate() + 7);
return { start, end };
}
if (viewMode === 'day') {
const start = new Date(visibleDay);
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(start.getDate() + 1);
return { start, end };
}
// range
const start = rangeStart ? new Date(rangeStart) : new Date();
start.setHours(0, 0, 0, 0);
const end = rangeEnd ? new Date(rangeEnd) : new Date(start);
end.setHours(23, 59, 59, 999);
end.setDate(end.getDate() + 1);
return { start, end };
}, [viewMode, visibleMonth, visibleWeek, visibleDay, rangeStart, rangeEnd]);
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]);
// Filter entries by visible calendars
const filteredEntries = useMemo(() => {
if (visibleCalendarIds.size === 0) return entries;
return entries.filter((e) => visibleCalendarIds.has(e.calendar_id));
}, [entries, visibleCalendarIds]);
const activeCalendar = calendars.find((c) => c.id === activeCalendarId) ?? null;
const selectedCalendar = selectedEntry
? calendars.find((c) => c.id === selectedEntry.calendar_id) ?? null
: null;
// ??? Handlers ???????????????????????????????????????????????????????????
const handleCreateAt = useCallback((date: Date) => {
setModalEntry(null);
setPrefillDate(date);
setModalOpen(true);
}, []);
const handleEditEntry = useCallback(
(entry: CalendarEntry) => {
setSelectedEntry(entry);
if (showDetails) {
setActiveView('details');
}
},
[setSelectedEntry, showDetails],
);
const handleOpenEditModal = useCallback(() => {
if (!selectedEntry) return;
setModalEntry(selectedEntry);
setPrefillDate(null);
setModalOpen(true);
}, [selectedEntry]);
const handleMoveEntry = useCallback(
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)));
if (selectedEntry?.id === updated.id) {
setSelectedEntry(updated);
}
} catch (e) {
setError((e as Error).message ?? t('calendar.errorSave'));
await loadEntries();
}
},
[t, loadEntries, selectedEntry, setSelectedEntry],
);
const handleSaved = useCallback(
(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];
});
if (selectedEntry?.id === saved.id) {
setSelectedEntry(saved);
}
},
[selectedEntry, setSelectedEntry],
);
const handleDeleted = useCallback(
(entryId: string) => {
setEntries((cur) => cur.filter((e) => e.id !== entryId));
if (selectedEntry?.id === entryId) {
setSelectedEntry(null);
}
},
[selectedEntry, setSelectedEntry],
);
const handleConfirmDelete = useCallback(async () => {
if (!deleteTarget) return;
try {
await deleteEntry(deleteTarget.id);
toast.success(t('calendar.detail.delete'));
handleDeleted(deleteTarget.id);
setDeleteTarget(null);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(msg);
}
}, [deleteTarget, handleDeleted, toast, t]);
const handleDeleteFromDetail = useCallback(() => {
if (!selectedEntry) return;
setDeleteTarget(selectedEntry);
}, [selectedEntry]);
const handleEntryChanged = useCallback(
(updated: CalendarEntry) => {
setEntries((cur) => cur.map((e) => (e.id === updated.id ? updated : e)));
if (selectedEntry?.id === updated.id) {
setSelectedEntry(updated);
}
},
[selectedEntry, setSelectedEntry],
);
const handleCreateCalendar = useCallback(async () => {
const name = window.prompt(t('calendar.tree.newCalendar'));
if (!name?.trim()) return;
try {
const created = await createCalendar({ name: name.trim() });
setCalendars([...calendars, created]);
setActiveCalendarId(created.id);
toast.success(t('calendar.tree.newCalendar'));
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
toast.error(msg);
}
}, [t, toast, setCalendars, setActiveCalendarId, calendars]);
const handleSelectCalendar = useCallback(
(id: string) => {
setActiveCalendarId(id);
setActiveView('calendar');
},
[setActiveCalendarId],
);
// ??? Toolbar registration ???????????????????????????????????????????????
const registerItems = usePluginToolbarStore((s) => s.registerItems);
const unregisterPlugin = usePluginToolbarStore((s) => s.unregisterPlugin);
useEffect(() => {
const items = [
// Navigation group
{
id: 'nav-prev',
plugin: 'calendar',
label: t('calendar.navigation.prev'),
group: 'navigation',
onClick:
viewMode === 'month'
? goToPrevMonth
: viewMode === 'week' ? goToPrevWeek
: viewMode === 'day' ? goToPrevDay
: () => {},
icon: (
<ChevronLeft className="w-3.5 h-3.5" strokeWidth={2} />
),
},
{
id: 'nav-today',
plugin: 'calendar',
label: t('calendar.navigation.today'),
group: 'navigation',
onClick: goToToday,
},
{
id: 'nav-next',
plugin: 'calendar',
label: t('calendar.navigation.next'),
group: 'navigation',
onClick:
viewMode === 'month'
? goToNextMonth
: viewMode === 'week'
? goToNextWeek
: viewMode === 'day'
? goToNextDay
: () => {},
icon: (
<ChevronRight className="w-3.5 h-3.5" strokeWidth={2} />
),
},
// View mode group
{
id: 'view-mode',
plugin: 'calendar',
label: t('calendar.viewMonth'),
group: 'view-mode',
type: 'select' as const,
selectValue: viewMode,
selectOptions: [
{ value: 'day', label: t('calendar.viewDay') },
{ value: 'week', label: t('calendar.viewWeek') },
{ value: 'month', label: t('calendar.viewMonth') },
{ value: 'range', label: t('calendar.viewRange') },
],
onSelect: (value: string) => setViewMode(value as CalendarViewMode),
onClick: () => {},
},
// Actions group
{
id: 'new-appointment',
plugin: 'calendar',
label: t('calendar.newAppointment'),
group: 'actions',
onClick: () => {
setModalEntry(null);
setPrefillDate(null);
setModalOpen(true);
},
icon: (
<Plus className="w-3.5 h-3.5" strokeWidth={2} />
),
},
{
id: 'ics-import',
plugin: 'calendar',
label: t('calendar.ics.import'),
group: 'actions',
onClick: () => setShowIcs((v) => !v),
},
{
id: 'ics-export',
plugin: 'calendar',
label: t('calendar.ics.export'),
group: 'actions',
onClick: () => setShowIcs((v) => !v),
},
// Details toggle group
{
id: 'toggle-details',
plugin: 'calendar',
label: t('calendar.detail.title'),
group: 'details',
active: showDetails,
onClick: () => setShowDetails((v) => !v),
icon: (
<Info className="w-3.5 h-3.5" strokeWidth={2} />
),
},
];
registerItems('calendar', items);
return () => unregisterPlugin('calendar');
}, [
viewMode,
showDetails,
goToPrevMonth,
goToNextMonth,
goToPrevWeek,
goToNextWeek,
goToPrevDay,
goToNextDay,
goToToday,
setViewMode,
registerItems,
unregisterPlugin,
t,
]);
// ??? Render ?????????????????????????????????????????????????????????????
const renderCalendarView = () => {
if (calendars.length === 0 && !loading) {
return (
<div className="flex items-center justify-center h-full p-6" data-testid="calendar-empty">
<div className="text-center">
<p className="text-sm font-medium text-secondary-600">{t('calendar.noCalendars')}</p>
<p className="mt-1 text-xs text-secondary-400">{t('calendar.noEntries')}</p>
</div>
</div>
);
}
switch (viewMode) {
case 'day':
return (
<DayView
visibleDay={visibleDay}
entries={filteredEntries}
loading={loading}
onCreateAt={handleCreateAt}
onEditEntry={handleEditEntry}
onMoveEntry={handleMoveEntry}
/>
);
case 'week':
return (
<WeekView
visibleWeek={visibleWeek}
entries={filteredEntries}
loading={loading}
onCreateAt={handleCreateAt}
onEditEntry={handleEditEntry}
onMoveEntry={handleMoveEntry}
/>
);
case 'range':
return (
<RangeView
rangeStart={rangeStart ?? new Date()}
rangeEnd={rangeEnd ?? new Date()}
entries={filteredEntries}
loading={loading}
onEditEntry={handleEditEntry}
/>
);
default:
return (
<MonthView
visibleMonth={visibleMonth}
entries={filteredEntries}
loading={loading}
onCreateAt={handleCreateAt}
onEditEntry={handleEditEntry}
onMoveEntry={handleMoveEntry}
/>
);
}
};
const renderRangeControls = () => {
if (viewMode !== 'range') return null;
return (
<div className="flex items-center gap-2 px-4 py-2 border-b border-secondary-200 bg-secondary-50">
<label className="text-xs text-secondary-600">{t('calendar.range.start')}</label>
<input
type="date"
value={rangeStart ? rangeStart.toISOString().slice(0, 10) : ''}
onChange={(e) => setRangeStart(e.target.value ? new Date(e.target.value) : null)}
className="text-sm rounded-md border border-secondary-300 px-2 py-1"
data-testid="calendar-range-start"
/>
<label className="text-xs text-secondary-600">{t('calendar.range.end')}</label>
<input
type="date"
value={rangeEnd ? rangeEnd.toISOString().slice(0, 10) : ''}
onChange={(e) => setRangeEnd(e.target.value ? new Date(e.target.value) : null)}
className="text-sm rounded-md border border-secondary-300 px-2 py-1"
data-testid="calendar-range-end"
/>
</div>
);
};
return (
<div className="flex flex-col h-full" data-testid="calendar-page">
{error && (
<div className="mb-2 p-3 bg-danger-50 border border-danger-200 rounded-md" role="alert" data-testid="calendar-error">
<p className="text-sm text-danger-700">{error}</p>
</div>
)}
{/* ICS controls panel */}
{showIcs && (
<div className="mb-2 p-3 bg-secondary-50 border-b border-secondary-200" data-testid="calendar-ics-panel">
<IcsControls calendar={activeCalendar} onImported={() => void loadEntries()} />
</div>
)}
{/* Sharing settings panel */}
{showSharing && activeCalendar && (
<div className="mb-2 p-3 bg-secondary-50 border-b border-secondary-200" data-testid="calendar-sharing-panel">
<SharingSettings calendar={activeCalendar} />
</div>
)}
{/* Desktop: three-pane layout with resizable panels */}
<div className="hidden md:flex flex-1 overflow-hidden">
{/* Left: CalendarTree */}
<ResizablePanel
initialWidth={240}
minWidth={180}
maxWidth={400}
className="border-r border-secondary-200 bg-white"
data-testid="calendar-tree-pane"
>
<CalendarTree
calendars={calendars}
activeCalendarId={activeCalendarId}
visibleCalendarIds={visibleCalendarIds}
onSelect={handleSelectCalendar}
onToggleVisibility={toggleCalendarVisibility}
onCreate={handleCreateCalendar}
loading={loadingCalendars}
/>
</ResizablePanel>
{/* Middle: Calendar view */}
<ResizablePanel
resizable={false}
className="bg-white"
data-testid="calendar-view-pane"
>
<div className="flex flex-col h-full">
{renderRangeControls()}
<div className="flex-1 overflow-hidden">{renderCalendarView()}</div>
</div>
</ResizablePanel>
{/* Right: CalendarDetail (only when toggled on) */}
{showDetails && (
<ResizablePanel
initialWidth={300}
minWidth={200}
maxWidth={450}
handleSide="left"
className="border-l border-secondary-200 bg-white"
data-testid="calendar-detail-pane"
>
<CalendarDetail
entry={selectedEntry}
calendar={selectedCalendar}
onEdit={handleOpenEditModal}
onDelete={handleDeleteFromDetail}
onClose={() => {
setSelectedEntry(null);
setShowDetails(false);
}}
onEntryChanged={handleEntryChanged}
/>
</ResizablePanel>
)}
</div>
{/* Mobile: single-pane view switching */}
<div className="flex md:hidden flex-1 overflow-hidden flex-col">
{/* View 1: CalendarTree */}
{activeView === 'tree' && (
<div className="flex-1 overflow-y-auto bg-white" data-testid="mobile-calendar-tree-pane">
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-200 sticky top-0 bg-white z-10">
<h2 className="text-sm font-semibold text-secondary-900">{t('calendar.tree.title')}</h2>
<button
onClick={() => setActiveView('calendar')}
className="ml-auto inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
aria-label={t('calendar.title')}
data-testid="mobile-go-to-calendar"
>
<span className="text-sm font-medium">{t('calendar.title')}</span>
<ChevronRight className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
</button>
</div>
<CalendarTree
calendars={calendars}
activeCalendarId={activeCalendarId}
visibleCalendarIds={visibleCalendarIds}
onSelect={handleSelectCalendar}
onToggleVisibility={toggleCalendarVisibility}
onCreate={handleCreateCalendar}
loading={loadingCalendars}
/>
</div>
)}
{/* View 2: Calendar */}
{activeView === 'calendar' && (
<div className="flex-1 overflow-hidden bg-white flex flex-col" data-testid="mobile-calendar-view-pane">
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-200 sticky top-0 bg-white z-10">
<button
onClick={() => setActiveView('tree')}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
aria-label={t('common.back')}
data-testid="mobile-back-to-tree"
>
<ChevronLeft className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
<span className="text-sm font-medium">{t('calendar.tree.title')}</span>
</button>
{showDetails && (
<button
onClick={() => setActiveView('details')}
className="ml-auto inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
aria-label={t('calendar.detail.title')}
data-testid="mobile-go-to-details"
>
<span className="text-sm font-medium">{t('calendar.detail.title')}</span>
<ChevronRight className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
</button>
)}
</div>
{renderRangeControls()}
<div className="flex-1 overflow-hidden">{renderCalendarView()}</div>
</div>
)}
{/* View 3: CalendarDetail */}
{activeView === 'details' && showDetails && (
<div className="flex-1 overflow-y-auto bg-white" data-testid="mobile-calendar-detail-pane">
<div className="flex items-center gap-2 px-3 py-2 border-b border-secondary-200 sticky top-0 bg-white z-10">
<button
onClick={() => setActiveView('calendar')}
className="inline-flex items-center gap-1 px-2 py-1 rounded text-sm text-secondary-700 hover:bg-secondary-100 min-h-touch"
aria-label={t('common.back')}
data-testid="mobile-back-to-calendar"
>
<ChevronLeft className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
<span className="text-sm font-medium">{t('calendar.title')}</span>
</button>
</div>
<CalendarDetail
entry={selectedEntry}
calendar={selectedCalendar}
onEdit={handleOpenEditModal}
onDelete={handleDeleteFromDetail}
onClose={() => {
setSelectedEntry(null);
setActiveView('calendar');
}}
onEntryChanged={handleEntryChanged}
/>
</div>
)}
</div>
{/* Delete confirmation */}
<ConfirmDialog
open={!!deleteTarget}
title={t('calendar.detail.delete')}
message={t('calendar.confirmDelete')}
confirmLabel={t('common.delete')}
variant="danger"
onConfirm={handleConfirmDelete}
onCancel={() => setDeleteTarget(null)}
/>
{/* Appointment modal */}
<AppointmentModal
open={modalOpen}
onClose={() => setModalOpen(false)}
entry={modalEntry}
prefillDate={prefillDate}
calendars={calendars}
defaultCalendarId={activeCalendarId}
onSaved={handleSaved}
onDeleted={handleDeleted}
/>
</div>
);
}
export default CalendarPage;