T08b: Frontend Calendar UI (month view, kanban, ICS, resources, sharing)

- 8 calendar components (MonthView, KanbanBoard, AppointmentModal, TaskDetailPanel, IcsControls, ResourceBooking, SharingSettings + API client)

- 2 pages (/calendar, /calendar/kanban) + zustand store

- 17 vitest tests (MonthView 5, KanbanBoard 6, AppointmentModal 6) all passing

- i18n: calendar namespace in en/de (104 lines each, +exportSuccess key)

- TS strict mode pass, npm run build pass
This commit is contained in:
A0-Orchestrator
2026-06-30 11:35:08 +02:00
parent f9b19bb777
commit 7350739554
17 changed files with 2837 additions and 0 deletions
+266
View File
@@ -0,0 +1,266 @@
/**
* 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;
+123
View File
@@ -0,0 +1,123 @@
/**
* Calendar Kanban page — three task columns with drag & drop status updates.
*/
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { KanbanBoard, type KanbanStatus } from '@/components/calendar/KanbanBoard';
import { TaskDetailPanel } from '@/components/calendar/TaskDetailPanel';
import {
fetchKanbanBoard,
updateEntry,
type CalendarEntry,
type KanbanBoard as KanbanBoardData,
} from '@/api/calendar';
const EMPTY_BOARD: KanbanBoardData = {
open: [],
in_progress: [],
done: [],
cancelled: [],
};
export function CalendarKanbanPage() {
const { t } = useTranslation();
const [board, setBoard] = useState<KanbanBoardData>(EMPTY_BOARD);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selectedTask, setSelectedTask] = useState<CalendarEntry | null>(null);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await fetchKanbanBoard();
setBoard(data);
} catch (e) {
setError((e as Error).message ?? t('calendar.errorLoad'));
} finally {
setLoading(false);
}
}, [t]);
useEffect(() => {
void load();
}, [load]);
const handleMove = async (entry: CalendarEntry, target: KanbanStatus) => {
// Optimistic update
const previous = board;
const next: KanbanBoardData = {
open: board.open.filter((e) => e.id !== entry.id),
in_progress: board.in_progress.filter((e) => e.id !== entry.id),
done: board.done.filter((e) => e.id !== entry.id),
cancelled: board.cancelled.filter((e) => e.id !== entry.id),
};
next[target] = [...next[target], { ...entry, status: target }];
setBoard(next);
try {
const updated = await updateEntry(entry.id, { status: target });
setBoard((cur) => {
const list = cur[target].map((e) => (e.id === updated.id ? updated : e));
return { ...cur, [target]: list };
});
} catch (e) {
setError((e as Error).message ?? t('calendar.errorSave'));
setBoard(previous);
}
};
const handleTaskChanged = (updated: CalendarEntry) => {
setSelectedTask(updated);
setBoard((cur) => {
const mapStatus = (s: string): KanbanStatus =>
s === 'open' || s === 'in_progress' || s === 'done' || s === 'cancelled'
? s
: 'open';
const target = mapStatus(updated.status);
const strip = (col: CalendarEntry[]) => col.filter((e) => e.id !== updated.id);
return {
open: target === 'open' ? [...strip(cur.open), updated] : strip(cur.open),
in_progress:
target === 'in_progress' ? [...strip(cur.in_progress), updated] : strip(cur.in_progress),
done: target === 'done' ? [...strip(cur.done), updated] : strip(cur.done),
cancelled:
target === 'cancelled' ? [...strip(cur.cancelled), updated] : strip(cur.cancelled),
};
});
};
return (
<div className="p-6 max-w-7xl mx-auto" data-testid="kanban-page">
<h1 className="text-2xl font-bold text-secondary-900 mb-4">
{t('calendar.kanbanTitle')}
</h1>
{error && (
<div
className="mb-3 text-sm text-danger-700 bg-danger-50 border border-danger-200 rounded-md p-2"
data-testid="kanban-error"
>
{error}
</div>
)}
<KanbanBoard
board={board}
loading={loading}
onMove={handleMove}
onSelect={(entry) => setSelectedTask(entry)}
/>
{selectedTask && (
<TaskDetailPanel
entry={selectedTask}
onClose={() => setSelectedTask(null)}
onChanged={handleTaskChanged}
/>
)}
</div>
);
}
export default CalendarKanbanPage;