From 868bb274ef536496730617ddc57109ef66370583 Mon Sep 17 00:00:00 2001 From: Agent Zero Date: Sat, 25 Jul 2026 00:21:37 +0200 Subject: [PATCH] feat: standalone buttons for all plugin pages + window system for modals Standalone buttons: - Add ExternalLink button to Dms, Calendar, Mail, ContactsList toolbars - Create standalone pages for each (no sidebar, full screen) - Add standalone routes outside ProtectedRoute Window system for modals: - AppointmentModal -> AppointmentEditForm + openWindow() - ComposeModal -> MailComposeForm + openWindow() - FilePreviewModal -> FilePreviewContent + openWindow() - Calendar, Mail, Dms use openWindow() instead of modal state --- .../calendar/AppointmentEditForm.tsx | 370 +++++++++++++++ .../src/components/dms/FilePreviewContent.tsx | 109 +++++ .../src/components/mail/MailComposeForm.tsx | 440 ++++++++++++++++++ frontend/src/pages/Calendar.tsx | 129 +++-- frontend/src/pages/CalendarStandalone.tsx | 10 + frontend/src/pages/ContactsList.tsx | 13 +- frontend/src/pages/ContactsStandalone.tsx | 10 + frontend/src/pages/Dms.tsx | 47 +- frontend/src/pages/DmsStandalone.tsx | 10 + frontend/src/pages/Mail.tsx | 176 ++++--- frontend/src/pages/MailStandalone.tsx | 10 + frontend/src/routes/index.tsx | 20 + 12 files changed, 1220 insertions(+), 124 deletions(-) create mode 100644 frontend/src/components/calendar/AppointmentEditForm.tsx create mode 100644 frontend/src/components/dms/FilePreviewContent.tsx create mode 100644 frontend/src/components/mail/MailComposeForm.tsx create mode 100644 frontend/src/pages/CalendarStandalone.tsx create mode 100644 frontend/src/pages/ContactsStandalone.tsx create mode 100644 frontend/src/pages/DmsStandalone.tsx create mode 100644 frontend/src/pages/MailStandalone.tsx diff --git a/frontend/src/components/calendar/AppointmentEditForm.tsx b/frontend/src/components/calendar/AppointmentEditForm.tsx new file mode 100644 index 0000000..a4e8b46 --- /dev/null +++ b/frontend/src/components/calendar/AppointmentEditForm.tsx @@ -0,0 +1,370 @@ +/** + * AppointmentEditForm — form content for create/edit an appointment (or task) entry. + * Extracted from AppointmentModal for use with the Window system. + * Form validation: React Hook Form + Zod. + */ + +import React, { useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { z } from 'zod'; +import { Button } from '@/components/ui/Button'; +import { Input } from '@/components/ui/Input'; +import { Select } from '@/components/ui/Select'; +import { useWindowStore } from '@/store/windowStore'; +import { + createEntry, + updateEntry, + deleteEntry, + type Calendar, + type CalendarEntry, + type EntryCreatePayload, +} from '@/api/calendar'; +import { formatDateTimeInput } from '@/utils/date'; + +export interface AppointmentEditFormProps { + entry?: CalendarEntry | null; + prefillDate?: Date | null; + calendars: Calendar[]; + defaultCalendarId?: string | null; + onSaved: (entry: CalendarEntry) => void; + onDeleted: (entryId: string) => void; + windowId?: string; +} + +function toDateTimeLocalValue(d: Date | null | undefined): string { + if (!d) return ''; + return formatDateTimeInput(d); +} + +function fromDateTimeLocalValue(v: string): Date | null { + if (!v) return null; + const d = new Date(v); + if (Number.isNaN(d.getTime())) return null; + return d; +} + +// ── Zod Schema ── + +const appointmentSchema = z + .object({ + title: z.string().min(1, 'required'), + calendar_id: z.string().min(1, 'required'), + start_at: z.string().min(1, 'required'), + end_at: z.string().min(1, 'required'), + all_day: z.boolean().default(false), + priority: z.enum(['low', 'medium', 'high']).default('medium'), + subtype: z.enum(['normal', 'follow_up', 'private']).default('normal'), + location: z.string().optional().default(''), + description: z.string().optional().default(''), + }) + .superRefine((data, ctx) => { + if (data.start_at && data.end_at) { + const start = new Date(data.start_at); + const end = new Date(data.end_at); + if (!Number.isNaN(start.getTime()) && !Number.isNaN(end.getTime())) { + if (end < start) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'endBeforeStart', + path: ['end_at'], + }); + } + } + } + }); + +type AppointmentFormData = z.infer; + +export function AppointmentEditForm({ + entry, + prefillDate, + calendars, + defaultCalendarId, + onSaved, + onDeleted, + windowId, +}: AppointmentEditFormProps) { + const { t } = useTranslation(); + const closeWindow = useWindowStore((s) => s.closeWindow); + const isEdit = !!entry; + + const handleClose = () => { + if (windowId) closeWindow(windowId); + }; + + 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 { + register, + handleSubmit, + reset, + formState: { errors, isSubmitting }, + } = useForm({ + resolver: zodResolver(appointmentSchema), + defaultValues: { + title: '', + calendar_id: '', + start_at: toDateTimeLocalValue(initialStart), + end_at: toDateTimeLocalValue(initialEnd), + all_day: false, + priority: 'medium', + subtype: 'normal', + location: '', + description: '', + }, + }); + + // Reset form when entry / prefill changes + useEffect(() => { + const fallbackId = + entry?.calendar_id ?? defaultCalendarId ?? calendars[0]?.id ?? ''; + reset({ + title: entry?.title ?? '', + calendar_id: fallbackId, + start_at: toDateTimeLocalValue(entry?.start_at ? new Date(entry.start_at) : initialStart), + end_at: toDateTimeLocalValue(entry?.end_at ? new Date(entry.end_at) : initialEnd), + all_day: entry?.all_day ?? false, + priority: entry?.priority ?? 'medium', + subtype: entry?.subtype ?? 'normal', + location: entry?.location ?? '', + description: entry?.description ?? '', + }); + }, [entry, defaultCalendarId, calendars, initialStart, initialEnd, reset]); + + const onSubmit = async (data: AppointmentFormData) => { + const startDate = fromDateTimeLocalValue(data.start_at); + const endDate = fromDateTimeLocalValue(data.end_at); + if (isEdit && entry) { + const updated = await updateEntry(entry.id, { + title: data.title, + description: data.description || null, + location: data.location || null, + start_at: startDate ? startDate.toISOString() : null, + end_at: endDate ? endDate.toISOString() : null, + all_day: data.all_day, + priority: data.priority, + subtype: data.subtype, + calendar_id: data.calendar_id, + }); + onSaved(updated); + } else { + const payload: EntryCreatePayload = { + calendar_id: data.calendar_id, + entry_type: 'appointment', + title: data.title, + description: data.description || null, + location: data.location || null, + start_at: startDate ? startDate.toISOString() : null, + end_at: endDate ? endDate.toISOString() : null, + all_day: data.all_day, + priority: data.priority, + subtype: data.subtype, + status: 'open', + }; + const created = await createEntry(payload); + onSaved(created); + } + handleClose(); + }; + + const handleDelete = async () => { + if (!entry) return; + if (!window.confirm(t('calendar.deleteEntryConfirm'))) return; + try { + await deleteEntry(entry.id); + onDeleted(entry.id); + handleClose(); + } catch (e) { + const err = e as { message?: string }; + console.error(err.message ?? t('calendar.errorSave')); + } + }; + + const calendarOptions = useMemo( + () => [...calendars.map((c) => ({ value: c.id, label: c.name }))], + [calendars] + ); + + const errorMsg = (key: string | undefined) => { + if (!key) return undefined; + if (key === 'required') return t('validation.required'); + if (key === 'endBeforeStart') return t('calendar.endBeforeStart', 'End must be after start'); + return key; + }; + + return ( +
+
+ + +
+ +
+ + +
+
+ + +
+ + +
+ + +
+ +
+ + +
+ +
+ +