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,182 @@
import React from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { AppointmentModal } from '@/components/calendar/AppointmentModal';
import type { Calendar, CalendarEntry } from '@/api/calendar';
vi.mock('@/api/calendar', async () => {
const actual = await vi.importActual<typeof import('@/api/calendar')>('@/api/calendar');
return {
...actual,
createEntry: vi.fn(),
updateEntry: vi.fn(),
deleteEntry: vi.fn(),
};
});
import { createEntry, updateEntry, deleteEntry } from '@/api/calendar';
const calendars: Calendar[] = [
{
id: 'cal-1',
name: 'Persönlich',
color: '#3B82F6',
type: 'personal',
owner_id: 'u-1',
},
];
const sampleEntry: CalendarEntry = {
id: 'e-1',
calendar_id: 'cal-1',
entry_type: 'appointment',
subtype: 'normal',
title: 'Bestehender Termin',
description: 'Notiz',
location: 'Berlin',
start_at: '2026-06-20T09:00:00.000Z',
end_at: '2026-06-20T10:00:00.000Z',
all_day: false,
priority: 'medium',
status: 'open',
created_by: 'u-1',
};
describe('AppointmentModal', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders the create modal with pre-filled date from prefillDate', () => {
render(
<AppointmentModal
open
onClose={vi.fn()}
prefillDate={new Date(2026, 6, 1)}
calendars={calendars}
defaultCalendarId="cal-1"
onSaved={vi.fn()}
onDeleted={vi.fn()}
/>
);
expect(screen.getByTestId('appointment-modal')).toBeInTheDocument();
const start = screen.getByTestId('appointment-start') as HTMLInputElement;
expect(start.value).toMatch(/2026-07-01/);
});
it('in edit mode, prefills the form with the entry fields and shows the delete button', () => {
render(
<AppointmentModal
open
onClose={vi.fn()}
entry={sampleEntry}
calendars={calendars}
defaultCalendarId="cal-1"
onSaved={vi.fn()}
onDeleted={vi.fn()}
/>
);
expect((screen.getByTestId('appointment-title') as HTMLInputElement).value).toBe(
'Bestehender Termin'
);
expect(screen.getByTestId('appointment-delete')).toBeInTheDocument();
});
it('save in create mode calls createEntry and onSaved', async () => {
(createEntry as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(sampleEntry);
const onSaved = vi.fn();
const onClose = vi.fn();
render(
<AppointmentModal
open
onClose={onClose}
prefillDate={new Date(2026, 6, 1, 9, 0)}
calendars={calendars}
defaultCalendarId="cal-1"
onSaved={onSaved}
onDeleted={vi.fn()}
/>
);
fireEvent.change(screen.getByTestId('appointment-title'), {
target: { value: 'Neuer Termin' },
});
fireEvent.click(screen.getByTestId('appointment-save'));
await waitFor(() => {
expect(createEntry).toHaveBeenCalledTimes(1);
});
expect(onSaved).toHaveBeenCalledWith(sampleEntry);
expect(onClose).toHaveBeenCalled();
});
it('save in edit mode calls updateEntry and onSaved', async () => {
(updateEntry as unknown as ReturnType<typeof vi.fn>).mockResolvedValue({
...sampleEntry,
title: 'Geändert',
});
const onSaved = vi.fn();
render(
<AppointmentModal
open
onClose={vi.fn()}
entry={sampleEntry}
calendars={calendars}
defaultCalendarId="cal-1"
onSaved={onSaved}
onDeleted={vi.fn()}
/>
);
fireEvent.change(screen.getByTestId('appointment-title'), {
target: { value: 'Geändert' },
});
fireEvent.click(screen.getByTestId('appointment-save'));
await waitFor(() => {
expect(updateEntry).toHaveBeenCalledWith(
'e-1',
expect.objectContaining({ title: 'Geändert' })
);
});
expect(onSaved).toHaveBeenCalled();
});
it('shows a validation error when title is empty', async () => {
render(
<AppointmentModal
open
onClose={vi.fn()}
prefillDate={new Date(2026, 6, 1)}
calendars={calendars}
defaultCalendarId="cal-1"
onSaved={vi.fn()}
onDeleted={vi.fn()}
/>
);
fireEvent.click(screen.getByTestId('appointment-save'));
const err = await screen.findByTestId('appointment-error');
expect(err.textContent?.toLowerCase()).toContain('erforderlich');
});
it('delete button calls deleteEntry and onDeleted', async () => {
(deleteEntry as unknown as ReturnType<typeof vi.fn>).mockResolvedValue(undefined);
const onDeleted = vi.fn();
const onClose = vi.fn();
// Stub confirm
window.confirm = vi.fn(() => true);
render(
<AppointmentModal
open
onClose={onClose}
entry={sampleEntry}
calendars={calendars}
defaultCalendarId="cal-1"
onSaved={vi.fn()}
onDeleted={onDeleted}
/>
);
fireEvent.click(screen.getByTestId('appointment-delete'));
await waitFor(() => {
expect(deleteEntry).toHaveBeenCalledWith('e-1');
});
expect(onDeleted).toHaveBeenCalledWith('e-1');
expect(onClose).toHaveBeenCalled();
});
});
@@ -0,0 +1,92 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { KanbanBoard } from '@/components/calendar/KanbanBoard';
import type { CalendarEntry } from '@/api/calendar';
function makeTask(id: string, title: string, status: CalendarEntry['status']): CalendarEntry {
return {
id,
calendar_id: 'cal-1',
entry_type: 'task',
subtype: 'normal',
title,
all_day: false,
priority: 'medium',
status,
created_by: 'u-1',
due_date: '2026-07-01T00:00:00.000Z',
};
}
const board = {
open: [makeTask('t-1', 'Recherche', 'open')],
in_progress: [makeTask('t-2', 'Implementierung', 'in_progress')],
done: [makeTask('t-3', 'Tests', 'done')],
cancelled: [],
};
describe('KanbanBoard', () => {
it('renders three columns (open / in_progress / done)', () => {
render(<KanbanBoard board={board} onMove={vi.fn()} />);
expect(screen.getByTestId('kanban-column-open')).toBeInTheDocument();
expect(screen.getByTestId('kanban-column-in_progress')).toBeInTheDocument();
expect(screen.getByTestId('kanban-column-done')).toBeInTheDocument();
});
it('renders task cards inside the correct columns', () => {
render(<KanbanBoard board={board} onMove={vi.fn()} />);
expect(screen.getByTestId('kanban-card-t-1').textContent).toContain('Recherche');
expect(screen.getByTestId('kanban-card-t-2').textContent).toContain('Implementierung');
expect(screen.getByTestId('kanban-card-t-3').textContent).toContain('Tests');
});
it('shows column counts', () => {
render(<KanbanBoard board={board} onMove={vi.fn()} />);
expect(screen.getByTestId('kanban-count-open').textContent).toBe('1');
expect(screen.getByTestId('kanban-count-in_progress').textContent).toBe('1');
expect(screen.getByTestId('kanban-count-done').textContent).toBe('1');
});
it('shows empty state when there are no tasks', () => {
render(
<KanbanBoard
board={{ open: [], in_progress: [], done: [], cancelled: [] }}
onMove={vi.fn()}
/>
);
expect(screen.getByTestId('kanban-empty')).toBeInTheDocument();
});
it('drag-and-drop into another column fires onMove with target status', () => {
const onMove = vi.fn();
render(<KanbanBoard board={board} onMove={onMove} />);
const card = screen.getByTestId('kanban-card-t-1');
const targetCol = screen.getByTestId('kanban-column-done');
const dataTransfer = {
setData: vi.fn(),
getData: (k: string) => {
if (k === 'application/x-kanban-entry') return 't-1';
if (k === 'application/x-kanban-source') return 'open';
return '';
},
types: ['application/x-kanban-entry', 'application/x-kanban-source'],
effectAllowed: '',
dropEffect: '',
};
fireEvent.dragStart(card, { dataTransfer });
fireEvent.dragOver(targetCol, { dataTransfer });
fireEvent.drop(targetCol, { dataTransfer });
expect(onMove).toHaveBeenCalledTimes(1);
const [movedEntry, target] = onMove.mock.calls[0];
expect(movedEntry.id).toBe('t-1');
expect(target).toBe('done');
});
it('clicking a card fires onSelect', () => {
const onSelect = vi.fn();
render(<KanbanBoard board={board} onMove={vi.fn()} onSelect={onSelect} />);
fireEvent.click(screen.getByTestId('kanban-card-t-2'));
expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 't-2' }));
});
});
@@ -0,0 +1,138 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/react';
import { MonthView } from '@/components/calendar/MonthView';
import type { CalendarEntry } from '@/api/calendar';
const sampleEntries: CalendarEntry[] = [
{
id: 'a-1',
calendar_id: 'cal-1',
entry_type: 'appointment',
subtype: 'normal',
title: 'Team-Meeting',
start_at: '2026-06-15T09:00:00.000Z',
end_at: '2026-06-15T10:00:00.000Z',
all_day: false,
priority: 'medium',
status: 'open',
created_by: 'u-1',
},
{
id: 'a-2',
calendar_id: 'cal-1',
entry_type: 'appointment',
subtype: 'normal',
title: 'Kundentermin',
start_at: '2026-06-22T14:00:00.000Z',
end_at: '2026-06-22T15:00:00.000Z',
all_day: false,
priority: 'high',
status: 'open',
created_by: 'u-1',
},
];
describe('MonthView', () => {
it('renders the month grid with header', () => {
render(
<MonthView
visibleMonth={new Date(2026, 5, 1)}
entries={[]}
onCreateAt={vi.fn()}
onEditEntry={vi.fn()}
onMoveEntry={vi.fn()}
/>
);
expect(screen.getByTestId('month-view')).toBeInTheDocument();
expect(screen.getByTestId('month-view-grid')).toBeInTheDocument();
// Header includes month name and year
expect(screen.getByTestId('month-view-header').textContent).toContain('2026');
});
it('renders all 42 day cells (6 weeks × 7 days)', () => {
render(
<MonthView
visibleMonth={new Date(2026, 5, 1)}
entries={[]}
onCreateAt={vi.fn()}
onEditEntry={vi.fn()}
onMoveEntry={vi.fn()}
/>
);
const cells = screen.getAllByRole('button', { hidden: false }).filter((el) =>
el.getAttribute('data-testid')?.startsWith('month-cell-')
);
expect(cells.length).toBe(42);
});
it('renders an entry chip for each appointment and clicking it fires onEditEntry', () => {
const onEdit = vi.fn();
render(
<MonthView
visibleMonth={new Date(2026, 5, 1)}
entries={sampleEntries}
onCreateAt={vi.fn()}
onEditEntry={onEdit}
onMoveEntry={vi.fn()}
/>
);
expect(screen.getByTestId('month-entry-a-1')).toBeInTheDocument();
expect(screen.getByTestId('month-entry-a-2')).toBeInTheDocument();
fireEvent.click(screen.getByTestId('month-entry-a-1'));
expect(onEdit).toHaveBeenCalledWith(
expect.objectContaining({ id: 'a-1', title: 'Team-Meeting' })
);
});
it('clicking an empty cell fires onCreateAt with the day Date', () => {
const onCreate = vi.fn();
render(
<MonthView
visibleMonth={new Date(2026, 5, 1)}
entries={[]}
onCreateAt={onCreate}
onEditEntry={vi.fn()}
onMoveEntry={vi.fn()}
/>
);
// Pick the first cell of the grid
const cells = screen.getAllByRole('button', { hidden: false }).filter((el) =>
el.getAttribute('data-testid')?.startsWith('month-cell-')
);
fireEvent.click(cells[0]);
expect(onCreate).toHaveBeenCalledTimes(1);
const arg = onCreate.mock.calls[0][0] as Date;
expect(arg).toBeInstanceOf(Date);
});
it('drag-and-drop fires onMoveEntry with the new start time-of-day preserved', () => {
const onMove = vi.fn();
const { container } = render(
<MonthView
visibleMonth={new Date(2026, 5, 1)}
entries={sampleEntries}
onCreateAt={vi.fn()}
onEditEntry={vi.fn()}
onMoveEntry={onMove}
/>
);
const entryEl = screen.getByTestId('month-entry-a-1');
const cells = container.querySelectorAll('[data-testid^="month-cell-"]');
// Pick a cell that's clearly different from June 15
const targetCell = cells[20] as HTMLElement;
const dataTransfer = {
setData: vi.fn(),
getData: (k: string) => (k === 'application/x-calendar-entry' ? 'a-1' : ''),
types: ['application/x-calendar-entry'],
effectAllowed: '',
dropEffect: '',
};
fireEvent.dragStart(entryEl, { dataTransfer });
fireEvent.dragOver(targetCell, { dataTransfer });
fireEvent.drop(targetCell, { dataTransfer });
expect(onMove).toHaveBeenCalledTimes(1);
const [, newStart] = onMove.mock.calls[0];
expect(newStart).toBeInstanceOf(Date);
});
});
+312
View File
@@ -0,0 +1,312 @@
/**
* Calendar plugin API client.
*
* All requests use the shared `apiClient` (`baseURL: '/api/v1'`) and target the
* Calendar plugin routes under `/calendar/...`, `/calendars/...` and
* `/resources/...`.
*/
import { apiClient, apiDelete, apiGet, apiPatch, apiPost } from './client';
// ─── Types ─────────────────────────────────────────────────────────────────
export type EntryType = 'appointment' | 'task';
export type EntryStatus = 'open' | 'in_progress' | 'done' | 'cancelled';
export type EntrySubtype = 'normal' | 'follow_up' | 'private';
export type EntryPriority = 'low' | 'medium' | 'high';
export type CalendarType = 'personal' | 'team' | 'project' | 'company';
export type SharePermission = 'read' | 'write';
export interface Subtask {
id: string;
entry_id: string;
title: string;
done: boolean;
created_at?: string | null;
updated_at?: string | null;
}
export interface CalendarEntry {
id: string;
calendar_id: string;
entry_type: EntryType;
subtype: EntrySubtype;
title: string;
description?: string | null;
start_at?: string | null;
end_at?: string | null;
all_day: boolean;
location?: string | null;
due_date?: string | null;
priority: EntryPriority;
status: EntryStatus;
assigned_to?: string | null;
reminder?: Record<string, unknown> | null;
recurrence?: Record<string, unknown> | null;
created_by: string;
created_at?: string | null;
updated_at?: string | null;
links?: CalendarEntryLink[];
subtasks?: Subtask[];
occurrence_date?: string | null;
}
export interface CalendarEntryLink {
id: string;
entity_type: 'company' | 'contact';
entity_id: string;
}
export interface Calendar {
id: string;
name: string;
color: string;
type: CalendarType;
owner_id: string;
ics_token?: string | null;
created_at?: string | null;
updated_at?: string | null;
}
export interface CalendarShare {
id: string;
calendar_id: string;
user_id?: string | null;
group_id?: string | null;
permission: SharePermission;
}
export interface Resource {
id: string;
name: string;
type: string;
}
export interface ResourceBooking {
id: string;
resource_id: string;
entry_id: string;
start_at: string;
end_at: string;
}
export interface KanbanBoard {
open: CalendarEntry[];
in_progress: CalendarEntry[];
done: CalendarEntry[];
cancelled: CalendarEntry[];
}
// ─── Payloads ──────────────────────────────────────────────────────────────
export interface CalendarCreatePayload {
name: string;
color?: string;
type?: CalendarType;
}
export interface EntryCreatePayload {
calendar_id: string;
entry_type: EntryType;
title: string;
subtype?: EntrySubtype;
description?: string | null;
start_at?: string | null;
end_at?: string | null;
all_day?: boolean;
location?: string | null;
due_date?: string | null;
priority?: EntryPriority;
status?: EntryStatus;
assigned_to?: string | null;
reminder?: Record<string, unknown> | null;
recurrence?: Record<string, unknown> | null;
}
export interface EntryUpdatePayload {
calendar_id?: string;
subtype?: EntrySubtype;
title?: string;
description?: string | null;
start_at?: string | null;
end_at?: string | null;
all_day?: boolean;
location?: string | null;
due_date?: string | null;
priority?: EntryPriority;
status?: EntryStatus;
assigned_to?: string | null;
reminder?: Record<string, unknown> | null;
recurrence?: Record<string, unknown> | null;
}
export interface SubtaskCreatePayload {
title: string;
}
export interface SubtaskUpdatePayload {
title?: string;
done?: boolean;
}
export interface SharePayload {
user_id?: string;
group_id?: string;
permission: SharePermission;
}
export interface ResourceCreatePayload {
name: string;
type: string;
}
export interface BookResourcePayload {
resource_id: string;
}
// ─── Calendars ─────────────────────────────────────────────────────────────
export function fetchCalendars(): Promise<Calendar[]> {
return apiGet<Calendar[]>('/calendars');
}
export function createCalendar(payload: CalendarCreatePayload): Promise<Calendar> {
return apiPost<Calendar>('/calendars', payload);
}
export function updateCalendar(
calendarId: string,
payload: Partial<CalendarCreatePayload>
): Promise<Calendar> {
return apiPatch<Calendar>(`/calendars/${calendarId}`, payload);
}
export function deleteCalendar(calendarId: string): Promise<void> {
return apiDelete<void>(`/calendars/${calendarId}`);
}
// ─── Shares ────────────────────────────────────────────────────────────────
export function shareCalendar(calendarId: string, payload: SharePayload): Promise<CalendarShare> {
return apiPost<CalendarShare>(`/calendars/${calendarId}/share`, payload);
}
export function fetchCalendarPermissions(calendarId: string): Promise<CalendarShare[]> {
return apiGet<CalendarShare[]>(`/calendars/${calendarId}/permissions`);
}
// ─── Entries ───────────────────────────────────────────────────────────────
export interface ListEntriesParams {
start?: string;
end?: string;
}
export function listEntries(params: ListEntriesParams = {}): Promise<CalendarEntry[]> {
const search: Record<string, string> = {};
if (params.start) search.start = params.start;
if (params.end) search.end = params.end;
const qs = new URLSearchParams(search).toString();
return apiGet<CalendarEntry[]>(`/calendar/entries${qs ? `?${qs}` : ''}`);
}
export function createEntry(payload: EntryCreatePayload): Promise<CalendarEntry> {
return apiPost<CalendarEntry>('/calendar/entries', payload);
}
export function getEntry(entryId: string): Promise<CalendarEntry> {
return apiGet<CalendarEntry>(`/calendar/entries/${entryId}`);
}
export function updateEntry(
entryId: string,
payload: EntryUpdatePayload
): Promise<CalendarEntry> {
return apiPatch<CalendarEntry>(`/calendar/entries/${entryId}`, payload);
}
export function deleteEntry(entryId: string): Promise<void> {
return apiDelete<void>(`/calendar/entries/${entryId}`);
}
// ─── Subtasks ──────────────────────────────────────────────────────────────
export function createSubtask(
entryId: string,
payload: SubtaskCreatePayload
): Promise<Subtask> {
return apiPost<Subtask>(`/calendar/entries/${entryId}/subtasks`, payload);
}
export function updateSubtask(
entryId: string,
subId: string,
payload: SubtaskUpdatePayload
): Promise<Subtask> {
return apiPatch<Subtask>(
`/calendar/entries/${entryId}/subtasks/${subId}`,
payload
);
}
// ─── Kanban ────────────────────────────────────────────────────────────────
export function fetchKanbanBoard(): Promise<KanbanBoard> {
return apiGet<KanbanBoard>('/calendar/kanban');
}
// ─── ICS Feed / Import ─────────────────────────────────────────────────────
export function getIcsFeedUrl(calendarId: string, token?: string | null): string {
const base = `/calendar/${calendarId}/ics-feed`;
if (!token) return base;
return `${base}?token=${encodeURIComponent(token)}`;
}
/** Fetch ICS file as text (used by "Download .ics" button). */
export async function downloadIcsFile(calendarId: string, calendarName: string): Promise<void> {
const response = await apiClient.get(`/calendar/${calendarId}/ics-feed-public`, {
responseType: 'text',
transformResponse: [(d) => d],
});
const blob = new Blob([response.data as BlobPart], { type: 'text/calendar' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const safeName = calendarName.replace(/[^a-zA-Z0-9_-]+/g, '_');
a.download = `${safeName || 'calendar'}.ics`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
}
export interface ImportIcsResult {
entries_created: number;
errors: string[];
}
export function importIcsFile(
file: File,
calendarId?: string
): Promise<ImportIcsResult> {
const form = new FormData();
form.append('file', file);
const url = calendarId ? `/calendar/import?calendar_id=${calendarId}` : '/calendar/import';
return apiPost<ImportIcsResult>(url, form, {
headers: { 'Content-Type': 'multipart/form-data' },
});
}
// ─── Resources ─────────────────────────────────────────────────────────────
export function createResource(payload: ResourceCreatePayload): Promise<Resource> {
return apiPost<Resource>('/resources', payload);
}
export function bookResource(
entryId: string,
payload: BookResourcePayload
): Promise<ResourceBooking> {
return apiPost<ResourceBooking>(`/calendar/entries/${entryId}/book-resource`, payload);
}
@@ -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;
+105
View File
@@ -258,5 +258,110 @@
"minLength": "Mindestens {{count}} Zeichen erforderlich.",
"maxLength": "Maximal {{count}} Zeichen erlaubt.",
"passwordMismatch": "Passwörter stimmen nicht überein."
},
"calendar": {
"title": "Kalender",
"kanbanTitle": "Aufgaben-Kanban",
"today": "Heute",
"previousMonth": "Vorheriger Monat",
"nextMonth": "Nächster Monat",
"monthView": "Monatsansicht",
"kanbanView": "Kanban",
"weekdays": ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"],
"months": [
"Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember"
],
"newAppointment": "Neuer Termin",
"editAppointment": "Termin bearbeiten",
"appointmentTitle": "Titel",
"appointmentDescription": "Beschreibung",
"appointmentLocation": "Ort",
"appointmentStart": "Beginn",
"appointmentEnd": "Ende",
"appointmentAllDay": "Ganztägig",
"appointmentPriority": "Priorität",
"appointmentSubtype": "Typ",
"appointmentCalendar": "Kalender",
"save": "Speichern",
"cancel": "Abbrechen",
"delete": "Löschen",
"deleteEntryConfirm": "Eintrag wirklich löschen?",
"noCalendars": "Keine Kalender vorhanden. Bitte zuerst einen Kalender anlegen.",
"noEntries": "Keine Termine in diesem Zeitraum.",
"loading": "Wird geladen...",
"errorLoad": "Einträge konnten nicht geladen werden.",
"errorSave": "Eintrag konnte nicht gespeichert werden.",
"created": "Termin angelegt.",
"updated": "Termin aktualisiert.",
"deleted": "Termin gelöscht.",
"moved": "Termin verschoben.",
"kanban": {
"open": "Offen",
"in_progress": "In Arbeit",
"done": "Erledigt",
"cancelled": "Abgebrochen",
"empty": "Keine Aufgaben in dieser Spalte.",
"noTasks": "Keine Aufgaben vorhanden."
},
"subtasks": {
"title": "Unteraufgaben",
"addPlaceholder": "Neue Unteraufgabe...",
"add": "Hinzufügen",
"empty": "Keine Unteraufgaben vorhanden.",
"added": "Unteraufgabe angelegt.",
"updated": "Unteraufgabe aktualisiert."
},
"ics": {
"export": "ICS exportieren",
"import": "ICS importieren",
"importSuccess": "{{count}} Termine importiert.",
"importPartial": "{{count}} importiert, {{errors}} Fehler.",
"importFailed": "Import fehlgeschlagen.",
"exportSuccess": "ICS-Datei heruntergeladen.",
"chooseFile": "Datei auswählen",
"noCalendar": "Bitte zuerst einen Kalender auswählen."
},
"resources": {
"title": "Ressourcen-Buchung",
"select": "Ressource auswählen",
"none": "Keine Ressource",
"book": "Ressource buchen",
"booked": "Ressource gebucht.",
"conflict": "Konflikt: Diese Ressource ist im gewählten Zeitraum bereits belegt.",
"missingTimes": "Der Termin benötigt Start- und Endzeit."
},
"sharing": {
"title": "Freigabe",
"user": "Benutzer",
"group": "Gruppe",
"userPlaceholder": "Benutzer-ID",
"groupPlaceholder": "Gruppen-ID",
"permission": "Berechtigung",
"permissionRead": "Lesen",
"permissionWrite": "Schreiben",
"add": "Hinzufügen",
"list": "Freigaben",
"empty": "Keine Freigaben vorhanden.",
"added": "Freigabe hinzugefügt.",
"removed": "Freigabe entfernt.",
"remove": "Entfernen"
},
"priority": {
"low": "Niedrig",
"medium": "Mittel",
"high": "Hoch"
},
"status": {
"open": "Offen",
"in_progress": "In Arbeit",
"done": "Erledigt",
"cancelled": "Abgebrochen"
},
"subtype": {
"normal": "Normal",
"follow_up": "Wiedervorlage",
"private": "Privat"
}
}
}
+105
View File
@@ -258,5 +258,110 @@
"minLength": "At least {{count}} characters required.",
"maxLength": "At most {{count}} characters allowed.",
"passwordMismatch": "Passwords do not match."
},
"calendar": {
"title": "Calendar",
"kanbanTitle": "Task Kanban",
"today": "Today",
"previousMonth": "Previous month",
"nextMonth": "Next month",
"monthView": "Month view",
"kanbanView": "Kanban",
"weekdays": ["Mo", "Tu", "We", "Th", "Fr", "Sa", "Su"],
"months": [
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December"
],
"newAppointment": "New appointment",
"editAppointment": "Edit appointment",
"appointmentTitle": "Title",
"appointmentDescription": "Description",
"appointmentLocation": "Location",
"appointmentStart": "Start",
"appointmentEnd": "End",
"appointmentAllDay": "All day",
"appointmentPriority": "Priority",
"appointmentSubtype": "Type",
"appointmentCalendar": "Calendar",
"save": "Save",
"cancel": "Cancel",
"delete": "Delete",
"deleteEntryConfirm": "Really delete this entry?",
"noCalendars": "No calendars available. Please create one first.",
"noEntries": "No entries in this period.",
"loading": "Loading...",
"errorLoad": "Could not load entries.",
"errorSave": "Could not save entry.",
"created": "Entry created.",
"updated": "Entry updated.",
"deleted": "Entry deleted.",
"moved": "Entry moved.",
"kanban": {
"open": "Open",
"in_progress": "In Progress",
"done": "Done",
"cancelled": "Cancelled",
"empty": "No tasks in this column.",
"noTasks": "No tasks available."
},
"subtasks": {
"title": "Subtasks",
"addPlaceholder": "New subtask...",
"add": "Add",
"empty": "No subtasks yet.",
"added": "Subtask created.",
"updated": "Subtask updated."
},
"ics": {
"export": "Export ICS",
"import": "Import ICS",
"importSuccess": "{{count}} entries imported.",
"importPartial": "{{count}} imported, {{errors}} errors.",
"importFailed": "Import failed.",
"exportSuccess": "ICS file downloaded.",
"chooseFile": "Choose file",
"noCalendar": "Please select a calendar first."
},
"resources": {
"title": "Resource booking",
"select": "Select resource",
"none": "No resource",
"book": "Book resource",
"booked": "Resource booked.",
"conflict": "Conflict: this resource is already booked for this time range.",
"missingTimes": "The appointment needs both start and end time."
},
"sharing": {
"title": "Sharing",
"user": "User",
"group": "Group",
"userPlaceholder": "User ID",
"groupPlaceholder": "Group ID",
"permission": "Permission",
"permissionRead": "Read",
"permissionWrite": "Write",
"add": "Add",
"list": "Shares",
"empty": "No shares yet.",
"added": "Share added.",
"removed": "Share removed.",
"remove": "Remove"
},
"priority": {
"low": "Low",
"medium": "Medium",
"high": "High"
},
"status": {
"open": "Open",
"in_progress": "In Progress",
"done": "Done",
"cancelled": "Cancelled"
},
"subtype": {
"normal": "Normal",
"follow_up": "Follow up",
"private": "Private"
}
}
}
+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;
+4
View File
@@ -18,6 +18,8 @@ import { SettingsPage } from '@/pages/Settings';
import { SettingsProfilePage } from '@/pages/SettingsProfile';
import { SettingsRolesPage } from '@/pages/SettingsRoles';
import { SettingsUsersPage } from '@/pages/SettingsUsers';
import { CalendarPage } from '@/pages/Calendar';
import { CalendarKanbanPage } from '@/pages/CalendarKanban';
const router = createBrowserRouter([
{
@@ -51,6 +53,8 @@ const router = createBrowserRouter([
{ path: '/contacts/:id/edit', element: <ContactFormPage /> },
{ path: '/audit-log', element: <AuditLogPage /> },
{ path: '/search', element: <GlobalSearchResultsPage /> },
{ path: '/calendar', element: <CalendarPage /> },
{ path: '/calendar/kanban', element: <CalendarKanbanPage /> },
{
path: '/settings',
element: <SettingsPage />,
+56
View File
@@ -0,0 +1,56 @@
/**
* Calendar plugin UI store (zustand).
*
* Holds the currently selected month/week offset, the list of calendars the
* user owns / can write to, and the active calendar selection used by the
* Month view, Kanban view and ICS controls.
*
* Server state (entries, kanban board) lives in the API hooks layer — this
* store only carries view/UI selection state.
*/
import { create } from 'zustand';
import type { Calendar } from '@/api/calendar';
export interface CalendarState {
/** First day of the visible month (UTC midnight). */
visibleMonth: Date;
/** Selected calendar id (null = first available). */
activeCalendarId: string | null;
/** Cached calendar list (filled by fetchCalendars()). */
calendars: Calendar[];
setVisibleMonth: (date: Date) => void;
goToNextMonth: () => void;
goToPrevMonth: () => void;
goToToday: () => void;
setActiveCalendarId: (id: string | null) => void;
setCalendars: (calendars: Calendar[]) => void;
}
function startOfMonthUtc(d: Date): Date {
return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), 1));
}
export const useCalendarStore = create<CalendarState>((set) => ({
visibleMonth: startOfMonthUtc(new Date()),
activeCalendarId: null,
calendars: [],
setVisibleMonth: (date) => set({ visibleMonth: startOfMonthUtc(date) }),
goToNextMonth: () =>
set((s) => {
const next = new Date(s.visibleMonth);
next.setUTCMonth(next.getUTCMonth() + 1);
return { visibleMonth: next };
}),
goToPrevMonth: () =>
set((s) => {
const next = new Date(s.visibleMonth);
next.setUTCMonth(next.getUTCMonth() - 1);
return { visibleMonth: next };
}),
goToToday: () => set({ visibleMonth: startOfMonthUtc(new Date()) }),
setActiveCalendarId: (id) => set({ activeCalendarId: id }),
setCalendars: (calendars) => set({ calendars }),
}));