mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
Modifiche varie
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
@@ -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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user