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
@@ -0,0 +1,361 @@
/**
* AppointmentModal — create/edit an appointment (or task) entry.
*
* Pre-fills start_at / end_at when the caller supplies a `prefillDate`
* (used by clicking an empty day cell in MonthView).
*/
import React, { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Modal } from '@/components/ui/Modal';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import {
createEntry,
updateEntry,
deleteEntry,
type Calendar,
type CalendarEntry,
type EntryCreatePayload,
type EntryPriority,
type EntrySubtype,
} from '@/api/calendar';
export interface AppointmentModalProps {
open: boolean;
onClose: () => void;
/** Existing entry for edit mode. */
entry?: CalendarEntry | null;
/** Date used to pre-fill start_at in create mode (defaults to today). */
prefillDate?: Date | null;
/** Available calendars (user must pick one for create). */
calendars: Calendar[];
/** Active calendar id, used as the default selection. */
defaultCalendarId?: string | null;
/** Called after a successful save. */
onSaved: (entry: CalendarEntry) => void;
/** Called after a successful delete. */
onDeleted: (entryId: string) => void;
}
function toDateTimeLocalValue(d: Date | null | undefined): string {
if (!d) return '';
// datetime-local needs YYYY-MM-DDTHH:mm in local time
const pad = (n: number) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
function fromDateTimeLocalValue(v: string): Date | null {
if (!v) return null;
const d = new Date(v);
if (Number.isNaN(d.getTime())) return null;
return d;
}
export function AppointmentModal({
open,
onClose,
entry,
prefillDate,
calendars,
defaultCalendarId,
onSaved,
onDeleted,
}: AppointmentModalProps) {
const { t } = useTranslation();
const isEdit = !!entry;
const initialStart = useMemo(() => {
if (entry?.start_at) return new Date(entry.start_at);
if (prefillDate) {
const d = new Date(prefillDate);
d.setHours(9, 0, 0, 0);
return d;
}
const d = new Date();
d.setHours(9, 0, 0, 0);
return d;
}, [entry, prefillDate]);
const initialEnd = useMemo(() => {
if (entry?.end_at) return new Date(entry.end_at);
const d = new Date(initialStart);
d.setHours(d.getHours() + 1);
return d;
}, [entry, initialStart]);
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [location, setLocation] = useState('');
const [startAt, setStartAt] = useState<string>(toDateTimeLocalValue(initialStart));
const [endAt, setEndAt] = useState<string>(toDateTimeLocalValue(initialEnd));
const [allDay, setAllDay] = useState(false);
const [priority, setPriority] = useState<EntryPriority>('medium');
const [subtype, setSubtype] = useState<EntrySubtype>('normal');
const [calendarId, setCalendarId] = useState<string>('');
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
// Reset form when entry / prefill / open changes
useEffect(() => {
if (!open) return;
setTitle(entry?.title ?? '');
setDescription(entry?.description ?? '');
setLocation(entry?.location ?? '');
setStartAt(toDateTimeLocalValue(entry?.start_at ? new Date(entry.start_at) : initialStart));
setEndAt(toDateTimeLocalValue(entry?.end_at ? new Date(entry.end_at) : initialEnd));
setAllDay(entry?.all_day ?? false);
setPriority(entry?.priority ?? 'medium');
setSubtype(entry?.subtype ?? 'normal');
const fallbackId =
entry?.calendar_id ?? defaultCalendarId ?? calendars[0]?.id ?? '';
setCalendarId(fallbackId);
setError(null);
}, [open, entry, defaultCalendarId, calendars, initialStart, initialEnd]);
const handleSave = async () => {
if (!title.trim()) {
setError(t('validation.required'));
return;
}
if (!calendarId) {
setError(t('calendar.noCalendars'));
return;
}
setSubmitting(true);
setError(null);
try {
const startDate = fromDateTimeLocalValue(startAt);
const endDate = fromDateTimeLocalValue(endAt);
if (isEdit && entry) {
const updated = await updateEntry(entry.id, {
title,
description: description || null,
location: location || null,
start_at: startDate ? startDate.toISOString() : null,
end_at: endDate ? endDate.toISOString() : null,
all_day: allDay,
priority,
subtype,
calendar_id: calendarId,
});
onSaved(updated);
} else {
const payload: EntryCreatePayload = {
calendar_id: calendarId,
entry_type: 'appointment',
title,
description: description || null,
location: location || null,
start_at: startDate ? startDate.toISOString() : null,
end_at: endDate ? endDate.toISOString() : null,
all_day: allDay,
priority,
subtype,
status: 'open',
};
const created = await createEntry(payload);
onSaved(created);
}
onClose();
} catch (e) {
const err = e as { message?: string };
setError(err.message ?? t('calendar.errorSave'));
} finally {
setSubmitting(false);
}
};
const handleDelete = async () => {
if (!entry) return;
if (!window.confirm(t('calendar.deleteEntryConfirm'))) return;
setSubmitting(true);
setError(null);
try {
await deleteEntry(entry.id);
onDeleted(entry.id);
onClose();
} catch (e) {
const err = e as { message?: string };
setError(err.message ?? t('calendar.errorSave'));
} finally {
setSubmitting(false);
}
};
const calendarOptions = useMemo(
() => [
...calendars.map((c) => ({ value: c.id, label: c.name })),
],
[calendars]
);
return (
<Modal
open={open}
onClose={onClose}
title={isEdit ? t('calendar.editAppointment') : t('calendar.newAppointment')}
size="lg"
>
<div className="space-y-4" data-testid="appointment-modal">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('calendar.appointmentTitle')} *
</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t('calendar.appointmentTitle')}
data-testid="appointment-title"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('calendar.appointmentCalendar')} *
</label>
<Select
value={calendarId}
onChange={(e) => setCalendarId(e.target.value)}
options={calendarOptions}
data-testid="appointment-calendar"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('calendar.appointmentStart')}
</label>
<Input
type="datetime-local"
value={startAt}
onChange={(e) => setStartAt(e.target.value)}
data-testid="appointment-start"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('calendar.appointmentEnd')}
</label>
<Input
type="datetime-local"
value={endAt}
onChange={(e) => setEndAt(e.target.value)}
data-testid="appointment-end"
/>
</div>
</div>
<div className="flex items-center gap-2">
<input
id="appointment-allday"
type="checkbox"
checked={allDay}
onChange={(e) => setAllDay(e.target.checked)}
className="h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
data-testid="appointment-allday"
/>
<label htmlFor="appointment-allday" className="text-sm text-secondary-700">
{t('calendar.appointmentAllDay')}
</label>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('calendar.appointmentLocation')}
</label>
<Input
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder={t('calendar.appointmentLocation')}
data-testid="appointment-location"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('calendar.appointmentDescription')}
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
className="w-full rounded-md border-secondary-300 focus:border-primary-500 focus:ring-primary-500"
data-testid="appointment-description"
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('calendar.appointmentPriority')}
</label>
<Select
value={priority}
onChange={(e) => setPriority(e.target.value as EntryPriority)}
options={[
{ value: 'low', label: t('calendar.priority.low') },
{ value: 'medium', label: t('calendar.priority.medium') },
{ value: 'high', label: t('calendar.priority.high') },
]}
data-testid="appointment-priority"
/>
</div>
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">
{t('calendar.appointmentSubtype')}
</label>
<Select
value={subtype}
onChange={(e) => setSubtype(e.target.value as EntrySubtype)}
options={[
{ value: 'normal', label: t('calendar.subtype.normal') },
{ value: 'follow_up', label: t('calendar.subtype.follow_up') },
{ value: 'private', label: t('calendar.subtype.private') },
]}
data-testid="appointment-subtype"
/>
</div>
</div>
{error && (
<div className="text-sm text-danger-700 bg-danger-50 border border-danger-200 rounded-md p-2" data-testid="appointment-error">
{error}
</div>
)}
<div className="flex justify-between gap-2 pt-2">
<div>
{isEdit && (
<Button
variant="danger"
onClick={handleDelete}
disabled={submitting}
data-testid="appointment-delete"
>
{t('calendar.delete')}
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="ghost" onClick={onClose} disabled={submitting}>
{t('calendar.cancel')}
</Button>
<Button
variant="primary"
onClick={handleSave}
isLoading={submitting}
data-testid="appointment-save"
>
{t('calendar.save')}
</Button>
</div>
</div>
</div>
</Modal>
);
}
export default AppointmentModal;
@@ -0,0 +1,148 @@
/**
* IcsControls — export the current calendar as an .ics file or import one.
*
* The export calls the public ICS feed endpoint with the calendar's `ics_token`
* (the backend issues a token on first read if missing) and downloads it as a
* file. The import posts the chosen file to `/api/v1/calendar/import`.
*/
import React, { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { apiClient } from '@/api/client';
import { importIcsFile, type Calendar } from '@/api/calendar';
export interface IcsControlsProps {
calendar: Calendar | null;
onImported?: () => void;
}
function downloadBlob(blob: Blob, filename: string): void {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}
export function IcsControls({ calendar, onImported }: IcsControlsProps) {
const { t } = useTranslation();
const fileInputRef = useRef<HTMLInputElement>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const handleExport = async () => {
if (!calendar) return;
setError(null);
setMessage(null);
setBusy(true);
try {
// First, fetch the calendar to make sure ics_token exists (backend may
// auto-generate it on first /ics-feed hit; if we have one already, use it).
const token = calendar.ics_token;
const url = token
? `/calendar/${calendar.id}/ics-feed?token=${encodeURIComponent(token)}`
: `/calendar/${calendar.id}/ics-feed`;
const response = await apiClient.get(url, {
responseType: 'text',
transformResponse: [(d: unknown) => d],
});
const text = typeof response.data === 'string' ? response.data : String(response.data ?? '');
const blob = new Blob([text], { type: 'text/calendar;charset=utf-8' });
const safeName = (calendar.name || 'kalender').replace(/[^a-zA-Z0-9_-]+/g, '_');
downloadBlob(blob, `${safeName}.ics`);
setMessage(t('calendar.ics.exportSuccess'));
} catch (e) {
setError((e as Error).message ?? t('calendar.ics.importFailed'));
} finally {
setBusy(false);
}
};
const handleImportClick = () => {
fileInputRef.current?.click();
};
const handleFileSelected = async (
e: React.ChangeEvent<HTMLInputElement>
) => {
const file = e.target.files?.[0];
if (!file) return;
setError(null);
setMessage(null);
setBusy(true);
try {
const result = await importIcsFile(file, calendar?.id);
if (result.errors.length > 0) {
setMessage(
t('calendar.ics.importPartial', {
count: result.entries_created,
errors: result.errors.length,
})
);
} else {
setMessage(t('calendar.ics.importSuccess', { count: result.entries_created }));
}
onImported?.();
} catch (err) {
setError((err as Error).message ?? t('calendar.ics.importFailed'));
} finally {
setBusy(false);
// Reset input so the same file can be re-picked
if (e.target) e.target.value = '';
}
};
return (
<div className="flex flex-wrap items-center gap-2" data-testid="ics-controls">
<Button
variant="secondary"
size="sm"
onClick={handleExport}
disabled={busy || !calendar}
data-testid="ics-export"
>
{t('calendar.ics.export')}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleImportClick}
disabled={busy}
data-testid="ics-import"
>
{t('calendar.ics.import')}
</Button>
<input
ref={fileInputRef}
type="file"
accept=".ics,text/calendar"
className="hidden"
onChange={handleFileSelected}
data-testid="ics-file-input"
/>
{message && (
<span
className="text-xs text-success-700"
data-testid="ics-success"
>
{message}
</span>
)}
{error && (
<span className="text-xs text-danger-700" data-testid="ics-error">
{error}
</span>
)}
</div>
);
}
export default IcsControls;
@@ -0,0 +1,174 @@
/**
* KanbanBoard — three task columns (open / in_progress / done) with
* drag-and-drop between columns to update task status.
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx';
import type { CalendarEntry } from '@/api/calendar';
export type KanbanStatus = 'open' | 'in_progress' | 'done' | 'cancelled';
export interface KanbanBoardProps {
board: Record<KanbanStatus, CalendarEntry[]>;
loading?: boolean;
/** Fired when a card is dropped into a different column. */
onMove: (entry: CalendarEntry, target: KanbanStatus) => void | Promise<void>;
/** Fired when a card is clicked (open detail panel). */
onSelect?: (entry: CalendarEntry) => void;
}
const COLUMNS: KanbanStatus[] = ['open', 'in_progress', 'done'];
function statusKey(s: KanbanStatus): string {
return `calendar.kanban.${s}`;
}
function priorityBadgeClass(p: string): string {
switch (p) {
case 'high':
return 'bg-danger-100 text-danger-800';
case 'low':
return 'bg-secondary-100 text-secondary-700';
default:
return 'bg-warning-100 text-warning-800';
}
}
function fmtDue(entry: CalendarEntry): string | null {
if (!entry.due_date) return null;
const d = new Date(entry.due_date);
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' });
}
export function KanbanBoard({ board, loading, onMove, onSelect }: KanbanBoardProps) {
const { t } = useTranslation();
const [dragOver, setDragOver] = useState<KanbanStatus | null>(null);
const handleDragStart = (e: React.DragEvent<HTMLDivElement>, entry: CalendarEntry) => {
e.dataTransfer.setData('application/x-kanban-entry', entry.id);
e.dataTransfer.setData('application/x-kanban-source', entry.status);
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>, status: KanbanStatus) => {
if (e.dataTransfer.types.includes('application/x-kanban-entry')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
if (dragOver !== status) setDragOver(status);
}
};
const handleDragLeave = (status: KanbanStatus) => {
setDragOver((cur) => (cur === status ? null : cur));
};
const handleDrop = async (e: React.DragEvent<HTMLDivElement>, target: KanbanStatus) => {
e.preventDefault();
setDragOver(null);
const id = e.dataTransfer.getData('application/x-kanban-entry');
const source = e.dataTransfer.getData('application/x-kanban-source') as KanbanStatus;
if (!id || source === target) return;
const allEntries = [...board.open, ...board.in_progress, ...board.done, ...board.cancelled];
const entry = allEntries.find((x) => x.id === id);
if (!entry) return;
await onMove(entry, target);
};
if (!loading && board.open.length === 0 && board.in_progress.length === 0 && board.done.length === 0) {
return (
<div className="p-8 text-center text-secondary-500" data-testid="kanban-empty">
{t('calendar.kanban.noTasks')}
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4" data-testid="kanban-board">
{COLUMNS.map((status) => {
const entries = board[status] ?? [];
const isOver = dragOver === status;
return (
<div
key={status}
data-testid={`kanban-column-${status}`}
onDragOver={(e) => handleDragOver(e, status)}
onDragLeave={() => handleDragLeave(status)}
onDrop={(e) => handleDrop(e, status)}
className={clsx(
'bg-secondary-50 rounded-lg border border-secondary-200 p-3 min-h-[12rem]',
'transition-colors motion-safe:duration-200',
isOver && 'ring-2 ring-primary-500 bg-primary-50'
)}
>
<div className="flex items-center justify-between mb-3 px-1">
<h3 className="text-sm font-semibold text-secondary-800 uppercase tracking-wide">
{t(statusKey(status))}
</h3>
<span
className="text-xs text-secondary-500 bg-secondary-200 rounded-full px-2 py-0.5"
data-testid={`kanban-count-${status}`}
>
{entries.length}
</span>
</div>
<div className="space-y-2">
{entries.length === 0 ? (
<div className="text-xs text-secondary-400 italic px-1">
{t('calendar.kanban.empty')}
</div>
) : (
entries.map((entry) => (
<div
key={entry.id}
draggable
onDragStart={(e) => handleDragStart(e, entry)}
onClick={() => onSelect?.(entry)}
data-testid={`kanban-card-${entry.id}`}
className={clsx(
'bg-white rounded-md border border-secondary-200 shadow-sm p-3',
'cursor-grab active:cursor-grabbing hover:shadow-md hover:border-primary-300',
'motion-safe:transition-shadow'
)}
>
<div className="flex items-start justify-between gap-2">
<div className="font-medium text-sm text-secondary-900 line-clamp-2">
{entry.title}
</div>
<span
className={clsx(
'text-xs px-1.5 py-0.5 rounded font-medium uppercase shrink-0',
priorityBadgeClass(entry.priority)
)}
>
{entry.priority}
</span>
</div>
{entry.description && (
<div className="mt-1 text-xs text-secondary-600 line-clamp-2">
{entry.description}
</div>
)}
<div className="mt-2 flex items-center justify-between text-xs text-secondary-500">
<span>{fmtDue(entry) ?? '—'}</span>
{(entry.subtasks?.length ?? 0) > 0 && (
<span data-testid={`kanban-subtask-count-${entry.id}`}>
{entry.subtasks?.filter((s) => s.done).length ?? 0}/
{entry.subtasks?.length ?? 0}
</span>
)}
</div>
</div>
))
)}
</div>
</div>
);
})}
</div>
);
}
export default KanbanBoard;
@@ -0,0 +1,226 @@
/**
* MonthView — monthly calendar grid with click-to-create and drag-to-move.
*
* Click an empty cell → fires `onCreateAt(date)`.
* Click an entry → fires `onEditEntry(entry)`.
* Drag an entry onto another day → calls `onMoveEntry(entry, newDate)`.
*/
import React, { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx';
import type { CalendarEntry } from '@/api/calendar';
export interface MonthViewProps {
/** First day of the visible month (UTC). */
visibleMonth: Date;
/** Entries to render. Entries with start_at fall on that day. */
entries: CalendarEntry[];
/** Loading state — disables interactions. */
loading?: boolean;
/** Clicked an empty cell — create a new entry. */
onCreateAt: (date: Date) => void;
/** Clicked an existing entry — open editor. */
onEditEntry: (entry: CalendarEntry) => void;
/** Dragged an entry to a new day. */
onMoveEntry: (entry: CalendarEntry, newStart: Date) => void | Promise<void>;
}
function startOfDay(d: Date): Date {
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
}
function addDays(d: Date, n: number): Date {
const out = new Date(d);
out.setDate(out.getDate() + n);
return out;
}
function sameDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
function fmtTime(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
export function MonthView({
visibleMonth,
entries,
loading,
onCreateAt,
onEditEntry,
onMoveEntry,
}: MonthViewProps) {
const { t } = useTranslation();
const today = startOfDay(new Date());
// Build a 6-row × 7-day grid (always 42 cells to keep height stable)
const gridDays = useMemo(() => {
const first = new Date(visibleMonth.getFullYear(), visibleMonth.getMonth(), 1);
// Week starts on Monday (DE locale)
const dayOfWeek = (first.getDay() + 6) % 7; // 0 = Monday
const start = addDays(first, -dayOfWeek);
return Array.from({ length: 42 }, (_, i) => addDays(start, i));
}, [visibleMonth]);
// Group entries by yyyy-mm-dd
const entriesByDay = useMemo(() => {
const map = new Map<string, CalendarEntry[]>();
for (const entry of entries) {
if (!entry.start_at) continue;
const d = new Date(entry.start_at);
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
const list = map.get(key) ?? [];
list.push(entry);
map.set(key, list);
}
return map;
}, [entries]);
const weekdays = t('calendar.weekdays', { returnObjects: true }) as string[];
const months = t('calendar.months', { returnObjects: true }) as string[];
const headerLabel = `${months[visibleMonth.getMonth()]} ${visibleMonth.getFullYear()}`;
const handleDragStart = (
e: React.DragEvent<HTMLDivElement>,
entry: CalendarEntry
) => {
e.dataTransfer.setData('application/x-calendar-entry', entry.id);
e.dataTransfer.effectAllowed = 'move';
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
if (e.dataTransfer.types.includes('application/x-calendar-entry')) {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
}
};
const handleDrop = (
e: React.DragEvent<HTMLDivElement>,
day: Date
) => {
e.preventDefault();
const id = e.dataTransfer.getData('application/x-calendar-entry');
if (!id) return;
const entry = entries.find((x) => x.id === id);
if (!entry || !entry.start_at) return;
// Preserve original time-of-day, change only date
const oldStart = new Date(entry.start_at);
const newStart = new Date(
day.getFullYear(),
day.getMonth(),
day.getDate(),
oldStart.getHours(),
oldStart.getMinutes(),
oldStart.getSeconds()
);
void onMoveEntry(entry, newStart);
};
return (
<div className="bg-white rounded-lg shadow border border-secondary-200" data-testid="month-view">
<div
className="px-4 py-3 border-b border-secondary-200 text-base font-semibold text-secondary-900"
data-testid="month-view-header"
>
{headerLabel}
</div>
<div className="grid grid-cols-7 border-b border-secondary-200 bg-secondary-50">
{weekdays.map((wd) => (
<div
key={wd}
className="px-2 py-2 text-xs font-medium text-secondary-600 text-center uppercase tracking-wide"
>
{wd}
</div>
))}
</div>
<div className="grid grid-cols-7 grid-rows-6" data-testid="month-view-grid">
{gridDays.map((day) => {
const inMonth = day.getMonth() === visibleMonth.getMonth();
const isToday = sameDay(day, today);
const key = `${day.getFullYear()}-${day.getMonth()}-${day.getDate()}`;
const dayEntries = entriesByDay.get(key) ?? [];
return (
<div
key={key}
role="button"
tabIndex={0}
data-testid={`month-cell-${key}`}
onClick={() => !loading && onCreateAt(day)}
onKeyDown={(e) => {
if ((e.key === 'Enter' || e.key === ' ') && !loading) {
e.preventDefault();
onCreateAt(day);
}
}}
onDragOver={handleDragOver}
onDrop={(e) => handleDrop(e, day)}
className={clsx(
'border border-secondary-100 min-h-[6rem] p-1 align-top cursor-pointer',
'hover:bg-primary-50 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
!inMonth && 'bg-secondary-50 text-secondary-400',
loading && 'opacity-60 pointer-events-none'
)}
>
<div
className={clsx(
'text-xs font-medium mb-1 inline-flex items-center justify-center',
'h-6 w-6 rounded-full',
isToday && 'bg-primary-600 text-white'
)}
data-testid={`month-day-number-${key}`}
>
{day.getDate()}
</div>
<div className="space-y-1">
{dayEntries.slice(0, 3).map((entry) => {
const time = entry.start_at ? fmtTime(new Date(entry.start_at)) : '';
return (
<div
key={entry.id}
draggable
onDragStart={(e) => handleDragStart(e, entry)}
onClick={(e) => {
e.stopPropagation();
onEditEntry(entry);
}}
data-testid={`month-entry-${entry.id}`}
className={clsx(
'text-xs truncate rounded px-1 py-0.5 cursor-grab active:cursor-grabbing',
'bg-primary-100 text-primary-900 hover:bg-primary-200'
)}
title={entry.title}
>
{time && <span className="font-mono mr-1">{time}</span>}
{entry.title}
</div>
);
})}
{dayEntries.length > 3 && (
<div className="text-xs text-secondary-500 px-1">
+{dayEntries.length - 3} {t('calendar.noEntries').includes('Keine')
? 'weitere'
: 'more'}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
);
}
export default MonthView;
@@ -0,0 +1,122 @@
/**
* ResourceBooking — pick a resource and book it for the selected appointment.
*
* The backend's book-resource endpoint returns 409 on time overlap, which we
* surface as a conflict warning. Resource list is loaded lazily by the parent
* (we accept it as a prop to keep this component simple).
*/
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Select } from '@/components/ui/Select';
import {
bookResource,
type CalendarEntry,
type Resource,
type ResourceBooking,
} from '@/api/calendar';
export interface ResourceBookingProps {
entry: CalendarEntry;
resources: Resource[];
onBooked?: (booking: ResourceBooking) => void;
}
export function ResourceBooking({ entry, resources, onBooked }: ResourceBookingProps) {
const { t } = useTranslation();
const [resourceId, setResourceId] = useState<string>('');
const [busy, setBusy] = useState(false);
const [conflict, setConflict] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const handleBook = async () => {
setError(null);
setConflict(null);
setSuccess(null);
if (!resourceId) {
setError(t('calendar.resources.select'));
return;
}
if (!entry.start_at || !entry.end_at) {
setError(t('calendar.resources.missingTimes'));
return;
}
setBusy(true);
try {
const booking = await bookResource(entry.id, { resource_id: resourceId });
setSuccess(t('calendar.resources.booked'));
onBooked?.(booking);
} catch (e) {
const err = e as { status?: number; message?: string };
if (err.status === 409) {
setConflict(t('calendar.resources.conflict'));
} else {
setError(err.message ?? t('calendar.errorSave'));
}
} finally {
setBusy(false);
}
};
const options = [
{ value: '', label: t('calendar.resources.none') },
...resources.map((r) => ({ value: r.id, label: `${r.name} (${r.type})` })),
];
return (
<div className="space-y-2" data-testid="resource-booking">
<div className="text-sm font-medium text-secondary-800">
{t('calendar.resources.title')}
</div>
<div className="flex gap-2">
<div className="flex-1">
<Select
value={resourceId}
onChange={(e) => setResourceId(e.target.value)}
options={options}
data-testid="resource-select"
/>
</div>
<Button
variant="primary"
size="sm"
onClick={handleBook}
isLoading={busy}
disabled={!resourceId}
data-testid="resource-book"
>
{t('calendar.resources.book')}
</Button>
</div>
{conflict && (
<div
className="text-xs text-warning-800 bg-warning-50 border border-warning-200 rounded-md p-2"
data-testid="resource-conflict"
>
{conflict}
</div>
)}
{success && (
<div
className="text-xs text-success-700 bg-success-50 border border-success-200 rounded-md p-2"
data-testid="resource-success"
>
{success}
</div>
)}
{error && (
<div
className="text-xs text-danger-700 bg-danger-50 border border-danger-200 rounded-md p-2"
data-testid="resource-error"
>
{error}
</div>
)}
</div>
);
}
export default ResourceBooking;
@@ -0,0 +1,201 @@
/**
* SharingSettings — manage per-calendar shares (user or group + permission).
*
* T05 backend exposes:
* POST /api/v1/calendars/{id}/share — add a share
* GET /api/v1/calendars/{id}/permissions — list shares
*
* Removing a share is done via DELETE on the share id; the backend does not
* expose a dedicated endpoint in T05, so we keep a local "remove" button that
* optimistically updates the list. (A future T05b can wire a real DELETE.)
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
import { Select } from '@/components/ui/Select';
import {
fetchCalendarPermissions,
shareCalendar,
type Calendar,
type CalendarShare,
type SharePermission,
} from '@/api/calendar';
export interface SharingSettingsProps {
calendar: Calendar;
}
export function SharingSettings({ calendar }: SharingSettingsProps) {
const { t } = useTranslation();
const [shares, setShares] = useState<CalendarShare[]>([]);
const [target, setTarget] = useState<'user' | 'group'>('user');
const [targetId, setTargetId] = useState('');
const [permission, setPermission] = useState<SharePermission>('read');
const [busy, setBusy] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [message, setMessage] = useState<string | null>(null);
const load = async () => {
setLoading(true);
setError(null);
try {
const list = await fetchCalendarPermissions(calendar.id);
setShares(list);
} catch (e) {
setError((e as Error).message ?? t('calendar.errorLoad'));
} finally {
setLoading(false);
}
};
useEffect(() => {
void load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [calendar.id]);
const handleAdd = async () => {
if (!targetId.trim()) {
setError(
target === 'user'
? t('calendar.sharing.userPlaceholder')
: t('calendar.sharing.groupPlaceholder')
);
return;
}
setBusy(true);
setError(null);
setMessage(null);
try {
const payload =
target === 'user'
? { user_id: targetId.trim(), permission }
: { group_id: targetId.trim(), permission };
const created = await shareCalendar(calendar.id, payload);
setShares((cur) => [...cur, created]);
setTargetId('');
setMessage(t('calendar.sharing.added'));
} catch (e) {
setError((e as Error).message ?? t('calendar.errorSave'));
} finally {
setBusy(false);
}
};
const handleRemove = (share: CalendarShare) => {
// Optimistic local removal — no DELETE endpoint in T05 yet
setShares((cur) => cur.filter((s) => s.id !== share.id));
setMessage(t('calendar.sharing.removed'));
};
return (
<div className="space-y-3" data-testid="sharing-settings">
<h3 className="text-sm font-semibold text-secondary-900">
{t('calendar.sharing.title')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-[1fr_2fr_1fr_auto] gap-2 items-end">
<Select
value={target}
onChange={(e) => setTarget(e.target.value as 'user' | 'group')}
options={[
{ value: 'user', label: t('calendar.sharing.user') },
{ value: 'group', label: t('calendar.sharing.group') },
]}
data-testid="sharing-target"
/>
<Input
value={targetId}
onChange={(e) => setTargetId(e.target.value)}
placeholder={
target === 'user'
? t('calendar.sharing.userPlaceholder')
: t('calendar.sharing.groupPlaceholder')
}
data-testid="sharing-target-id"
/>
<Select
value={permission}
onChange={(e) => setPermission(e.target.value as SharePermission)}
options={[
{ value: 'read', label: t('calendar.sharing.permissionRead') },
{ value: 'write', label: t('calendar.sharing.permissionWrite') },
]}
data-testid="sharing-permission"
/>
<Button
variant="primary"
size="sm"
onClick={handleAdd}
isLoading={busy}
data-testid="sharing-add"
>
{t('calendar.sharing.add')}
</Button>
</div>
{error && (
<div
className="text-xs text-danger-700 bg-danger-50 border border-danger-200 rounded-md p-2"
data-testid="sharing-error"
>
{error}
</div>
)}
{message && (
<div
className="text-xs text-success-700 bg-success-50 border border-success-200 rounded-md p-2"
data-testid="sharing-message"
>
{message}
</div>
)}
<div>
<div className="text-xs uppercase tracking-wide text-secondary-500 mb-1">
{t('calendar.sharing.list')}
</div>
{loading ? (
<div className="text-xs text-secondary-500">{t('calendar.loading')}</div>
) : shares.length === 0 ? (
<div className="text-xs italic text-secondary-400" data-testid="sharing-empty">
{t('calendar.sharing.empty')}
</div>
) : (
<ul className="space-y-1" data-testid="sharing-list">
{shares.map((s) => (
<li
key={s.id}
className="flex items-center justify-between bg-secondary-50 border border-secondary-200 rounded px-2 py-1 text-xs"
data-testid={`sharing-item-${s.id}`}
>
<span className="text-secondary-800">
{s.user_id
? `${t('calendar.sharing.user')}: ${s.user_id}`
: `${t('calendar.sharing.group')}: ${s.group_id}`}
<span className="ml-2 text-secondary-500">
({s.permission === 'read'
? t('calendar.sharing.permissionRead')
: t('calendar.sharing.permissionWrite')})
</span>
</span>
<button
type="button"
onClick={() => handleRemove(s)}
className="text-danger-600 hover:text-danger-800"
data-testid={`sharing-remove-${s.id}`}
>
{t('calendar.sharing.remove')}
</button>
</li>
))}
</ul>
)}
</div>
</div>
);
}
export default SharingSettings;
@@ -0,0 +1,222 @@
/**
* TaskDetailPanel — display a task and manage its subtask checklist.
*
* Subtasks are loaded lazily from the entry if absent and updated via the
* `createSubtask` / `updateSubtask` API endpoints (T05).
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import clsx from 'clsx';
import {
createSubtask,
getEntry,
updateSubtask,
type CalendarEntry,
type Subtask,
} from '@/api/calendar';
import { Button } from '@/components/ui/Button';
import { Input } from '@/components/ui/Input';
export interface TaskDetailPanelProps {
entry: CalendarEntry;
onClose: () => void;
/** Notifies the parent that subtasks (or the entry) changed. */
onChanged: (entry: CalendarEntry) => void;
}
function priorityClass(p: string): string {
switch (p) {
case 'high':
return 'bg-danger-100 text-danger-800';
case 'low':
return 'bg-secondary-100 text-secondary-700';
default:
return 'bg-warning-100 text-warning-800';
}
}
export function TaskDetailPanel({ entry, onClose, onChanged }: TaskDetailPanelProps) {
const { t } = useTranslation();
const [subtasks, setSubtasks] = useState<Subtask[]>(entry.subtasks ?? []);
const [loading, setLoading] = useState(false);
const [newTitle, setNewTitle] = useState('');
const [error, setError] = useState<string | null>(null);
// If parent hands us an entry without subtasks, fetch them
useEffect(() => {
let cancelled = false;
if (entry.entry_type !== 'task') return;
if ((entry.subtasks?.length ?? 0) > 0) {
setSubtasks(entry.subtasks ?? []);
return;
}
setLoading(true);
getEntry(entry.id)
.then((full) => {
if (cancelled) return;
setSubtasks(full.subtasks ?? []);
onChanged(full);
})
.catch((e: Error) => {
if (!cancelled) setError(e.message);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [entry.id, entry.entry_type, entry.subtasks, onChanged]);
const handleAdd = async () => {
const title = newTitle.trim();
if (!title) return;
setError(null);
try {
const created = await createSubtask(entry.id, { title });
setSubtasks((cur) => [...cur, created]);
setNewTitle('');
} catch (e) {
setError((e as Error).message ?? t('calendar.errorSave'));
}
};
const handleToggle = async (sub: Subtask) => {
setError(null);
// optimistic update
const previous = sub.done;
setSubtasks((cur) =>
cur.map((s) => (s.id === sub.id ? { ...s, done: !previous } : s))
);
try {
const updated = await updateSubtask(entry.id, sub.id, { done: !previous });
setSubtasks((cur) => cur.map((s) => (s.id === sub.id ? updated : s)));
} catch (e) {
// revert
setSubtasks((cur) =>
cur.map((s) => (s.id === sub.id ? { ...s, done: previous } : s))
);
setError((e as Error).message ?? t('calendar.errorSave'));
}
};
return (
<aside
className="fixed inset-y-0 right-0 z-40 w-full sm:w-96 bg-white shadow-xl border-l border-secondary-200 overflow-y-auto"
data-testid="task-detail-panel"
>
<div className="p-4 border-b border-secondary-200 flex items-center justify-between">
<h2 className="text-lg font-semibold text-secondary-900">{entry.title}</h2>
<button
type="button"
onClick={onClose}
className="text-secondary-500 hover:text-secondary-900"
aria-label={t('calendar.cancel')}
data-testid="task-detail-close"
>
</button>
</div>
<div className="p-4 space-y-4">
<div className="flex flex-wrap gap-2 text-xs">
<span className={clsx('px-2 py-0.5 rounded font-medium uppercase', priorityClass(entry.priority))}>
{entry.priority}
</span>
<span className="px-2 py-0.5 rounded bg-secondary-100 text-secondary-700">
{t(`calendar.status.${entry.status}`)}
</span>
{entry.due_date && (
<span className="px-2 py-0.5 rounded bg-primary-50 text-primary-700">
{new Date(entry.due_date).toLocaleDateString('de-DE')}
</span>
)}
</div>
{entry.description && (
<p className="text-sm text-secondary-700 whitespace-pre-wrap">
{entry.description}
</p>
)}
<div className="border-t border-secondary-200 pt-4">
<h3 className="text-sm font-semibold text-secondary-900 mb-2">
{t('calendar.subtasks.title')}
</h3>
{loading && (
<div className="text-xs text-secondary-500 mb-2">{t('calendar.loading')}</div>
)}
<ul className="space-y-1 mb-3" data-testid="subtask-list">
{subtasks.length === 0 && !loading && (
<li className="text-xs text-secondary-400 italic">
{t('calendar.subtasks.empty')}
</li>
)}
{subtasks.map((sub) => (
<li
key={sub.id}
className="flex items-start gap-2"
data-testid={`subtask-item-${sub.id}`}
>
<input
type="checkbox"
checked={sub.done}
onChange={() => handleToggle(sub)}
className="mt-1 h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500"
data-testid={`subtask-toggle-${sub.id}`}
/>
<span
className={clsx(
'text-sm flex-1',
sub.done && 'line-through text-secondary-400'
)}
>
{sub.title}
</span>
</li>
))}
</ul>
<form
onSubmit={(e) => {
e.preventDefault();
void handleAdd();
}}
className="flex gap-2"
data-testid="subtask-add-form"
>
<Input
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
placeholder={t('calendar.subtasks.addPlaceholder')}
data-testid="subtask-add-input"
/>
<Button
type="submit"
variant="primary"
size="sm"
disabled={!newTitle.trim()}
data-testid="subtask-add-button"
>
{t('calendar.subtasks.add')}
</Button>
</form>
</div>
{error && (
<div
className="text-sm text-danger-700 bg-danger-50 border border-danger-200 rounded-md p-2"
data-testid="task-detail-error"
>
{error}
</div>
)}
</div>
</aside>
);
}
export default TaskDetailPanel;