modifiche varie

This commit is contained in:
2026-06-04 16:38:22 +02:00
parent 46784aca4c
commit 9e766c249a
7 changed files with 1011 additions and 82 deletions
@@ -0,0 +1,598 @@
import { useState, useRef, useMemo, useEffect } from 'react'
import { useForm, useFieldArray } from 'react-hook-form'
import {
Send,
X,
Plus,
AlertCircle,
Paperclip,
Upload,
Filter,
Minus,
Maximize2,
Minimize2,
} from 'lucide-react'
import { useQuery, useMutation } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { Label } from '@/components/ui/Label'
import { RichTextEditor } from '@/components/RichTextEditor/RichTextEditor'
import { sendApi } from '@/api/send.api'
import { mailboxesApi } from '@/api/mailboxes.api'
import { virtualBoxesApi } from '@/api/virtual_boxes.api'
import { getErrorMessage } from '@/api/client'
import { useComposeStore } from '@/store/compose.store'
import type { MessageResponse } from '@/types/api.types'
// ─── Tipi ─────────────────────────────────────────────────────────────────────
interface MailboxSelectItem {
id: string
email_address: string
display_name: string | null
status: string
fromVbox?: boolean
}
interface ComposeFormValues {
mailbox_id: string
to_addresses: { value: string }[]
cc_addresses: { value: string }[]
subject: string
}
// ─── Utility ──────────────────────────────────────────────────────────────────
const MAX_FILE_SIZE = 20 * 1024 * 1024
function formatFileSize(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`
}
function htmlToText(html: string): string {
if (!html || html === '<p></p>') return ''
const div = document.createElement('div')
div.innerHTML = html
return div.textContent || div.innerText || ''
}
function buildInitialBody(replyTo?: MessageResponse | null, forwardOf?: MessageResponse | null): string {
if (replyTo) {
const date = new Date(replyTo.received_at || replyTo.created_at).toLocaleDateString('it-IT')
return [
'<p></p>',
'<p></p>',
'<hr>',
`<p><strong>In risposta al messaggio del ${date}</strong></p>`,
`<p>Da: ${replyTo.from_address || ''}</p>`,
`<p>A: ${replyTo.to_addresses?.join(', ') || ''}</p>`,
`<p>Oggetto: ${replyTo.subject || ''}</p>`,
].join('')
}
if (forwardOf) {
const date = new Date(forwardOf.received_at || forwardOf.sent_at || forwardOf.created_at).toLocaleDateString('it-IT')
const bodyContent = forwardOf.body_text
? `<pre style="white-space:pre-wrap;font-family:inherit">${forwardOf.body_text}</pre>`
: ''
return [
'<p></p>',
'<p></p>',
'<hr>',
`<p><strong>Messaggio inoltrato</strong></p>`,
`<p>Da: ${forwardOf.from_address || ''}</p>`,
`<p>A: ${forwardOf.to_addresses?.join(', ') || ''}</p>`,
`<p>Data: ${date}</p>`,
`<p>Oggetto: ${forwardOf.subject || ''}</p>`,
bodyContent,
].join('')
}
return ''
}
// ─── Form interno (rimontato ogni volta che cambia il messaggio) ───────────────
interface ComposeFormProps {
replyTo: MessageResponse | null
forwardOf: MessageResponse | null
onClose: () => void
}
function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [showCc, setShowCc] = useState(false)
const [attachments, setAttachments] = useState<File[]>([])
const [bodyHtml, setBodyHtml] = useState<string>(() =>
buildInitialBody(replyTo, forwardOf)
)
const {
register,
control,
handleSubmit,
formState: { errors },
} = useForm<ComposeFormValues>({
defaultValues: {
mailbox_id: replyTo?.mailbox_id || forwardOf?.mailbox_id || '',
to_addresses: replyTo ? [{ value: replyTo.from_address || '' }] : [{ value: '' }],
cc_addresses: [],
subject: replyTo
? `Re: ${replyTo.subject || ''}`
: forwardOf
? `Fwd: ${forwardOf.subject || ''}`
: '',
},
})
const { fields: toFields, append: appendTo, remove: removeTo } = useFieldArray({ control, name: 'to_addresses' })
const { fields: ccFields, append: appendCc, remove: removeCc } = useFieldArray({ control, name: 'cc_addresses' })
const { data: mailboxesData, isLoading: mailboxesLoading } = useQuery({
queryKey: ['mailboxes'],
queryFn: () => mailboxesApi.list(),
})
const { data: vboxMailboxes = [], isLoading: vboxMailboxesLoading } = useQuery({
queryKey: ['virtual-boxes', 'my-mailboxes'],
queryFn: () => virtualBoxesApi.getMyMailboxes(),
})
const sendMutation = useMutation({
mutationFn: (args: { data: Parameters<typeof sendApi.sendMultipart>[0]; files: File[] }) =>
sendApi.sendMultipart(args.data, args.files),
onSuccess: (job) => {
toast.success(`PEC inviata! Job ID: ${job.id.slice(0, 8)}...`)
onClose()
},
onError: (error) => {
toast.error(getErrorMessage(error))
},
})
const activeCaselle = useMemo((): MailboxSelectItem[] => {
const regularActive: MailboxSelectItem[] = (
mailboxesData?.items.filter((m) => m.status === 'active') || []
).map((m) => ({
id: m.id,
email_address: m.email_address,
display_name: m.display_name,
status: m.status,
fromVbox: false,
}))
const regularIds = new Set(regularActive.map((m) => m.id))
const vboxActive: MailboxSelectItem[] = vboxMailboxes
.filter((m) => m.status === 'active' && !regularIds.has(m.id))
.map((m) => ({
id: m.id,
email_address: m.email_address,
display_name: m.display_name,
status: m.status,
fromVbox: true,
}))
return [...regularActive, ...vboxActive]
}, [mailboxesData, vboxMailboxes])
const isLoadingMailboxes = mailboxesLoading || vboxMailboxesLoading
const handleFileAdd = (files: FileList | null) => {
if (!files) return
const valid: File[] = []
for (const file of Array.from(files)) {
if (file.size > MAX_FILE_SIZE) {
toast.error(`"${file.name}" supera il limite di 20 MB`)
} else {
valid.push(file)
}
}
if (valid.length) setAttachments((prev) => [...prev, ...valid])
}
const removeAttachment = (idx: number) => {
setAttachments((prev) => prev.filter((_, i) => i !== idx))
}
const onSubmit = async (formData: ComposeFormValues) => {
const toAddresses = formData.to_addresses.map((t) => t.value.trim()).filter((v) => v.length > 0)
if (toAddresses.length === 0) {
toast.error('Inserisci almeno un destinatario')
return
}
await sendMutation.mutateAsync({
data: {
mailbox_id: formData.mailbox_id,
to_addresses: toAddresses,
cc_addresses: formData.cc_addresses.map((c) => c.value.trim()).filter((v) => v.length > 0),
subject: formData.subject,
body_text: htmlToText(bodyHtml),
body_html: bodyHtml,
reply_to_message_id: replyTo?.id,
},
files: attachments,
})
}
return (
<form onSubmit={handleSubmit(onSubmit)} noValidate className="flex flex-col gap-0 h-full">
{/* Corpo scrollabile */}
<div className="flex-1 overflow-y-auto px-4 py-3 space-y-3">
{/* Avviso informativo */}
<div className="rounded-md border border-blue-200 bg-blue-50 p-2.5 flex items-start gap-2">
<AlertCircle className="h-3.5 w-3.5 text-blue-600 mt-0.5 flex-shrink-0" />
<p className="text-xs text-blue-700">
L'invio avviene tramite il server PEC della casella selezionata.
</p>
</div>
{/* Allegati del messaggio inoltrato */}
{forwardOf && forwardOf.has_attachments && (
<div className="rounded-md border border-amber-200 bg-amber-50 p-2.5 flex items-start gap-2">
<Paperclip className="h-3.5 w-3.5 text-amber-600 mt-0.5 flex-shrink-0" />
<p className="text-xs text-amber-700">
Il messaggio originale contiene allegati. Scaricali e aggiungili qui manualmente.
</p>
</div>
)}
{/* Casella mittente */}
<div className="space-y-1">
<Label className="text-xs">Casella mittente *</Label>
{isLoadingMailboxes ? (
<div className="h-8 rounded-md border bg-muted animate-pulse" />
) : activeCaselle.length === 0 ? (
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-2 text-xs text-destructive">
Nessuna casella PEC attiva disponibile.
</div>
) : (
<>
<select
className="flex h-8 w-full rounded-md border border-input bg-background px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
{...register('mailbox_id', { required: 'Seleziona una casella mittente' })}
>
<option value="">Seleziona casella...</option>
{activeCaselle.map((mb) => (
<option key={mb.id} value={mb.id}>
{mb.fromVbox ? ' ' : ''}{mb.display_name || mb.email_address} ({mb.email_address})
</option>
))}
</select>
{activeCaselle.some((m) => m.fromVbox) && (
<p className="text-xs text-purple-600 flex items-center gap-1">
<Filter className="h-3 w-3" />
Le caselle con sono accessibili tramite Virtual Box
</p>
)}
</>
)}
{errors.mailbox_id && (
<p className="text-xs text-destructive">{errors.mailbox_id.message}</p>
)}
</div>
{/* Destinatari A: */}
<div className="space-y-1">
<div className="flex items-center justify-between">
<Label className="text-xs">Destinatari (A:) *</Label>
<button
type="button"
onClick={() => appendTo({ value: '' })}
className="text-xs text-primary hover:underline flex items-center gap-0.5"
>
<Plus className="h-3 w-3" />
Aggiungi
</button>
</div>
<div className="space-y-1.5">
{toFields.map((field, idx) => (
<div key={field.id} className="flex flex-col gap-0.5">
<div className="flex gap-1.5">
<Input
type="email"
placeholder="destinatario@pec.it"
className="h-8 text-sm"
{...register(`to_addresses.${idx}.value`, {
required: idx === 0 ? 'Almeno un destinatario e obbligatorio' : false,
pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Indirizzo non valido' },
})}
/>
{toFields.length > 1 && (
<button
type="button"
onClick={() => removeTo(idx)}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-destructive"
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{errors.to_addresses?.[idx]?.value && (
<p className="text-xs text-destructive">{errors.to_addresses[idx]?.value?.message}</p>
)}
</div>
))}
</div>
</div>
{/* CC */}
<div className="space-y-1">
{!showCc ? (
<button
type="button"
onClick={() => setShowCc(true)}
className="text-xs text-primary hover:underline"
>
+ Aggiungi Cc
</button>
) : (
<>
<div className="flex items-center justify-between">
<Label className="text-xs">Copia (Cc:)</Label>
<button
type="button"
onClick={() => appendCc({ value: '' })}
className="text-xs text-primary hover:underline flex items-center gap-0.5"
>
<Plus className="h-3 w-3" />
Aggiungi
</button>
</div>
{ccFields.map((field, idx) => (
<div key={field.id} className="flex flex-col gap-0.5">
<div className="flex gap-1.5">
<Input
type="email"
placeholder="cc@pec.it"
className="h-8 text-sm"
{...register(`cc_addresses.${idx}.value`, {
pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Indirizzo non valido' },
})}
/>
<button
type="button"
onClick={() => removeCc(idx)}
className="p-1 rounded hover:bg-muted text-muted-foreground hover:text-destructive"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
{errors.cc_addresses?.[idx]?.value && (
<p className="text-xs text-destructive">{errors.cc_addresses[idx]?.value?.message}</p>
)}
</div>
))}
</>
)}
</div>
{/* Oggetto */}
<div className="space-y-1">
<Label className="text-xs">Oggetto *</Label>
<Input
placeholder="Oggetto della PEC"
className="h-8 text-sm"
{...register('subject', { required: "L'oggetto e obbligatorio" })}
/>
{errors.subject && (
<p className="text-xs text-destructive">{errors.subject.message}</p>
)}
</div>
{/* Corpo */}
<div className="space-y-1">
<Label className="text-xs">Testo del messaggio</Label>
<RichTextEditor
value={bodyHtml}
onChange={setBodyHtml}
placeholder="Scrivi il testo della PEC..."
minHeight="160px"
/>
</div>
{/* Allegati */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs">Allegati</Label>
<span className="text-xs text-muted-foreground">Max 20 MB</span>
</div>
{attachments.length > 0 && (
<div className="space-y-1">
{attachments.map((file, idx) => (
<div
key={`${file.name}-${file.size}-${idx}`}
className="flex items-center gap-2 px-2.5 py-1.5 bg-muted/50 border rounded-md"
>
<Paperclip className="h-3 w-3 text-muted-foreground flex-shrink-0" />
<span className="text-xs flex-1 truncate font-medium" title={file.name}>{file.name}</span>
<span className="text-xs text-muted-foreground whitespace-nowrap">{formatFileSize(file.size)}</span>
<button
type="button"
className="ml-1 rounded p-0.5 text-muted-foreground hover:text-destructive"
onClick={() => removeAttachment(idx)}
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
<div
className="border-2 border-dashed rounded-md p-3 flex flex-col items-center gap-1.5 cursor-pointer hover:border-primary/60 hover:bg-primary/5 transition-colors"
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => { e.preventDefault(); e.stopPropagation() }}
onDrop={(e) => { e.preventDefault(); handleFileAdd(e.dataTransfer.files) }}
>
<Upload className="h-5 w-5 text-muted-foreground" />
<p className="text-xs text-muted-foreground">Clicca o trascina i file qui</p>
</div>
<input
ref={fileInputRef}
type="file"
multiple
className="hidden"
onChange={(e) => handleFileAdd(e.target.files)}
/>
</div>
</div>
{/* Footer azioni */}
<div className="px-4 py-3 border-t flex items-center justify-between bg-muted/30">
<button
type="button"
onClick={onClose}
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Annulla
</button>
<Button
type="submit"
size="sm"
isLoading={sendMutation.isPending}
disabled={activeCaselle.length === 0}
>
<Send className="h-3.5 w-3.5 mr-1.5" />
Invia PEC
{attachments.length > 0 && (
<span className="ml-1.5 bg-primary-foreground/20 text-primary-foreground rounded-full px-1.5 text-xs font-semibold">
+{attachments.length}
</span>
)}
</Button>
</div>
</form>
)
}
// ─── Componente principale flottante ──────────────────────────────────────────
export function ComposeModal() {
const { isOpen, mode, replyTo, forwardOf, closeCompose, setMode } = useComposeStore()
// Chiudi con ESC (solo quando non minimizzato)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen && mode !== 'minimized') {
closeCompose()
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [isOpen, mode, closeCompose])
if (!isOpen) return null
const title = replyTo ? 'Rispondi a PEC' : forwardOf ? 'Inoltra PEC' : 'Nuova PEC'
const subtitle = replyTo?.subject || forwardOf?.subject || null
// Stile inline per il posizionamento (piu' affidabile delle classi Tailwind dinamiche)
const containerStyle = (() => {
if (mode === 'fullscreen') {
return {
position: 'fixed' as const,
top: '1rem',
right: '1rem',
bottom: '1rem',
left: '1rem',
}
}
if (mode === 'minimized') {
return {
position: 'fixed' as const,
bottom: 0,
right: '1.5rem',
width: '320px',
height: '48px',
}
}
// normal
return {
position: 'fixed' as const,
bottom: 0,
right: '1.5rem',
width: '560px',
height: '580px',
}
})()
return (
<div
className="z-50 flex flex-col bg-background border shadow-2xl rounded-t-xl overflow-hidden transition-all duration-200"
style={containerStyle}
>
{/* Barra del titolo */}
<div
className="flex items-center justify-between px-4 h-12 flex-shrink-0 bg-primary text-primary-foreground select-none cursor-pointer"
onClick={() => {
if (mode === 'minimized') setMode('normal')
}}
>
<div className="flex flex-col min-w-0">
<span className="text-sm font-semibold truncate leading-tight">{title}</span>
{subtitle && mode !== 'normal' && (
<span className="text-xs opacity-75 truncate leading-tight">{subtitle}</span>
)}
</div>
<div
className="flex items-center gap-1 ml-2 flex-shrink-0"
onClick={(e) => e.stopPropagation()}
>
{/* Minimizza */}
<button
type="button"
title={mode === 'minimized' ? 'Espandi' : 'Minimizza'}
onClick={() => setMode(mode === 'minimized' ? 'normal' : 'minimized')}
className="p-1.5 rounded hover:bg-white/20 transition-colors"
>
<Minus className="h-3.5 w-3.5" />
</button>
{/* Fullscreen / Ripristina */}
{mode !== 'minimized' && (
<button
type="button"
title={mode === 'fullscreen' ? 'Ripristina dimensione' : 'Schermo intero'}
onClick={() => setMode(mode === 'fullscreen' ? 'normal' : 'fullscreen')}
className="p-1.5 rounded hover:bg-white/20 transition-colors"
>
{mode === 'fullscreen' ? (
<Minimize2 className="h-3.5 w-3.5" />
) : (
<Maximize2 className="h-3.5 w-3.5" />
)}
</button>
)}
{/* Chiudi */}
<button
type="button"
title="Chiudi"
onClick={closeCompose}
className="p-1.5 rounded hover:bg-white/20 transition-colors"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
{/* Corpo (nascosto quando minimizzato) */}
{mode !== 'minimized' && (
<div className="flex-1 overflow-hidden flex flex-col">
{/*
Key basata su replyTo/forwardOf: garantisce che il form venga rimontato
(e quindi resettato) ogni volta che si apre per un messaggio diverso.
*/}
<ComposeForm
key={`${replyTo?.id ?? 'new'}-${forwardOf?.id ?? 'new'}`}
replyTo={replyTo}
forwardOf={forwardOf}
onClose={closeCompose}
/>
</div>
)}
</div>
)
}
@@ -1,6 +1,7 @@
import { Outlet, Navigate } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'
import { Sidebar } from './Sidebar'
import { ComposeModal } from '@/components/ComposeModal/ComposeModal'
import { useAuth } from '@/hooks/useAuth'
import { useWebSocket } from '@/hooks/useWebSocket'
import { useEffect } from 'react'
@@ -59,6 +60,9 @@ export function AppLayout() {
},
}}
/>
{/* Finestra di composizione PEC flottante (stile Gmail) */}
<ComposeModal />
</div>
)
}
+56 -37
View File
@@ -259,6 +259,7 @@ export function ComposePage() {
<div className="flex-1 overflow-y-auto">
<form
onSubmit={handleSubmit(onSubmit)}
noValidate
className="max-w-4xl mx-auto px-6 py-6 space-y-5"
>
{/* Avviso informativo */}
@@ -335,33 +336,39 @@ export function ComposePage() {
</div>
<div className="space-y-2">
{toFields.map((field, idx) => (
<div key={field.id} className="flex gap-2">
<Input
type="email"
placeholder="destinatario@pec.it"
{...register(`to_addresses.${idx}.value`, {
required:
idx === 0 ? 'Almeno un destinatario è obbligatorio' : false,
})}
/>
{toFields.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeTo(idx)}
>
<X className="h-4 w-4" />
</Button>
<div key={field.id} className="flex flex-col gap-1">
<div className="flex gap-2">
<Input
type="email"
placeholder="destinatario@pec.it"
{...register(`to_addresses.${idx}.value`, {
required:
idx === 0 ? 'Almeno un destinatario è obbligatorio' : false,
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Inserisci un indirizzo email valido',
},
})}
/>
{toFields.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeTo(idx)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{errors.to_addresses?.[idx]?.value && (
<p className="text-xs text-destructive">
{errors.to_addresses[idx]?.value?.message}
</p>
)}
</div>
))}
</div>
{errors.to_addresses?.[0]?.value && (
<p className="text-xs text-destructive">
{errors.to_addresses[0].value.message}
</p>
)}
</div>
{/* CC */}
@@ -389,20 +396,32 @@ export function ComposePage() {
</Button>
</div>
{ccFields.map((field, idx) => (
<div key={field.id} className="flex gap-2">
<Input
type="email"
placeholder="cc@pec.it"
{...register(`cc_addresses.${idx}.value`)}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeCc(idx)}
>
<X className="h-4 w-4" />
</Button>
<div key={field.id} className="flex flex-col gap-1">
<div className="flex gap-2">
<Input
type="email"
placeholder="cc@pec.it"
{...register(`cc_addresses.${idx}.value`, {
pattern: {
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
message: 'Inserisci un indirizzo email valido',
},
})}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeCc(idx)}
>
<X className="h-4 w-4" />
</Button>
</div>
{errors.cc_addresses?.[idx]?.value && (
<p className="text-xs text-destructive">
{errors.cc_addresses[idx]?.value?.message}
</p>
)}
</div>
))}
</>
@@ -37,6 +37,7 @@ import { deadlinesApi } from '@/api/deadlines.api'
import { formatDate, formatBytes } from '@/lib/utils'
import { getErrorMessage } from '@/api/client'
import apiClient from '@/api/client'
import { useComposeStore } from '@/store/compose.store'
// ─── Thread section (Feature 3) ──────────────────────────────────────────────
@@ -100,13 +101,23 @@ interface AttachmentPreviewProps {
}
function AttachmentPreviewModal({ messageId, attachmentId, filename, contentType, onClose }: AttachmentPreviewProps) {
const { data, isLoading } = useQuery({
queryKey: ['attachment-preview', messageId, attachmentId],
queryFn: () => messagesApi.getAttachmentPreviewUrl(messageId, attachmentId),
const isPreviewable = contentType.startsWith('image/') || contentType === 'application/pdf'
const { data: blobUrl, isLoading, isError } = useQuery({
queryKey: ['attachment-inline', messageId, attachmentId],
queryFn: async () => {
const response = await apiClient.get(
`/messages/${messageId}/attachments/${attachmentId}/inline`,
{ responseType: 'blob' },
)
return window.URL.createObjectURL(new Blob([response.data], { type: contentType }))
},
enabled: isPreviewable,
staleTime: 5 * 60 * 1000,
})
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80">
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/80">
<div className="relative bg-background rounded-xl shadow-2xl w-full max-w-4xl max-h-[90vh] flex flex-col">
<div className="flex items-center justify-between px-4 py-3 border-b">
<span className="font-medium truncate">{filename}</span>
@@ -118,24 +129,27 @@ function AttachmentPreviewModal({ messageId, attachmentId, filename, contentType
{isLoading && (
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
)}
{!isLoading && data && (
{isError && (
<div className="text-center text-muted-foreground">
<Eye className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p>Errore nel caricamento del file.</p>
</div>
)}
{!isLoading && !isError && blobUrl && (
<>
{data.previewable && data.url ? (
<>
{contentType.startsWith('image/') ? (
<img src={data.url} alt={filename} className="max-w-full max-h-full object-contain" />
) : contentType === 'application/pdf' ? (
<iframe src={data.url} className="w-full h-[70vh]" title={filename} />
) : null}
</>
) : (
<div className="text-center text-muted-foreground">
<Eye className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p>Anteprima non disponibile per questo tipo di file.</p>
</div>
)}
{contentType.startsWith('image/') ? (
<img src={blobUrl} alt={filename} className="max-w-full max-h-full object-contain" />
) : contentType === 'application/pdf' ? (
<iframe src={blobUrl} className="w-full h-[70vh]" title={filename} />
) : null}
</>
)}
{!isLoading && !isError && !isPreviewable && (
<div className="text-center text-muted-foreground">
<Eye className="h-12 w-12 mx-auto mb-3 opacity-30" />
<p>Anteprima non disponibile per questo tipo di file.</p>
</div>
)}
</div>
</div>
</div>
@@ -148,6 +162,7 @@ export function MessageDetailPage() {
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const queryClient = useQueryClient()
const openCompose = useComposeStore((s) => s.openCompose)
const [showTagSelector, setShowTagSelector] = useState(false)
const [isDownloadingPackage, setIsDownloadingPackage] = useState(false)
@@ -161,6 +176,12 @@ export function MessageDetailPage() {
// Feature 7: Preview allegati
const [previewAtt, setPreviewAtt] = useState<{ id: string; filename: string; contentType: string } | null>(null)
// Modal stampa con allegati
const [showPrintModal, setShowPrintModal] = useState(false)
const [printBody, setPrintBody] = useState(true)
const [printAttIds, setPrintAttIds] = useState<Set<string>>(new Set())
const [printModalPreviewAtt, setPrintModalPreviewAtt] = useState<{ id: string; filename: string; contentType: string } | null>(null)
// Carica messaggio
const {
data: message,
@@ -254,6 +275,79 @@ export function MessageDetailPage() {
onError: (error) => toast.error(getErrorMessage(error)),
})
// Stampa diretta (senza modal)
const handlePrintDirect = async () => {
if (!message) return
setIsPrinting(true)
try {
const response = await apiClient.get(`/messages/${message.id}/print`, { responseType: 'blob' })
const html = await response.data.text()
const w = window.open('', '_blank')
if (w) {
w.document.write(html)
w.document.close()
setTimeout(() => w.print(), 500)
}
} catch (e) {
toast.error('Errore apertura stampa')
} finally {
setIsPrinting(false)
}
}
// Stampa con selezione dal modal
const handlePrintFromModal = async () => {
if (!message) return
setShowPrintModal(false)
setIsPrinting(true)
try {
// 1. Stampa corpo email se selezionato
if (printBody) {
const response = await apiClient.get(`/messages/${message.id}/print`, { responseType: 'blob' })
const html = await response.data.text()
const w = window.open('', '_blank')
if (w) {
w.document.write(html)
w.document.close()
setTimeout(() => w.print(), 500)
}
}
// 2. Per ogni allegato selezionato, apre in nuova finestra tramite blob URL
const selectedAtts = attachments.filter((a) => printAttIds.has(a.id))
for (const att of selectedAtts) {
const isPrintable = att.content_type?.startsWith('image/') || att.content_type === 'application/pdf'
if (!isPrintable) {
toast(`Allegato "${att.filename}" non stampabile direttamente: scaricarlo e stamparlo manualmente.`, { icon: 'i' })
continue
}
try {
const resp = await apiClient.get(
`/messages/${message.id}/attachments/${att.id}/inline`,
{ responseType: 'blob' },
)
const blobUrl = window.URL.createObjectURL(new Blob([resp.data], { type: att.content_type || 'application/octet-stream' }))
const attWin = window.open(blobUrl, '_blank')
if (attWin) {
setTimeout(() => {
try { attWin.print() } catch (_) { /* ignora */ }
window.URL.revokeObjectURL(blobUrl)
}, 1500)
} else {
window.URL.revokeObjectURL(blobUrl)
toast.error(`Impossibile aprire "${att.filename}": controlla che i popup non siano bloccati.`)
}
} catch (_) {
toast.error(`Errore apertura allegato "${att.filename}"`)
}
}
} catch (e) {
toast.error('Errore apertura stampa')
} finally {
setIsPrinting(false)
}
}
// Download allegato autenticato
const handleDownloadAttachment = async (att: { id: string; filename: string }) => {
try {
@@ -444,11 +538,7 @@ export function MessageDetailPage() {
<Button
variant="outline"
size="sm"
onClick={() =>
navigate('/compose', {
state: { replyTo: message },
})
}
onClick={() => openCompose({ replyTo: message })}
>
<Reply className="h-4 w-4 mr-1" />
Rispondi
@@ -460,11 +550,7 @@ export function MessageDetailPage() {
<Button
variant="outline"
size="sm"
onClick={() =>
navigate('/compose', {
state: { forwardOf: message },
})
}
onClick={() => openCompose({ forwardOf: message })}
>
<Forward className="h-4 w-4 mr-1" />
Inoltra
@@ -492,22 +578,17 @@ export function MessageDetailPage() {
variant="outline"
size="sm"
isLoading={isPrinting}
onClick={async () => {
onClick={() => {
if (!message) return
setIsPrinting(true)
try {
const response = await apiClient.get(`/messages/${message.id}/print`, { responseType: 'blob' })
const html = await response.data.text()
const w = window.open('', '_blank')
if (w) {
w.document.write(html)
w.document.close()
setTimeout(() => w.print(), 500)
}
} catch (e) {
toast.error('Errore apertura stampa')
} finally {
setIsPrinting(false)
if (attachments.length > 0) {
// Apre modal di selezione stampa
setPrintBody(true)
setPrintAttIds(new Set())
setPrintModalPreviewAtt(null)
setShowPrintModal(true)
} else {
// Nessun allegato: stampa direttamente come prima
handlePrintDirect()
}
}}
title="Stampa / Salva come PDF"
@@ -851,6 +932,132 @@ export function MessageDetailPage() {
/>
)}
{/* Modal stampa con selezione allegati */}
{showPrintModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="bg-background rounded-xl shadow-2xl w-full max-w-lg">
{/* Header */}
<div className="flex items-center justify-between px-5 py-4 border-b">
<h3 className="text-lg font-semibold flex items-center gap-2">
<Printer className="h-5 w-5 text-primary" />
Stampa messaggio
</h3>
<button
onClick={() => setShowPrintModal(false)}
className="p-1 rounded hover:bg-muted"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Body */}
<div className="px-5 py-4 space-y-4">
<p className="text-sm text-muted-foreground">Seleziona cosa includere nella stampa:</p>
{/* Checkbox corpo email */}
<label className="flex items-center gap-3 cursor-pointer group">
<input
type="checkbox"
checked={printBody}
onChange={(e) => setPrintBody(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 accent-primary"
/>
<div className="flex-1">
<span className="text-sm font-medium">Corpo della email</span>
<p className="text-xs text-muted-foreground">Intestazione, mittente, destinatari e testo del messaggio</p>
</div>
</label>
{/* Separatore */}
<div className="border-t pt-3 space-y-1">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wide flex items-center gap-1.5">
<Paperclip className="h-3.5 w-3.5" />
Allegati ({attachments.length})
</p>
<div className="space-y-2 mt-2 max-h-60 overflow-y-auto pr-1">
{attachments.map((att) => {
const isPreviewable = att.content_type?.startsWith('image/') || att.content_type === 'application/pdf'
const isPrintable = isPreviewable
return (
<div
key={att.id}
className="flex items-center gap-3 rounded-lg border bg-muted/30 px-3 py-2"
>
<input
type="checkbox"
checked={printAttIds.has(att.id)}
onChange={(e) => {
setPrintAttIds((prev) => {
const next = new Set(prev)
if (e.target.checked) next.add(att.id)
else next.delete(att.id)
return next
})
}}
className="h-4 w-4 rounded border-gray-300 accent-primary flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{att.filename}</p>
<p className="text-xs text-muted-foreground">
{att.content_type || 'Tipo sconosciuto'} &bull; {formatBytes(att.size_bytes)}
{!isPrintable && (
<span className="ml-1.5 text-amber-600">(stampa non diretta)</span>
)}
</p>
</div>
{/* Pulsante anteprima all'interno del modal */}
{isPreviewable && (
<button
type="button"
onClick={() => setPrintModalPreviewAtt({ id: att.id, filename: att.filename, contentType: att.content_type || '' })}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-primary flex-shrink-0"
title="Visualizza allegato"
>
<Eye className="h-4 w-4" />
</button>
)}
</div>
)
})}
</div>
</div>
{/* Nota per allegati non stampabili */}
{attachments.some((a) => !a.content_type?.startsWith('image/') && a.content_type !== 'application/pdf') && (
<p className="text-xs text-amber-700 bg-amber-50 border border-amber-200 rounded-md px-3 py-2">
Gli allegati contrassegnati con "stampa non diretta" (es. .eml, .xml) non possono essere aperti direttamente dal browser. Scaricali e stampali manualmente.
</p>
)}
</div>
{/* Footer */}
<div className="flex justify-end gap-3 px-5 py-4 border-t">
<Button variant="outline" onClick={() => setShowPrintModal(false)}>
Annulla
</Button>
<Button
onClick={handlePrintFromModal}
disabled={!printBody && printAttIds.size === 0}
>
<Printer className="h-4 w-4 mr-1" />
Stampa
</Button>
</div>
</div>
{/* Anteprima allegato aperta dall'interno del modal (z-index superiore) */}
{printModalPreviewAtt && (
<AttachmentPreviewModal
messageId={message.id}
attachmentId={printModalPreviewAtt.id}
filename={printModalPreviewAtt.filename}
contentType={printModalPreviewAtt.contentType}
onClose={() => setPrintModalPreviewAtt(null)}
/>
)}
</div>
)}
{/* Dialog gestione tag */}
{showTagSelector && (
<TagSelector
+44
View File
@@ -0,0 +1,44 @@
/**
* Compose Store (Zustand) stato globale della finestra di composizione.
*/
import { create } from 'zustand'
import type { MessageResponse } from '@/types/api.types'
type ComposeMode = 'normal' | 'minimized' | 'fullscreen'
interface ComposeState {
isOpen: boolean
mode: ComposeMode
replyTo: MessageResponse | null
forwardOf: MessageResponse | null
openCompose: (opts?: { replyTo?: MessageResponse; forwardOf?: MessageResponse }) => void
closeCompose: () => void
setMode: (mode: ComposeMode) => void
}
export const useComposeStore = create<ComposeState>()((set) => ({
isOpen: false,
mode: 'normal',
replyTo: null,
forwardOf: null,
openCompose: (opts) =>
set({
isOpen: true,
mode: 'normal',
replyTo: opts?.replyTo ?? null,
forwardOf: opts?.forwardOf ?? null,
}),
closeCompose: () =>
set({
isOpen: false,
mode: 'normal',
replyTo: null,
forwardOf: null,
}),
setMode: (mode) => set({ mode }),
}))