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
This commit is contained in:
@@ -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<typeof appointmentSchema>;
|
||||
|
||||
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<AppointmentFormData>({
|
||||
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 (
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4 p-4" data-testid="appointment-modal">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-secondary-700 mb-1">
|
||||
{t('calendar.appointmentTitle')} *
|
||||
</label>
|
||||
<Input
|
||||
{...register('title')}
|
||||
error={errorMsg(errors.title?.message)}
|
||||
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
|
||||
{...register('calendar_id')}
|
||||
error={errorMsg(errors.calendar_id?.message)}
|
||||
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"
|
||||
{...register('start_at')}
|
||||
error={errorMsg(errors.start_at?.message)}
|
||||
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"
|
||||
{...register('end_at')}
|
||||
error={errorMsg(errors.end_at?.message)}
|
||||
data-testid="appointment-end"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="appointment-allday"
|
||||
type="checkbox"
|
||||
{...register('all_day')}
|
||||
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
|
||||
{...register('location')}
|
||||
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
|
||||
{...register('description')}
|
||||
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
|
||||
{...register('priority')}
|
||||
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
|
||||
{...register('subtype')}
|
||||
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>
|
||||
|
||||
{(errors.title || errors.calendar_id || errors.start_at || errors.end_at) && (
|
||||
<div className="text-sm text-danger-700 bg-danger-50 border border-danger-200 rounded-md p-2" data-testid="appointment-error">
|
||||
{errorMsg(errors.title?.message) || errorMsg(errors.calendar_id?.message) || errorMsg(errors.start_at?.message) || errorMsg(errors.end_at?.message)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between gap-2 pt-2">
|
||||
<div>
|
||||
{isEdit && (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={handleDelete}
|
||||
disabled={isSubmitting}
|
||||
data-testid="appointment-delete"
|
||||
type="button"
|
||||
>
|
||||
{t('calendar.delete')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={handleClose} disabled={isSubmitting} type="button">
|
||||
{t('calendar.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
isLoading={isSubmitting}
|
||||
data-testid="appointment-save"
|
||||
>
|
||||
{t('calendar.save')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppointmentEditForm;
|
||||
Reference in New Issue
Block a user