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 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 ────────────────────────────────────────────
|
||||
|
||||
@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 { 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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,13 +336,18 @@ export function ComposePage() {
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{toFields.map((field, idx) => (
|
||||
<div key={field.id} className="flex gap-2">
|
||||
<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 && (
|
||||
@@ -355,14 +361,15 @@ export function ComposePage() {
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{errors.to_addresses?.[0]?.value && (
|
||||
{errors.to_addresses?.[idx]?.value && (
|
||||
<p className="text-xs text-destructive">
|
||||
{errors.to_addresses[0].value.message}
|
||||
{errors.to_addresses[idx]?.value?.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CC */}
|
||||
<div className="space-y-1.5">
|
||||
@@ -389,11 +396,17 @@ export function ComposePage() {
|
||||
</Button>
|
||||
</div>
|
||||
{ccFields.map((field, idx) => (
|
||||
<div key={field.id} className="flex gap-2">
|
||||
<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`)}
|
||||
{...register(`cc_addresses.${idx}.value`, {
|
||||
pattern: {
|
||||
value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
|
||||
message: 'Inserisci un indirizzo email valido',
|
||||
},
|
||||
})}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -404,6 +417,12 @@ export function ComposePage() {
|
||||
<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 && (
|
||||
<>
|
||||
{data.previewable && data.url ? (
|
||||
{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 && (
|
||||
<>
|
||||
{contentType.startsWith('image/') ? (
|
||||
<img src={data.url} alt={filename} className="max-w-full max-h-full object-contain" />
|
||||
<img src={blobUrl} 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} />
|
||||
<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'} • {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
|
||||
|
||||
@@ -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