Calendar rewrite: 3-column layout with tree, multi-view (day/week/month/range), detail panel
This commit is contained in:
+593
-142
@@ -1,55 +1,91 @@
|
||||
/**
|
||||
* Calendar page — month view with toolbar, ICS controls, sharing.
|
||||
* Calendar page - 3-column explorer-style layout.
|
||||
*
|
||||
* Owns the data fetching (entries + calendars + resources) for the visible
|
||||
* month and dispatches user actions to the appropriate API client call.
|
||||
* 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 { Button } from '@/components/ui/Button';
|
||||
import { Select } from '@/components/ui/Select';
|
||||
import { EmptyState } from '@/components/ui/EmptyState';
|
||||
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 { TaskDetailPanel } from '@/components/calendar/TaskDetailPanel';
|
||||
import { useCalendarStore } from '@/stores/calendarStore';
|
||||
import { useCalendarStore, type CalendarViewMode } from '@/stores/calendarStore';
|
||||
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
|
||||
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 [selectedTask, setSelectedTask] = useState<CalendarEntry | 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;
|
||||
@@ -60,6 +96,9 @@ export function CalendarPage() {
|
||||
})
|
||||
.catch((e: Error) => {
|
||||
if (!cancelled) setError(e.message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoadingCalendars(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
@@ -67,17 +106,40 @@ export function CalendarPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Range covering the visible grid (always 6 weeks = 42 days)
|
||||
// Compute date range based on viewMode
|
||||
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);
|
||||
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 = new Date(start);
|
||||
end.setDate(start.getDate() + 42);
|
||||
const end = rangeEnd ? new Date(rangeEnd) : new Date(start);
|
||||
end.setHours(23, 59, 59, 999);
|
||||
end.setDate(end.getDate() + 1);
|
||||
return { start, end };
|
||||
}, [visibleMonth]);
|
||||
}, [viewMode, visibleMonth, visibleWeek, visibleDay, rangeStart, rangeEnd]);
|
||||
|
||||
const loadEntries = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -99,148 +161,545 @@ export function CalendarPage() {
|
||||
void loadEntries();
|
||||
}, [loadEntries]);
|
||||
|
||||
const handleCreateAt = (date: Date) => {
|
||||
// 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 = (entry: CalendarEntry) => {
|
||||
if (entry.entry_type === 'task') {
|
||||
setSelectedTask(entry);
|
||||
return;
|
||||
}
|
||||
setModalEntry(entry);
|
||||
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 = 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(),
|
||||
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)));
|
||||
} catch (e) {
|
||||
setError((e as Error).message ?? t('calendar.errorSave'));
|
||||
await loadEntries();
|
||||
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: (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
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: (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
// 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: (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
{
|
||||
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: (
|
||||
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
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 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 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>
|
||||
);
|
||||
};
|
||||
|
||||
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 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>
|
||||
|
||||
<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}
|
||||
{/* 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>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
</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()}
|
||||
<CalendarTree
|
||||
calendars={calendars}
|
||||
activeCalendarId={activeCalendarId}
|
||||
visibleCalendarIds={visibleCalendarIds}
|
||||
onSelect={handleSelectCalendar}
|
||||
onToggleVisibility={toggleCalendarVisibility}
|
||||
onCreate={handleCreateCalendar}
|
||||
loading={loadingCalendars}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeCalendar && (
|
||||
<div className="bg-white border border-secondary-200 rounded-lg p-4">
|
||||
<SharingSettings calendar={activeCalendar} />
|
||||
{/* 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"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<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>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
{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"
|
||||
>
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<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)}
|
||||
@@ -251,14 +710,6 @@ export function CalendarPage() {
|
||||
onSaved={handleSaved}
|
||||
onDeleted={handleDeleted}
|
||||
/>
|
||||
|
||||
{selectedTask && (
|
||||
<TaskDetailPanel
|
||||
entry={selectedTask}
|
||||
onClose={() => setSelectedTask(null)}
|
||||
onChanged={handleTaskChanged}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user