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.",
|
"minLength": "Mindestens {{count}} Zeichen erforderlich.",
|
||||||
"maxLength": "Maximal {{count}} Zeichen erlaubt.",
|
"maxLength": "Maximal {{count}} Zeichen erlaubt.",
|
||||||
"passwordMismatch": "Passwörter stimmen nicht überein."
|
"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.",
|
"minLength": "At least {{count}} characters required.",
|
||||||
"maxLength": "At most {{count}} characters allowed.",
|
"maxLength": "At most {{count}} characters allowed.",
|
||||||
"passwordMismatch": "Passwords do not match."
|
"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 { SettingsProfilePage } from '@/pages/SettingsProfile';
|
||||||
import { SettingsRolesPage } from '@/pages/SettingsRoles';
|
import { SettingsRolesPage } from '@/pages/SettingsRoles';
|
||||||
import { SettingsUsersPage } from '@/pages/SettingsUsers';
|
import { SettingsUsersPage } from '@/pages/SettingsUsers';
|
||||||
|
import { CalendarPage } from '@/pages/Calendar';
|
||||||
|
import { CalendarKanbanPage } from '@/pages/CalendarKanban';
|
||||||
|
|
||||||
const router = createBrowserRouter([
|
const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@@ -51,6 +53,8 @@ const router = createBrowserRouter([
|
|||||||
{ path: '/contacts/:id/edit', element: <ContactFormPage /> },
|
{ path: '/contacts/:id/edit', element: <ContactFormPage /> },
|
||||||
{ path: '/audit-log', element: <AuditLogPage /> },
|
{ path: '/audit-log', element: <AuditLogPage /> },
|
||||||
{ path: '/search', element: <GlobalSearchResultsPage /> },
|
{ path: '/search', element: <GlobalSearchResultsPage /> },
|
||||||
|
{ path: '/calendar', element: <CalendarPage /> },
|
||||||
|
{ path: '/calendar/kanban', element: <CalendarKanbanPage /> },
|
||||||
{
|
{
|
||||||
path: '/settings',
|
path: '/settings',
|
||||||
element: <SettingsPage />,
|
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