Semantic search

This commit is contained in:
2026-03-25 18:39:50 +01:00
parent f5fb537fed
commit cbeedc2d2f
14 changed files with 1336 additions and 56 deletions
+4
View File
@@ -11,6 +11,7 @@ import { SettingsPage } from '@/pages/Settings/SettingsPage'
import { VirtualBoxesPage } from '@/pages/VirtualBoxes/VirtualBoxesPage'
import { NotificationsPage } from '@/pages/Notifications/NotificationsPage'
import { MultiTenantPage } from '@/pages/MultiTenant/MultiTenantPage'
import { SearchPage } from '@/pages/Search/SearchPage'
/**
* Routing principale dell'applicazione PEChub.
@@ -76,6 +77,9 @@ export default function App() {
{/* Super Admin Gestione Multi-Tenant */}
<Route path="/multitenant" element={<MultiTenantPage />} />
{/* Ricerca avanzata full-text */}
<Route path="/search" element={<SearchPage />} />
{/* Profilo utente */}
<Route path="/settings" element={<SettingsPage />} />
+5
View File
@@ -13,11 +13,16 @@ export interface MessageFilters {
mailbox_id?: string
direction?: 'inbound' | 'outbound'
state?: string
pec_type?: string
is_read?: boolean
is_starred?: boolean
is_archived?: boolean
is_trashed?: boolean
search?: string
/** Data minima nel formato ISO 8601 (es. "2026-01-01T00:00:00Z") */
date_from?: string
/** Data massima nel formato ISO 8601 */
date_to?: string
}
export interface MessageBulkUpdatePayload {
@@ -50,6 +50,7 @@ import {
Archive,
Building2,
Trash2,
Search,
} from 'lucide-react'
import { cn } from '@/lib/utils'
import { useAuth } from '@/hooks/useAuth'
@@ -343,6 +344,29 @@ export function Sidebar() {
</div>
)}
{/* ── Ricerca avanzata ── */}
<div>
<div className="border-t border-gray-700 mx-4 mb-3" />
<div className="px-2">
<NavLink
to="/search"
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',
)
}
title={collapsed ? 'Ricerca avanzata' : undefined}
>
<Search className="h-4 w-4 flex-shrink-0" />
{!collapsed && <span>Ricerca</span>}
</NavLink>
</div>
</div>
{/* ── Nuova PEC ── */}
<div>
<div className="border-t border-gray-700 mx-4 mb-3" />
+106 -6
View File
@@ -36,6 +36,9 @@ import {
Trash2,
RotateCcw,
MailX,
SlidersHorizontal,
ChevronDown,
ChevronUp,
} from 'lucide-react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
@@ -80,6 +83,14 @@ export function InboxPage({ viewMode }: InboxPageProps) {
const [page, setPage] = useState(1)
const PAGE_SIZE = 50
// ── Filtri avanzati ──────────────────────────────────────────────────────────
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false)
const [dateFrom, setDateFrom] = useState('')
const [dateTo, setDateTo] = useState('')
const [pecTypeFilter, setPecTypeFilter] = useState('')
const activeAdvancedFiltersCount = [dateFrom, dateTo, pecTypeFilter].filter(Boolean).length
// ── Stato selezione ─────────────────────────────────────────────────────────
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
@@ -91,6 +102,10 @@ export function InboxPage({ viewMode }: InboxPageProps) {
setDebouncedSearch('')
setIsReadFilter(undefined)
setIsStarredFilter(undefined)
setDateFrom('')
setDateTo('')
setPecTypeFilter('')
setShowAdvancedFilters(false)
setPage(1)
setSelectedIds(new Set())
}, [mailboxId, vboxId, viewMode])
@@ -125,19 +140,27 @@ export function InboxPage({ viewMode }: InboxPageProps) {
? myVboxes.find((v) => v.id === vboxId)
: undefined
// ── Reset pagina per filtri avanzati ─────────────────────────────────────────
useEffect(() => {
setPage(1)
}, [dateFrom, dateTo, pecTypeFilter])
// ── Query messaggi ───────────────────────────────────────────────────────────
const queryFilters = (() => {
const base = {
const advancedBase = {
vbox_id: vboxId,
mailbox_id: mailboxId,
search: debouncedSearch || undefined,
pec_type: pecTypeFilter || undefined,
date_from: dateFrom ? new Date(dateFrom).toISOString() : undefined,
date_to: dateTo ? new Date(dateTo + 'T23:59:59').toISOString() : undefined,
page,
page_size: PAGE_SIZE,
}
switch (viewMode) {
case 'inbox':
return {
...base,
...advancedBase,
direction: 'inbound' as const,
is_read: isReadFilter,
is_starred: isStarredFilter,
@@ -146,7 +169,7 @@ export function InboxPage({ viewMode }: InboxPageProps) {
}
case 'sent':
return {
...base,
...advancedBase,
direction: 'outbound' as const,
is_starred: isStarredFilter,
is_archived: false,
@@ -154,20 +177,20 @@ export function InboxPage({ viewMode }: InboxPageProps) {
}
case 'starred':
return {
...base,
...advancedBase,
is_starred: true,
is_archived: false,
is_trashed: false,
}
case 'archived':
return {
...base,
...advancedBase,
is_archived: true,
is_trashed: false,
}
case 'trash':
return {
...base,
...advancedBase,
is_trashed: true,
}
}
@@ -499,7 +522,84 @@ export function InboxPage({ viewMode }: InboxPageProps) {
Preferiti
</Button>
)}
{/* Pulsante filtri avanzati */}
<Button
variant="outline"
size="sm"
className={cn('h-9 text-xs', activeAdvancedFiltersCount > 0 && 'border-primary text-primary')}
onClick={() => setShowAdvancedFilters(!showAdvancedFilters)}
>
<SlidersHorizontal className="h-3.5 w-3.5 mr-1" />
Filtri
{activeAdvancedFiltersCount > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center h-4 w-4 rounded-full bg-primary text-white text-[10px] font-bold">
{activeAdvancedFiltersCount}
</span>
)}
{showAdvancedFilters ? <ChevronUp className="h-3 w-3 ml-1" /> : <ChevronDown className="h-3 w-3 ml-1" />}
</Button>
</div>
{/* ── Pannello filtri avanzati ── */}
{showAdvancedFilters && (
<div className="mt-3 bg-muted/40 rounded-lg border p-3 grid grid-cols-2 md:grid-cols-4 gap-3">
{/* Data da */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">Data da</label>
<input
type="date"
className="w-full h-8 px-2 rounded-md border bg-background text-xs focus:outline-none focus:ring-2 focus:ring-ring"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
/>
</div>
{/* Data a */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">Data a</label>
<input
type="date"
className="w-full h-8 px-2 rounded-md border bg-background text-xs focus:outline-none focus:ring-2 focus:ring-ring"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
/>
</div>
{/* Tipo PEC */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">Tipo PEC</label>
<select
className="w-full h-8 px-2 rounded-md border bg-background text-xs focus:outline-none focus:ring-2 focus:ring-ring"
value={pecTypeFilter}
onChange={(e) => setPecTypeFilter(e.target.value)}
>
<option value="">Tutti i tipi</option>
<option value="posta_certificata">Posta certificata</option>
<option value="accettazione">Accettazione</option>
<option value="avvenuta_consegna">Avvenuta consegna</option>
<option value="mancata_consegna">Mancata consegna</option>
<option value="non_accettazione">Non accettazione</option>
<option value="errore_consegna">Errore consegna</option>
</select>
</div>
{/* Reset filtri */}
{activeAdvancedFiltersCount > 0 && (
<div className="flex items-end">
<Button
variant="ghost"
size="sm"
className="h-8 text-xs text-muted-foreground w-full"
onClick={() => { setDateFrom(''); setDateTo(''); setPecTypeFilter('') }}
>
<X className="h-3.5 w-3.5 mr-1" />
Azzera
</Button>
</div>
)}
</div>
)}
</div>
{/* ── Barra azioni bulk ── */}
+556
View File
@@ -0,0 +1,556 @@
/**
* SearchPage ricerca full-text avanzata tra tutti i messaggi PEC.
*
* Funzionalita':
* - Barra di ricerca full-text (usa FTS su search_vector PostgreSQL)
* - Pannello filtri avanzati collassabile:
* - Data da / Data a
* - Direzione (in arrivo / inviata)
* - Stato PEC
* - Tipo PEC
* - Casella specifica
* - Lista risultati ordinata per rilevanza
* - Paginazione
*/
import { useState, useEffect, useCallback } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import {
Search,
SlidersHorizontal,
ChevronDown,
ChevronUp,
X,
Inbox,
Send,
MailOpen,
Mail,
ChevronLeft,
ChevronRight,
} from 'lucide-react'
import { useQuery } from '@tanstack/react-query'
import { Button } from '@/components/ui/Button'
import { Input } from '@/components/ui/Input'
import { PecStateBadge } from '@/components/PecBadge/PecBadge'
import { TagBadgeList } from '@/components/TagManager/TagBadge'
import { messagesApi } from '@/api/messages.api'
import { mailboxesApi } from '@/api/mailboxes.api'
import { formatRelative, truncate } from '@/lib/utils'
import { cn } from '@/lib/utils'
import type { MessageResponse } from '@/types/api.types'
// ─── Costanti ─────────────────────────────────────────────────────────────────
const PAGE_SIZE = 50
const PEC_STATES = [
{ value: '', label: 'Tutti gli stati' },
{ value: 'received', label: 'Ricevuta' },
{ value: 'sent', label: 'Inviata' },
{ value: 'accepted', label: 'Accettata' },
{ value: 'delivered', label: 'Consegnata' },
{ value: 'anomaly', label: 'Anomalia' },
{ value: 'failed', label: 'Fallita' },
]
const PEC_TYPES = [
{ value: '', label: 'Tutti i tipi' },
{ value: 'posta_certificata', label: 'Posta certificata' },
{ value: 'accettazione', label: 'Accettazione' },
{ value: 'avvenuta_consegna', label: 'Avvenuta consegna' },
{ value: 'mancata_consegna', label: 'Mancata consegna' },
{ value: 'non_accettazione', label: 'Non accettazione' },
{ value: 'presa_in_carico', label: 'Presa in carico' },
{ value: 'errore_consegna', label: 'Errore consegna' },
{ value: 'preavviso_mancata_consegna', label: 'Preavviso mancata consegna' },
{ value: 'rilevazione_virus', label: 'Rilevazione virus' },
]
const DIRECTIONS = [
{ value: '', label: 'Tutte le direzioni' },
{ value: 'inbound', label: 'In arrivo' },
{ value: 'outbound', label: 'Inviata' },
]
// ─── Componente principale ────────────────────────────────────────────────────
export function SearchPage() {
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
// Legge i parametri dall'URL (permette di condividere/bookmark la ricerca)
const [searchInput, setSearchInput] = useState(searchParams.get('q') || '')
const [committedSearch, setCommittedSearch] = useState(searchParams.get('q') || '')
const [showFilters, setShowFilters] = useState(false)
const [page, setPage] = useState(1)
// Filtri avanzati
const [dateFrom, setDateFrom] = useState(searchParams.get('date_from') || '')
const [dateTo, setDateTo] = useState(searchParams.get('date_to') || '')
const [direction, setDirection] = useState(searchParams.get('direction') || '')
const [state, setState] = useState(searchParams.get('state') || '')
const [pecType, setPecType] = useState(searchParams.get('pec_type') || '')
const [mailboxId, setMailboxId] = useState(searchParams.get('mailbox_id') || '')
// Caselle disponibili per il filtro
const { data: mailboxesData } = useQuery({
queryKey: ['mailboxes'],
queryFn: () => mailboxesApi.list(),
staleTime: 5 * 60 * 1000,
})
const mailboxes = mailboxesData?.items ?? []
// Numero di filtri attivi
const activeFiltersCount = [dateFrom, dateTo, direction, state, pecType, mailboxId].filter(Boolean).length
// Sincronizza l'URL quando cambiano i filtri
useEffect(() => {
const params: Record<string, string> = {}
if (committedSearch) params.q = committedSearch
if (dateFrom) params.date_from = dateFrom
if (dateTo) params.date_to = dateTo
if (direction) params.direction = direction
if (state) params.state = state
if (pecType) params.pec_type = pecType
if (mailboxId) params.mailbox_id = mailboxId
setSearchParams(params, { replace: true })
}, [committedSearch, dateFrom, dateTo, direction, state, pecType, mailboxId, setSearchParams])
// Reset pagina quando cambiano i filtri
useEffect(() => {
setPage(1)
}, [committedSearch, dateFrom, dateTo, direction, state, pecType, mailboxId])
// Costruisce i filtri per l'API
const filters = {
search: committedSearch || undefined,
date_from: dateFrom ? new Date(dateFrom).toISOString() : undefined,
date_to: dateTo ? new Date(dateTo + 'T23:59:59').toISOString() : undefined,
direction: direction as 'inbound' | 'outbound' | undefined || undefined,
state: state || undefined,
pec_type: pecType || undefined,
mailbox_id: mailboxId || undefined,
is_archived: undefined as boolean | undefined,
is_trashed: false,
page,
page_size: PAGE_SIZE,
}
// Query messaggi
const {
data: messagesData,
isLoading,
isFetching,
} = useQuery({
queryKey: ['search', filters],
queryFn: () => messagesApi.list(filters),
enabled: !!(committedSearch || dateFrom || dateTo || direction || state || pecType || mailboxId),
})
const messages = messagesData?.items || []
const total = messagesData?.total || 0
const totalPages = Math.ceil(total / PAGE_SIZE)
const hasQuery = !!(committedSearch || activeFiltersCount > 0)
const handleSearch = useCallback(() => {
setCommittedSearch(searchInput.trim())
setPage(1)
}, [searchInput])
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') handleSearch()
}
const handleClearAll = () => {
setSearchInput('')
setCommittedSearch('')
setDateFrom('')
setDateTo('')
setDirection('')
setState('')
setPecType('')
setMailboxId('')
setPage(1)
}
const handleMessageClick = (message: MessageResponse) => {
navigate(`/messages/${message.id}`)
}
return (
<div className="flex flex-col h-full">
{/* ── Header ricerca ── */}
<div className="border-b bg-background px-6 py-4">
<div className="mb-4">
<h1 className="text-xl font-semibold flex items-center gap-2">
<Search className="h-5 w-5 text-primary" />
Ricerca avanzata
</h1>
<p className="text-sm text-muted-foreground mt-0.5">
Ricerca full-text su oggetto, corpo del messaggio e allegati PDF/DOCX
</p>
</div>
{/* Barra di ricerca principale */}
<div className="flex gap-2 mb-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder='Cerca in tutti i messaggi… (es. "fattura gennaio" -spam)'
className="pl-9 h-10 text-sm"
value={searchInput}
onChange={(e) => setSearchInput(e.target.value)}
onKeyDown={handleKeyDown}
/>
{searchInput && (
<button
onClick={() => { setSearchInput(''); setCommittedSearch('') }}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
)}
</div>
<Button onClick={handleSearch} className="h-10 px-5">
<Search className="h-4 w-4 mr-2" />
Cerca
</Button>
<Button
variant="outline"
className={cn('h-10', activeFiltersCount > 0 && 'border-primary text-primary')}
onClick={() => setShowFilters(!showFilters)}
>
<SlidersHorizontal className="h-4 w-4 mr-2" />
Filtri
{activeFiltersCount > 0 && (
<span className="ml-1.5 inline-flex items-center justify-center h-5 w-5 rounded-full bg-primary text-white text-xs font-bold">
{activeFiltersCount}
</span>
)}
{showFilters ? <ChevronUp className="h-3.5 w-3.5 ml-2" /> : <ChevronDown className="h-3.5 w-3.5 ml-2" />}
</Button>
</div>
{/* Pannello filtri avanzati */}
{showFilters && (
<div className="bg-muted/40 rounded-lg border p-4 space-y-3">
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
{/* Data da */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
Data da
</label>
<input
type="date"
className="w-full h-9 px-3 rounded-md border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
value={dateFrom}
onChange={(e) => setDateFrom(e.target.value)}
/>
</div>
{/* Data a */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
Data a
</label>
<input
type="date"
className="w-full h-9 px-3 rounded-md border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
value={dateTo}
onChange={(e) => setDateTo(e.target.value)}
/>
</div>
{/* Direzione */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
Direzione
</label>
<select
className="w-full h-9 px-3 rounded-md border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
value={direction}
onChange={(e) => setDirection(e.target.value)}
>
{DIRECTIONS.map((d) => (
<option key={d.value} value={d.value}>{d.label}</option>
))}
</select>
</div>
{/* Stato PEC */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
Stato PEC
</label>
<select
className="w-full h-9 px-3 rounded-md border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
value={state}
onChange={(e) => setState(e.target.value)}
>
{PEC_STATES.map((s) => (
<option key={s.value} value={s.value}>{s.label}</option>
))}
</select>
</div>
{/* Tipo PEC */}
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
Tipo PEC
</label>
<select
className="w-full h-9 px-3 rounded-md border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
value={pecType}
onChange={(e) => setPecType(e.target.value)}
>
{PEC_TYPES.map((t) => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</select>
</div>
{/* Casella */}
{mailboxes.length > 0 && (
<div>
<label className="text-xs font-medium text-muted-foreground mb-1 block">
Casella PEC
</label>
<select
className="w-full h-9 px-3 rounded-md border bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring"
value={mailboxId}
onChange={(e) => setMailboxId(e.target.value)}
>
<option value="">Tutte le caselle</option>
{mailboxes.map((m) => (
<option key={m.id} value={m.id}>
{m.display_name || m.email_address}
</option>
))}
</select>
</div>
)}
</div>
{/* Pulsante reset filtri */}
{activeFiltersCount > 0 && (
<div className="flex justify-end">
<Button variant="ghost" size="sm" onClick={handleClearAll} className="text-xs text-muted-foreground">
<X className="h-3.5 w-3.5 mr-1" />
Azzera tutti i filtri
</Button>
</div>
)}
</div>
)}
</div>
{/* ── Risultati ── */}
<div className="flex-1 overflow-y-auto">
{!hasQuery ? (
<div className="flex flex-col items-center justify-center h-64 text-center">
<Search className="h-12 w-12 text-muted-foreground/30 mb-3" />
<p className="text-muted-foreground font-medium">Inizia a cercare</p>
<p className="text-sm text-muted-foreground/70 mt-1 max-w-xs">
Digita un termine nella barra di ricerca o usa i filtri avanzati per trovare i messaggi
</p>
</div>
) : isLoading || isFetching ? (
<div className="flex items-center justify-center h-64">
<div className="flex flex-col items-center gap-3">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
<p className="text-sm text-muted-foreground">Ricerca in corso</p>
</div>
</div>
) : messages.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 text-center">
<Search className="h-12 w-12 text-muted-foreground/30 mb-3" />
<p className="text-muted-foreground font-medium">Nessun risultato</p>
<p className="text-sm text-muted-foreground/70 mt-1">
Prova a modificare i termini di ricerca o i filtri
</p>
{hasQuery && (
<Button variant="ghost" size="sm" className="mt-3" onClick={handleClearAll}>
<X className="h-3.5 w-3.5 mr-1" />
Azzera ricerca
</Button>
)}
</div>
) : (
<>
{/* Intestazione risultati */}
<div className="px-6 py-2.5 bg-muted/20 border-b flex items-center justify-between">
<p className="text-sm text-muted-foreground">
<span className="font-medium text-foreground">{total}</span>{' '}
{total === 1 ? 'risultato trovato' : 'risultati trovati'}
{committedSearch && (
<> per <span className="font-medium text-foreground italic">"{committedSearch}"</span></>
)}
</p>
{(isFetching) && (
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
)}
</div>
{/* Lista risultati */}
<div className="divide-y">
{messages.map((message) => (
<SearchResultRow
key={message.id}
message={message}
searchTerm={committedSearch}
mailboxName={
mailboxes.find((m) => m.id === message.mailbox_id)?.email_address
}
onClick={() => handleMessageClick(message)}
/>
))}
</div>
</>
)}
</div>
{/* ── Paginazione ── */}
{totalPages > 1 && (
<div className="border-t px-6 py-3 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Pagina {page} di {totalPages} ({total} risultati)
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
)
}
// ─── Riga singolo risultato ───────────────────────────────────────────────────
interface SearchResultRowProps {
message: MessageResponse
searchTerm: string
mailboxName?: string
onClick: () => void
}
function SearchResultRow({ message, searchTerm, mailboxName, onClick }: SearchResultRowProps) {
const isUnread = !message.is_read && message.direction === 'inbound'
// Evidenzia il termine cercato nel testo
const highlight = (text: string | null | undefined, term: string): React.ReactNode => {
if (!text || !term) return text || ''
const idx = text.toLowerCase().indexOf(term.toLowerCase())
if (idx === -1) return truncate(text, 150)
const start = Math.max(0, idx - 60)
const end = Math.min(text.length, idx + term.length + 90)
const snippet = (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : '')
const snipIdx = snippet.toLowerCase().indexOf(term.toLowerCase())
if (snipIdx === -1) return snippet
return (
<>
{snippet.slice(0, snipIdx)}
<mark className="bg-yellow-200 dark:bg-yellow-800/60 rounded px-0.5 font-medium">
{snippet.slice(snipIdx, snipIdx + term.length)}
</mark>
{snippet.slice(snipIdx + term.length)}
</>
)
}
return (
<div
className={cn(
'flex items-start gap-3 px-6 py-3.5 cursor-pointer hover:bg-muted/50 transition-colors',
isUnread && 'bg-blue-50/50 dark:bg-blue-950/20',
)}
onClick={onClick}
>
{/* Icona direzione */}
<div className="flex-shrink-0 mt-0.5">
{message.direction === 'inbound' ? (
isUnread ? (
<Mail className="h-5 w-5 text-blue-600" />
) : (
<MailOpen className="h-5 w-5 text-muted-foreground" />
)
) : (
<Send className="h-5 w-5 text-muted-foreground" />
)}
</div>
{/* Contenuto */}
<div className="flex-1 min-w-0">
{/* Riga 1: mittente + badge casella + stato + data */}
<div className="flex items-center justify-between gap-2 mb-0.5">
<div className="flex items-center gap-2 min-w-0">
<span className={cn(
'text-sm truncate',
isUnread ? 'font-semibold' : 'font-medium',
)}>
{message.direction === 'inbound'
? (message.from_address || 'Mittente sconosciuto')
: (message.to_addresses?.[0] || 'Destinatario sconosciuto')}
</span>
{mailboxName && (
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0.5 rounded flex-shrink-0">
{mailboxName}
</span>
)}
</div>
<div className="flex items-center gap-2 flex-shrink-0">
<PecStateBadge state={message.state} />
<span className="text-xs text-muted-foreground whitespace-nowrap">
{formatRelative(message.received_at || message.sent_at || message.created_at)}
</span>
</div>
</div>
{/* Riga 2: oggetto */}
<div className="flex items-center gap-1.5 mb-0.5">
{isUnread && <span className="h-2 w-2 rounded-full bg-blue-600 flex-shrink-0" />}
<p className={cn(
'text-sm',
isUnread ? 'font-medium text-foreground' : 'text-foreground',
)}>
{searchTerm
? highlight(message.subject || '(nessun oggetto)', searchTerm)
: truncate(message.subject || '(nessun oggetto)', 100)}
</p>
</div>
{/* Riga 3: snippet corpo */}
{message.body_text && (
<p className="text-xs text-muted-foreground line-clamp-2">
{searchTerm ? highlight(message.body_text, searchTerm) : truncate(message.body_text, 150)}
</p>
)}
{/* Tag */}
{message.labels && message.labels.length > 0 && (
<div className="mt-1">
<TagBadgeList labels={message.labels} maxVisible={4} size="sm" />
</div>
)}
</div>
{/* Indicatore allegati */}
{message.has_attachments && (
<span className="text-xs text-muted-foreground flex-shrink-0 mt-1"></span>
)}
</div>
)
}