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,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);
|
||||
}
|
||||
Reference in New Issue
Block a user