Scadenzario aggiunta pratiche
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
]
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user