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:
Agent Zero
2026-07-25 00:21:37 +02:00
parent 2f6c3175a3
commit 868bb274ef
12 changed files with 1220 additions and 124 deletions
@@ -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;
@@ -0,0 +1,109 @@
/**
* FilePreviewContent — preview content for DMS files.
* Extracted from FilePreviewModal for use with the Window system.
* Renders PDF files in an iframe via the preview endpoint.
* Shows file metadata and download button for non-PDF files.
*/
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/Button';
import { formatDateShort } from '@/utils/date';
import { Badge } from '@/components/ui/Badge';
import { getFilePreviewUrl, type DmsFile } from '@/api/dms';
import { useWindowStore } from '@/store/windowStore';
import { FileText } from 'lucide-react';
export interface FilePreviewContentProps {
file: DmsFile | null;
windowId?: string;
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
export function FilePreviewContent({ file, windowId }: FilePreviewContentProps) {
const { t } = useTranslation();
const closeWindow = useWindowStore((s) => s.closeWindow);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
useEffect(() => {
if (file) {
setPreviewUrl(getFilePreviewUrl(file.id));
} else {
setPreviewUrl(null);
}
}, [file]);
if (!file) return null;
const isPdf = file.mime_type === 'application/pdf';
const isImage = file.mime_type.startsWith('image/');
const handleClose = () => {
if (windowId) closeWindow(windowId);
};
return (
<div className="space-y-4 p-4" data-testid="file-preview-modal">
<div className="flex items-center gap-4 flex-wrap">
<Badge variant="info">{file.mime_type.split('/')[1]?.toUpperCase() || t('dms.icon.other')}</Badge>
<span className="text-sm text-secondary-500">{formatFileSize(file.size_bytes ?? file.size ?? 0)}</span>
{file.created_at && (
<span className="text-sm text-secondary-500">
{t('dms.fileModified')}: {formatDateShort(file.created_at)}
</span>
)}
</div>
{isPdf && previewUrl && (
<div className="border border-secondary-200 rounded-lg overflow-hidden" data-testid="pdf-preview">
<iframe
src={previewUrl}
className="w-full h-[60vh]"
title={file.name}
aria-label={t('dms.preview')}
/>
</div>
)}
{isImage && previewUrl && (
<div className="flex justify-center border border-secondary-200 rounded-lg p-4" data-testid="image-preview">
<img
src={previewUrl}
alt={file.name}
className="max-h-[60vh] object-contain"
/>
</div>
)}
{!isPdf && !isImage && (
<div className="text-center py-12" data-testid="no-preview-available">
<FileText className="mx-auto h-12 w-12 text-secondary-300" aria-hidden="true" strokeWidth={1.5} />
<p className="mt-2 text-sm text-secondary-500">{t('dms.preview')}</p>
</div>
)}
<div className="flex justify-end gap-3">
{previewUrl && (
<a
href={previewUrl}
download={file.name}
className="inline-flex items-center px-4 py-2 rounded-md bg-primary-600 text-white text-sm font-medium hover:bg-primary-700 min-h-touch"
>
{t('dms.download')}
</a>
)}
<Button variant="secondary" onClick={handleClose}>
{t('dms.cancel')}
</Button>
</div>
</div>
);
}
export default FilePreviewContent;
@@ -0,0 +1,440 @@
/**
* MailComposeForm — compose form content for new mail, reply, and forward.
* Extracted from ComposeModal for use with the Window system.
* Form validation: React Hook Form + Zod.
*/
import React, { useState, useRef, useCallback, useEffect } 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 { TemplatePicker } from './TemplatePicker';
import { RichTextEditor } from './RichTextEditor';
import type { Mail, MailSignature, SendMailPayload, ReplyPayload, ForwardPayload, MailDraftPayload } from '@/api/mail';
import { uploadAttachment, type UploadedAttachment, replaceSignatureVariables } from '@/api/mail';
import { useAuthStore } from '@/store/authStore';
import { useWindowStore } from '@/store/windowStore';
import { FileText, Paperclip, X } from 'lucide-react';
export type ComposeMode = 'new' | 'reply' | 'forward' | 'draft';
export interface MailComposeFormProps {
mode: ComposeMode;
accountId: string;
replyToMail: Mail | null;
forwardMail: Mail | null;
draftMail?: Mail | null;
signatures: MailSignature[];
onSend: (payload: SendMailPayload | ReplyPayload | ForwardPayload, mode: ComposeMode) => Promise<void>;
onSaveDraft?: (payload: MailDraftPayload) => Promise<void>;
windowId?: string;
}
// ── Zod Schema ──
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
function validateEmailList(val: string, ctx: z.RefinementCtx, field: string, required: boolean) {
const emails = val.split(',').map((s) => s.trim()).filter(Boolean);
if (required && emails.length === 0) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'required', path: [field] });
return;
}
for (const email of emails) {
if (!emailRegex.test(email)) {
ctx.addIssue({ code: z.ZodIssueCode.custom, message: 'invalidEmail', path: [field] });
return;
}
}
}
const composeSchema = z.object({
to: z.string().default(''),
cc: z.string().optional().default(''),
bcc: z.string().optional().default(''),
subject: z.string().min(1, 'required'),
body: z.string().default(''),
}).superRefine((data, ctx) => {
validateEmailList(data.to, ctx, 'to', true);
if (data.cc) validateEmailList(data.cc, ctx, 'cc', false);
if (data.bcc) validateEmailList(data.bcc, ctx, 'bcc', false);
});
type ComposeFormData = z.infer<typeof composeSchema>;
export function MailComposeForm({
mode,
accountId,
replyToMail,
forwardMail,
draftMail,
signatures,
onSend,
onSaveDraft,
windowId,
}: MailComposeFormProps) {
const { t } = useTranslation();
const authUser = useAuthStore((s) => s.user);
const authTenant = useAuthStore((s) => s.currentTenant);
const closeWindow = useWindowStore((s) => s.closeWindow);
const [showCc, setShowCc] = useState(false);
const [selectedSignatureId, setSelectedSignatureId] = useState('');
const [showTemplatePicker, setShowTemplatePicker] = useState(false);
const [attachments, setAttachments] = useState<UploadedAttachment[]>([]);
const [uploadingFile, setUploadingFile] = useState(false);
const [sending, setSending] = useState(false);
const [savingDraft, setSavingDraft] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleClose = () => {
if (windowId) closeWindow(windowId);
};
const {
register,
handleSubmit,
reset,
setValue,
watch,
formState: { errors },
} = useForm<ComposeFormData>({
resolver: zodResolver(composeSchema),
defaultValues: { to: '', cc: '', bcc: '', subject: '', body: '' },
});
const bodyValue = watch('body');
useEffect(() => {
if (mode === 'reply' && replyToMail) {
reset({
to: replyToMail.from_address,
cc: '',
bcc: '',
subject: replyToMail.subject.startsWith('Re: ') ? replyToMail.subject : `Re: ${replyToMail.subject}`,
body: `\n\n---\n${replyToMail.body_text.slice(0, 200)}`,
});
} else if (mode === 'forward' && forwardMail) {
reset({
to: '',
cc: '',
bcc: '',
subject: forwardMail.subject.startsWith('Fwd: ') ? forwardMail.subject : `Fwd: ${forwardMail.subject}`,
body: `\n\n---\n${t('mail.forwarding')}\n${t('mail.from')}: ${forwardMail.from_address}\n${t('mail.subject')}: ${forwardMail.subject}\n\n${forwardMail.body_text.slice(0, 200)}`,
});
} else if (mode === 'draft' && draftMail) {
reset({
to: draftMail.to_addresses.join(', '),
cc: draftMail.cc_addresses.join(', '),
bcc: draftMail.bcc_addresses.join(', '),
subject: draftMail.subject,
body: draftMail.body_html || draftMail.body_text || '',
});
setShowCc(draftMail.cc_addresses.length > 0 || draftMail.bcc_addresses.length > 0);
} else {
reset({ to: '', cc: '', bcc: '', subject: '', body: '' });
setAttachments([]);
}
}, [mode, replyToMail, forwardMail, draftMail, t, reset]);
const insertSignature = useCallback((signatureId: string) => {
setSelectedSignatureId(signatureId);
const sig = signatures.find((s) => s.id === signatureId);
if (sig) {
const processedHtml = replaceSignatureVariables(
sig.body_html,
{ name: authUser ? `${authUser.first_name} ${authUser.last_name}`.trim() : undefined, email: authUser?.email, role: authUser?.role, first_name: authUser?.first_name, last_name: authUser?.last_name },
{ name: authTenant?.name },
);
setValue('body', `${bodyValue}<br/><br/>---<br/>${processedHtml}`);
}
}, [signatures, authUser, authTenant, setValue, bodyValue]);
const handleTemplateSelect = useCallback((templateBody: string, templateSubject: string) => {
setValue('body', templateBody);
if (templateSubject) {
setValue('subject', templateSubject);
}
setShowTemplatePicker(false);
}, [setValue]);
const handleFileSelect = useCallback(async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
setUploadingFile(true);
try {
for (let i = 0; i < files.length; i++) {
const file = files[i];
if (file.size > 25 * 1024 * 1024) {
alert(`${file.name} exceeds 25 MB limit`);
continue;
}
const uploaded = await uploadAttachment(file);
setAttachments((prev) => [...prev, uploaded]);
}
} catch (err) {
console.error('Attachment upload failed:', err);
alert('Failed to upload attachment');
} finally {
setUploadingFile(false);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
}, []);
const handleRemoveAttachment = useCallback((attId: string) => {
setAttachments((prev) => prev.filter((a) => a.id !== attId));
}, []);
const formatBytes = (bytes: number): string => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
const parseEmailList = (val: string): string[] =>
val ? val.split(',').map((s) => s.trim()).filter(Boolean) : [];
const onSendValidated = useCallback(async (data: ComposeFormData) => {
setSending(true);
try {
const toList = parseEmailList(data.to);
const ccList = data.cc ? parseEmailList(data.cc) : undefined;
const bccList = data.bcc ? parseEmailList(data.bcc) : undefined;
if (mode === 'reply' && replyToMail) {
const replyPayload: ReplyPayload = {
account_id: accountId,
body: data.body,
is_html: true,
to: toList,
cc: ccList,
signature_id: selectedSignatureId || null,
};
await onSend(replyPayload, 'reply');
} else if (mode === 'forward' && forwardMail) {
const fwdPayload: ForwardPayload = {
account_id: accountId,
to: toList,
body: data.body,
is_html: true,
signature_id: selectedSignatureId || null,
};
await onSend(fwdPayload, 'forward');
} else {
const sendPayload: SendMailPayload = {
account_id: accountId,
to: toList,
cc: ccList,
bcc: bccList,
subject: data.subject,
body: data.body,
is_html: true,
signature_id: selectedSignatureId || null,
attachments: attachments.map((a) => a.id),
};
await onSend(sendPayload, 'new');
}
setAttachments([]);
handleClose();
} finally {
setSending(false);
}
}, [mode, replyToMail, forwardMail, accountId, selectedSignatureId, attachments, onSend, handleClose]);
const handleSaveDraft = useCallback(async () => {
if (!onSaveDraft) return;
const data = watch();
setSavingDraft(true);
try {
const toList = parseEmailList(data.to);
const ccList = data.cc ? parseEmailList(data.cc) : [];
const bccList = data.bcc ? parseEmailList(data.bcc) : [];
const payload: MailDraftPayload = {
account_id: accountId,
to: toList,
cc: ccList,
bcc: bccList,
subject: data.subject,
body_text: data.body.replace(/<[^>]*>/g, ''),
body_html: data.body,
};
await onSaveDraft(payload);
} finally {
setSavingDraft(false);
}
}, [onSaveDraft, watch, accountId]);
const title = mode === 'reply' ? t('mail.reply') : mode === 'forward' ? t('mail.forward') : mode === 'draft' ? t('mail.editDraft') : t('mail.compose');
const errorMsg = (key: string | undefined) => {
if (!key) return undefined;
if (key === 'required') return t('validation.required');
if (key === 'invalidEmail') return t('validation.email');
return key;
};
return (
<form onSubmit={handleSubmit(onSendValidated)} className="space-y-4 p-4" data-testid="compose-modal">
{/* Template picker toggle */}
<div className="flex items-center gap-2" data-testid="compose-toolbar">
<button
onClick={() => setShowTemplatePicker(!showTemplatePicker)}
className="px-3 py-1.5 rounded hover:bg-secondary-100 text-sm"
aria-label={t('mail.insertTemplate')}
title={t('mail.insertTemplate')}
type="button"
data-testid="template-picker-toggle"
>
{t('mail.template')}
</button>
</div>
{showTemplatePicker && (
<TemplatePicker onSelect={handleTemplateSelect} />
)}
{/* Recipients */}
<div className="space-y-2">
<Input
label={t('mail.to')}
{...register('to')}
error={errorMsg(errors.to?.message)}
placeholder="recipient@example.com"
required
data-testid="compose-to"
/>
<div className="flex items-center gap-2">
{!showCc ? (
<button
onClick={() => setShowCc(true)}
className="text-sm text-primary-600 hover:text-primary-700"
type="button"
>
{t('mail.showCcBcc')}
</button>
) : (
<div className="w-full grid grid-cols-1 sm:grid-cols-2 gap-2">
<Input
label={t('mail.cc')}
{...register('cc')}
error={errorMsg(errors.cc?.message)}
placeholder="cc@example.com"
/>
<Input
label={t('mail.bcc')}
{...register('bcc')}
error={errorMsg(errors.bcc?.message)}
placeholder="bcc@example.com"
/>
</div>
)}
</div>
<Input
label={t('mail.subject')}
{...register('subject')}
error={errorMsg(errors.subject?.message)}
placeholder={t('mail.subjectPlaceholder')}
data-testid="compose-subject"
/>
</div>
{/* Editor */}
<div>
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('mail.body')}</label>
<RichTextEditor
content={bodyValue}
onChange={(html: string) => setValue('body', html)}
placeholder={t('mail.body')}
/>
</div>
{/* Attachments */}
<div data-testid="compose-attachments">
<label className="block text-sm font-medium text-secondary-700 mb-1">{t('mail.attachments')}</label>
<div className="flex items-center gap-2">
<input
ref={fileInputRef}
type="file"
multiple
onChange={handleFileSelect}
className="hidden"
data-testid="compose-file-input"
/>
<Button
variant="secondary"
size="sm"
type="button"
onClick={() => fileInputRef.current?.click()}
isLoading={uploadingFile}
icon={
<Paperclip className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
}
>
{t('mail.addAttachment')}
</Button>
<span className="text-xs text-secondary-400">Max 25 MB per file</span>
</div>
{attachments.length > 0 && (
<ul className="mt-2 space-y-1">
{attachments.map((att) => (
<li key={att.id} className="flex items-center gap-3 p-2 rounded-md bg-secondary-50">
<FileText className="w-5 h-5 text-secondary-400 flex-shrink-0" aria-hidden="true" strokeWidth={2} />
<div className="flex-1 min-w-0">
<p className="text-sm text-secondary-800 truncate">{att.filename}</p>
<p className="text-xs text-secondary-400">{formatBytes(att.size_bytes)}</p>
</div>
<button
type="button"
onClick={() => handleRemoveAttachment(att.id)}
className="p-1 rounded hover:bg-secondary-200 text-secondary-500"
aria-label={t('common.remove')}
>
<X className="w-4 h-4" aria-hidden="true" strokeWidth={2} />
</button>
</li>
))}
</ul>
)}
</div>
{/* Signature */}
<Select
label={t('mail.signature')}
value={selectedSignatureId}
onChange={(e) => insertSignature(e.target.value)}
options={[
{ value: '', label: t('mail.noSignature') },
...signatures.map((sig) => ({ value: sig.id, label: sig.name })),
]}
/>
{/* Actions */}
<div className="flex justify-end gap-2 pt-2 border-t border-secondary-200 sticky bottom-0 bg-white py-3">
<Button variant="secondary" onClick={handleClose} type="button">
{t('common.cancel')}
</Button>
{onSaveDraft && (
<Button
variant="secondary"
onClick={handleSaveDraft}
isLoading={savingDraft}
type="button"
data-testid="compose-save-draft"
>
{t('mail.saveDraft')}
</Button>
)}
<Button type="submit" isLoading={sending} data-testid="compose-send">
{t('mail.send')}
</Button>
</div>
</form>
);
}
export default MailComposeForm;