mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
modifiche varie
This commit is contained in:
@@ -48,4 +48,3 @@ Ho docker installato, compose v2 (docker cmpose senza trattino)
|
|||||||
|
|
||||||
Il progetto si trova in `/opt/pechub`
|
Il progetto si trova in `/opt/pechub`
|
||||||
|
|
||||||
Il servizio deve essere esposto su 0.0.0.0 e prevedere l'esposizione anche col dominio pechub.it
|
|
||||||
@@ -860,6 +860,64 @@ async def get_attachment_preview_url(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Feature 7b: Serve allegato inline per preview ───────────────────────────
|
||||||
|
|
||||||
|
@router.get("/{message_id}/attachments/{attachment_id}/inline")
|
||||||
|
async def inline_attachment(
|
||||||
|
message_id: uuid.UUID,
|
||||||
|
attachment_id: uuid.UUID,
|
||||||
|
current_user: CurrentUser,
|
||||||
|
db: DB,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Serve l'allegato con Content-Disposition: inline per la preview nel browser.
|
||||||
|
|
||||||
|
Legge il file da MinIO internamente e lo restituisce attraverso il backend,
|
||||||
|
evitando i problemi di accessibilita' con i presigned URL di MinIO in ambienti Docker.
|
||||||
|
Supporta PDF e immagini; per altri tipi restituisce 404.
|
||||||
|
"""
|
||||||
|
await _resolve_message(message_id, current_user, db)
|
||||||
|
|
||||||
|
result = await db.execute(
|
||||||
|
select(Attachment).where(
|
||||||
|
Attachment.id == attachment_id,
|
||||||
|
Attachment.message_id == message_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
attachment = result.scalar_one_or_none()
|
||||||
|
if not attachment:
|
||||||
|
raise NotFoundError(f"Allegato {attachment_id} non trovato")
|
||||||
|
|
||||||
|
content_type = attachment.content_type or "application/octet-stream"
|
||||||
|
filename = attachment.filename.replace('"', "'")
|
||||||
|
|
||||||
|
try:
|
||||||
|
from miniopy_async import Minio
|
||||||
|
|
||||||
|
client = Minio(
|
||||||
|
endpoint=settings.minio_endpoint,
|
||||||
|
access_key=settings.minio_access_key,
|
||||||
|
secret_key=settings.minio_secret_key,
|
||||||
|
secure=settings.minio_use_ssl,
|
||||||
|
)
|
||||||
|
response = await client.get_object(settings.minio_bucket, attachment.storage_path)
|
||||||
|
data = await response.content.read()
|
||||||
|
response.close()
|
||||||
|
return StreamingResponse(
|
||||||
|
iter([data]),
|
||||||
|
media_type=content_type,
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f'inline; filename="{filename}"',
|
||||||
|
"Content-Length": str(len(data)),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
from app.core.logging import get_logger
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
logger.error(f"Errore inline allegato {attachment_id}: {e}")
|
||||||
|
raise NotFoundError("File non disponibile al momento")
|
||||||
|
|
||||||
|
|
||||||
# ─── Feature 8: Stampa/export HTML ────────────────────────────────────────────
|
# ─── Feature 8: Stampa/export HTML ────────────────────────────────────────────
|
||||||
|
|
||||||
@router.get("/{message_id}/print")
|
@router.get("/{message_id}/print")
|
||||||
|
|||||||
@@ -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 { Outlet, Navigate } from 'react-router-dom'
|
||||||
import { Toaster } from 'react-hot-toast'
|
import { Toaster } from 'react-hot-toast'
|
||||||
import { Sidebar } from './Sidebar'
|
import { Sidebar } from './Sidebar'
|
||||||
|
import { ComposeModal } from '@/components/ComposeModal/ComposeModal'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
import { useWebSocket } from '@/hooks/useWebSocket'
|
import { useWebSocket } from '@/hooks/useWebSocket'
|
||||||
import { useEffect } from 'react'
|
import { useEffect } from 'react'
|
||||||
@@ -59,6 +60,9 @@ export function AppLayout() {
|
|||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Finestra di composizione PEC flottante (stile Gmail) */}
|
||||||
|
<ComposeModal />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -259,6 +259,7 @@ export function ComposePage() {
|
|||||||
<div className="flex-1 overflow-y-auto">
|
<div className="flex-1 overflow-y-auto">
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSubmit(onSubmit)}
|
onSubmit={handleSubmit(onSubmit)}
|
||||||
|
noValidate
|
||||||
className="max-w-4xl mx-auto px-6 py-6 space-y-5"
|
className="max-w-4xl mx-auto px-6 py-6 space-y-5"
|
||||||
>
|
>
|
||||||
{/* Avviso informativo */}
|
{/* Avviso informativo */}
|
||||||
@@ -335,33 +336,39 @@ export function ComposePage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{toFields.map((field, idx) => (
|
{toFields.map((field, idx) => (
|
||||||
<div key={field.id} className="flex gap-2">
|
<div key={field.id} className="flex flex-col gap-1">
|
||||||
<Input
|
<div className="flex gap-2">
|
||||||
type="email"
|
<Input
|
||||||
placeholder="destinatario@pec.it"
|
type="email"
|
||||||
{...register(`to_addresses.${idx}.value`, {
|
placeholder="destinatario@pec.it"
|
||||||
required:
|
{...register(`to_addresses.${idx}.value`, {
|
||||||
idx === 0 ? 'Almeno un destinatario è obbligatorio' : false,
|
required:
|
||||||
})}
|
idx === 0 ? 'Almeno un destinatario è obbligatorio' : false,
|
||||||
/>
|
pattern: {
|
||||||
{toFields.length > 1 && (
|
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||||
<Button
|
message: 'Inserisci un indirizzo email valido',
|
||||||
type="button"
|
},
|
||||||
variant="ghost"
|
})}
|
||||||
size="icon"
|
/>
|
||||||
onClick={() => removeTo(idx)}
|
{toFields.length > 1 && (
|
||||||
>
|
<Button
|
||||||
<X className="h-4 w-4" />
|
type="button"
|
||||||
</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>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{errors.to_addresses?.[0]?.value && (
|
|
||||||
<p className="text-xs text-destructive">
|
|
||||||
{errors.to_addresses[0].value.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* CC */}
|
{/* CC */}
|
||||||
@@ -389,20 +396,32 @@ export function ComposePage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{ccFields.map((field, idx) => (
|
{ccFields.map((field, idx) => (
|
||||||
<div key={field.id} className="flex gap-2">
|
<div key={field.id} className="flex flex-col gap-1">
|
||||||
<Input
|
<div className="flex gap-2">
|
||||||
type="email"
|
<Input
|
||||||
placeholder="cc@pec.it"
|
type="email"
|
||||||
{...register(`cc_addresses.${idx}.value`)}
|
placeholder="cc@pec.it"
|
||||||
/>
|
{...register(`cc_addresses.${idx}.value`, {
|
||||||
<Button
|
pattern: {
|
||||||
type="button"
|
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||||
variant="ghost"
|
message: 'Inserisci un indirizzo email valido',
|
||||||
size="icon"
|
},
|
||||||
onClick={() => removeCc(idx)}
|
})}
|
||||||
>
|
/>
|
||||||
<X className="h-4 w-4" />
|
<Button
|
||||||
</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>
|
</div>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import { deadlinesApi } from '@/api/deadlines.api'
|
|||||||
import { formatDate, formatBytes } from '@/lib/utils'
|
import { formatDate, formatBytes } from '@/lib/utils'
|
||||||
import { getErrorMessage } from '@/api/client'
|
import { getErrorMessage } from '@/api/client'
|
||||||
import apiClient from '@/api/client'
|
import apiClient from '@/api/client'
|
||||||
|
import { useComposeStore } from '@/store/compose.store'
|
||||||
|
|
||||||
// ─── Thread section (Feature 3) ──────────────────────────────────────────────
|
// ─── Thread section (Feature 3) ──────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -100,13 +101,23 @@ interface AttachmentPreviewProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function AttachmentPreviewModal({ messageId, attachmentId, filename, contentType, onClose }: AttachmentPreviewProps) {
|
function AttachmentPreviewModal({ messageId, attachmentId, filename, contentType, onClose }: AttachmentPreviewProps) {
|
||||||
const { data, isLoading } = useQuery({
|
const isPreviewable = contentType.startsWith('image/') || contentType === 'application/pdf'
|
||||||
queryKey: ['attachment-preview', messageId, attachmentId],
|
|
||||||
queryFn: () => messagesApi.getAttachmentPreviewUrl(messageId, attachmentId),
|
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 (
|
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="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">
|
<div className="flex items-center justify-between px-4 py-3 border-b">
|
||||||
<span className="font-medium truncate">{filename}</span>
|
<span className="font-medium truncate">{filename}</span>
|
||||||
@@ -118,24 +129,27 @@ function AttachmentPreviewModal({ messageId, attachmentId, filename, contentType
|
|||||||
{isLoading && (
|
{isLoading && (
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
<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={blobUrl} alt={filename} className="max-w-full max-h-full object-contain" />
|
||||||
{contentType.startsWith('image/') ? (
|
) : contentType === 'application/pdf' ? (
|
||||||
<img src={data.url} alt={filename} className="max-w-full max-h-full object-contain" />
|
<iframe src={blobUrl} className="w-full h-[70vh]" title={filename} />
|
||||||
) : contentType === 'application/pdf' ? (
|
) : null}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{!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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,6 +162,7 @@ export function MessageDetailPage() {
|
|||||||
const { id } = useParams<{ id: string }>()
|
const { id } = useParams<{ id: string }>()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const queryClient = useQueryClient()
|
const queryClient = useQueryClient()
|
||||||
|
const openCompose = useComposeStore((s) => s.openCompose)
|
||||||
|
|
||||||
const [showTagSelector, setShowTagSelector] = useState(false)
|
const [showTagSelector, setShowTagSelector] = useState(false)
|
||||||
const [isDownloadingPackage, setIsDownloadingPackage] = useState(false)
|
const [isDownloadingPackage, setIsDownloadingPackage] = useState(false)
|
||||||
@@ -161,6 +176,12 @@ export function MessageDetailPage() {
|
|||||||
// Feature 7: Preview allegati
|
// Feature 7: Preview allegati
|
||||||
const [previewAtt, setPreviewAtt] = useState<{ id: string; filename: string; contentType: string } | null>(null)
|
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
|
// Carica messaggio
|
||||||
const {
|
const {
|
||||||
data: message,
|
data: message,
|
||||||
@@ -254,6 +275,79 @@ export function MessageDetailPage() {
|
|||||||
onError: (error) => toast.error(getErrorMessage(error)),
|
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
|
// Download allegato autenticato
|
||||||
const handleDownloadAttachment = async (att: { id: string; filename: string }) => {
|
const handleDownloadAttachment = async (att: { id: string; filename: string }) => {
|
||||||
try {
|
try {
|
||||||
@@ -444,11 +538,7 @@ export function MessageDetailPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() => openCompose({ replyTo: message })}
|
||||||
navigate('/compose', {
|
|
||||||
state: { replyTo: message },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Reply className="h-4 w-4 mr-1" />
|
<Reply className="h-4 w-4 mr-1" />
|
||||||
Rispondi
|
Rispondi
|
||||||
@@ -460,11 +550,7 @@ export function MessageDetailPage() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() => openCompose({ forwardOf: message })}
|
||||||
navigate('/compose', {
|
|
||||||
state: { forwardOf: message },
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<Forward className="h-4 w-4 mr-1" />
|
<Forward className="h-4 w-4 mr-1" />
|
||||||
Inoltra
|
Inoltra
|
||||||
@@ -492,22 +578,17 @@ export function MessageDetailPage() {
|
|||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
isLoading={isPrinting}
|
isLoading={isPrinting}
|
||||||
onClick={async () => {
|
onClick={() => {
|
||||||
if (!message) return
|
if (!message) return
|
||||||
setIsPrinting(true)
|
if (attachments.length > 0) {
|
||||||
try {
|
// Apre modal di selezione stampa
|
||||||
const response = await apiClient.get(`/messages/${message.id}/print`, { responseType: 'blob' })
|
setPrintBody(true)
|
||||||
const html = await response.data.text()
|
setPrintAttIds(new Set())
|
||||||
const w = window.open('', '_blank')
|
setPrintModalPreviewAtt(null)
|
||||||
if (w) {
|
setShowPrintModal(true)
|
||||||
w.document.write(html)
|
} else {
|
||||||
w.document.close()
|
// Nessun allegato: stampa direttamente come prima
|
||||||
setTimeout(() => w.print(), 500)
|
handlePrintDirect()
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
toast.error('Errore apertura stampa')
|
|
||||||
} finally {
|
|
||||||
setIsPrinting(false)
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Stampa / Salva come PDF"
|
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'} • {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 */}
|
{/* Dialog gestione tag */}
|
||||||
{showTagSelector && (
|
{showTagSelector && (
|
||||||
<TagSelector
|
<TagSelector
|
||||||
|
|||||||
@@ -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 }),
|
||||||
|
}))
|
||||||
Reference in New Issue
Block a user