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:
@@ -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;
|
||||
Reference in New Issue
Block a user