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
+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);
}