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;
|
||||||
@@ -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;
|
||||||
@@ -20,12 +20,13 @@ import { DayView } from '@/components/calendar/DayView';
|
|||||||
import { RangeView } from '@/components/calendar/RangeView';
|
import { RangeView } from '@/components/calendar/RangeView';
|
||||||
import { formatDateInput } from '@/utils/date';
|
import { formatDateInput } from '@/utils/date';
|
||||||
import { CalendarDetail } from '@/components/calendar/CalendarDetail';
|
import { CalendarDetail } from '@/components/calendar/CalendarDetail';
|
||||||
import { AppointmentModal } from '@/components/calendar/AppointmentModal';
|
import { AppointmentEditForm } from '@/components/calendar/AppointmentEditForm';
|
||||||
|
import { useWindowStore } from '@/store/windowStore';
|
||||||
import { IcsControls } from '@/components/calendar/IcsControls';
|
import { IcsControls } from '@/components/calendar/IcsControls';
|
||||||
import { SharingSettings } from '@/components/calendar/SharingSettings';
|
import { SharingSettings } from '@/components/calendar/SharingSettings';
|
||||||
import { useCalendarStore, type CalendarViewMode } from '@/store/calendarStore';
|
import { useCalendarStore, type CalendarViewMode } from '@/store/calendarStore';
|
||||||
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
|
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
|
||||||
import { ChevronLeft, ChevronRight, Info, Plus } from 'lucide-react';
|
import { ChevronLeft, ChevronRight, ExternalLink, Info, Plus } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
fetchCalendars,
|
fetchCalendars,
|
||||||
createCalendar,
|
createCalendar,
|
||||||
@@ -72,9 +73,7 @@ export function CalendarPage() {
|
|||||||
const [loadingCalendars, setLoadingCalendars] = useState(true);
|
const [loadingCalendars, setLoadingCalendars] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const openWindow = useWindowStore((s) => s.openWindow);
|
||||||
const [modalEntry, setModalEntry] = useState<CalendarEntry | null>(null);
|
|
||||||
const [prefillDate, setPrefillDate] = useState<Date | null>(null);
|
|
||||||
|
|
||||||
const [showSharing, setShowSharing] = useState(false);
|
const [showSharing, setShowSharing] = useState(false);
|
||||||
const [showIcs, setShowIcs] = useState(false);
|
const [showIcs, setShowIcs] = useState(false);
|
||||||
@@ -176,11 +175,44 @@ export function CalendarPage() {
|
|||||||
|
|
||||||
// ??? Handlers ???????????????????????????????????????????????????????????
|
// ??? Handlers ???????????????????????????????????????????????????????????
|
||||||
|
|
||||||
|
const handleSaved = useCallback(
|
||||||
|
(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];
|
||||||
|
});
|
||||||
|
if (selectedEntry?.id === saved.id) {
|
||||||
|
setSelectedEntry(saved);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedEntry, setSelectedEntry],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDeleted = useCallback(
|
||||||
|
(entryId: string) => {
|
||||||
|
setEntries((cur) => cur.filter((e) => e.id !== entryId));
|
||||||
|
if (selectedEntry?.id === entryId) {
|
||||||
|
setSelectedEntry(null);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedEntry, setSelectedEntry],
|
||||||
|
);
|
||||||
|
|
||||||
const handleCreateAt = useCallback((date: Date) => {
|
const handleCreateAt = useCallback((date: Date) => {
|
||||||
setModalEntry(null);
|
openWindow({
|
||||||
setPrefillDate(date);
|
title: t('calendar.newAppointment'),
|
||||||
setModalOpen(true);
|
type: 'appointment-create',
|
||||||
}, []);
|
component: AppointmentEditForm,
|
||||||
|
componentProps: {
|
||||||
|
entry: null,
|
||||||
|
prefillDate: date,
|
||||||
|
calendars,
|
||||||
|
defaultCalendarId: activeCalendarId,
|
||||||
|
onSaved: handleSaved,
|
||||||
|
onDeleted: handleDeleted,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [openWindow, t, calendars, activeCalendarId, handleSaved, handleDeleted]);
|
||||||
|
|
||||||
const handleEditEntry = useCallback(
|
const handleEditEntry = useCallback(
|
||||||
(entry: CalendarEntry) => {
|
(entry: CalendarEntry) => {
|
||||||
@@ -194,10 +226,20 @@ export function CalendarPage() {
|
|||||||
|
|
||||||
const handleOpenEditModal = useCallback(() => {
|
const handleOpenEditModal = useCallback(() => {
|
||||||
if (!selectedEntry) return;
|
if (!selectedEntry) return;
|
||||||
setModalEntry(selectedEntry);
|
openWindow({
|
||||||
setPrefillDate(null);
|
title: t('calendar.editAppointment'),
|
||||||
setModalOpen(true);
|
type: 'appointment-edit',
|
||||||
}, [selectedEntry]);
|
component: AppointmentEditForm,
|
||||||
|
componentProps: {
|
||||||
|
entry: selectedEntry,
|
||||||
|
prefillDate: null,
|
||||||
|
calendars,
|
||||||
|
defaultCalendarId: activeCalendarId,
|
||||||
|
onSaved: handleSaved,
|
||||||
|
onDeleted: handleDeleted,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [selectedEntry, openWindow, t, calendars, activeCalendarId, handleSaved, handleDeleted]);
|
||||||
|
|
||||||
const handleMoveEntry = useCallback(
|
const handleMoveEntry = useCallback(
|
||||||
async (entry: CalendarEntry, newStart: Date) => {
|
async (entry: CalendarEntry, newStart: Date) => {
|
||||||
@@ -223,29 +265,6 @@ export function CalendarPage() {
|
|||||||
[t, loadEntries, selectedEntry, setSelectedEntry],
|
[t, loadEntries, selectedEntry, setSelectedEntry],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSaved = useCallback(
|
|
||||||
(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];
|
|
||||||
});
|
|
||||||
if (selectedEntry?.id === saved.id) {
|
|
||||||
setSelectedEntry(saved);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[selectedEntry, setSelectedEntry],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDeleted = useCallback(
|
|
||||||
(entryId: string) => {
|
|
||||||
setEntries((cur) => cur.filter((e) => e.id !== entryId));
|
|
||||||
if (selectedEntry?.id === entryId) {
|
|
||||||
setSelectedEntry(null);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[selectedEntry, setSelectedEntry],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleConfirmDelete = useCallback(async () => {
|
const handleConfirmDelete = useCallback(async () => {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
try {
|
try {
|
||||||
@@ -367,9 +386,19 @@ export function CalendarPage() {
|
|||||||
label: t('calendar.newAppointment'),
|
label: t('calendar.newAppointment'),
|
||||||
group: 'actions',
|
group: 'actions',
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
setModalEntry(null);
|
openWindow({
|
||||||
setPrefillDate(null);
|
title: t('calendar.newAppointment'),
|
||||||
setModalOpen(true);
|
type: 'appointment-create',
|
||||||
|
component: AppointmentEditForm,
|
||||||
|
componentProps: {
|
||||||
|
entry: null,
|
||||||
|
prefillDate: null,
|
||||||
|
calendars,
|
||||||
|
defaultCalendarId: activeCalendarId,
|
||||||
|
onSaved: handleSaved,
|
||||||
|
onDeleted: handleDeleted,
|
||||||
|
},
|
||||||
|
});
|
||||||
},
|
},
|
||||||
icon: (
|
icon: (
|
||||||
<Plus className="w-3.5 h-3.5" strokeWidth={2} />
|
<Plus className="w-3.5 h-3.5" strokeWidth={2} />
|
||||||
@@ -401,6 +430,17 @@ export function CalendarPage() {
|
|||||||
<Info className="w-3.5 h-3.5" strokeWidth={2} />
|
<Info className="w-3.5 h-3.5" strokeWidth={2} />
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'open-standalone',
|
||||||
|
plugin: 'calendar',
|
||||||
|
label: 'In neuem Fenster',
|
||||||
|
type: 'button' as const,
|
||||||
|
group: 'actions',
|
||||||
|
icon: (
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" strokeWidth={2} />
|
||||||
|
),
|
||||||
|
onClick: () => window.open('/calendar-standalone', '_blank', 'width=1200,height=800'),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
registerItems('calendar', items);
|
registerItems('calendar', items);
|
||||||
@@ -685,17 +725,6 @@ export function CalendarPage() {
|
|||||||
onCancel={() => setDeleteTarget(null)}
|
onCancel={() => setDeleteTarget(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Appointment modal */}
|
|
||||||
<AppointmentModal
|
|
||||||
open={modalOpen}
|
|
||||||
onClose={() => setModalOpen(false)}
|
|
||||||
entry={modalEntry}
|
|
||||||
prefillDate={prefillDate}
|
|
||||||
calendars={calendars}
|
|
||||||
defaultCalendarId={activeCalendarId}
|
|
||||||
onSaved={handleSaved}
|
|
||||||
onDeleted={handleDeleted}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { CalendarPage } from './Calendar';
|
||||||
|
|
||||||
|
export function CalendarStandalonePage() {
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-screen overflow-hidden bg-white">
|
||||||
|
<CalendarPage />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ import { ContactDetail } from '@/components/contacts/ContactDetail';
|
|||||||
import { ContactEditForm } from '@/components/contacts/ContactEditForm';
|
import { ContactEditForm } from '@/components/contacts/ContactEditForm';
|
||||||
import { useWindowStore } from '@/store/windowStore';
|
import { useWindowStore } from '@/store/windowStore';
|
||||||
import { SavedFilters } from '@/components/SavedFilters';
|
import { SavedFilters } from '@/components/SavedFilters';
|
||||||
import { ArrowDownAZ, ArrowUpZA, ChevronLeft, LayoutGrid, List, Plus } from 'lucide-react';
|
import { ArrowDownAZ, ArrowUpZA, ChevronLeft, ExternalLink, LayoutGrid, List, Plus } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
useUnifiedContacts,
|
useUnifiedContacts,
|
||||||
useUnifiedContact,
|
useUnifiedContact,
|
||||||
@@ -201,6 +201,17 @@ export function ContactsListPage() {
|
|||||||
),
|
),
|
||||||
onClick: handleCreate,
|
onClick: handleCreate,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'open-standalone',
|
||||||
|
plugin: 'contacts',
|
||||||
|
label: 'In neuem Fenster',
|
||||||
|
type: 'button' as const,
|
||||||
|
group: 'actions',
|
||||||
|
icon: (
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" strokeWidth={2} />
|
||||||
|
),
|
||||||
|
onClick: () => window.open('/contacts-standalone', '_blank', 'width=1200,height=800'),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
registerItems('contacts', items);
|
registerItems('contacts', items);
|
||||||
return () => unregisterPlugin('contacts');
|
return () => unregisterPlugin('contacts');
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ContactsListPage } from './ContactsList';
|
||||||
|
|
||||||
|
export function ContactsStandalonePage() {
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-screen overflow-hidden bg-white">
|
||||||
|
<ContactsListPage />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+33
-14
@@ -20,11 +20,12 @@ import { SourceTree } from '@/components/dms/SourceTree';
|
|||||||
import { FileExplorer, type ViewMode, type SortBy, type SortOrder } from '@/components/dms/FileExplorer';
|
import { FileExplorer, type ViewMode, type SortBy, type SortOrder } from '@/components/dms/FileExplorer';
|
||||||
import { FileDetails } from '@/components/dms/FileDetails';
|
import { FileDetails } from '@/components/dms/FileDetails';
|
||||||
import { UploadDropzone } from '@/components/dms/UploadDropzone';
|
import { UploadDropzone } from '@/components/dms/UploadDropzone';
|
||||||
import { FilePreviewModal } from '@/components/dms/FilePreviewModal';
|
import { FilePreviewContent } from '@/components/dms/FilePreviewContent';
|
||||||
|
import { useWindowStore } from '@/store/windowStore';
|
||||||
import { ShareDialog } from '@/components/dms/ShareDialog';
|
import { ShareDialog } from '@/components/dms/ShareDialog';
|
||||||
import { BulkActions } from '@/components/dms/BulkActions';
|
import { BulkActions } from '@/components/dms/BulkActions';
|
||||||
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
|
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
|
||||||
import { ArrowRight, ChevronLeft, ChevronRight, Info, Plus, Trash2, Upload } from 'lucide-react';
|
import { ArrowRight, ChevronLeft, ChevronRight, ExternalLink, Info, Plus, Trash2, Upload } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
fetchFolders,
|
fetchFolders,
|
||||||
createFolder,
|
createFolder,
|
||||||
@@ -72,7 +73,7 @@ export function DmsPage() {
|
|||||||
// Modal state
|
// Modal state
|
||||||
const [showUpload, setShowUpload] = useState(false);
|
const [showUpload, setShowUpload] = useState(false);
|
||||||
const [showNewFolder, setShowNewFolder] = useState(false);
|
const [showNewFolder, setShowNewFolder] = useState(false);
|
||||||
const [previewFile, setPreviewFile] = useState<DmsFile | null>(null);
|
const openWindow = useWindowStore((s) => s.openWindow);
|
||||||
const [shareFile, setShareFile] = useState<DmsFile | null>(null);
|
const [shareFile, setShareFile] = useState<DmsFile | null>(null);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<DmsFile | null>(null);
|
const [deleteTarget, setDeleteTarget] = useState<DmsFile | null>(null);
|
||||||
const [submittingFolder, setSubmittingFolder] = useState(false);
|
const [submittingFolder, setSubmittingFolder] = useState(false);
|
||||||
@@ -195,8 +196,15 @@ export function DmsPage() {
|
|||||||
|
|
||||||
// Handle file double click (open preview)
|
// Handle file double click (open preview)
|
||||||
const handleFileDoubleClick = useCallback((file: DmsFile) => {
|
const handleFileDoubleClick = useCallback((file: DmsFile) => {
|
||||||
setPreviewFile(file);
|
openWindow({
|
||||||
}, []);
|
title: file.name,
|
||||||
|
type: 'file-preview',
|
||||||
|
component: FilePreviewContent,
|
||||||
|
componentProps: {
|
||||||
|
file,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [openWindow]);
|
||||||
|
|
||||||
// Handle file share
|
// Handle file share
|
||||||
const handleFileShare = useCallback((file: DmsFile) => {
|
const handleFileShare = useCallback((file: DmsFile) => {
|
||||||
@@ -210,8 +218,15 @@ export function DmsPage() {
|
|||||||
|
|
||||||
// Handle file preview
|
// Handle file preview
|
||||||
const handleFilePreview = useCallback((file: DmsFile) => {
|
const handleFilePreview = useCallback((file: DmsFile) => {
|
||||||
setPreviewFile(file);
|
openWindow({
|
||||||
}, []);
|
title: file.name,
|
||||||
|
type: 'file-preview',
|
||||||
|
component: FilePreviewContent,
|
||||||
|
componentProps: {
|
||||||
|
file,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [openWindow]);
|
||||||
|
|
||||||
// Handle toggle select
|
// Handle toggle select
|
||||||
const handleToggleSelect = useCallback((fileId: string) => {
|
const handleToggleSelect = useCallback((fileId: string) => {
|
||||||
@@ -439,6 +454,17 @@ export function DmsPage() {
|
|||||||
),
|
),
|
||||||
onClick: () => setShowDetails((v) => !v),
|
onClick: () => setShowDetails((v) => !v),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'open-standalone',
|
||||||
|
plugin: 'dms',
|
||||||
|
label: 'In neuem Fenster',
|
||||||
|
type: 'button' as const,
|
||||||
|
group: 'actions',
|
||||||
|
icon: (
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" strokeWidth={2} />
|
||||||
|
),
|
||||||
|
onClick: () => window.open('/dms-standalone', '_blank', 'width=1200,height=800'),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add bulk actions when files are selected
|
// Add bulk actions when files are selected
|
||||||
@@ -706,13 +732,6 @@ export function DmsPage() {
|
|||||||
onCancel={() => setDeleteTarget(null)}
|
onCancel={() => setDeleteTarget(null)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* File preview modal */}
|
|
||||||
<FilePreviewModal
|
|
||||||
open={!!previewFile}
|
|
||||||
file={previewFile}
|
|
||||||
onClose={() => setPreviewFile(null)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Share dialog */}
|
{/* Share dialog */}
|
||||||
<ShareDialog
|
<ShareDialog
|
||||||
open={!!shareFile}
|
open={!!shareFile}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { DmsPage } from './Dms';
|
||||||
|
|
||||||
|
export function DmsStandalonePage() {
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-screen overflow-hidden bg-white">
|
||||||
|
<DmsPage />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+117
-59
@@ -15,9 +15,10 @@ import { ResizablePanel } from '@/components/ui/ResizablePanel';
|
|||||||
import { MailFolderTree } from '@/components/mail/MailFolderTree';
|
import { MailFolderTree } from '@/components/mail/MailFolderTree';
|
||||||
import { MailList } from '@/components/mail/MailList';
|
import { MailList } from '@/components/mail/MailList';
|
||||||
import { MailDetail } from '@/components/mail/MailDetail';
|
import { MailDetail } from '@/components/mail/MailDetail';
|
||||||
import { ComposeModal, type ComposeMode } from '@/components/mail/ComposeModal';
|
import { MailComposeForm, type ComposeMode } from '@/components/mail/MailComposeForm';
|
||||||
|
import { useWindowStore } from '@/store/windowStore';
|
||||||
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
|
import { usePluginToolbarStore } from '@/store/pluginToolbarStore';
|
||||||
import { ArrowRight, Check, ChevronLeft, Loader2, Plus, Redo2, Trash2, TrendingUp, Undo2 } from 'lucide-react';
|
import { ArrowRight, Check, ChevronLeft, ExternalLink, Loader2, Plus, Redo2, Trash2, TrendingUp, Undo2 } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
fetchAccounts,
|
fetchAccounts,
|
||||||
fetchFolders,
|
fetchFolders,
|
||||||
@@ -69,11 +70,11 @@ export function MailPage() {
|
|||||||
const [loadingMails, setLoadingMails] = useState(false);
|
const [loadingMails, setLoadingMails] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [signatures, setSignatures] = useState<MailSignature[]>([]);
|
const [signatures, setSignatures] = useState<MailSignature[]>([]);
|
||||||
const [composeOpen, setComposeOpen] = useState(false);
|
|
||||||
const [composeMode, setComposeMode] = useState<ComposeMode>('new');
|
const [composeMode, setComposeMode] = useState<ComposeMode>('new');
|
||||||
const [replyToMail, setReplyToMail] = useState<Mail | null>(null);
|
const [replyToMail, setReplyToMail] = useState<Mail | null>(null);
|
||||||
const [forwardMailState, setForwardMailState] = useState<Mail | null>(null);
|
const [forwardMailState, setForwardMailState] = useState<Mail | null>(null);
|
||||||
const [draftMailState, setDraftMailState] = useState<Mail | null>(null);
|
const [draftMailState, setDraftMailState] = useState<Mail | null>(null);
|
||||||
|
const openWindow = useWindowStore((s) => s.openWindow);
|
||||||
const [downloadingAttachmentId, setDownloadingAttachmentId] = useState<string | null>(null);
|
const [downloadingAttachmentId, setDownloadingAttachmentId] = useState<string | null>(null);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [activeView, setActiveView] = useState<'folders' | 'list' | 'detail'>('folders');
|
const [activeView, setActiveView] = useState<'folders' | 'list' | 'detail'>('folders');
|
||||||
@@ -233,14 +234,66 @@ export function MailPage() {
|
|||||||
[toast],
|
[toast],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle send (compose)
|
||||||
|
const handleSend = useCallback(
|
||||||
|
async (payload: SendMailPayload | ReplyPayload | ForwardPayload, mode: ComposeMode) => {
|
||||||
|
try {
|
||||||
|
if (mode === 'reply' && replyToMail) {
|
||||||
|
await replyMail(replyToMail.id, payload as ReplyPayload);
|
||||||
|
} else if (mode === 'forward' && forwardMailState) {
|
||||||
|
await forwardMail(forwardMailState.id, payload as ForwardPayload);
|
||||||
|
} else {
|
||||||
|
await sendMail(payload as SendMailPayload);
|
||||||
|
}
|
||||||
|
toast.success(t('mail.sent'));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[replyToMail, forwardMailState, toast, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle save draft (new or existing)
|
||||||
|
const handleSaveDraft = useCallback(
|
||||||
|
async (payload: MailDraftPayload) => {
|
||||||
|
try {
|
||||||
|
if (composeMode === 'draft' && draftMailState) {
|
||||||
|
await updateDraft(draftMailState.id, payload);
|
||||||
|
} else {
|
||||||
|
await saveDraft(payload);
|
||||||
|
}
|
||||||
|
toast.success(t('mail.draftSaved'));
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[composeMode, draftMailState, toast, t],
|
||||||
|
);
|
||||||
|
|
||||||
// Handle compose
|
// Handle compose
|
||||||
const handleCompose = useCallback(() => {
|
const handleCompose = useCallback(() => {
|
||||||
setComposeMode('new');
|
setComposeMode('new');
|
||||||
setReplyToMail(null);
|
setReplyToMail(null);
|
||||||
setForwardMailState(null);
|
setForwardMailState(null);
|
||||||
setDraftMailState(null);
|
setDraftMailState(null);
|
||||||
setComposeOpen(true);
|
openWindow({
|
||||||
}, []);
|
title: t('mail.compose'),
|
||||||
|
type: 'mail-compose',
|
||||||
|
component: MailComposeForm,
|
||||||
|
componentProps: {
|
||||||
|
mode: 'new',
|
||||||
|
accountId: selectedAccountId,
|
||||||
|
replyToMail: null,
|
||||||
|
forwardMail: null,
|
||||||
|
draftMail: null,
|
||||||
|
signatures,
|
||||||
|
onSend: handleSend,
|
||||||
|
onSaveDraft: handleSaveDraft,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [openWindow, t, selectedAccountId, signatures, handleSend, handleSaveDraft]);
|
||||||
|
|
||||||
// Handle reply
|
// Handle reply
|
||||||
const handleReply = useCallback((mail: Mail) => {
|
const handleReply = useCallback((mail: Mail) => {
|
||||||
@@ -248,8 +301,22 @@ export function MailPage() {
|
|||||||
setReplyToMail(mail);
|
setReplyToMail(mail);
|
||||||
setForwardMailState(null);
|
setForwardMailState(null);
|
||||||
setDraftMailState(null);
|
setDraftMailState(null);
|
||||||
setComposeOpen(true);
|
openWindow({
|
||||||
}, []);
|
title: t('mail.reply'),
|
||||||
|
type: 'mail-compose',
|
||||||
|
component: MailComposeForm,
|
||||||
|
componentProps: {
|
||||||
|
mode: 'reply',
|
||||||
|
accountId: selectedAccountId,
|
||||||
|
replyToMail: mail,
|
||||||
|
forwardMail: null,
|
||||||
|
draftMail: null,
|
||||||
|
signatures,
|
||||||
|
onSend: handleSend,
|
||||||
|
onSaveDraft: handleSaveDraft,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [openWindow, t, selectedAccountId, signatures, handleSend, handleSaveDraft]);
|
||||||
|
|
||||||
// Handle forward
|
// Handle forward
|
||||||
const handleForward = useCallback((mail: Mail) => {
|
const handleForward = useCallback((mail: Mail) => {
|
||||||
@@ -257,8 +324,22 @@ export function MailPage() {
|
|||||||
setForwardMailState(mail);
|
setForwardMailState(mail);
|
||||||
setReplyToMail(null);
|
setReplyToMail(null);
|
||||||
setDraftMailState(null);
|
setDraftMailState(null);
|
||||||
setComposeOpen(true);
|
openWindow({
|
||||||
}, []);
|
title: t('mail.forward'),
|
||||||
|
type: 'mail-compose',
|
||||||
|
component: MailComposeForm,
|
||||||
|
componentProps: {
|
||||||
|
mode: 'forward',
|
||||||
|
accountId: selectedAccountId,
|
||||||
|
replyToMail: null,
|
||||||
|
forwardMail: mail,
|
||||||
|
draftMail: null,
|
||||||
|
signatures,
|
||||||
|
onSend: handleSend,
|
||||||
|
onSaveDraft: handleSaveDraft,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [openWindow, t, selectedAccountId, signatures, handleSend, handleSaveDraft]);
|
||||||
|
|
||||||
// Handle delete single mail
|
// Handle delete single mail
|
||||||
const handleDeleteMail = useCallback(
|
const handleDeleteMail = useCallback(
|
||||||
@@ -324,23 +405,6 @@ export function MailPage() {
|
|||||||
[selectedMailIds, toast, t],
|
[selectedMailIds, toast, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle save draft (new or existing)
|
|
||||||
const handleSaveDraft = useCallback(
|
|
||||||
async (payload: MailDraftPayload) => {
|
|
||||||
try {
|
|
||||||
if (composeMode === 'draft' && draftMailState) {
|
|
||||||
await updateDraft(draftMailState.id, payload);
|
|
||||||
} else {
|
|
||||||
await saveDraft(payload);
|
|
||||||
}
|
|
||||||
toast.success(t('mail.draftSaved'));
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : String(err));
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[composeMode, draftMailState, toast, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle edit draft
|
// Handle edit draft
|
||||||
const handleEditDraft = useCallback((mail: Mail) => {
|
const handleEditDraft = useCallback((mail: Mail) => {
|
||||||
@@ -348,8 +412,22 @@ export function MailPage() {
|
|||||||
setDraftMailState(mail);
|
setDraftMailState(mail);
|
||||||
setReplyToMail(null);
|
setReplyToMail(null);
|
||||||
setForwardMailState(null);
|
setForwardMailState(null);
|
||||||
setComposeOpen(true);
|
openWindow({
|
||||||
}, []);
|
title: t('mail.editDraft'),
|
||||||
|
type: 'mail-compose',
|
||||||
|
component: MailComposeForm,
|
||||||
|
componentProps: {
|
||||||
|
mode: 'draft',
|
||||||
|
accountId: selectedAccountId,
|
||||||
|
replyToMail: null,
|
||||||
|
forwardMail: null,
|
||||||
|
draftMail: mail,
|
||||||
|
signatures,
|
||||||
|
onSend: handleSend,
|
||||||
|
onSaveDraft: handleSaveDraft,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [openWindow, t, selectedAccountId, signatures, handleSend, handleSaveDraft]);
|
||||||
|
|
||||||
// Handle toggle flag
|
// Handle toggle flag
|
||||||
const handleToggleFlag = useCallback(
|
const handleToggleFlag = useCallback(
|
||||||
@@ -486,25 +564,6 @@ export function MailPage() {
|
|||||||
[toast],
|
[toast],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle send (compose)
|
|
||||||
const handleSend = useCallback(
|
|
||||||
async (payload: SendMailPayload | ReplyPayload | ForwardPayload, mode: ComposeMode) => {
|
|
||||||
try {
|
|
||||||
if (mode === 'reply' && replyToMail) {
|
|
||||||
await replyMail(replyToMail.id, payload as ReplyPayload);
|
|
||||||
} else if (mode === 'forward' && forwardMailState) {
|
|
||||||
await forwardMail(forwardMailState.id, payload as ForwardPayload);
|
|
||||||
} else {
|
|
||||||
await sendMail(payload as SendMailPayload);
|
|
||||||
}
|
|
||||||
toast.success(t('mail.sent'));
|
|
||||||
} catch (err) {
|
|
||||||
toast.error(err instanceof Error ? err.message : String(err));
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[replyToMail, forwardMailState, toast, t],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Handle search
|
// Handle search
|
||||||
const handleSearch = useCallback((query: string) => {
|
const handleSearch = useCallback((query: string) => {
|
||||||
@@ -632,6 +691,17 @@ export function MailPage() {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'open-standalone',
|
||||||
|
plugin: 'mail',
|
||||||
|
label: 'In neuem Fenster',
|
||||||
|
type: 'button' as const,
|
||||||
|
group: 'tools',
|
||||||
|
icon: (
|
||||||
|
<ExternalLink className="w-3.5 h-3.5" strokeWidth={2} />
|
||||||
|
),
|
||||||
|
onClick: () => window.open('/mail-standalone', '_blank', 'width=1200,height=800'),
|
||||||
|
},
|
||||||
];
|
];
|
||||||
if (hasBulkSelection) {
|
if (hasBulkSelection) {
|
||||||
items.push(
|
items.push(
|
||||||
@@ -935,18 +1005,6 @@ export function MailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ComposeModal
|
|
||||||
open={composeOpen}
|
|
||||||
mode={composeMode}
|
|
||||||
accountId={selectedAccountId}
|
|
||||||
replyToMail={replyToMail}
|
|
||||||
forwardMail={forwardMailState}
|
|
||||||
draftMail={draftMailState}
|
|
||||||
signatures={signatures}
|
|
||||||
onSend={handleSend}
|
|
||||||
onSaveDraft={handleSaveDraft}
|
|
||||||
onClose={() => setComposeOpen(false)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { MailPage } from './Mail';
|
||||||
|
|
||||||
|
export function MailStandalonePage() {
|
||||||
|
return (
|
||||||
|
<div className="h-screen w-screen overflow-hidden bg-white">
|
||||||
|
<MailPage />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -37,6 +37,10 @@ const ProactiveAISettings = React.lazy(() => import('@/pages/ProactiveAISettings
|
|||||||
const SettingsThemePage = React.lazy(() => import('@/pages/SettingsTheme').then(m => ({ default: m.SettingsThemePage })));
|
const SettingsThemePage = React.lazy(() => import('@/pages/SettingsTheme').then(m => ({ default: m.SettingsThemePage })));
|
||||||
const SettingsMcpPage = React.lazy(() => import('@/pages/SettingsMcp').then(m => ({ default: m.SettingsMcpPage })));
|
const SettingsMcpPage = React.lazy(() => import('@/pages/SettingsMcp').then(m => ({ default: m.SettingsMcpPage })));
|
||||||
const AIAssistantStandalonePage = React.lazy(() => import('@/pages/AIAssistantStandalone').then(m => ({ default: m.AIAssistantStandalonePage })));
|
const AIAssistantStandalonePage = React.lazy(() => import('@/pages/AIAssistantStandalone').then(m => ({ default: m.AIAssistantStandalonePage })));
|
||||||
|
const DmsStandalonePage = React.lazy(() => import('@/pages/DmsStandalone').then(m => ({ default: m.DmsStandalonePage })));
|
||||||
|
const CalendarStandalonePage = React.lazy(() => import('@/pages/CalendarStandalone').then(m => ({ default: m.CalendarStandalonePage })));
|
||||||
|
const MailStandalonePage = React.lazy(() => import('@/pages/MailStandalone').then(m => ({ default: m.MailStandalonePage })));
|
||||||
|
const ContactsStandalonePage = React.lazy(() => import('@/pages/ContactsStandalone').then(m => ({ default: m.ContactsStandalonePage })));
|
||||||
const SettingsAIPage = React.lazy(() => import('@/pages/SettingsAI').then(m => ({ default: m.SettingsAIPage })));
|
const SettingsAIPage = React.lazy(() => import('@/pages/SettingsAI').then(m => ({ default: m.SettingsAIPage })));
|
||||||
const SettingsMenuOrderPage = React.lazy(() => import('@/pages/SettingsMenuOrder').then(m => ({ default: m.SettingsMenuOrderPage })));
|
const SettingsMenuOrderPage = React.lazy(() => import('@/pages/SettingsMenuOrder').then(m => ({ default: m.SettingsMenuOrderPage })));
|
||||||
const SettingsStammdatenPage = React.lazy(() => import('@/pages/SettingsStammdaten').then(m => ({ default: m.SettingsStammdatenPage })));
|
const SettingsStammdatenPage = React.lazy(() => import('@/pages/SettingsStammdaten').then(m => ({ default: m.SettingsStammdatenPage })));
|
||||||
@@ -70,6 +74,22 @@ const router = createBrowserRouter([
|
|||||||
path: '/ai-assistant-standalone',
|
path: '/ai-assistant-standalone',
|
||||||
element: withSuspense(<AIAssistantStandalonePage />),
|
element: withSuspense(<AIAssistantStandalonePage />),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/dms-standalone',
|
||||||
|
element: withSuspense(<DmsStandalonePage />),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/calendar-standalone',
|
||||||
|
element: withSuspense(<CalendarStandalonePage />),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/mail-standalone',
|
||||||
|
element: withSuspense(<MailStandalonePage />),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/contacts-standalone',
|
||||||
|
element: withSuspense(<ContactsStandalonePage />),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/password-reset',
|
path: '/password-reset',
|
||||||
element: <PasswordResetRequestPage />,
|
element: <PasswordResetRequestPage />,
|
||||||
|
|||||||
Reference in New Issue
Block a user