mirror of
https://github.com/idrainformatica/PecFlow.git
synced 2026-06-16 12:45:42 +02:00
Semantic search
This commit is contained in:
@@ -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 />} />
|
||||
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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 ── */}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user