Modifiche varie

This commit is contained in:
2026-06-04 20:54:49 +02:00
parent ccc4167e28
commit e31676d22e
31 changed files with 3058 additions and 153 deletions
+2
View File
@@ -18,6 +18,7 @@ import { TemplatesPage } from '@/pages/Templates/TemplatesPage'
import { RoutingRulesPage } from '@/pages/RoutingRules/RoutingRulesPage'
import { ContactsPage } from '@/pages/Contacts/ContactsPage'
import { DeadlinesPage } from '@/pages/Deadlines/DeadlinesPage'
import { SignaturesPage } from '@/pages/Signatures/SignaturesPage'
/**
* Routing principale dell'applicazione PEChub.
@@ -101,6 +102,7 @@ export default function App() {
<Route path="/routing-rules" element={<RoutingRulesPage />} />
<Route path="/contacts" element={<ContactsPage />} />
<Route path="/deadlines" element={<DeadlinesPage />} />
<Route path="/signatures" element={<SignaturesPage />} />
{/* Profilo utente */}
<Route path="/settings" element={<SettingsPage />} />
+17
View File
@@ -9,6 +9,8 @@ export interface AuditLogEntry {
id: number
tenant_id: string | null
user_id: string | null
user_email: string | null
user_full_name: string | null
action: string
resource_type: string | null
resource_id: string | null
@@ -38,4 +40,19 @@ export const auditLogApi = {
apiClient
.get<AuditLogListResponse>('/audit-log', { params })
.then((r) => r.data),
/**
* Esporta i log come file (CSV o PDF).
* Restituisce un Blob per il download lato browser.
*/
export: async (
format: 'csv' | 'pdf',
params: Omit<AuditLogParams, 'page' | 'page_size'> = {},
): Promise<Blob> => {
const response = await apiClient.get('/audit-log/export', {
params: { format, ...params },
responseType: 'blob',
})
return response.data as Blob
},
}
+97
View File
@@ -0,0 +1,97 @@
import apiClient from './client'
// ─── Firme ────────────────────────────────────────────────────────────────────
export interface SignatureResponse {
id: string
tenant_id: string
name: string
description: string | null
body_html: string | null
body_text: string | null
created_by: string | null
created_at: string
updated_at: string
}
export interface SignatureCreate {
name: string
description?: string | null
body_html?: string | null
body_text?: string | null
}
export interface SignatureUpdate {
name?: string
description?: string | null
body_html?: string | null
body_text?: string | null
}
// ─── Assegnazioni ─────────────────────────────────────────────────────────────
export type SignatureContext = 'reply' | 'compose' | 'both'
export interface SignatureAssignmentResponse {
id: string
tenant_id: string
signature_id: string
mailbox_id: string | null
virtual_box_id: string | null
context: SignatureContext
created_at: string
signature_name: string | null
}
export interface SignatureAssignmentCreate {
signature_id: string
mailbox_id?: string | null
virtual_box_id?: string | null
context: SignatureContext
}
// ─── API client ───────────────────────────────────────────────────────────────
export const signaturesApi = {
// Firme
list: (q?: string) =>
apiClient
.get<{ items: SignatureResponse[]; total: number }>('/signatures', {
params: { q },
})
.then((r) => r.data),
get: (id: string) =>
apiClient.get<SignatureResponse>(`/signatures/${id}`).then((r) => r.data),
create: (data: SignatureCreate) =>
apiClient.post<SignatureResponse>('/signatures', data).then((r) => r.data),
update: (id: string, data: SignatureUpdate) =>
apiClient.put<SignatureResponse>(`/signatures/${id}`, data).then((r) => r.data),
delete: (id: string) =>
apiClient.delete(`/signatures/${id}`).then((r) => r.data),
// Assegnazioni
listAssignments: (params?: { mailbox_id?: string; virtual_box_id?: string }) =>
apiClient
.get<{ items: SignatureAssignmentResponse[]; total: number }>('/signatures/assignments', {
params,
})
.then((r) => r.data),
createAssignment: (data: SignatureAssignmentCreate) =>
apiClient
.post<SignatureAssignmentResponse>('/signatures/assignments', data)
.then((r) => r.data),
deleteAssignment: (id: string) =>
apiClient.delete(`/signatures/assignments/${id}`).then((r) => r.data),
// Risolve la firma per una casella/vbox nel contesto dato (usato dal ComposeModal)
resolve: (params: { context: 'reply' | 'compose'; mailbox_id?: string; virtual_box_id?: string }) =>
apiClient
.get<SignatureResponse | null>('/signatures/resolve', { params })
.then((r) => r.data),
}
@@ -1,5 +1,5 @@
import { useState, useRef, useMemo, useEffect } from 'react'
import { useForm, useFieldArray } from 'react-hook-form'
import { useForm, useFieldArray, useWatch } from 'react-hook-form'
import {
Send,
X,
@@ -11,6 +11,8 @@ import {
Minus,
Maximize2,
Minimize2,
FileText,
ChevronDown,
} from 'lucide-react'
import { useQuery, useMutation } from '@tanstack/react-query'
import toast from 'react-hot-toast'
@@ -18,13 +20,38 @@ 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 { ContactPickerPopover } from '@/components/ComposeModal/ContactPickerPopover'
import { sendApi } from '@/api/send.api'
import { mailboxesApi } from '@/api/mailboxes.api'
import { virtualBoxesApi } from '@/api/virtual_boxes.api'
import { signaturesApi } from '@/api/signatures.api'
import { templatesApi } from '@/api/templates.api'
import type { TemplateResponse } from '@/api/templates.api'
import { getErrorMessage } from '@/api/client'
import { useComposeStore } from '@/store/compose.store'
import type { MessageResponse } from '@/types/api.types'
// ─── Utilita' firma ───────────────────────────────────────────────────────────
const SIG_ATTR = 'data-pechub-sig'
/** Rimuove il blocco firma esistente e (opzionalmente) ne inserisce uno nuovo. */
function injectSignature(body: string, sigHtml: string | null): string {
const withoutSig = body.replace(
new RegExp(`<div[^>]*${SIG_ATTR}="1"[^>]*>[\\s\\S]*?<\\/div>`, 'g'),
''
)
if (!sigHtml) return withoutSig
const sigBlock = `<div ${SIG_ATTR}="1" style="margin-top:12px;padding-top:8px;border-top:1px solid #d1d5db">${sigHtml}</div>`
if (withoutSig.includes('<hr>')) {
return withoutSig.replace('<hr>', sigBlock + '<hr>')
}
// Se il corpo e' vuoto (nuova PEC), anteponi un paragrafo vuoto sopra la firma
// cosi' il cursore si posiziona sopra di essa e non dentro/dopo
const content = withoutSig.trim() ? withoutSig : '<p></p>'
return content + sigBlock
}
// ─── Tipi ─────────────────────────────────────────────────────────────────────
interface MailboxSelectItem {
@@ -92,32 +119,73 @@ function buildInitialBody(replyTo?: MessageResponse | null, forwardOf?: MessageR
return ''
}
/**
* Calcola i destinatari iniziali per "Rispondi a tutti":
* from + tutti i to, deduplicati (la propria email verra' filtrata dopo che le caselle si caricano).
*/
function buildReplyAllToAddresses(replyTo: MessageResponse): { value: string }[] {
const seen = new Set<string>()
const result: { value: string }[] = []
const addIfNew = (addr: string) => {
const normalized = addr.trim().toLowerCase()
if (normalized && !seen.has(normalized)) {
seen.add(normalized)
result.push({ value: addr.trim() })
}
}
if (replyTo.from_address) addIfNew(replyTo.from_address)
for (const addr of replyTo.to_addresses || []) addIfNew(addr)
return result.length > 0 ? result : [{ value: '' }]
}
// ─── Form interno (rimontato ogni volta che cambia il messaggio) ───────────────
interface ComposeFormProps {
replyTo: MessageResponse | null
forwardOf: MessageResponse | null
replyAll: boolean
onClose: () => void
}
function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) {
function ComposeForm({ replyTo, forwardOf, replyAll, onClose }: ComposeFormProps) {
const fileInputRef = useRef<HTMLInputElement>(null)
const [showCc, setShowCc] = useState(false)
// In modalita' "rispondi a tutti" apri CC automaticamente se ci sono CC originali
const [showCc, setShowCc] = useState(
replyAll && (replyTo?.cc_addresses?.length ?? 0) > 0
)
const [attachments, setAttachments] = useState<File[]>([])
const [bodyHtml, setBodyHtml] = useState<string>(() =>
buildInitialBody(replyTo, forwardOf)
)
// Flag per eseguire il filtro della propria email una sola volta dopo il caricamento delle caselle
const ownEmailFilteredRef = useRef(false)
const buildDefaultTo = (): { value: string }[] => {
if (replyAll && replyTo) return buildReplyAllToAddresses(replyTo)
if (replyTo) return [{ value: replyTo.from_address || '' }]
return [{ value: '' }]
}
const buildDefaultCc = (): { value: string }[] => {
if (replyAll && replyTo && replyTo.cc_addresses?.length > 0) {
return replyTo.cc_addresses.map((a) => ({ value: a }))
}
return []
}
const {
register,
control,
handleSubmit,
setValue,
formState: { errors },
} = useForm<ComposeFormValues>({
defaultValues: {
mailbox_id: replyTo?.mailbox_id || forwardOf?.mailbox_id || '',
to_addresses: replyTo ? [{ value: replyTo.from_address || '' }] : [{ value: '' }],
cc_addresses: [],
to_addresses: buildDefaultTo(),
cc_addresses: buildDefaultCc(),
subject: replyTo
? `Re: ${replyTo.subject || ''}`
: forwardOf
@@ -126,9 +194,56 @@ function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) {
},
})
const { fields: toFields, append: appendTo, remove: removeTo } = useFieldArray({ control, name: 'to_addresses' })
const { fields: toFields, append: appendTo, remove: removeTo, replace: replaceTo } = useFieldArray({ control, name: 'to_addresses' })
const { fields: ccFields, append: appendCc, remove: removeCc } = useFieldArray({ control, name: 'cc_addresses' })
// ── Auto-inserimento firma ────────────────────────────────────────────────
const watchedMailboxId = useWatch({ control, name: 'mailbox_id' })
const signatureContext = replyTo ? 'reply' : 'compose'
const sigHtmlRef = useRef<string | null>(null)
useEffect(() => {
if (!watchedMailboxId) {
sigHtmlRef.current = null
setBodyHtml((prev) => injectSignature(prev, null))
return
}
signaturesApi
.resolve({ context: signatureContext, mailbox_id: watchedMailboxId })
.then((sig) => {
sigHtmlRef.current = sig?.body_html ?? null
setBodyHtml((prev) => injectSignature(prev, sig?.body_html ?? null))
})
.catch(() => {
sigHtmlRef.current = null
})
}, [watchedMailboxId, signatureContext])
// ─────────────────────────────────────────────────────────────────────────
// ── Selettore template ────────────────────────────────────────────────────
const [showTemplatePicker, setShowTemplatePicker] = useState(false)
const { data: templatesData, isLoading: templatesLoading } = useQuery({
queryKey: ['templates'],
queryFn: () => templatesApi.list(),
enabled: showTemplatePicker,
staleTime: 60 * 1000,
})
const applyTemplate = (tpl: TemplateResponse) => {
const hrIndex = bodyHtml.indexOf('<hr>')
const quotedPart = hrIndex >= 0 ? bodyHtml.slice(hrIndex) : ''
const templateBody = tpl.body_html || (tpl.body_text ? `<p>${tpl.body_text}</p>` : '')
const newBodyBase = templateBody + quotedPart
setBodyHtml(injectSignature(newBodyBase, sigHtmlRef.current))
if (!replyTo && !forwardOf && tpl.subject) {
setValue('subject', tpl.subject)
}
setShowTemplatePicker(false)
toast.success(`Template "${tpl.name}" applicato`)
}
// ─────────────────────────────────────────────────────────────────────────
const { data: mailboxesData, isLoading: mailboxesLoading } = useQuery({
queryKey: ['mailboxes'],
queryFn: () => mailboxesApi.list(),
@@ -139,18 +254,6 @@ function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) {
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') || []
@@ -179,6 +282,33 @@ function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) {
const isLoadingMailboxes = mailboxesLoading || vboxMailboxesLoading
// ── Filtro propria email per "Rispondi a tutti" ───────────────────────────
// Eseguito una sola volta dopo che le caselle si caricano e la mailbox e' selezionata
useEffect(() => {
if (!replyAll || !replyTo || isLoadingMailboxes || !watchedMailboxId || ownEmailFilteredRef.current) return
const ownMailbox = activeCaselle.find((m) => m.id === watchedMailboxId)
if (!ownMailbox) return
ownEmailFilteredRef.current = true
const ownEmail = ownMailbox.email_address.toLowerCase()
const current = toFields.map((f) => f.value)
const filtered = current.filter((v) => v.toLowerCase() !== ownEmail)
if (filtered.length === 0) filtered.push('')
replaceTo(filtered.map((v) => ({ value: v })))
}, [replyAll, replyTo, isLoadingMailboxes, watchedMailboxId, activeCaselle, toFields, replaceTo])
// ─────────────────────────────────────────────────────────────────────────
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 handleFileAdd = (files: FileList | null) => {
if (!files) return
const valid: File[] = []
@@ -299,6 +429,10 @@ function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) {
pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Indirizzo non valido' },
})}
/>
<ContactPickerPopover
size="sm"
onSelect={(email) => setValue(`to_addresses.${idx}.value`, email)}
/>
{toFields.length > 1 && (
<button
type="button"
@@ -322,7 +456,7 @@ function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) {
{!showCc ? (
<button
type="button"
onClick={() => setShowCc(true)}
onClick={() => { setShowCc(true); if (ccFields.length === 0) appendCc({ value: '' }) }}
className="text-xs text-primary hover:underline"
>
+ Aggiungi Cc
@@ -351,6 +485,10 @@ function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) {
pattern: { value: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, message: 'Indirizzo non valido' },
})}
/>
<ContactPickerPopover
size="sm"
onSelect={(email) => setValue(`cc_addresses.${idx}.value`, email)}
/>
<button
type="button"
onClick={() => removeCc(idx)}
@@ -383,7 +521,58 @@ function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) {
{/* Corpo */}
<div className="space-y-1">
<Label className="text-xs">Testo del messaggio</Label>
<div className="flex items-center justify-between">
<Label className="text-xs">Testo del messaggio</Label>
<button
type="button"
onClick={() => setShowTemplatePicker((v) => !v)}
className="flex items-center gap-1 text-xs text-primary hover:underline"
title="Usa un template come punto di partenza"
>
<FileText className="h-3 w-3" />
Usa template
<ChevronDown
className={`h-3 w-3 transition-transform ${showTemplatePicker ? 'rotate-180' : ''}`}
/>
</button>
</div>
{/* Pannello selezione template */}
{showTemplatePicker && (
<div className="rounded-md border bg-background shadow-sm overflow-hidden">
{templatesLoading ? (
<div className="p-3 text-xs text-muted-foreground text-center">
Caricamento template...
</div>
) : !templatesData?.items.length ? (
<div className="p-3 text-xs text-muted-foreground text-center">
Nessun template disponibile. Creane uno in Amministrazione.
</div>
) : (
<div className="max-h-44 overflow-y-auto divide-y">
{templatesData.items.map((tpl) => (
<button
key={tpl.id}
type="button"
onClick={() => applyTemplate(tpl)}
className="w-full text-left px-3 py-2 hover:bg-muted/60 transition-colors"
>
<p className="text-xs font-semibold text-foreground">{tpl.name}</p>
{tpl.description && (
<p className="text-xs text-muted-foreground truncate">{tpl.description}</p>
)}
{tpl.subject && (
<p className="text-xs text-muted-foreground/70 italic truncate">
Oggetto: {tpl.subject}
</p>
)}
</button>
))}
</div>
)}
</div>
)}
<RichTextEditor
value={bodyHtml}
onChange={setBodyHtml}
@@ -469,7 +658,7 @@ function ComposeForm({ replyTo, forwardOf, onClose }: ComposeFormProps) {
// ─── Componente principale flottante ──────────────────────────────────────────
export function ComposeModal() {
const { isOpen, mode, replyTo, forwardOf, closeCompose, setMode } = useComposeStore()
const { isOpen, mode, replyTo, forwardOf, replyAll, closeCompose, setMode } = useComposeStore()
// Chiudi con ESC (solo quando non minimizzato)
useEffect(() => {
@@ -484,10 +673,15 @@ export function ComposeModal() {
if (!isOpen) return null
const title = replyTo ? 'Rispondi a PEC' : forwardOf ? 'Inoltra PEC' : 'Nuova PEC'
const title = replyAll
? 'Rispondi a tutti'
: 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 {
@@ -507,7 +701,6 @@ export function ComposeModal() {
height: '48px',
}
}
// normal
return {
position: 'fixed' as const,
bottom: 0,
@@ -582,13 +775,14 @@ export function ComposeModal() {
{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.
Key basata su replyTo/forwardOf/replyAll: garantisce che il form venga rimontato
ogni volta che si apre per un messaggio diverso o cambia la modalita'.
*/}
<ComposeForm
key={`${replyTo?.id ?? 'new'}-${forwardOf?.id ?? 'new'}`}
key={`${replyTo?.id ?? 'new'}-${forwardOf?.id ?? 'new'}-${replyAll ? 'all' : 'single'}`}
replyTo={replyTo}
forwardOf={forwardOf}
replyAll={replyAll}
onClose={closeCompose}
/>
</div>
@@ -0,0 +1,167 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { BookUser, Search, Star, User } from 'lucide-react'
import { contactsApi, type PecContactResponse } from '@/api/contacts.api'
interface ContactPickerPopoverProps {
onSelect: (email: string) => void
size?: 'sm' | 'md'
}
function useDebounce<T>(value: T, delay: number): T {
const [debounced, setDebounced] = useState<T>(value)
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(timer)
}, [value, delay])
return debounced
}
export function ContactPickerPopover({ onSelect, size = 'md' }: ContactPickerPopoverProps) {
const [open, setOpen] = useState(false)
const [query, setQuery] = useState('')
const [results, setResults] = useState<PecContactResponse[]>([])
const [loading, setLoading] = useState(false)
const containerRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null)
const debouncedQuery = useDebounce(query, 250)
// Chiudi il popover cliccando fuori
useEffect(() => {
if (!open) return
const handler = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setOpen(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [open])
// Focus sull'input quando si apre
useEffect(() => {
if (open) {
setTimeout(() => inputRef.current?.focus(), 50)
} else {
setQuery('')
setResults([])
}
}, [open])
// Ricerca contatti
const fetchContacts = useCallback(async (q: string) => {
setLoading(true)
try {
if (q.trim().length >= 1) {
const data = await contactsApi.autocomplete(q.trim())
setResults(data)
} else {
// Senza query: mostra tutti (primi 20 ordinati per preferiti)
const data = await contactsApi.list({ page_size: 20 })
setResults(data.items)
}
} catch {
setResults([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
if (open) {
fetchContacts(debouncedQuery)
}
}, [debouncedQuery, open, fetchContacts])
const handleSelect = (contact: PecContactResponse) => {
onSelect(contact.email)
setOpen(false)
}
const isSm = size === 'sm'
return (
<div ref={containerRef} className="relative flex-shrink-0">
{/* Bottone apertura */}
<button
type="button"
title="Seleziona dalla rubrica"
onClick={() => setOpen((v) => !v)}
className={`
flex items-center justify-center rounded border border-input bg-background
text-muted-foreground hover:text-primary hover:border-primary/60
transition-colors
${isSm ? 'h-8 w-8' : 'h-10 w-10'}
${open ? 'border-primary/60 text-primary bg-primary/5' : ''}
`}
>
<BookUser className={isSm ? 'h-3.5 w-3.5' : 'h-4 w-4'} />
</button>
{/* Popover */}
{open && (
<div
className="absolute z-50 right-0 top-full mt-1 w-72 rounded-lg border bg-background shadow-lg overflow-hidden"
style={{ minWidth: '260px' }}
>
{/* Campo ricerca */}
<div className="flex items-center gap-2 px-3 py-2 border-b bg-muted/30">
<Search className="h-3.5 w-3.5 text-muted-foreground flex-shrink-0" />
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Cerca nella rubrica..."
className="flex-1 bg-transparent text-sm outline-none placeholder:text-muted-foreground"
/>
</div>
{/* Lista risultati */}
<div className="max-h-52 overflow-y-auto">
{loading ? (
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
Ricerca in corso...
</div>
) : results.length === 0 ? (
<div className="px-3 py-4 text-center text-xs text-muted-foreground">
{query.trim() ? 'Nessun contatto trovato' : 'Rubrica vuota'}
</div>
) : (
results.map((contact) => (
<button
key={contact.id}
type="button"
onClick={() => handleSelect(contact)}
className="w-full text-left px-3 py-2 hover:bg-muted/60 transition-colors flex items-start gap-2 group"
>
<div className="mt-0.5 flex-shrink-0 h-6 w-6 rounded-full bg-primary/10 flex items-center justify-center">
{contact.is_favorite ? (
<Star className="h-3 w-3 text-amber-500 fill-amber-500" />
) : (
<User className="h-3 w-3 text-primary/60" />
)}
</div>
<div className="min-w-0 flex-1">
{(contact.name || contact.organization) && (
<p className="text-xs font-medium text-foreground truncate">
{contact.name || contact.organization}
</p>
)}
<p className="text-xs text-muted-foreground truncate">
{contact.email}
</p>
{contact.name && contact.organization && (
<p className="text-xs text-muted-foreground/70 truncate">
{contact.organization}
</p>
)}
</div>
</button>
))
)}
</div>
</div>
)}
</div>
)
}
+13 -12
View File
@@ -58,10 +58,12 @@ import {
Settings2,
BookUser,
Calendar,
PenLine,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useAuth } from '@/hooks/useAuth'
import { useInboxStore } from '@/store/inbox.store'
import { useComposeStore } from '@/store/compose.store'
import { useState } from 'react'
import toast from 'react-hot-toast'
import { useQuery } from '@tanstack/react-query'
@@ -83,6 +85,7 @@ export function Sidebar() {
const { user, isAdmin, isSuperAdmin, isSupervisor, logout } = useAuth()
const unreadCount = useInboxStore((s) => s.unreadCount)
const openCompose = useComposeStore((s) => s.openCompose)
// Le caselle PEC vengono caricate qui e condivise via React Query cache
const { data: mailboxesData } = useQuery({
@@ -482,22 +485,19 @@ export function Sidebar() {
<div>
<div className="border-t border-gray-700 mx-4 mb-3" />
<div className="px-2">
<NavLink
to="/compose"
className={({ isActive }) =>
cn(
'flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
isActive
? 'bg-blue-600 text-white'
: 'text-gray-300 hover:bg-gray-700 hover:text-white',
collapsed && 'justify-center px-2',
)
}
<button
type="button"
onClick={() => openCompose()}
title={collapsed ? 'Nuova PEC' : undefined}
className={cn(
'w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
'text-gray-300 hover:bg-gray-700 hover:text-white',
collapsed && 'justify-center px-2',
)}
>
<MailCheck className="h-5 w-5 flex-shrink-0" />
{!collapsed && <span>Nuova PEC</span>}
</NavLink>
</button>
</div>
</div>
@@ -556,6 +556,7 @@ export function Sidebar() {
{ to: '/virtual-boxes', label: 'Virtual Box', icon: Filter },
{ to: '/routing-rules', label: 'Regole smistamento', icon: Settings2 },
{ to: '/templates', label: 'Template messaggi', icon: FileText },
{ to: '/signatures', label: 'Firme automatiche', icon: PenLine },
{ to: '/notifications', label: 'Notifiche', icon: Bell },
{ to: '/audit-log', label: 'Audit Log', icon: ClipboardList },
] as const).map((item) => (
+127 -28
View File
@@ -1,18 +1,60 @@
/**
* Pagina Audit Log visualizzazione eventi di sistema.
* Pagina Audit Log visualizzazione ed esportazione eventi di sistema.
*
* Accessibile solo ad admin e super_admin.
* Mostra una tabella paginata con filtri per data, azione ed esito.
* Permette di esportare i risultati in CSV o PDF.
*/
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { format } from 'date-fns'
import { it } from 'date-fns/locale'
import { ShieldCheck, AlertCircle, Search, RotateCcw } from 'lucide-react'
import { ShieldCheck, AlertCircle, Search, RotateCcw, Download, FileText } from 'lucide-react'
import { auditLogApi } from '@/api/audit_log.api'
import type { AuditLogParams } from '@/api/audit_log.api'
import { cn } from '@/lib/utils'
import toast from 'react-hot-toast'
// ─── Lista completa degli eventi monitorati ───────────────────────────────────
const ACTION_OPTIONS: { value: string; label: string; group: string }[] = [
// Auth
{ value: 'auth.login', label: 'Login', group: 'Autenticazione' },
{ value: 'auth.password_changed', label: 'Cambio password', group: 'Autenticazione' },
// Utenti
{ value: 'user.created', label: 'Utente creato', group: 'Utenti' },
{ value: 'user.updated', label: 'Utente modificato', group: 'Utenti' },
{ value: 'user.deleted', label: 'Utente eliminato', group: 'Utenti' },
// Caselle
{ value: 'mailbox.created', label: 'Casella creata', group: 'Caselle PEC' },
{ value: 'mailbox.updated', label: 'Casella modificata', group: 'Caselle PEC' },
{ value: 'mailbox.deleted', label: 'Casella eliminata', group: 'Caselle PEC' },
// Messaggi - invio
{ value: 'message.sent', label: 'PEC inviata', group: 'Messaggi' },
// Messaggi - stato
{ value: 'message.read', label: 'Messaggio letto', group: 'Messaggi' },
{ value: 'message.unread', label: 'Segnato non letto', group: 'Messaggi' },
{ value: 'message.opened', label: 'Messaggio aperto', group: 'Messaggi' },
{ value: 'message.starred', label: 'Aggiunto ai preferiti', group: 'Messaggi' },
{ value: 'message.unstarred', label: 'Rimosso dai preferiti', group: 'Messaggi' },
{ value: 'message.archived', label: 'Archiviato', group: 'Messaggi' },
{ value: 'message.unarchived', label: 'Ripristinato da archivio', group: 'Messaggi' },
{ value: 'message.trashed', label: 'Spostato nel cestino', group: 'Messaggi' },
{ value: 'message.restored', label: 'Ripristinato dal cestino', group: 'Messaggi' },
{ value: 'message.bulk_updated', label: 'Aggiornamento massivo', group: 'Messaggi' },
// Messaggi - allegati
{ value: 'message.attachment_downloaded', label: 'Allegato scaricato', group: 'Messaggi' },
{ value: 'message.package_downloaded', label: 'Pacchetto PEC scaricato', group: 'Messaggi' },
// Conservazione
{ value: 'message.pending_conservation', label: 'Messa in conservazione', group: 'Conservazione' },
{ value: 'message.conserved', label: 'Conservata', group: 'Conservazione' },
{ value: 'message.conservation_cancelled', label: 'Conservazione annullata', group: 'Conservazione' },
{ value: 'message.conservation_removed', label: 'Rimossa dalla conservazione', group: 'Conservazione' },
]
// Raggruppamento per group
const ACTION_GROUPS = Array.from(new Set(ACTION_OPTIONS.map((o) => o.group)))
// ─── Badge esito ──────────────────────────────────────────────────────────────
@@ -40,18 +82,20 @@ function OutcomeBadge({ outcome }: { outcome: string }) {
// ─── Etichetta azione leggibile ───────────────────────────────────────────────
function actionLabel(action: string): string {
const map: Record<string, string> = {
'auth.login': 'Login',
'auth.password_changed': 'Cambio password',
'user.created': 'Utente creato',
'user.updated': 'Utente modificato',
'user.deleted': 'Utente eliminato',
'mailbox.created': 'Casella creata',
'mailbox.updated': 'Casella modificata',
'mailbox.deleted': 'Casella eliminata',
'message.sent': 'PEC inviata',
}
return map[action] ?? action
return ACTION_OPTIONS.find((o) => o.value === action)?.label ?? action
}
// ─── Helper download ─────────────────────────────────────────────────────────
function triggerDownload(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
}
// ─── Componente principale ────────────────────────────────────────────────────
@@ -69,6 +113,10 @@ export function AuditLogPage() {
// Parametri query attivi (applicati al click su "Cerca")
const [activeParams, setActiveParams] = useState<AuditLogParams>({})
// Stato export
const [exportingCsv, setExportingCsv] = useState(false)
const [exportingPdf, setExportingPdf] = useState(false)
const { data, isLoading, isError } = useQuery({
queryKey: ['audit-log', page, activeParams],
queryFn: () =>
@@ -99,6 +147,23 @@ export function AuditLogPage() {
setPage(1)
}
const handleExport = async (format: 'csv' | 'pdf') => {
const setter = format === 'csv' ? setExportingCsv : setExportingPdf
setter(true)
try {
const blob = await auditLogApi.export(format, activeParams)
const ext = format === 'csv' ? 'csv' : 'pdf'
const suffix = activeParams.date_from
? `_dal_${activeParams.date_from.slice(0, 10).replace(/-/g, '')}`
: ''
triggerDownload(blob, `audit_log${suffix}.${ext}`)
} catch {
toast.error(`Errore durante l'esportazione ${format.toUpperCase()}`)
} finally {
setter(false)
}
}
const items = data?.items ?? []
const total = data?.total ?? 0
const pages = data?.pages ?? 1
@@ -125,15 +190,15 @@ export function AuditLogPage() {
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
>
<option value="">Tutte le azioni</option>
<option value="auth.login">Login</option>
<option value="auth.password_changed">Cambio password</option>
<option value="user.created">Utente creato</option>
<option value="user.updated">Utente modificato</option>
<option value="user.deleted">Utente eliminato</option>
<option value="mailbox.created">Casella creata</option>
<option value="mailbox.updated">Casella modificata</option>
<option value="mailbox.deleted">Casella eliminata</option>
<option value="message.sent">PEC inviata</option>
{ACTION_GROUPS.map((group) => (
<optgroup key={group} label={group}>
{ACTION_OPTIONS.filter((o) => o.group === group).map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</optgroup>
))}
</select>
</div>
@@ -175,7 +240,7 @@ export function AuditLogPage() {
</div>
{/* Bottoni */}
<div className="flex gap-2 mt-4">
<div className="flex flex-wrap gap-2 mt-4">
<button
onClick={handleSearch}
className="flex items-center gap-2 px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 transition-colors"
@@ -190,6 +255,29 @@ export function AuditLogPage() {
<RotateCcw className="h-4 w-4" />
Reimposta
</button>
{/* Separatore */}
<div className="flex-1" />
{/* Export CSV */}
<button
onClick={() => handleExport('csv')}
disabled={exportingCsv}
className="flex items-center gap-2 px-4 py-2 border border-green-600 text-green-700 text-sm font-medium rounded-md hover:bg-green-50 transition-colors disabled:opacity-50"
>
<Download className="h-4 w-4" />
{exportingCsv ? 'Esportazione...' : 'Esporta CSV'}
</button>
{/* Export PDF */}
<button
onClick={() => handleExport('pdf')}
disabled={exportingPdf}
className="flex items-center gap-2 px-4 py-2 border border-red-600 text-red-700 text-sm font-medium rounded-md hover:bg-red-50 transition-colors disabled:opacity-50"
>
<FileText className="h-4 w-4" />
{exportingPdf ? 'Esportazione...' : 'Esporta PDF'}
</button>
</div>
</div>
@@ -293,10 +381,21 @@ export function AuditLogPage() {
{entry.ip_address ?? <span className="text-gray-300"></span>}
</td>
{/* Utente (UUID abbreviato) */}
<td className="px-4 py-3 text-sm text-gray-500 whitespace-nowrap font-mono">
{entry.user_id ? (
<span title={entry.user_id}>
{/* Utente: mostra nome, poi email, poi UUID abbreviato */}
<td className="px-4 py-3 text-sm text-gray-600 whitespace-nowrap">
{entry.user_full_name ? (
<span title={entry.user_email ?? entry.user_id ?? ''}>
{entry.user_full_name}
{entry.user_email && (
<span className="ml-1 text-gray-400 text-xs">
({entry.user_email})
</span>
)}
</span>
) : entry.user_email ? (
<span title={entry.user_id ?? ''}>{entry.user_email}</span>
) : entry.user_id ? (
<span className="font-mono text-xs text-gray-400" title={entry.user_id}>
{entry.user_id.split('-')[0]}...
</span>
) : (
+135 -4
View File
@@ -1,7 +1,8 @@
import { useState, useRef, useMemo } from 'react'
import { useState, useRef, useMemo, useEffect } from 'react'
import { useNavigate, useLocation } from 'react-router-dom'
import { useForm, useFieldArray } from 'react-hook-form'
import { Send, X, Plus, ArrowLeft, AlertCircle, Paperclip, Upload, Filter } from 'lucide-react'
import { useForm, useFieldArray, useWatch } from 'react-hook-form'
import { Send, X, Plus, ArrowLeft, AlertCircle, Paperclip, Upload, Filter, FileText, ChevronDown } from 'lucide-react'
import { ContactPickerPopover } from '@/components/ComposeModal/ContactPickerPopover'
import { useQuery, useMutation } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
@@ -11,9 +12,29 @@ 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 { signaturesApi } from '@/api/signatures.api'
import { templatesApi } from '@/api/templates.api'
import type { TemplateResponse } from '@/api/templates.api'
import { getErrorMessage } from '@/api/client'
import type { MessageResponse } from '@/types/api.types'
// ─── Utilita' firma ───────────────────────────────────────────────────────────
const SIG_ATTR = 'data-pechub-sig'
function injectSignature(body: string, sigHtml: string | null): string {
const withoutSig = body.replace(
new RegExp(`<div[^>]*${SIG_ATTR}="1"[^>]*>[\\s\\S]*?<\\/div>`, 'g'),
''
)
if (!sigHtml) return withoutSig
const sigBlock = `<div ${SIG_ATTR}="1" style="margin-top:12px;padding-top:8px;border-top:1px solid #d1d5db">${sigHtml}</div>`
if (withoutSig.includes('<hr>')) {
return withoutSig.replace('<hr>', sigBlock + '<hr>')
}
return withoutSig + sigBlock
}
/** Tipo unificato casella mittente (sia da permessi diretti che da Virtual Box) */
interface MailboxSelectItem {
id: string
@@ -99,6 +120,7 @@ export function ComposePage() {
register,
control,
handleSubmit,
setValue,
formState: { errors },
} = useForm<ComposeFormValues>({
defaultValues: {
@@ -127,6 +149,56 @@ export function ComposePage() {
remove: removeCc,
} = useFieldArray({ control, name: 'cc_addresses' })
// ── Auto-inserimento firma ────────────────────────────────────────────────
const watchedMailboxId = useWatch({ control, name: 'mailbox_id' })
const signatureContext = replyTo ? 'reply' : 'compose'
// Ref per tenere traccia dell'HTML firma corrente (usato da applyTemplate)
const sigHtmlRef = useRef<string | null>(null)
useEffect(() => {
if (!watchedMailboxId) {
sigHtmlRef.current = null
setBodyHtml((prev) => injectSignature(prev, null))
return
}
signaturesApi
.resolve({ context: signatureContext, mailbox_id: watchedMailboxId })
.then((sig) => {
sigHtmlRef.current = sig?.body_html ?? null
setBodyHtml((prev) => injectSignature(prev, sig?.body_html ?? null))
})
.catch(() => {
sigHtmlRef.current = null
})
}, [watchedMailboxId, signatureContext])
// ─────────────────────────────────────────────────────────────────────────
// ── Selettore template ────────────────────────────────────────────────────
const [showTemplatePicker, setShowTemplatePicker] = useState(false)
const { data: templatesData, isLoading: templatesLoading } = useQuery({
queryKey: ['templates'],
queryFn: () => templatesApi.list(),
enabled: showTemplatePicker,
staleTime: 60 * 1000,
})
const applyTemplate = (tpl: TemplateResponse) => {
// Preserva la parte citata (tutto dal separatore <hr> in poi)
const hrIndex = bodyHtml.indexOf('<hr>')
const quotedPart = hrIndex >= 0 ? bodyHtml.slice(hrIndex) : ''
const templateBody = tpl.body_html || (tpl.body_text ? `<p>${tpl.body_text}</p>` : '')
const newBodyBase = templateBody + quotedPart
setBodyHtml(injectSignature(newBodyBase, sigHtmlRef.current))
// Applica oggetto solo su nuova PEC (non su risposta/inoltro)
if (!replyTo && !forwardOf && tpl.subject) {
setValue('subject', tpl.subject)
}
setShowTemplatePicker(false)
toast.success(`Template "${tpl.name}" applicato`)
}
// ─────────────────────────────────────────────────────────────────────────
// Carica caselle disponibili per l'invio (permessi diretti)
const { data: mailboxesData, isLoading: mailboxesLoading } = useQuery({
queryKey: ['mailboxes'],
@@ -350,6 +422,10 @@ export function ComposePage() {
},
})}
/>
<ContactPickerPopover
size="md"
onSelect={(email) => setValue(`to_addresses.${idx}.value`, email)}
/>
{toFields.length > 1 && (
<Button
type="button"
@@ -408,6 +484,10 @@ export function ComposePage() {
},
})}
/>
<ContactPickerPopover
size="md"
onSelect={(email) => setValue(`cc_addresses.${idx}.value`, email)}
/>
<Button
type="button"
variant="ghost"
@@ -443,7 +523,58 @@ export function ComposePage() {
{/* Corpo Rich Text Editor */}
<div className="space-y-1.5">
<Label>Testo del messaggio</Label>
<div className="flex items-center justify-between">
<Label>Testo del messaggio</Label>
<button
type="button"
onClick={() => setShowTemplatePicker((v) => !v)}
className="flex items-center gap-1.5 text-sm text-primary hover:underline"
title="Usa un template come punto di partenza"
>
<FileText className="h-4 w-4" />
Usa template
<ChevronDown
className={`h-4 w-4 transition-transform ${showTemplatePicker ? 'rotate-180' : ''}`}
/>
</button>
</div>
{/* Pannello selezione template */}
{showTemplatePicker && (
<div className="rounded-lg border bg-background shadow-sm overflow-hidden">
{templatesLoading ? (
<div className="p-4 text-sm text-muted-foreground text-center">
Caricamento template...
</div>
) : !templatesData?.items.length ? (
<div className="p-4 text-sm text-muted-foreground text-center">
Nessun template disponibile. Creane uno nella sezione Amministrazione.
</div>
) : (
<div className="max-h-56 overflow-y-auto divide-y">
{templatesData.items.map((tpl) => (
<button
key={tpl.id}
type="button"
onClick={() => applyTemplate(tpl)}
className="w-full text-left px-4 py-3 hover:bg-muted/60 transition-colors"
>
<p className="text-sm font-semibold text-foreground">{tpl.name}</p>
{tpl.description && (
<p className="text-xs text-muted-foreground mt-0.5">{tpl.description}</p>
)}
{tpl.subject && (
<p className="text-xs text-muted-foreground/70 italic mt-0.5 truncate">
Oggetto: {tpl.subject}
</p>
)}
</button>
))}
</div>
)}
</div>
)}
<RichTextEditor
value={bodyHtml}
onChange={setBodyHtml}
+37 -1
View File
@@ -41,6 +41,9 @@ import {
ChevronUp,
ShieldCheck,
ShieldX,
FileText,
Paperclip,
AlignLeft,
} from 'lucide-react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
@@ -911,6 +914,7 @@ export function InboxPage({ viewMode }: InboxPageProps) {
? mailboxesData?.items.find((m) => m.id === message.mailbox_id)?.email_address
: undefined
}
searchTerm={debouncedSearch || undefined}
/>
))}
</div>
@@ -975,6 +979,7 @@ interface MessageRowProps {
onToggleTrash: (e: React.MouseEvent) => void
onToggleConserve: (e: React.MouseEvent) => void
mailboxName?: string
searchTerm?: string
}
function MessageRow({
@@ -991,6 +996,7 @@ function MessageRow({
onToggleTrash,
onToggleConserve,
mailboxName,
searchTerm,
}: MessageRowProps) {
const [hovered, setHovered] = useState(false)
const isUnread = !message.is_read && message.direction === 'inbound'
@@ -998,7 +1004,8 @@ function MessageRow({
return (
<div
className={cn(
'flex items-center gap-3 px-4 py-3 cursor-pointer hover:bg-muted/50 transition-colors group',
'flex gap-3 px-4 py-3 cursor-pointer hover:bg-muted/50 transition-colors group',
searchTerm ? 'items-start' : 'items-center',
isUnread && 'bg-blue-50/50 dark:bg-blue-950/20',
isSelected && 'bg-blue-100/60 dark:bg-blue-900/30',
)}
@@ -1091,6 +1098,35 @@ function MessageRow({
{truncate(message.body_text, 120)}
</p>
)}
{/* Badge "Trovato in" (solo durante ricerca attiva con match info) */}
{searchTerm && message.search_match && (
<div className="flex items-center flex-wrap gap-1 mt-1.5">
<span className="text-xs text-muted-foreground mr-0.5">Trovato in:</span>
{message.search_match.in_subject && (
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 font-medium">
<FileText className="h-3 w-3" />
Oggetto
</span>
)}
{message.search_match.in_body && (
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 font-medium">
<AlignLeft className="h-3 w-3" />
Corpo del messaggio
</span>
)}
{message.search_match.in_attachments.map((att) => (
<span
key={att.id}
className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300 font-medium max-w-[200px]"
title={att.filename}
>
<Paperclip className="h-3 w-3 flex-shrink-0" />
<span className="truncate">{att.filename}</span>
</span>
))}
</div>
)}
</div>
{/* ── Azioni rapide (visibili su hover) ── */}
@@ -1,4 +1,4 @@
import { useState } from 'react'
import { useState, useRef, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import {
@@ -8,6 +8,7 @@ import {
ArchiveX,
Download,
Reply,
ReplyAll,
Forward,
Paperclip,
Mail,
@@ -22,6 +23,7 @@ import {
Eye,
MessageSquare,
X,
ChevronDown,
} from 'lucide-react'
import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button'
@@ -168,6 +170,21 @@ export function MessageDetailPage() {
const [isDownloadingPackage, setIsDownloadingPackage] = useState(false)
const [isPrinting, setIsPrinting] = useState(false)
// Dropdown "Rispondi / Rispondi a tutti"
const [showReplyDropdown, setShowReplyDropdown] = useState(false)
const replyDropdownRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!showReplyDropdown) return
const handler = (e: MouseEvent) => {
if (replyDropdownRef.current && !replyDropdownRef.current.contains(e.target as Node)) {
setShowReplyDropdown(false)
}
}
document.addEventListener('mousedown', handler)
return () => document.removeEventListener('mousedown', handler)
}, [showReplyDropdown])
// Feature 4: Deadline
const [showDeadlineForm, setShowDeadlineForm] = useState(false)
const [deadlineDate, setDeadlineDate] = useState('')
@@ -533,17 +550,66 @@ export function MessageDetailPage() {
</Button>
)}
{/* Rispondi (solo per messaggi inbound PEC certificata, non nel cestino) */}
{message.direction === 'inbound' && message.pec_type === 'posta_certificata' && !message.is_trashed && (
<Button
variant="outline"
size="sm"
onClick={() => openCompose({ replyTo: message })}
>
<Reply className="h-4 w-4 mr-1" />
Rispondi
</Button>
)}
{/* Rispondi / Rispondi a tutti (solo per messaggi inbound PEC certificata, non nel cestino) */}
{message.direction === 'inbound' && message.pec_type === 'posta_certificata' && !message.is_trashed && (() => {
const hasMultipleRecipients =
(message.to_addresses?.length ?? 0) > 1 ||
(message.cc_addresses?.length ?? 0) > 0
return hasMultipleRecipients ? (
/* Split button con dropdown */
<div ref={replyDropdownRef} className="relative flex">
{/* Parte sinistra: "Rispondi" */}
<button
type="button"
onClick={() => { openCompose({ replyTo: message }); setShowReplyDropdown(false) }}
className="inline-flex items-center h-8 px-3 text-sm border border-input bg-background rounded-l-md hover:bg-muted transition-colors"
>
<Reply className="h-4 w-4 mr-1" />
Rispondi
</button>
{/* Parte destra: freccia dropdown */}
<button
type="button"
onClick={() => setShowReplyDropdown((v) => !v)}
className="inline-flex items-center h-8 px-1.5 text-sm border border-l-0 border-input bg-background rounded-r-md hover:bg-muted transition-colors"
title="Altre opzioni di risposta"
>
<ChevronDown className={`h-3.5 w-3.5 text-muted-foreground transition-transform ${showReplyDropdown ? 'rotate-180' : ''}`} />
</button>
{/* Dropdown */}
{showReplyDropdown && (
<div className="absolute right-0 top-full mt-1 z-30 min-w-[180px] rounded-md border bg-background shadow-lg overflow-hidden">
<button
type="button"
onClick={() => { openCompose({ replyTo: message }); setShowReplyDropdown(false) }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
>
<Reply className="h-4 w-4 text-muted-foreground" />
Rispondi
</button>
<button
type="button"
onClick={() => { openCompose({ replyTo: message, replyAll: true }); setShowReplyDropdown(false) }}
className="w-full flex items-center gap-2 px-3 py-2 text-sm hover:bg-muted transition-colors text-left"
>
<ReplyAll className="h-4 w-4 text-muted-foreground" />
Rispondi a tutti
</button>
</div>
)}
</div>
) : (
/* Pulsante semplice senza tendina */
<Button
variant="outline"
size="sm"
onClick={() => openCompose({ replyTo: message })}
>
<Reply className="h-4 w-4 mr-1" />
Rispondi
</Button>
)
})()}
{/* Inoltra (disponibile per qualsiasi messaggio PEC certificata, non nel cestino) */}
{message.pec_type === 'posta_certificata' && !message.is_trashed && (
+32
View File
@@ -26,6 +26,9 @@ import {
Mail,
ChevronLeft,
ChevronRight,
FileText,
Paperclip,
AlignLeft,
} from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { Button } from '@/components/ui/Button'
@@ -539,6 +542,35 @@ function SearchResultRow({ message, searchTerm, mailboxName, onClick }: SearchRe
</p>
)}
{/* Riga 4: badge "Trovato in" (solo durante ricerca con match info) */}
{searchTerm && message.search_match && (
<div className="flex items-center flex-wrap gap-1 mt-1.5">
<span className="text-xs text-muted-foreground mr-0.5">Trovato in:</span>
{message.search_match.in_subject && (
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300 font-medium">
<FileText className="h-3 w-3" />
Oggetto
</span>
)}
{message.search_match.in_body && (
<span className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300 font-medium">
<AlignLeft className="h-3 w-3" />
Corpo del messaggio
</span>
)}
{message.search_match.in_attachments.map((att) => (
<span
key={att.id}
className="inline-flex items-center gap-1 text-xs px-1.5 py-0.5 rounded bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300 font-medium max-w-[200px]"
title={att.filename}
>
<Paperclip className="h-3 w-3 flex-shrink-0" />
<span className="truncate">{att.filename}</span>
</span>
))}
</div>
)}
{/* Tag */}
{message.labels && message.labels.length > 0 && (
<div className="mt-1">
@@ -0,0 +1,661 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Pencil, Trash2, PenLine, Search } from 'lucide-react'
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/Dialog'
import { RichTextEditor } from '@/components/RichTextEditor/RichTextEditor'
import {
signaturesApi,
type SignatureResponse,
type SignatureCreate,
type SignatureContext,
} from '@/api/signatures.api'
import { mailboxesApi } from '@/api/mailboxes.api'
import { virtualBoxesApi } from '@/api/virtual_boxes.api'
import { getErrorMessage } from '@/api/client'
import { formatDate } from '@/lib/utils'
import { useAuth } from '@/hooks/useAuth'
// ─── Tipi locali ──────────────────────────────────────────────────────────────
type Tab = 'library' | 'mailboxes' | 'vboxes'
// ─── Componente principale ────────────────────────────────────────────────────
export function SignaturesPage() {
const queryClient = useQueryClient()
const { isAdmin } = useAuth()
const [activeTab, setActiveTab] = useState<Tab>('library')
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="border-b bg-background px-6 py-4">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold">Firme automatiche</h1>
<p className="text-sm text-muted-foreground">
Gestisci le firme inserite automaticamente nelle PEC inviate
</p>
</div>
</div>
{/* Tab bar */}
<div className="flex gap-1 mt-4 border-b -mb-[1px]">
{[
{ id: 'library' as Tab, label: 'Libreria firme' },
{ id: 'mailboxes' as Tab, label: 'Caselle PEC' },
{ id: 'vboxes' as Tab, label: 'Virtual Box' },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === tab.id
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{tab.label}
</button>
))}
</div>
</div>
{/* Contenuto tab */}
<div className="flex-1 overflow-y-auto">
{activeTab === 'library' && <LibraryTab isAdmin={isAdmin} queryClient={queryClient} />}
{activeTab === 'mailboxes' && <MailboxAssignmentsTab isAdmin={isAdmin} />}
{activeTab === 'vboxes' && <VboxAssignmentsTab isAdmin={isAdmin} />}
</div>
</div>
)
}
// ─── Tab: Libreria firme ──────────────────────────────────────────────────────
function LibraryTab({ isAdmin, queryClient }: { isAdmin: boolean; queryClient: ReturnType<typeof useQueryClient> }) {
const [q, setQ] = useState('')
const [showForm, setShowForm] = useState(false)
const [editing, setEditing] = useState<SignatureResponse | null>(null)
// Stato form
const [formName, setFormName] = useState('')
const [formDescription, setFormDescription] = useState('')
const [formBody, setFormBody] = useState('')
const { data, isLoading } = useQuery({
queryKey: ['signatures', q],
queryFn: () => signaturesApi.list(q || undefined),
})
const createMutation = useMutation({
mutationFn: (data: SignatureCreate) => signaturesApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['signatures'] })
toast.success('Firma creata')
closeForm()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: SignatureCreate }) =>
signaturesApi.update(id, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['signatures'] })
toast.success('Firma aggiornata')
closeForm()
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => signaturesApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['signatures'] })
toast.success('Firma eliminata')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const openCreate = () => {
setEditing(null)
setFormName('')
setFormDescription('')
setFormBody('')
setShowForm(true)
}
const openEdit = (s: SignatureResponse) => {
setEditing(s)
setFormName(s.name)
setFormDescription(s.description ?? '')
setFormBody(s.body_html ?? s.body_text ?? '')
setShowForm(true)
}
const closeForm = () => {
setShowForm(false)
setEditing(null)
}
const handleSubmit = () => {
if (!formName.trim()) return toast.error('Il nome e\' obbligatorio')
const payload: SignatureCreate = {
name: formName.trim(),
description: formDescription.trim() || null,
body_html: formBody || null,
body_text: null,
}
if (editing) {
updateMutation.mutate({ id: editing.id, data: payload })
} else {
createMutation.mutate(payload)
}
}
const items = data?.items ?? []
return (
<div className="p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="relative w-full max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Cerca per nome..."
value={q}
onChange={(e) => setQ(e.target.value)}
className="pl-9"
/>
</div>
{isAdmin && (
<Button onClick={openCreate}>
<Plus className="h-4 w-4 mr-2" />
Nuova firma
</Button>
)}
</div>
{isLoading ? (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : items.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<PenLine className="h-12 w-12 mx-auto mb-4 opacity-30" />
<p className="text-lg font-medium">Nessuna firma trovata</p>
<p className="text-sm mt-1">
{isAdmin
? 'Crea la prima firma con il pulsante in alto.'
: 'Nessuna firma disponibile.'}
</p>
</div>
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{items.map((s) => (
<div
key={s.id}
className="rounded-lg border bg-card p-4 space-y-2 hover:shadow-sm transition-shadow"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="font-semibold truncate">{s.name}</h3>
{s.description && (
<p className="text-xs text-muted-foreground line-clamp-2">{s.description}</p>
)}
</div>
{isAdmin && (
<div className="flex gap-1 flex-shrink-0">
<Button
variant="ghost"
size="icon"
onClick={() => openEdit(s)}
className="h-8 w-8"
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => {
if (confirm(`Eliminare la firma "${s.name}"?`)) {
deleteMutation.mutate(s.id)
}
}}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
)}
</div>
{/* Anteprima corpo */}
{s.body_html && (
<div
className="text-xs text-muted-foreground border rounded p-2 bg-muted/30 max-h-20 overflow-hidden line-clamp-3"
dangerouslySetInnerHTML={{ __html: s.body_html }}
/>
)}
<p className="text-xs text-muted-foreground">
Aggiornato: {formatDate(s.updated_at)}
</p>
</div>
))}
</div>
)}
{/* Dialog form firma */}
<Dialog open={showForm} onOpenChange={(o) => !o && closeForm()}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{editing ? 'Modifica firma' : 'Nuova firma'}</DialogTitle>
</DialogHeader>
<div className="space-y-4 mt-4">
<div className="space-y-2">
<Label>Nome *</Label>
<Input
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="Es. Firma aziendale standard"
/>
</div>
<div className="space-y-2">
<Label>Descrizione (opzionale)</Label>
<Input
value={formDescription}
onChange={(e) => setFormDescription(e.target.value)}
placeholder="Breve descrizione d'uso"
/>
</div>
<div className="space-y-2">
<Label>Testo della firma</Label>
<div className="min-h-[200px] border rounded-md overflow-hidden">
<RichTextEditor
value={formBody}
onChange={setFormBody}
placeholder="Scrivi qui la firma..."
/>
</div>
</div>
</div>
<DialogFooter className="mt-4">
<Button variant="outline" onClick={closeForm}>
Annulla
</Button>
<Button
onClick={handleSubmit}
isLoading={createMutation.isPending || updateMutation.isPending}
>
{editing ? 'Salva modifiche' : 'Crea firma'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
// ─── Tab: Assegnazioni caselle PEC ────────────────────────────────────────────
function MailboxAssignmentsTab({ isAdmin }: { isAdmin: boolean }) {
const queryClient = useQueryClient()
const { data: mailboxesData, isLoading: loadingMailboxes } = useQuery({
queryKey: ['mailboxes'],
queryFn: () => mailboxesApi.list(),
})
const { data: signaturesData, isLoading: loadingSignatures } = useQuery({
queryKey: ['signatures', ''],
queryFn: () => signaturesApi.list(),
})
const { data: assignmentsData, isLoading: loadingAssignments } = useQuery({
queryKey: ['signature-assignments'],
queryFn: () => signaturesApi.listAssignments(),
})
const assignMutation = useMutation({
mutationFn: signaturesApi.createAssignment,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['signature-assignments'] })
toast.success('Firma assegnata')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const removeMutation = useMutation({
mutationFn: signaturesApi.deleteAssignment,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['signature-assignments'] })
toast.success('Assegnazione rimossa')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const mailboxes = mailboxesData?.items ?? []
const signatures = signaturesData?.items ?? []
const assignments = assignmentsData?.items ?? []
// Mappa: mailbox_id + context -> assignment
const assignmentMap = new Map<string, (typeof assignments)[0]>()
for (const a of assignments) {
if (a.mailbox_id) {
assignmentMap.set(`${a.mailbox_id}:${a.context}`, a)
}
}
const getAssignment = (mailboxId: string, context: SignatureContext) =>
assignmentMap.get(`${mailboxId}:${context}`) ?? null
const handleChange = (mailboxId: string, context: SignatureContext, signatureId: string) => {
if (!signatureId) {
// Rimuovi assegnazione esistente
const existing = getAssignment(mailboxId, context)
if (existing) removeMutation.mutate(existing.id)
} else {
assignMutation.mutate({ signature_id: signatureId, mailbox_id: mailboxId, context })
}
}
const isLoading = loadingMailboxes || loadingSignatures || loadingAssignments
if (isLoading) {
return (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
if (mailboxes.length === 0) {
return (
<div className="text-center py-12 text-muted-foreground p-6">
<p className="text-lg font-medium">Nessuna casella PEC configurata</p>
<p className="text-sm mt-1">Aggiungi prima le caselle PEC nella sezione Caselle PEC.</p>
</div>
)
}
return (
<div className="p-6 space-y-4">
<p className="text-sm text-muted-foreground">
Assegna una firma per ogni casella PEC e per ogni contesto d'uso.
{!isAdmin && ' Solo gli amministratori possono modificare le assegnazioni.'}
</p>
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="text-left px-4 py-3 font-medium">Casella PEC</th>
<th className="text-left px-4 py-3 font-medium">Firma per Risposta</th>
<th className="text-left px-4 py-3 font-medium">Firma per Nuova PEC</th>
</tr>
</thead>
<tbody className="divide-y">
{mailboxes.map((mb) => (
<MailboxAssignmentRow
key={mb.id}
mailboxId={mb.id}
displayName={mb.display_name || mb.email_address}
emailAddress={mb.email_address}
status={mb.status}
signatures={signatures}
replyAssignment={getAssignment(mb.id, 'reply')}
composeAssignment={getAssignment(mb.id, 'compose')}
isAdmin={isAdmin}
onChangeReply={(sigId) => handleChange(mb.id, 'reply', sigId)}
onChangeCompose={(sigId) => handleChange(mb.id, 'compose', sigId)}
isPending={assignMutation.isPending || removeMutation.isPending}
/>
))}
</tbody>
</table>
</div>
</div>
)
}
interface MailboxAssignmentRowProps {
mailboxId: string
displayName: string
emailAddress: string
status: string
signatures: SignatureResponse[]
replyAssignment: { id: string; signature_id: string } | null
composeAssignment: { id: string; signature_id: string } | null
isAdmin: boolean
onChangeReply: (sigId: string) => void
onChangeCompose: (sigId: string) => void
isPending: boolean
}
function MailboxAssignmentRow({
displayName,
emailAddress,
status,
signatures,
replyAssignment,
composeAssignment,
isAdmin,
onChangeReply,
onChangeCompose,
isPending,
}: MailboxAssignmentRowProps) {
const statusDot =
status === 'active'
? 'bg-green-500'
: status === 'paused'
? 'bg-yellow-400'
: status === 'error'
? 'bg-red-500'
: 'bg-gray-400'
return (
<tr className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full flex-shrink-0 ${statusDot}`} />
<div>
<p className="font-medium">{displayName}</p>
{displayName !== emailAddress && (
<p className="text-xs text-muted-foreground">{emailAddress}</p>
)}
</div>
</div>
</td>
<td className="px-4 py-3">
<SignatureSelect
signatures={signatures}
value={replyAssignment?.signature_id ?? ''}
onChange={onChangeReply}
disabled={!isAdmin || isPending}
/>
</td>
<td className="px-4 py-3">
<SignatureSelect
signatures={signatures}
value={composeAssignment?.signature_id ?? ''}
onChange={onChangeCompose}
disabled={!isAdmin || isPending}
/>
</td>
</tr>
)
}
// ─── Tab: Assegnazioni Virtual Box ────────────────────────────────────────────
function VboxAssignmentsTab({ isAdmin }: { isAdmin: boolean }) {
const queryClient = useQueryClient()
const { data: vboxesData, isLoading: loadingVboxes } = useQuery({
queryKey: ['virtual-boxes', 'all'],
queryFn: () => virtualBoxesApi.list(),
})
const { data: signaturesData, isLoading: loadingSignatures } = useQuery({
queryKey: ['signatures', ''],
queryFn: () => signaturesApi.list(),
})
const { data: assignmentsData, isLoading: loadingAssignments } = useQuery({
queryKey: ['signature-assignments'],
queryFn: () => signaturesApi.listAssignments(),
})
const assignMutation = useMutation({
mutationFn: signaturesApi.createAssignment,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['signature-assignments'] })
toast.success('Firma assegnata')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const removeMutation = useMutation({
mutationFn: signaturesApi.deleteAssignment,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['signature-assignments'] })
toast.success('Assegnazione rimossa')
},
onError: (e) => toast.error(getErrorMessage(e)),
})
const vboxes = vboxesData?.items ?? []
const signatures = signaturesData?.items ?? []
const assignments = assignmentsData?.items ?? []
// Mappa: virtual_box_id + context -> assignment
const assignmentMap = new Map<string, (typeof assignments)[0]>()
for (const a of assignments) {
if (a.virtual_box_id) {
assignmentMap.set(`${a.virtual_box_id}:${a.context}`, a)
}
}
const getAssignment = (vboxId: string, context: SignatureContext) =>
assignmentMap.get(`${vboxId}:${context}`) ?? null
const handleChange = (vboxId: string, context: SignatureContext, signatureId: string) => {
if (!signatureId) {
const existing = getAssignment(vboxId, context)
if (existing) removeMutation.mutate(existing.id)
} else {
assignMutation.mutate({ signature_id: signatureId, virtual_box_id: vboxId, context })
}
}
const isLoading = loadingVboxes || loadingSignatures || loadingAssignments
if (isLoading) {
return (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)
}
if (vboxes.length === 0) {
return (
<div className="text-center py-12 text-muted-foreground p-6">
<p className="text-lg font-medium">Nessuna Virtual Box configurata</p>
<p className="text-sm mt-1">Aggiungi prima le Virtual Box nella sezione Virtual Box.</p>
</div>
)
}
return (
<div className="p-6 space-y-4">
<p className="text-sm text-muted-foreground">
Assegna una firma per ogni Virtual Box e per ogni contesto d'uso.
{!isAdmin && ' Solo gli amministratori possono modificare le assegnazioni.'}
</p>
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/50">
<th className="text-left px-4 py-3 font-medium">Virtual Box</th>
<th className="text-left px-4 py-3 font-medium">Firma per Risposta</th>
<th className="text-left px-4 py-3 font-medium">Firma per Nuova PEC</th>
</tr>
</thead>
<tbody className="divide-y">
{vboxes.map((vbox) => (
<tr key={vbox.id} className="hover:bg-muted/30 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<div className="h-6 w-6 rounded-full bg-purple-800 flex items-center justify-center text-white text-xs font-semibold flex-shrink-0">
{(vbox.label || vbox.name)[0]?.toUpperCase() ?? '?'}
</div>
<div>
<p className="font-medium">{vbox.label || vbox.name}</p>
{vbox.description && (
<p className="text-xs text-muted-foreground truncate max-w-[200px]">
{vbox.description}
</p>
)}
</div>
</div>
</td>
<td className="px-4 py-3">
<SignatureSelect
signatures={signatures}
value={getAssignment(vbox.id, 'reply')?.signature_id ?? ''}
onChange={(sigId) => handleChange(vbox.id, 'reply', sigId)}
disabled={!isAdmin || assignMutation.isPending || removeMutation.isPending}
/>
</td>
<td className="px-4 py-3">
<SignatureSelect
signatures={signatures}
value={getAssignment(vbox.id, 'compose')?.signature_id ?? ''}
onChange={(sigId) => handleChange(vbox.id, 'compose', sigId)}
disabled={!isAdmin || assignMutation.isPending || removeMutation.isPending}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}
// ─── Select firma riutilizzabile ──────────────────────────────────────────────
interface SignatureSelectProps {
signatures: SignatureResponse[]
value: string
onChange: (signatureId: string) => void
disabled?: boolean
}
function SignatureSelect({ signatures, value, onChange, disabled }: SignatureSelectProps) {
return (
<select
className="flex h-8 w-full max-w-[220px] rounded-md border border-input bg-background px-3 py-1 text-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50 disabled:cursor-not-allowed"
value={value}
onChange={(e) => onChange(e.target.value)}
disabled={disabled}
>
<option value="">-- Nessuna firma --</option>
{signatures.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
)
}
+5 -1
View File
@@ -12,8 +12,9 @@ interface ComposeState {
mode: ComposeMode
replyTo: MessageResponse | null
forwardOf: MessageResponse | null
replyAll: boolean
openCompose: (opts?: { replyTo?: MessageResponse; forwardOf?: MessageResponse }) => void
openCompose: (opts?: { replyTo?: MessageResponse; forwardOf?: MessageResponse; replyAll?: boolean }) => void
closeCompose: () => void
setMode: (mode: ComposeMode) => void
}
@@ -23,6 +24,7 @@ export const useComposeStore = create<ComposeState>()((set) => ({
mode: 'normal',
replyTo: null,
forwardOf: null,
replyAll: false,
openCompose: (opts) =>
set({
@@ -30,6 +32,7 @@ export const useComposeStore = create<ComposeState>()((set) => ({
mode: 'normal',
replyTo: opts?.replyTo ?? null,
forwardOf: opts?.forwardOf ?? null,
replyAll: opts?.replyAll ?? false,
}),
closeCompose: () =>
@@ -38,6 +41,7 @@ export const useComposeStore = create<ComposeState>()((set) => ({
mode: 'normal',
replyTo: null,
forwardOf: null,
replyAll: false,
}),
setMode: (mode) => set({ mode }),
+15
View File
@@ -211,6 +211,19 @@ export interface MessageBulkLabelResponse {
updated: number
}
// ─── Search match info ────────────────────────────────────────────────────────
export interface AttachmentMatchInfo {
id: string
filename: string
}
export interface SearchMatchInfo {
in_subject: boolean
in_body: boolean
in_attachments: AttachmentMatchInfo[]
}
// ─── Message ──────────────────────────────────────────────────────────────────
export type PecDirection = 'inbound' | 'outbound'
@@ -270,6 +283,8 @@ export interface MessageResponse {
created_at: string
updated_at: string
labels: LabelResponse[]
// Popolato solo nelle risposte di ricerca full-text
search_match?: SearchMatchInfo | null
}
export interface MessageListResponse {