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