Scadenzario aggiunta pratiche

This commit is contained in:
2026-06-17 22:09:18 +02:00
parent 3fd3c72f06
commit 64442af182
7 changed files with 722 additions and 157 deletions
+28
View File
@@ -33,6 +33,7 @@ from app.models.message import Message
from app.schemas.fascicolo import ( from app.schemas.fascicolo import (
FascicoloAddMessagesRequest, FascicoloAddMessagesRequest,
FascicoloCreate, FascicoloCreate,
FascicoloDeadlineResponse,
FascicoloMessageItem, FascicoloMessageItem,
FascicoloRemoveMessagesRequest, FascicoloRemoveMessagesRequest,
FascicoloResponse, FascicoloResponse,
@@ -73,6 +74,33 @@ async def list_fascicoli(
return [_to_response(f, cnt) for f, cnt in rows] return [_to_response(f, cnt) for f, cnt in rows]
@router.get("/fascicoli/scadenzario", response_model=list[FascicoloDeadlineResponse])
async def list_fascicoli_scadenzario(
current_user: CurrentUser,
db: DB,
days_ahead: int = Query(30, ge=1, le=365, description="Giorni da considerare in avanti"),
include_overdue: bool = Query(True, description="Includi fascicoli gia' scaduti"),
) -> list[FascicoloDeadlineResponse]:
"""
Scadenzario pratiche: fascicoli con scadenza imminente o scaduta.
Ordinati per scadenza ASC (scaduti prima, poi futuri).
"""
svc = FascicoloService(db)
rows = await svc.list_fascicoli_scadenzario(
current_user.tenant_id,
days_ahead=days_ahead,
include_overdue=include_overdue,
)
items = []
for fascicolo, cnt, is_overdue in rows:
resp = FascicoloDeadlineResponse.model_validate(fascicolo)
resp.message_count = cnt
resp.is_overdue = is_overdue
items.append(resp)
return items
@router.post("/fascicoli", response_model=FascicoloResponse, status_code=status.HTTP_201_CREATED) @router.post("/fascicoli", response_model=FascicoloResponse, status_code=status.HTTP_201_CREATED)
async def create_fascicolo( async def create_fascicolo(
data: FascicoloCreate, data: FascicoloCreate,
+21
View File
@@ -90,3 +90,24 @@ class MessageFascicoloSummary(BaseModel):
categoria: Optional[str] = None categoria: Optional[str] = None
model_config = {"from_attributes": True} model_config = {"from_attributes": True}
# ─── Scadenzario fascicoli ────────────────────────────────────────────────────
class FascicoloDeadlineResponse(BaseModel):
"""Fascicolo con scadenza per lo scadenzario pratiche."""
id: uuid.UUID
tenant_id: uuid.UUID
titolo: str
numero_pratica: Optional[str] = None
stato: str
categoria: Optional[str] = None
responsabile_id: Optional[uuid.UUID] = None
scadenza: Optional[datetime] = None
note: Optional[str] = None
created_at: datetime
updated_at: datetime
message_count: int = 0
is_overdue: bool = False
model_config = {"from_attributes": True}
+55 -1
View File
@@ -3,7 +3,7 @@ Service per la gestione dei Fascicoli (fascicolazione pratiche).
""" """
import uuid import uuid
from datetime import datetime from datetime import datetime, timedelta, timezone
from sqlalchemy import delete, func, select from sqlalchemy import delete, func, select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
@@ -268,3 +268,57 @@ class FascicoloService:
.order_by(Fascicolo.titolo) .order_by(Fascicolo.titolo)
) )
return list(result.scalars().all()) return list(result.scalars().all())
# ─── Scadenzario fascicoli ────────────────────────────────────────────────
async def list_fascicoli_scadenzario(
self,
tenant_id: uuid.UUID,
days_ahead: int = 30,
include_overdue: bool = True,
) -> list[tuple[Fascicolo, int, bool]]:
"""
Restituisce lista di (Fascicolo, message_count, is_overdue) per lo scadenzario.
Filtra fascicoli con scadenza impostata nel range richiesto.
Ordinati: scaduti prima (ASC scadenza), poi futuri (ASC scadenza).
"""
now = datetime.now(timezone.utc)
future_limit = now + timedelta(days=days_ahead)
count_sub = (
select(
FascicoloMessage.fascicolo_id,
func.count(FascicoloMessage.message_id).label("cnt"),
)
.group_by(FascicoloMessage.fascicolo_id)
.subquery()
)
stmt = (
select(Fascicolo, func.coalesce(count_sub.c.cnt, 0).label("message_count"))
.outerjoin(count_sub, Fascicolo.id == count_sub.c.fascicolo_id)
.where(
Fascicolo.tenant_id == tenant_id,
Fascicolo.scadenza.is_not(None),
)
.order_by(Fascicolo.scadenza.asc())
)
if include_overdue:
# Scaduti (qualsiasi data passata) + futuri fino al limite
stmt = stmt.where(Fascicolo.scadenza <= future_limit)
else:
# Solo scadenze future entro il limite
stmt = stmt.where(
Fascicolo.scadenza > now,
Fascicolo.scadenza <= future_limit,
)
result = await self.db.execute(stmt)
rows = result.all()
return [
(fascicolo, int(cnt), fascicolo.scadenza < now if fascicolo.scadenza else False)
for fascicolo, cnt in rows
]
+22
View File
@@ -61,6 +61,22 @@ export interface MessageFascicoloSummary {
categoria: string | null categoria: string | null
} }
export interface FascicoloDeadlineResponse {
id: string
tenant_id: string
titolo: string
numero_pratica: string | null
stato: 'aperto' | 'chiuso' | 'archiviato'
categoria: string | null
responsabile_id: string | null
scadenza: string | null
note: string | null
created_at: string
updated_at: string
message_count: number
is_overdue: boolean
}
// ─── Client API ─────────────────────────────────────────────────────────────── // ─── Client API ───────────────────────────────────────────────────────────────
export const fascicoliApi = { export const fascicoliApi = {
@@ -117,4 +133,10 @@ export const fascicoliApi = {
apiClient apiClient
.get<MessageFascicoloSummary[]>(`/messages/${messageId}/fascicoli`) .get<MessageFascicoloSummary[]>(`/messages/${messageId}/fascicoli`)
.then((r) => r.data), .then((r) => r.data),
/** Scadenzario pratiche: fascicoli con scadenza imminente o scaduta */
scadenzario: (params?: { days_ahead?: number; include_overdue?: boolean }) =>
apiClient
.get<FascicoloDeadlineResponse[]>('/fascicoli/scadenzario', { params })
.then((r) => r.data),
} }
+373 -83
View File
@@ -1,28 +1,41 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { Calendar, AlertTriangle, Clock, CheckCircle2, ExternalLink } from 'lucide-react' import {
import { Button } from '@/components/ui/Button' Calendar,
AlertTriangle,
Clock,
CheckCircle2,
ExternalLink,
FolderOpen,
FolderCheck,
FolderArchive,
MessageSquare,
} from 'lucide-react'
import { deadlinesApi, type DeadlineMessageResponse } from '@/api/deadlines.api' import { deadlinesApi, type DeadlineMessageResponse } from '@/api/deadlines.api'
import { fascicoliApi, type FascicoloDeadlineResponse } from '@/api/fascicoli.api'
import { formatDate } from '@/lib/utils' import { formatDate } from '@/lib/utils'
function groupDeadlines(items: DeadlineMessageResponse[]) { // ─── Utilita' ─────────────────────────────────────────────────────────────────
function groupByScadenza<T extends { scadenza?: string | null; is_overdue?: boolean }>(
items: T[]
) {
const now = new Date() const now = new Date()
const todayEnd = new Date(now) const todayEnd = new Date(now)
todayEnd.setHours(23, 59, 59, 999) todayEnd.setHours(23, 59, 59, 999)
const weekEnd = new Date(now) const weekEnd = new Date(now)
weekEnd.setDate(weekEnd.getDate() + 7) weekEnd.setDate(weekEnd.getDate() + 7)
const monthEnd = new Date(now)
monthEnd.setDate(monthEnd.getDate() + 30)
const overdue: DeadlineMessageResponse[] = [] const overdue: T[] = []
const today: DeadlineMessageResponse[] = [] const today: T[] = []
const thisWeek: DeadlineMessageResponse[] = [] const thisWeek: T[] = []
const later: DeadlineMessageResponse[] = [] const later: T[] = []
for (const item of items) { for (const item of items) {
if (!item.deadline_at) continue const raw = (item as any).scadenza ?? (item as any).deadline_at
const d = new Date(item.deadline_at) if (!raw) continue
const d = new Date(raw)
if (d < now) { if (d < now) {
overdue.push(item) overdue.push(item)
} else if (d <= todayEnd) { } else if (d <= todayEnd) {
@@ -36,13 +49,67 @@ function groupDeadlines(items: DeadlineMessageResponse[]) {
return { overdue, today, thisWeek, later } return { overdue, today, thisWeek, later }
} }
function DeadlineItem({ item }: { item: DeadlineMessageResponse }) { // ─── Gruppo scadenze ─────────────────────────────────────────────────────────
function DeadlineGroup<T>({
title,
items,
icon: Icon,
color,
renderItem,
}: {
title: string
items: T[]
icon: React.ComponentType<{ className?: string }>
color: string
renderItem: (item: T) => React.ReactNode
}) {
if (items.length === 0) return null
return (
<div className="space-y-2">
<div className={`flex items-center gap-2 ${color}`}>
<Icon className="h-4 w-4" />
<h3 className="font-semibold text-sm">
{title} ({items.length})
</h3>
</div>
<div className="space-y-2">{items.map((item, i) => <div key={i}>{renderItem(item)}</div>)}</div>
</div>
)
}
// ─── Tab: Messaggi ────────────────────────────────────────────────────────────
function groupMessages(items: DeadlineMessageResponse[]) {
const now = new Date()
const todayEnd = new Date(now); todayEnd.setHours(23, 59, 59, 999)
const weekEnd = new Date(now); weekEnd.setDate(weekEnd.getDate() + 7)
const overdue: DeadlineMessageResponse[] = []
const today: DeadlineMessageResponse[] = []
const thisWeek: DeadlineMessageResponse[] = []
const later: DeadlineMessageResponse[] = []
for (const item of items) {
if (!item.deadline_at) continue
const d = new Date(item.deadline_at)
if (d < now) overdue.push(item)
else if (d <= todayEnd) today.push(item)
else if (d <= weekEnd) thisWeek.push(item)
else later.push(item)
}
return { overdue, today, thisWeek, later }
}
function MessageDeadlineItem({ item }: { item: DeadlineMessageResponse }) {
const navigate = useNavigate() const navigate = useNavigate()
const deadlineDate = item.deadline_at ? new Date(item.deadline_at) : null const deadlineDate = item.deadline_at ? new Date(item.deadline_at) : null
return ( return (
<div <div
className={`flex items-center gap-4 p-4 rounded-lg border bg-card hover:shadow-sm transition-shadow cursor-pointer ${item.is_overdue ? 'border-destructive/30 bg-destructive/5' : ''}`} className={`flex items-center gap-4 p-4 rounded-lg border bg-card hover:shadow-sm transition-shadow cursor-pointer ${
item.is_overdue ? 'border-destructive/30 bg-destructive/5' : ''
}`}
onClick={() => navigate(`/messages/${item.id}`)} onClick={() => navigate(`/messages/${item.id}`)}
> >
<div className="flex-shrink-0"> <div className="flex-shrink-0">
@@ -55,7 +122,9 @@ function DeadlineItem({ item }: { item: DeadlineMessageResponse }) {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="font-medium truncate">{item.subject || '(nessun oggetto)'}</p> <p className="font-medium truncate">{item.subject || '(nessun oggetto)'}</p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{item.direction === 'inbound' ? `Da: ${item.from_address}` : `A: ${(item.to_addresses ?? []).join(', ')}`} {item.direction === 'inbound'
? `Da: ${item.from_address}`
: `A: ${(item.to_addresses ?? []).join(', ')}`}
</p> </p>
{item.deadline_note && ( {item.deadline_note && (
<p className="text-xs text-muted-foreground italic mt-0.5">{item.deadline_note}</p> <p className="text-xs text-muted-foreground italic mt-0.5">{item.deadline_note}</p>
@@ -65,51 +134,254 @@ function DeadlineItem({ item }: { item: DeadlineMessageResponse }) {
<p className={`text-sm font-semibold ${item.is_overdue ? 'text-destructive' : 'text-amber-600'}`}> <p className={`text-sm font-semibold ${item.is_overdue ? 'text-destructive' : 'text-amber-600'}`}>
{deadlineDate ? formatDate(deadlineDate.toISOString()) : '-'} {deadlineDate ? formatDate(deadlineDate.toISOString()) : '-'}
</p> </p>
{item.is_overdue && ( {item.is_overdue && <p className="text-xs text-destructive">Scaduto</p>}
<p className="text-xs text-destructive">Scaduto</p>
)}
</div> </div>
<ExternalLink className="h-4 w-4 text-muted-foreground flex-shrink-0" /> <ExternalLink className="h-4 w-4 text-muted-foreground flex-shrink-0" />
</div> </div>
) )
} }
function DeadlineGroup({ title, items, icon: Icon, color }: { function MessaggiTab({
title: string daysAhead,
items: DeadlineMessageResponse[] includeOverdue,
icon: React.ComponentType<{ className?: string }> }: {
color: string daysAhead: number
includeOverdue: boolean
}) { }) {
if (items.length === 0) return null
return (
<div className="space-y-2">
<div className={`flex items-center gap-2 ${color}`}>
<Icon className="h-4 w-4" />
<h3 className="font-semibold text-sm">{title} ({items.length})</h3>
</div>
<div className="space-y-2">
{items.map((item) => (
<DeadlineItem key={item.id} item={item} />
))}
</div>
</div>
)
}
export function DeadlinesPage() {
const [daysAhead, setDaysAhead] = useState(30)
const [includeOverdue, setIncludeOverdue] = useState(true)
const { data = [], isLoading } = useQuery({ const { data = [], isLoading } = useQuery({
queryKey: ['deadlines', daysAhead, includeOverdue], queryKey: ['deadlines', daysAhead, includeOverdue],
queryFn: () => deadlinesApi.list({ days_ahead: daysAhead, include_overdue: includeOverdue }), queryFn: () => deadlinesApi.list({ days_ahead: daysAhead, include_overdue: includeOverdue }),
}) })
const groups = groupDeadlines(data) const groups = groupMessages(data)
const total = data.length
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 (data.length === 0) {
return (
<div className="text-center py-12 text-muted-foreground">
<CheckCircle2 className="h-12 w-12 mx-auto mb-4 opacity-30 text-green-500" />
<p className="text-lg font-medium">Nessuna scadenza sui messaggi</p>
<p className="text-sm mt-1">
Le scadenze si impostano dal dettaglio di ogni messaggio.
</p>
</div>
)
}
return (
<div className="max-w-3xl mx-auto space-y-8">
<DeadlineGroup
title="Scaduti"
items={groups.overdue}
icon={AlertTriangle}
color="text-destructive"
renderItem={(item) => <MessageDeadlineItem item={item} />}
/>
<DeadlineGroup
title="Oggi"
items={groups.today}
icon={Clock}
color="text-amber-600"
renderItem={(item) => <MessageDeadlineItem item={item} />}
/>
<DeadlineGroup
title="Questa settimana"
items={groups.thisWeek}
icon={Calendar}
color="text-blue-600"
renderItem={(item) => <MessageDeadlineItem item={item} />}
/>
<DeadlineGroup
title="Successivamente"
items={groups.later}
icon={Calendar}
color="text-muted-foreground"
renderItem={(item) => <MessageDeadlineItem item={item} />}
/>
</div>
)
}
// ─── Tab: Fascicoli ───────────────────────────────────────────────────────────
function FascicoloDeadlineItem({ item }: { item: FascicoloDeadlineResponse }) {
const navigate = useNavigate()
const scadenzaDate = item.scadenza ? new Date(item.scadenza) : null
const FolderIcon =
item.stato === 'aperto'
? FolderOpen
: item.stato === 'chiuso'
? FolderCheck
: FolderArchive
return (
<div
className={`flex items-center gap-4 p-4 rounded-lg border bg-card hover:shadow-sm transition-shadow cursor-pointer ${
item.is_overdue ? 'border-destructive/30 bg-destructive/5' : ''
}`}
onClick={() => navigate(`/fascicoli/${item.id}`)}
>
<div className="flex-shrink-0">
{item.is_overdue ? (
<AlertTriangle className="h-5 w-5 text-destructive" />
) : (
<FolderIcon className="h-5 w-5 text-amber-500" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-medium truncate">{item.titolo}</p>
{item.numero_pratica && (
<span className="text-xs text-muted-foreground font-mono bg-muted px-1.5 py-0.5 rounded">
#{item.numero_pratica}
</span>
)}
{item.categoria && (
<span className="text-xs text-muted-foreground bg-blue-50 border border-blue-100 px-1.5 py-0.5 rounded">
{item.categoria}
</span>
)}
</div>
<p className="text-xs text-muted-foreground mt-0.5 flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
{item.message_count} {item.message_count === 1 ? 'messaggio' : 'messaggi'}
{item.note && <span className="italic ml-2 truncate">{item.note}</span>}
</p>
</div>
<div className="flex-shrink-0 text-right">
<p className={`text-sm font-semibold ${item.is_overdue ? 'text-destructive' : 'text-amber-600'}`}>
{scadenzaDate ? formatDate(scadenzaDate.toISOString()) : '-'}
</p>
{item.is_overdue && <p className="text-xs text-destructive">Scaduto</p>}
</div>
<ExternalLink className="h-4 w-4 text-muted-foreground flex-shrink-0" />
</div>
)
}
function groupFascicoli(items: FascicoloDeadlineResponse[]) {
const now = new Date()
const todayEnd = new Date(now); todayEnd.setHours(23, 59, 59, 999)
const weekEnd = new Date(now); weekEnd.setDate(weekEnd.getDate() + 7)
const overdue: FascicoloDeadlineResponse[] = []
const today: FascicoloDeadlineResponse[] = []
const thisWeek: FascicoloDeadlineResponse[] = []
const later: FascicoloDeadlineResponse[] = []
for (const item of items) {
if (!item.scadenza) continue
const d = new Date(item.scadenza)
if (d < now) overdue.push(item)
else if (d <= todayEnd) today.push(item)
else if (d <= weekEnd) thisWeek.push(item)
else later.push(item)
}
return { overdue, today, thisWeek, later }
}
function FascicoliTab({
daysAhead,
includeOverdue,
}: {
daysAhead: number
includeOverdue: boolean
}) {
const { data = [], isLoading } = useQuery({
queryKey: ['fascicoli-scadenzario', daysAhead, includeOverdue],
queryFn: () => fascicoliApi.scadenzario({ days_ahead: daysAhead, include_overdue: includeOverdue }),
})
const groups = groupFascicoli(data)
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 (data.length === 0) {
return (
<div className="text-center py-12 text-muted-foreground">
<CheckCircle2 className="h-12 w-12 mx-auto mb-4 opacity-30 text-green-500" />
<p className="text-lg font-medium">Nessuna scadenza sui fascicoli</p>
<p className="text-sm mt-1">
Le scadenze si impostano creando o modificando un fascicolo.
</p>
</div>
)
}
return (
<div className="max-w-3xl mx-auto space-y-8">
<DeadlineGroup
title="Scaduti"
items={groups.overdue}
icon={AlertTriangle}
color="text-destructive"
renderItem={(item) => <FascicoloDeadlineItem item={item} />}
/>
<DeadlineGroup
title="Oggi"
items={groups.today}
icon={Clock}
color="text-amber-600"
renderItem={(item) => <FascicoloDeadlineItem item={item} />}
/>
<DeadlineGroup
title="Questa settimana"
items={groups.thisWeek}
icon={Calendar}
color="text-blue-600"
renderItem={(item) => <FascicoloDeadlineItem item={item} />}
/>
<DeadlineGroup
title="Successivamente"
items={groups.later}
icon={Calendar}
color="text-muted-foreground"
renderItem={(item) => <FascicoloDeadlineItem item={item} />}
/>
</div>
)
}
// ─── Pagina principale ────────────────────────────────────────────────────────
type Tab = 'messaggi' | 'fascicoli'
export function DeadlinesPage() {
const [activeTab, setActiveTab] = useState<Tab>('messaggi')
const [daysAhead, setDaysAhead] = useState(30)
const [includeOverdue, setIncludeOverdue] = useState(true)
// Conteggi per i badge sulle tab
const { data: msgData = [] } = useQuery({
queryKey: ['deadlines', daysAhead, includeOverdue],
queryFn: () => deadlinesApi.list({ days_ahead: daysAhead, include_overdue: includeOverdue }),
})
const { data: fascData = [] } = useQuery({
queryKey: ['fascicoli-scadenzario', daysAhead, includeOverdue],
queryFn: () => fascicoliApi.scadenzario({ days_ahead: daysAhead, include_overdue: includeOverdue }),
})
const msgOverdue = msgData.filter((m: { is_overdue: boolean }) => m.is_overdue).length
const fascOverdue = fascData.filter((f: { is_overdue: boolean }) => f.is_overdue).length
const totalOverdue = msgOverdue + fascOverdue
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Header */}
<div className="border-b bg-background px-6 py-4 flex items-center justify-between"> <div className="border-b bg-background px-6 py-4 flex items-center justify-between">
<div> <div>
<h1 className="text-xl font-semibold flex items-center gap-2"> <h1 className="text-xl font-semibold flex items-center gap-2">
@@ -117,7 +389,13 @@ export function DeadlinesPage() {
Scadenzario Scadenzario
</h1> </h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{total} messaggi con scadenze {totalOverdue > 0 ? (
<span className="text-destructive font-medium">
{totalOverdue} {totalOverdue === 1 ? 'elemento scaduto' : 'elementi scaduti'}
</span>
) : (
`${msgData.length + fascData.length} scadenze totali`
)}
</p> </p>
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -143,46 +421,58 @@ export function DeadlinesPage() {
</div> </div>
</div> </div>
{/* Tab bar */}
<div className="border-b bg-background px-6">
<div className="flex gap-0">
<button
onClick={() => setActiveTab('messaggi')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'messaggi'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
<Clock className="h-4 w-4" />
Messaggi
{msgData.length > 0 && (
<span className={`text-xs px-1.5 py-0.5 rounded-full font-medium ${
msgOverdue > 0
? 'bg-destructive/10 text-destructive'
: 'bg-muted text-muted-foreground'
}`}>
{msgData.length}
</span>
)}
</button>
<button
onClick={() => setActiveTab('fascicoli')}
className={`flex items-center gap-2 px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
activeTab === 'fascicoli'
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
<FolderOpen className="h-4 w-4" />
Pratiche
{fascData.length > 0 && (
<span className={`text-xs px-1.5 py-0.5 rounded-full font-medium ${
fascOverdue > 0
? 'bg-destructive/10 text-destructive'
: 'bg-muted text-muted-foreground'
}`}>
{fascData.length}
</span>
)}
</button>
</div>
</div>
{/* Contenuto tab */}
<div className="flex-1 overflow-y-auto p-6"> <div className="flex-1 overflow-y-auto p-6">
{isLoading ? ( {activeTab === 'messaggi' ? (
<div className="flex justify-center py-12"> <MessaggiTab daysAhead={daysAhead} includeOverdue={includeOverdue} />
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : total === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<CheckCircle2 className="h-12 w-12 mx-auto mb-4 opacity-30 text-green-500" />
<p className="text-lg font-medium">Nessuna scadenza trovata</p>
<p className="text-sm mt-1">
Le scadenze si impostano dal dettaglio di ogni messaggio.
</p>
</div>
) : ( ) : (
<div className="max-w-3xl mx-auto space-y-8"> <FascicoliTab daysAhead={daysAhead} includeOverdue={includeOverdue} />
<DeadlineGroup
title="Scaduti"
items={groups.overdue}
icon={AlertTriangle}
color="text-destructive"
/>
<DeadlineGroup
title="Oggi"
items={groups.today}
icon={Clock}
color="text-amber-600"
/>
<DeadlineGroup
title="Questa settimana"
items={groups.thisWeek}
icon={Calendar}
color="text-blue-600"
/>
<DeadlineGroup
title="Successivamente"
items={groups.later}
icon={Calendar}
color="text-muted-foreground"
/>
</div>
)} )}
</div> </div>
</div> </div>
+178 -73
View File
@@ -1,4 +1,4 @@
import { useState } from 'react' import { useState, useMemo } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { import {
@@ -13,6 +13,8 @@ import {
ChevronRight, ChevronRight,
MessageSquare, MessageSquare,
Calendar, Calendar,
AlertTriangle,
Clock,
} from 'lucide-react' } from 'lucide-react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
@@ -23,6 +25,31 @@ import { formatDate } from '@/lib/utils'
import { getErrorMessage } from '@/api/client' import { getErrorMessage } from '@/api/client'
import { useAuth } from '@/hooks/useAuth' import { useAuth } from '@/hooks/useAuth'
// ─── Utilita' scadenza ────────────────────────────────────────────────────────
type ScadenzaStatus = 'overdue' | 'imminent' | 'ok' | null
function getScadenzaStatus(scadenza: string | null): ScadenzaStatus {
if (!scadenza) return null
const now = new Date()
const d = new Date(scadenza)
if (d < now) return 'overdue'
const diff = (d.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
if (diff <= 7) return 'imminent'
return 'ok'
}
function getDaysLabel(scadenza: string): string {
const now = new Date()
const d = new Date(scadenza)
const diffMs = d.getTime() - now.getTime()
const diffDays = Math.round(diffMs / (1000 * 60 * 60 * 24))
if (diffDays < 0) return `Scaduto da ${Math.abs(diffDays)} gg`
if (diffDays === 0) return 'Scade oggi'
if (diffDays === 1) return 'Scade domani'
return `Scade tra ${diffDays} gg`
}
// ─── Badge stato ────────────────────────────────────────────────────────────── // ─── Badge stato ──────────────────────────────────────────────────────────────
function StatoBadge({ stato }: { stato: FascicoloResponse['stato'] }) { function StatoBadge({ stato }: { stato: FascicoloResponse['stato'] }) {
@@ -52,6 +79,29 @@ function StatoBadge({ stato }: { stato: FascicoloResponse['stato'] }) {
) )
} }
// ─── Badge scadenza ───────────────────────────────────────────────────────────
function ScadenzaBadge({ scadenza }: { scadenza: string }) {
const status = getScadenzaStatus(scadenza)
if (status === 'overdue') {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-destructive/10 text-destructive border border-destructive/20">
<AlertTriangle className="h-3 w-3" />
Scaduto
</span>
)
}
if (status === 'imminent') {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 border border-amber-200">
<Clock className="h-3 w-3" />
Imminente
</span>
)
}
return null
}
// ─── Dialog crea / modifica fascicolo ───────────────────────────────────────── // ─── Dialog crea / modifica fascicolo ─────────────────────────────────────────
interface FascicoloDialogProps { interface FascicoloDialogProps {
@@ -208,6 +258,8 @@ function FascicoloDialog({ fascicolo, onClose }: FascicoloDialogProps) {
// ─── Pagina principale ──────────────────────────────────────────────────────── // ─── Pagina principale ────────────────────────────────────────────────────────
type ScadenzaFilter = '' | 'overdue' | 'imminent' | 'has_deadline'
export function FascicoliPage() { export function FascicoliPage() {
const navigate = useNavigate() const navigate = useNavigate()
const queryClient = useQueryClient() const queryClient = useQueryClient()
@@ -215,11 +267,12 @@ export function FascicoliPage() {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [filterStato, setFilterStato] = useState('') const [filterStato, setFilterStato] = useState('')
const [filterScadenza, setFilterScadenza] = useState<ScadenzaFilter>('')
const [showDialog, setShowDialog] = useState(false) const [showDialog, setShowDialog] = useState(false)
const [editFascicolo, setEditFascicolo] = useState<FascicoloResponse | null>(null) const [editFascicolo, setEditFascicolo] = useState<FascicoloResponse | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<FascicoloResponse | null>(null) const [deleteConfirm, setDeleteConfirm] = useState<FascicoloResponse | null>(null)
const { data: fascicoli = [], isLoading } = useQuery({ const { data: allFascicoli = [], isLoading } = useQuery({
queryKey: ['fascicoli', filterStato, search], queryKey: ['fascicoli', filterStato, search],
queryFn: () => queryFn: () =>
fascicoliApi.list({ fascicoliApi.list({
@@ -228,6 +281,25 @@ export function FascicoliPage() {
}), }),
}) })
// Filtro scadenza applicato lato client
const fascicoli = useMemo(() => {
if (!filterScadenza) return allFascicoli
const now = new Date()
const weekEnd = new Date(now)
weekEnd.setDate(weekEnd.getDate() + 7)
return allFascicoli.filter((f) => {
if (!f.scadenza) return false
const d = new Date(f.scadenza)
if (filterScadenza === 'overdue') return d < now
if (filterScadenza === 'imminent') return d >= now && d <= weekEnd
if (filterScadenza === 'has_deadline') return true
return true
})
}, [allFascicoli, filterScadenza])
const hasActiveFilters = search || filterStato || filterScadenza
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: string) => fascicoliApi.delete(id), mutationFn: (id: string) => fascicoliApi.delete(id),
onSuccess: () => { onSuccess: () => {
@@ -249,6 +321,9 @@ export function FascicoliPage() {
</h1> </h1>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{fascicoli.length} {fascicoli.length === 1 ? 'fascicolo' : 'fascicoli'} {fascicoli.length} {fascicoli.length === 1 ? 'fascicolo' : 'fascicoli'}
{hasActiveFilters && allFascicoli.length !== fascicoli.length && (
<span className="ml-1">su {allFascicoli.length} totali</span>
)}
</p> </p>
</div> </div>
<Button onClick={() => { setEditFascicolo(null); setShowDialog(true) }}> <Button onClick={() => { setEditFascicolo(null); setShowDialog(true) }}>
@@ -258,7 +333,7 @@ export function FascicoliPage() {
</div> </div>
{/* Filtri */} {/* Filtri */}
<div className="border-b bg-background px-6 py-3 flex items-center gap-3"> <div className="border-b bg-background px-6 py-3 flex items-center gap-3 flex-wrap">
<div className="relative flex-1 max-w-xs"> <div className="relative flex-1 max-w-xs">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input <Input
@@ -278,11 +353,21 @@ export function FascicoliPage() {
<option value="chiuso">Chiusi</option> <option value="chiuso">Chiusi</option>
<option value="archiviato">Archiviati</option> <option value="archiviato">Archiviati</option>
</select> </select>
{(search || filterStato) && ( <select
value={filterScadenza}
onChange={(e) => setFilterScadenza(e.target.value as ScadenzaFilter)}
className="h-9 rounded-md border border-input bg-background px-3 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="">Tutte le scadenze</option>
<option value="overdue">Solo scaduti</option>
<option value="imminent">Scadenza entro 7 giorni</option>
<option value="has_deadline">Con scadenza</option>
</select>
{hasActiveFilters && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => { setSearch(''); setFilterStato('') }} onClick={() => { setSearch(''); setFilterStato(''); setFilterScadenza('') }}
> >
<X className="h-4 w-4 mr-1" /> <X className="h-4 w-4 mr-1" />
Pulisci Pulisci
@@ -301,11 +386,11 @@ export function FascicoliPage() {
<FolderOpen className="h-14 w-14 mx-auto mb-4 opacity-20" /> <FolderOpen className="h-14 w-14 mx-auto mb-4 opacity-20" />
<p className="text-lg font-medium">Nessun fascicolo trovato</p> <p className="text-lg font-medium">Nessun fascicolo trovato</p>
<p className="text-sm mt-1"> <p className="text-sm mt-1">
{search || filterStato {hasActiveFilters
? 'Prova a modificare i filtri di ricerca.' ? 'Prova a modificare i filtri di ricerca.'
: 'Crea il primo fascicolo per raggruppare le comunicazioni PEC.'} : 'Crea il primo fascicolo per raggruppare le comunicazioni PEC.'}
</p> </p>
{!search && !filterStato && ( {!hasActiveFilters && (
<Button <Button
className="mt-4" className="mt-4"
onClick={() => { setEditFascicolo(null); setShowDialog(true) }} onClick={() => { setEditFascicolo(null); setShowDialog(true) }}
@@ -317,85 +402,105 @@ export function FascicoliPage() {
</div> </div>
) : ( ) : (
<div className="max-w-4xl mx-auto space-y-3"> <div className="max-w-4xl mx-auto space-y-3">
{fascicoli.map((f) => ( {fascicoli.map((f) => {
<div const scadenzaStatus = getScadenzaStatus(f.scadenza)
key={f.id} const isOverdue = scadenzaStatus === 'overdue'
className="flex items-center gap-4 p-4 rounded-lg border bg-card hover:shadow-sm transition-shadow cursor-pointer group" const isImminent = scadenzaStatus === 'imminent'
onClick={() => navigate(`/fascicoli/${f.id}`)}
>
{/* Icona stato */}
<div className="flex-shrink-0">
{f.stato === 'aperto' && <FolderOpen className="h-6 w-6 text-green-600" />}
{f.stato === 'chiuso' && <FolderCheck className="h-6 w-6 text-gray-500" />}
{f.stato === 'archiviato' && <FolderArchive className="h-6 w-6 text-amber-600" />}
</div>
{/* Info principale */} return (
<div className="flex-1 min-w-0"> <div
<div className="flex items-center gap-2 flex-wrap"> key={f.id}
<p className="font-semibold truncate">{f.titolo}</p> className={`flex items-center gap-4 p-4 rounded-lg border bg-card hover:shadow-sm transition-shadow cursor-pointer group ${
<StatoBadge stato={f.stato} /> isOverdue
{f.numero_pratica && ( ? 'border-destructive/30 bg-destructive/5'
<span className="text-xs text-muted-foreground font-mono bg-muted px-1.5 py-0.5 rounded"> : isImminent
#{f.numero_pratica} ? 'border-amber-300/50 bg-amber-50/30'
</span> : ''
)} }`}
{f.categoria && ( onClick={() => navigate(`/fascicoli/${f.id}`)}
<span className="text-xs text-muted-foreground bg-blue-50 border border-blue-100 px-1.5 py-0.5 rounded"> >
{f.categoria} {/* Icona stato */}
<div className="flex-shrink-0">
{f.stato === 'aperto' && <FolderOpen className={`h-6 w-6 ${isOverdue ? 'text-destructive' : 'text-green-600'}`} />}
{f.stato === 'chiuso' && <FolderCheck className="h-6 w-6 text-gray-500" />}
{f.stato === 'archiviato' && <FolderArchive className="h-6 w-6 text-amber-600" />}
</div>
{/* Info principale */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-semibold truncate">{f.titolo}</p>
<StatoBadge stato={f.stato} />
{f.scadenza && <ScadenzaBadge scadenza={f.scadenza} />}
{f.numero_pratica && (
<span className="text-xs text-muted-foreground font-mono bg-muted px-1.5 py-0.5 rounded">
#{f.numero_pratica}
</span>
)}
{f.categoria && (
<span className="text-xs text-muted-foreground bg-blue-50 border border-blue-100 px-1.5 py-0.5 rounded">
{f.categoria}
</span>
)}
</div>
<div className="flex items-center gap-4 mt-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
{f.message_count} {f.message_count === 1 ? 'messaggio' : 'messaggi'}
</span> </span>
{f.scadenza && (
<span className={`flex items-center gap-1 ${
isOverdue ? 'text-destructive font-medium' : isImminent ? 'text-amber-600 font-medium' : 'text-muted-foreground'
}`}>
{isOverdue
? <AlertTriangle className="h-3 w-3" />
: <Calendar className="h-3 w-3" />}
{getDaysLabel(f.scadenza)}
<span className="text-muted-foreground font-normal">
({formatDate(f.scadenza)})
</span>
</span>
)}
<span>Aggiornato: {formatDate(f.updated_at)}</span>
</div>
{f.note && (
<p className="text-xs text-muted-foreground italic mt-0.5 truncate">{f.note}</p>
)} )}
</div> </div>
<div className="flex items-center gap-4 mt-1 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
{f.message_count} {f.message_count === 1 ? 'messaggio' : 'messaggi'}
</span>
{f.scadenza && (
<span className="flex items-center gap-1 text-amber-600">
<Calendar className="h-3 w-3" />
Scade: {formatDate(f.scadenza)}
</span>
)}
<span>Aggiornato: {formatDate(f.updated_at)}</span>
</div>
{f.note && (
<p className="text-xs text-muted-foreground italic mt-0.5 truncate">{f.note}</p>
)}
</div>
{/* Azioni */} {/* Azioni */}
<div className="flex items-center gap-1 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"> <div className="flex items-center gap-1 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button
type="button"
onClick={(e) => {
e.stopPropagation()
setEditFascicolo(f)
setShowDialog(true)
}}
className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
title="Modifica"
>
<Pencil className="h-4 w-4" />
</button>
{isAdmin && (
<button <button
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation() e.stopPropagation()
setDeleteConfirm(f) setEditFascicolo(f)
setShowDialog(true)
}} }}
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive" className="p-1.5 rounded hover:bg-muted text-muted-foreground hover:text-foreground"
title="Elimina" title="Modifica"
> >
<Trash2 className="h-4 w-4" /> <Pencil className="h-4 w-4" />
</button> </button>
)} {isAdmin && (
</div> <button
type="button"
onClick={(e) => {
e.stopPropagation()
setDeleteConfirm(f)
}}
className="p-1.5 rounded hover:bg-destructive/10 text-muted-foreground hover:text-destructive"
title="Elimina"
>
<Trash2 className="h-4 w-4" />
</button>
)}
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" /> <ChevronRight className="h-4 w-4 text-muted-foreground flex-shrink-0" />
</div> </div>
))} )
})}
</div> </div>
)} )}
</div> </div>
@@ -18,6 +18,7 @@ import {
ExternalLink, ExternalLink,
AlertTriangle, AlertTriangle,
CheckCircle2, CheckCircle2,
Clock,
} from 'lucide-react' } from 'lucide-react'
import toast from 'react-hot-toast' import toast from 'react-hot-toast'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
@@ -344,6 +345,27 @@ export function FascicoloDetailPage() {
? FolderCheck ? FolderCheck
: FolderArchive : FolderArchive
// Calcola stato scadenza
const scadenzaStatus = (() => {
if (!fascicolo.scadenza) return null
const now = new Date()
const d = new Date(fascicolo.scadenza)
if (d < now) return 'overdue'
const diff = (d.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)
if (diff <= 7) return 'imminent'
return 'ok'
})()
const getDaysFromNow = (dateStr: string): string => {
const now = new Date()
const d = new Date(dateStr)
const diffDays = Math.round((d.getTime() - now.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays < 0) return `Scaduto da ${Math.abs(diffDays)} giorn${Math.abs(diffDays) === 1 ? 'o' : 'i'}`
if (diffDays === 0) return 'Scade oggi'
if (diffDays === 1) return 'Scade domani'
return `Scade tra ${diffDays} giorni`
}
return ( return (
<div className="flex flex-col h-full"> <div className="flex flex-col h-full">
{/* Toolbar */} {/* Toolbar */}
@@ -369,6 +391,29 @@ export function FascicoloDetailPage() {
</div> </div>
</div> </div>
{/* Banner alert scadenza */}
{scadenzaStatus === 'overdue' && fascicolo.scadenza && (
<div className="bg-destructive/10 border-b border-destructive/20 px-6 py-2.5">
<div className="max-w-4xl mx-auto flex items-center gap-2 text-sm text-destructive font-medium">
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
<span>
{getDaysFromNow(fascicolo.scadenza)} scadenza del {formatDate(fascicolo.scadenza)}.
Aggiorna la scadenza o chiudi il fascicolo.
</span>
</div>
</div>
)}
{scadenzaStatus === 'imminent' && fascicolo.scadenza && (
<div className="bg-amber-50 border-b border-amber-200 px-6 py-2.5">
<div className="max-w-4xl mx-auto flex items-center gap-2 text-sm text-amber-700 font-medium">
<Clock className="h-4 w-4 flex-shrink-0" />
<span>
{getDaysFromNow(fascicolo.scadenza)} scadenza del {formatDate(fascicolo.scadenza)}.
</span>
</div>
</div>
)}
{/* Intestazione fascicolo */} {/* Intestazione fascicolo */}
<div className="border-b bg-muted/30 px-6 py-5"> <div className="border-b bg-muted/30 px-6 py-5">
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">