From 735073955471cecb8cffe90612b3d3754fbcf306 Mon Sep 17 00:00:00 2001 From: A0-Orchestrator Date: Tue, 30 Jun 2026 11:35:08 +0200 Subject: [PATCH] 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 --- .../calendar/AppointmentModal.test.tsx | 182 +++++++++ .../__tests__/calendar/KanbanBoard.test.tsx | 92 +++++ .../src/__tests__/calendar/MonthView.test.tsx | 138 +++++++ frontend/src/api/calendar.ts | 312 +++++++++++++++ .../components/calendar/AppointmentModal.tsx | 361 ++++++++++++++++++ .../src/components/calendar/IcsControls.tsx | 148 +++++++ .../src/components/calendar/KanbanBoard.tsx | 174 +++++++++ .../src/components/calendar/MonthView.tsx | 226 +++++++++++ .../components/calendar/ResourceBooking.tsx | 122 ++++++ .../components/calendar/SharingSettings.tsx | 201 ++++++++++ .../components/calendar/TaskDetailPanel.tsx | 222 +++++++++++ frontend/src/i18n/locales/de.json | 105 +++++ frontend/src/i18n/locales/en.json | 105 +++++ frontend/src/pages/Calendar.tsx | 266 +++++++++++++ frontend/src/pages/CalendarKanban.tsx | 123 ++++++ frontend/src/routes/index.tsx | 4 + frontend/src/stores/calendarStore.ts | 56 +++ 17 files changed, 2837 insertions(+) create mode 100644 frontend/src/__tests__/calendar/AppointmentModal.test.tsx create mode 100644 frontend/src/__tests__/calendar/KanbanBoard.test.tsx create mode 100644 frontend/src/__tests__/calendar/MonthView.test.tsx create mode 100644 frontend/src/api/calendar.ts create mode 100644 frontend/src/components/calendar/AppointmentModal.tsx create mode 100644 frontend/src/components/calendar/IcsControls.tsx create mode 100644 frontend/src/components/calendar/KanbanBoard.tsx create mode 100644 frontend/src/components/calendar/MonthView.tsx create mode 100644 frontend/src/components/calendar/ResourceBooking.tsx create mode 100644 frontend/src/components/calendar/SharingSettings.tsx create mode 100644 frontend/src/components/calendar/TaskDetailPanel.tsx create mode 100644 frontend/src/pages/Calendar.tsx create mode 100644 frontend/src/pages/CalendarKanban.tsx create mode 100644 frontend/src/stores/calendarStore.ts diff --git a/frontend/src/__tests__/calendar/AppointmentModal.test.tsx b/frontend/src/__tests__/calendar/AppointmentModal.test.tsx new file mode 100644 index 0000000..b36dff6 --- /dev/null +++ b/frontend/src/__tests__/calendar/AppointmentModal.test.tsx @@ -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('@/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( + + ); + 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( + + ); + 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).mockResolvedValue(sampleEntry); + const onSaved = vi.fn(); + const onClose = vi.fn(); + render( + + ); + 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).mockResolvedValue({ + ...sampleEntry, + title: 'Geändert', + }); + const onSaved = vi.fn(); + render( + + ); + 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( + + ); + 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).mockResolvedValue(undefined); + const onDeleted = vi.fn(); + const onClose = vi.fn(); + // Stub confirm + window.confirm = vi.fn(() => true); + render( + + ); + fireEvent.click(screen.getByTestId('appointment-delete')); + await waitFor(() => { + expect(deleteEntry).toHaveBeenCalledWith('e-1'); + }); + expect(onDeleted).toHaveBeenCalledWith('e-1'); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/__tests__/calendar/KanbanBoard.test.tsx b/frontend/src/__tests__/calendar/KanbanBoard.test.tsx new file mode 100644 index 0000000..8dc95c7 --- /dev/null +++ b/frontend/src/__tests__/calendar/KanbanBoard.test.tsx @@ -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(); + 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(); + 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(); + 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( + + ); + expect(screen.getByTestId('kanban-empty')).toBeInTheDocument(); + }); + + it('drag-and-drop into another column fires onMove with target status', () => { + const onMove = vi.fn(); + render(); + 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(); + fireEvent.click(screen.getByTestId('kanban-card-t-2')); + expect(onSelect).toHaveBeenCalledWith(expect.objectContaining({ id: 't-2' })); + }); +}); diff --git a/frontend/src/__tests__/calendar/MonthView.test.tsx b/frontend/src/__tests__/calendar/MonthView.test.tsx new file mode 100644 index 0000000..6ca1915 --- /dev/null +++ b/frontend/src/__tests__/calendar/MonthView.test.tsx @@ -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( + + ); + 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( + + ); + 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( + + ); + 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( + + ); + // 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( + + ); + 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); + }); +}); diff --git a/frontend/src/api/calendar.ts b/frontend/src/api/calendar.ts new file mode 100644 index 0000000..715f95b --- /dev/null +++ b/frontend/src/api/calendar.ts @@ -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 | null; + recurrence?: Record | 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 | null; + recurrence?: Record | 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 | null; + recurrence?: Record | 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 { + return apiGet('/calendars'); +} + +export function createCalendar(payload: CalendarCreatePayload): Promise { + return apiPost('/calendars', payload); +} + +export function updateCalendar( + calendarId: string, + payload: Partial +): Promise { + return apiPatch(`/calendars/${calendarId}`, payload); +} + +export function deleteCalendar(calendarId: string): Promise { + return apiDelete(`/calendars/${calendarId}`); +} + +// ─── Shares ──────────────────────────────────────────────────────────────── + +export function shareCalendar(calendarId: string, payload: SharePayload): Promise { + return apiPost(`/calendars/${calendarId}/share`, payload); +} + +export function fetchCalendarPermissions(calendarId: string): Promise { + return apiGet(`/calendars/${calendarId}/permissions`); +} + +// ─── Entries ─────────────────────────────────────────────────────────────── + +export interface ListEntriesParams { + start?: string; + end?: string; +} + +export function listEntries(params: ListEntriesParams = {}): Promise { + const search: Record = {}; + if (params.start) search.start = params.start; + if (params.end) search.end = params.end; + const qs = new URLSearchParams(search).toString(); + return apiGet(`/calendar/entries${qs ? `?${qs}` : ''}`); +} + +export function createEntry(payload: EntryCreatePayload): Promise { + return apiPost('/calendar/entries', payload); +} + +export function getEntry(entryId: string): Promise { + return apiGet(`/calendar/entries/${entryId}`); +} + +export function updateEntry( + entryId: string, + payload: EntryUpdatePayload +): Promise { + return apiPatch(`/calendar/entries/${entryId}`, payload); +} + +export function deleteEntry(entryId: string): Promise { + return apiDelete(`/calendar/entries/${entryId}`); +} + +// ─── Subtasks ────────────────────────────────────────────────────────────── + +export function createSubtask( + entryId: string, + payload: SubtaskCreatePayload +): Promise { + return apiPost(`/calendar/entries/${entryId}/subtasks`, payload); +} + +export function updateSubtask( + entryId: string, + subId: string, + payload: SubtaskUpdatePayload +): Promise { + return apiPatch( + `/calendar/entries/${entryId}/subtasks/${subId}`, + payload + ); +} + +// ─── Kanban ──────────────────────────────────────────────────────────────── + +export function fetchKanbanBoard(): Promise { + return apiGet('/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 { + 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 { + const form = new FormData(); + form.append('file', file); + const url = calendarId ? `/calendar/import?calendar_id=${calendarId}` : '/calendar/import'; + return apiPost(url, form, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); +} + +// ─── Resources ───────────────────────────────────────────────────────────── + +export function createResource(payload: ResourceCreatePayload): Promise { + return apiPost('/resources', payload); +} + +export function bookResource( + entryId: string, + payload: BookResourcePayload +): Promise { + return apiPost(`/calendar/entries/${entryId}/book-resource`, payload); +} diff --git a/frontend/src/components/calendar/AppointmentModal.tsx b/frontend/src/components/calendar/AppointmentModal.tsx new file mode 100644 index 0000000..6c3d1da --- /dev/null +++ b/frontend/src/components/calendar/AppointmentModal.tsx @@ -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(toDateTimeLocalValue(initialStart)); + const [endAt, setEndAt] = useState(toDateTimeLocalValue(initialEnd)); + const [allDay, setAllDay] = useState(false); + const [priority, setPriority] = useState('medium'); + const [subtype, setSubtype] = useState('normal'); + const [calendarId, setCalendarId] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(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 ( + +
+
+ + setTitle(e.target.value)} + placeholder={t('calendar.appointmentTitle')} + data-testid="appointment-title" + /> +
+ +
+ + setStartAt(e.target.value)} + data-testid="appointment-start" + /> +
+
+ + setEndAt(e.target.value)} + data-testid="appointment-end" + /> +
+
+ +
+ setAllDay(e.target.checked)} + className="h-4 w-4 rounded border-secondary-300 text-primary-600 focus:ring-primary-500" + data-testid="appointment-allday" + /> + +
+ +
+ + setLocation(e.target.value)} + placeholder={t('calendar.appointmentLocation')} + data-testid="appointment-location" + /> +
+ +
+ +