diff --git a/src/app/admin/clients/[id]/actions.ts b/src/app/admin/clients/[id]/actions.ts index 4e989cf..35b5f8a 100644 --- a/src/app/admin/clients/[id]/actions.ts +++ b/src/app/admin/clients/[id]/actions.ts @@ -23,7 +23,7 @@ import { } from "@/db/schema"; import { eq, asc, and, isNull } from "drizzle-orm"; import { z } from "zod"; -import { isTaskStatus } from "@/lib/task-status"; +import { countsTowardProgress, isTaskStatus } from "@/lib/task-status"; // ── ENTITY RESOLUTION ──────────────────────────────────────────────────────── // Both clientId and projectId are passed as "clientId" by tab components. @@ -187,16 +187,24 @@ export async function addTask(phaseId: string, id: string, formData: FormData) { // that count as started: as a list it silently forgot new statuses, and a phase // whose tasks were all in review fell back to "upcoming" — reading as // not-started when it was nearly finished. +// +// I task cancellati sono invisibili a questo calcolo: non tengono la fase aperta +// (senza questo, cancellare l'ultima voce la lascerebbe "in corso" per sempre) e +// da soli non la fanno partire. Se restano SOLO cancellati la fase torna +// "Da iniziare": è degenere, ma è meglio di "Completata", che al cliente +// racconterebbe una consegna che non c'è stata. export async function recomputePhaseStatus(phaseId: string): Promise { const phaseTasks = await db .select({ status: tasks.status }) .from(tasks) .where(eq(tasks.phase_id, phaseId)); + const countedTasks = phaseTasks.filter((t) => countsTowardProgress(t.status)); + let newStatus: "upcoming" | "active" | "done" = "upcoming"; - if (phaseTasks.length > 0) { - const allDone = phaseTasks.every((t) => t.status === "done"); - const anyActive = phaseTasks.some((t) => t.status !== "todo"); + if (countedTasks.length > 0) { + const allDone = countedTasks.every((t) => t.status === "done"); + const anyActive = countedTasks.some((t) => t.status !== "todo"); if (allDone) newStatus = "done"; else if (anyActive) newStatus = "active"; } @@ -326,17 +334,42 @@ export async function updatePaymentStatus(paymentId: string, id: string, status: revalidatePath(path); } -// Imposta il mese in cui un pagamento è stato incassato (formato "YYYY-MM"). -// Mappa al primo giorno del mese (mezzogiorno UTC per evitare drift di fuso) e -// porta lo stato a "saldato" così l'incasso viene attribuito a quel mese nelle analytics. -export async function setPaymentPaidAt(paymentId: string, id: string, monthStr: string) { +// Parsing di una data inserita dall'admin, in due formati: +// "YYYY-MM-DD" → quel giorno (quello che scrivono i campi di oggi) +// "YYYY-MM" → primo del mese (formato storico di paid_at, campo ) +// +// Mezzogiorno UTC, non mezzanotte: a mezzanotte UTC il giorno civile a Roma è già +// quello dopo per due ore in estate, e la data tornerebbe indietro di un giorno +// una volta riletta. A mezzogiorno nessun fuso reale sposta il giorno. +function parseAdminDate(value: string): Date { + const day = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value); + if (day) { + const [, y, m, d] = day; + const year = parseInt(y, 10); + const month = parseInt(m, 10); + const date = parseInt(d, 10); + if (month < 1 || month > 12 || date < 1 || date > 31) throw new Error("Data non valida"); + return new Date(Date.UTC(year, month - 1, date, 12, 0, 0)); + } + + const month = /^(\d{4})-(\d{2})$/.exec(value); + if (month) { + const [, y, m] = month; + const monthNum = parseInt(m, 10); + if (monthNum < 1 || monthNum > 12) throw new Error("Data non valida"); + return new Date(Date.UTC(parseInt(y, 10), monthNum - 1, 1, 12, 0, 0)); + } + + throw new Error("Data non valida"); +} + +// Imposta il giorno in cui un pagamento è stato incassato e porta lo stato a +// "saldato", così l'incasso viene attribuito a quel mese nelle analytics (che +// raggruppano con `extract(month from paid_at)`: la precisione al giorno non le +// tocca). Accetta anche il vecchio "YYYY-MM" delle righe storiche. +export async function setPaymentPaidAt(paymentId: string, id: string, dateStr: string) { await requireAdmin(); - const m = /^(\d{4})-(\d{2})$/.exec(monthStr); - if (!m) throw new Error("Mese non valido"); - const year = parseInt(m[1], 10); - const month = parseInt(m[2], 10); - if (month < 1 || month > 12) throw new Error("Mese non valido"); - const paid_at = new Date(Date.UTC(year, month - 1, 1, 12, 0, 0)); + const paid_at = parseAdminDate(dateStr); await db .update(payments) .set({ paid_at, status: "saldato" }) @@ -345,6 +378,17 @@ export async function setPaymentPaidAt(paymentId: string, id: string, monthStr: revalidatePath(path); } +// Scadenza concordata della rata. Stringa vuota = la scadenza si toglie: era +// stata messa e non vale più, e cancellarla deve essere possibile senza dover +// cancellare la rata. +export async function setPaymentDueDate(paymentId: string, id: string, dateStr: string) { + await requireAdmin(); + const due_date = dateStr.trim() === "" ? null : parseAdminDate(dateStr); + await db.update(payments).set({ due_date }).where(eq(payments.id, paymentId)); + const { path } = await resolveEntity(id); + revalidatePath(path); +} + // Rescales payment amounts when the total changes. // // The rule is PER ROW, not per project. The previous version decided with diff --git a/src/app/client/[token]/page.tsx b/src/app/client/[token]/page.tsx index f0892b7..ec622dd 100644 --- a/src/app/client/[token]/page.tsx +++ b/src/app/client/[token]/page.tsx @@ -58,6 +58,8 @@ function projectViewToClientView( id: p.id, label: p.label, status: p.status as "da_saldare" | "inviata" | "saldato", + due_date: p.due_date instanceof Date ? p.due_date.toISOString() : null, + paid_at: p.paid_at instanceof Date ? p.paid_at.toISOString() : null, })), documents: view.documents.map((d) => ({ id: d.id, diff --git a/src/components/admin/ProjectSummary.tsx b/src/components/admin/ProjectSummary.tsx index 07dccef..8d53cd4 100644 --- a/src/components/admin/ProjectSummary.tsx +++ b/src/components/admin/ProjectSummary.tsx @@ -1,5 +1,6 @@ import { MetricCard, fmtEur0 } from "@/components/admin/MetricCard"; import type { ProjectFullDetail } from "@/lib/admin-queries"; +import { countsTowardProgress } from "@/lib/task-status"; type Props = { acceptedTotal: string; @@ -38,7 +39,9 @@ export function ProjectSummary({ const collectedPct = contracted > 0 ? Math.round((collected / contracted) * 100) : 0; - const allTasks = phases.flatMap((p) => p.tasks); + // Le cancellate escono dal denominatore, come nel portale cliente: le due + // percentuali devono raccontare la stessa cosa. + const allTasks = phases.flatMap((p) => p.tasks).filter((t) => countsTowardProgress(t.status)); const doneTasks = allTasks.filter((t) => t.status === "done").length; const progressPct = allTasks.length > 0 ? Math.round((doneTasks / allTasks.length) * 100) : 0; diff --git a/src/components/admin/kanban/KanbanBoard.tsx b/src/components/admin/kanban/KanbanBoard.tsx index 9e9efcb..e13087e 100644 --- a/src/components/admin/kanban/KanbanBoard.tsx +++ b/src/components/admin/kanban/KanbanBoard.tsx @@ -38,6 +38,7 @@ const COLUMN_STYLES: Record = dotClass: "bg-violet-500", }, done: { headerClass: "text-[#1A463C]", dotClass: "bg-[#1A463C]" }, + cancelled: { headerClass: "text-[#a1a1aa]", dotClass: "bg-[#a1a1aa]" }, }; const COLUMNS: { id: Status; label: string; headerClass: string; dotClass: string }[] = @@ -92,6 +93,7 @@ function DroppableColumn({ key={task.id} task={task} isActive={activeId === task.id} + status={id} /> ))} {tasks.length === 0 && ( @@ -107,9 +109,11 @@ function DroppableColumn({ function DraggableCard({ task, isActive, + status, }: { task: Task; isActive: boolean; + status: Status; }) { const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: task.id }); @@ -131,7 +135,13 @@ function DraggableCard({

{task.phaseTitle}

-

{task.title}

+

+ {task.title} +

{task.description && (

{task.description} @@ -211,7 +221,7 @@ export function KanbanBoard({ onDragStart={(e) => setActiveId(e.active.id as string)} onDragEnd={handleDragEnd} > -

+
{COLUMNS.map((col) => ( -function toMonthValue(paidAt: Date | string | null | undefined): string { - if (!paidAt) { - const now = new Date(); - return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`; - } - const d = paidAt instanceof Date ? paidAt : new Date(paidAt); - return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`; +// Date | string | null (serializzato sul confine RSC) → "YYYY-MM-DD" per . +// Le date sono salvate a mezzogiorno UTC apposta, quindi i getter locali leggono +// il giorno giusto in qualunque fuso senza scivolare di uno. +function toDateValue(value: Date | string | null | undefined): string { + if (!value) return ""; + const d = value instanceof Date ? value : new Date(value); + if (Number.isNaN(d.getTime())) return ""; + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + +function todayValue(): string { + const now = new Date(); + return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; } type PlanMode = "single" | "two" | "three"; @@ -118,16 +124,19 @@ export function PaymentsTab({ async function handleSetPlan(mode: PlanMode) { if (!projectId) return; - // Picking a plan deletes every existing row — including status and paid_at. - // Only worth interrupting when there is actually payment history to lose. + // Picking a plan deletes every existing row — including status, paid_at and + // due_date. Only worth interrupting when there is actually something to lose. + // La scadenza conta quanto lo stato: una rata "da saldare" con una data + // concordata è informazione che il cliente sta già leggendo nel portale, e + // sparirebbe senza che nessuno se ne accorga. const tracked = payments.filter( - (p) => p.status === "saldato" || p.status === "inviata" + (p) => p.status === "saldato" || p.status === "inviata" || p.due_date !== null ).length; if (tracked > 0) { const what = - tracked === 1 ? "1 rata già segnata" : `${tracked} rate già segnate`; + tracked === 1 ? "1 rata con dati inseriti" : `${tracked} rate con dati inseriti`; const ok = window.confirm( - `Cambiare schema cancella tutte le rate di questo progetto.\n\nCi sono ${what} come inviate o saldate: lo stato e il mese di incasso andranno persi.\n\nProcedere?` + `Cambiare schema cancella tutte le rate di questo progetto.\n\nCi sono ${what} (stato, scadenza o data di incasso): andranno persi.\n\nProcedere?` ); if (!ok) return; } @@ -155,11 +164,22 @@ export function PaymentsTab({ } } - async function handlePaidMonthUpdate(paymentId: string, monthStr: string) { - if (!monthStr) return; + async function handlePaidDateUpdate(paymentId: string, dateStr: string) { + if (!dateStr) return; setStatusLoading(paymentId); try { - await setPaymentPaidAt(paymentId, clientId, monthStr); + await setPaymentPaidAt(paymentId, clientId, dateStr); + router.refresh(); + } finally { + setStatusLoading(null); + } + } + + // La scadenza si può anche togliere: la stringa vuota è un valore, non un no-op. + async function handleDueDateUpdate(paymentId: string, dateStr: string) { + setStatusLoading(paymentId); + try { + await setPaymentDueDate(paymentId, clientId, dateStr); router.refresh(); } finally { setStatusLoading(null); @@ -339,17 +359,36 @@ export function PaymentsTab({ {statusLoading === p.id && ...}
+ {/* Scadenza: è questa che il cliente vede nel portale, col conto alla + rovescia, ed è l'aggancio del futuro promemoria via email. + Sempre modificabile, anche a rata saldata: resta lo storico. */} +
+ + handleDueDateUpdate(p.id, e.target.value)} + className="text-sm border border-gray-200 rounded px-2 py-1 bg-white" + /> + {!p.due_date && ( + non mostrata al cliente + )} +
{p.status === "saldato" && (
handlePaidMonthUpdate(p.id, e.target.value)} + onChange={(e) => handlePaidDateUpdate(p.id, e.target.value)} className="text-sm border border-gray-200 rounded px-2 py-1 bg-white" />
diff --git a/src/components/client/PhaseCard.tsx b/src/components/client/PhaseCard.tsx index 00ad613..1e08168 100644 --- a/src/components/client/PhaseCard.tsx +++ b/src/components/client/PhaseCard.tsx @@ -3,7 +3,12 @@ import { useState } from "react"; import { ApproveButton } from "@/components/client/ApproveButton"; import { useChatContext } from "@/components/client/ChatProvider"; -import { TaskStatusIcon, TaskStatusPill } from "@/components/client/TaskStatusIndicator"; +import { + isClosedTaskStatus, + TaskStatusIcon, + TaskStatusPill, +} from "@/components/client/TaskStatusIndicator"; +import { countsTowardProgress } from "@/lib/task-status"; import type { ClientView } from "@/lib/client-view"; type Phase = ClientView["phases"][number]; @@ -41,6 +46,10 @@ export function PhaseCard({ const [open, setOpen] = useState(defaultOpen); const { openChat } = useChatContext(); const doneCount = phase.tasks.filter((t) => t.status === "done").length; + // Il denominatore esclude le cancellate: "3 di 4" con una quarta voce annullata + // resterebbe fermo lì per sempre, e la barra non toccherebbe mai il 100%. + const countedTotal = phase.tasks.filter((t) => countsTowardProgress(t.status)).length; + const cancelledCount = phase.tasks.length - countedTotal; // I task in revisione aspettano il cliente, non noi. Il conteggio sta nell'header, // che resta visibile anche a card chiusa: altrimenti la richiesta si nasconde // dentro una fase collassata e il cliente non sa che tocca a lui. @@ -94,13 +103,19 @@ export function PhaseCard({

- {doneCount} di {phase.tasks.length} task + {doneCount} di {countedTotal} task {reviewCount > 0 && ( {" · "} {reviewCount} in attesa di riscontro )} + {cancelledCount > 0 && ( + + {" · "} + {cancelledCount} {cancelledCount === 1 ? "cancellata" : "cancellate"} + + )}

{phase.progress_pct}%

@@ -121,7 +136,10 @@ export function PhaseCard({ ) : (
    {phase.tasks.map((task) => ( -
  • +
  • {/* flex-wrap: su un titolo lungo la pill va a capo invece di @@ -129,7 +147,7 @@ export function PhaseCard({

    = { count: "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400", hint: "Fatto — completato.", }, + cancelled: { + ring: "border-2 border-border bg-muted text-muted-foreground", + // Unica pill fra gli stati chiusi: "fatto" e "cancellata" sono entrambi + // barrati, e la differenza fra i due — consegnato o tolto dal lavoro — è + // troppo importante per affidarla al solo disegno dentro il cerchio. + pill: "bg-muted text-muted-foreground border border-border", + dot: "bg-muted-foreground/40", + label: "text-muted-foreground", + count: "bg-muted text-muted-foreground", + hint: "Cancellata — non verrà realizzata.", + }, }; +/** Stati chiusi: il titolo si legge barrato, il lavoro non è più in corso. */ +export function isClosedTaskStatus(status: TaskStatus): boolean { + return status === "done" || status === "cancelled"; +} + function CheckGlyph({ className }: { className: string }) { return ( + ); +} + /** * Il cerchio di stato accanto al titolo del task. * * L'icona è decorativa (`aria-hidden`) e lo stato viaggia in uno `sr-only`: così - * lo screen reader lo annuncia su tutti e quattro gli stati, mentre a schermo solo - * i due ambigui portano la pill. + * lo screen reader lo annuncia su tutti gli stati, mentre a schermo solo quelli + * ambigui portano la pill. */ export function TaskStatusIcon({ status }: { status: TaskStatus }) { const v = TASK_STATUS_VISUALS[status]; @@ -99,6 +131,7 @@ export function TaskStatusIcon({ status }: { status: TaskStatus }) { className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full ${v.ring}`} > {status === "done" && } + {status === "cancelled" && } {status === "in_review" && ( )} @@ -115,8 +148,8 @@ export function TaskStatusIcon({ status }: { status: TaskStatus }) { * Etichetta di testo. Nulla per "da fare" e "fatto", che si leggono da soli. * * `aria-hidden` perché è ridondanza *visiva*: allo screen reader lo stato lo dice già - * lo `sr-only` dell'icona, su tutti e quattro gli stati. Senza questo, i due che hanno - * la pill verrebbero annunciati due volte. + * lo `sr-only` dell'icona, su tutti gli stati. Senza questo, quelli che hanno la pill + * verrebbero annunciati due volte. */ export function TaskStatusPill({ status }: { status: TaskStatus }) { const v = TASK_STATUS_VISUALS[status]; diff --git a/src/components/client/kanban/ClientKanban.tsx b/src/components/client/kanban/ClientKanban.tsx index 08c2220..df6abf1 100644 --- a/src/components/client/kanban/ClientKanban.tsx +++ b/src/components/client/kanban/ClientKanban.tsx @@ -2,7 +2,7 @@ import type { ClientView } from "@/lib/client-view"; import { ApproveButton } from "@/components/client/ApproveButton"; -import { TASK_STATUS_VISUALS } from "@/components/client/TaskStatusIndicator"; +import { isClosedTaskStatus, TASK_STATUS_VISUALS } from "@/components/client/TaskStatusIndicator"; import { TASK_STATUS_LABELS, TASK_STATUSES, type TaskStatus } from "@/lib/task-status"; type Task = ClientView["phases"][number]["tasks"][number] & { @@ -16,11 +16,15 @@ const COLUMNS: { id: TaskStatus; label: string }[] = TASK_STATUSES.map((id) => ( function TaskCard({ task, token }: { task: Task; token: string }) { return ( -

    +

    {task.phaseTitle}

    -

    +

    {task.title}

    {task.description && ( @@ -58,9 +62,21 @@ export function ClientKanban({ phases, token }: { phases: ClientView["phases"]; {} as Record ); + // "Cancellate" è l'unica colonna che si nasconde da vuota: nella maggior parte + // dei progetti non ce n'è nessuna, e una quinta colonna vuota ruberebbe spazio + // alle quattro che raccontano il lavoro. Si nasconde solo se è vuota, quindi + // nessun task sparisce mai dalla board — era proprio quello il bug di prima. + const visibleColumns = COLUMNS.filter( + (col) => col.id !== "cancelled" || tasksByStatus.cancelled.length > 0 + ); + return ( -
    - {COLUMNS.map((col) => { +
    4 ? "xl:grid-cols-5" : "xl:grid-cols-4" + }`} + > + {visibleColumns.map((col) => { // Stessa tinta dell'icona in Timeline: passando da una vista all'altra // "in corso" e "in revisione" restano riconoscibili senza rileggere. const v = TASK_STATUS_VISUALS[col.id]; diff --git a/src/components/payment-status.tsx b/src/components/payment-status.tsx index 8e8ad6f..1a9790e 100644 --- a/src/components/payment-status.tsx +++ b/src/components/payment-status.tsx @@ -1,4 +1,11 @@ import type { ClientView } from '@/lib/client-view'; +import { + daysUntil, + findNextDue, + formatCountdown, + formatLongDate, + formatShortDate, +} from '@/lib/payment-dates'; interface PaymentStatusProps { accepted_total: string; @@ -49,6 +56,16 @@ export function PaymentStatus({ maximumFractionDigits: 2, }); + // Il conto alla rovescia si calcola qui, sul server: la pagina è dinamica + // (`revalidate = 0`), quindi il numero è fresco a ogni apertura del portale. + // + // Il riquadro compare anche quando le righe sono nascoste (retainer): non + // contiene importi, e una scadenza esiste solo se l'admin l'ha scritta — se + // l'ha scritta, è perché il cliente la deve sapere. + const nextDue = findNextDue(payments); + const nextDueDays = nextDue?.due_date ? daysUntil(nextDue.due_date) : null; + const isLate = nextDueDays !== null && nextDueDays < 0; + return (
    {/* Totale — unico importo visibile al cliente (LOCKED) */} @@ -61,7 +78,34 @@ export function PaymentStatus({

    - {/* Righe pagamento: solo etichetta + stato, MAI importo singolo — omesse se hideRows */} + {/* Prossima scadenza — la domanda più ovvia del cliente, in cima e senza cifre */} + {nextDue && nextDue.due_date && nextDueDays !== null && ( +
    +

    + {isLate ? 'Pagamento scaduto' : 'Prossimo pagamento'} +

    +

    + {formatLongDate(nextDue.due_date)} +

    +

    + {nextDue.label} · {formatCountdown(nextDueDays)} +

    +
    + )} + + {/* Righe pagamento: solo etichetta, stato e date — MAI importo singolo */} {!hideRows && (
    {payments.length === 0 ? ( @@ -73,14 +117,42 @@ export function PaymentStatus({ const status = payment.status as PaymentStatusValue; const config = statusConfig[status] ?? statusConfig.da_saldare; + // Una riga saldata mostra quando è stata incassata; una ancora + // aperta mostra quando scade. Nessuna delle due inventa la data: + // se in gestionale non c'è, sotto l'etichetta non compare nulla. + const paidOn = + status === 'saldato' && payment.paid_at + ? `Pagato il ${formatShortDate(payment.paid_at)}` + : null; + const dueOn = + status !== 'saldato' && payment.due_date + ? `Scade il ${formatShortDate(payment.due_date)} · ${formatCountdown( + daysUntil(payment.due_date) + )}` + : null; + const overdue = dueOn !== null && daysUntil(payment.due_date!) < 0; + return (
    -

    - {payment.label} -

    +
    +

    + {payment.label} +

    + {(paidOn || dueOn) && ( +

    + {paidOn ?? dueOn} +

    + )} +
    {/* Pill stato — nessun importo */} ; documents: Array<{ id: string; @@ -108,6 +112,8 @@ export interface ProjectView { label: string; // amount intentionally excluded — client API never exposes payment amounts (CLAUDE.md + DASH-07) status: string; + due_date: Date | null; + paid_at: Date | null; }>; documents: Array<{ id: string; @@ -326,6 +332,10 @@ export async function getProjectView(projectId: string): Promise d.task_id === task.id), })); - const doneCount = phaseTasks.filter((t) => t.status === "done").length; + // I task cancellati escono dal denominatore: restano in lista, barrati, ma + // non tengono la fase sotto il 100% per un lavoro che nessuno farà più. + const countedTasks = phaseTasks.filter((t) => countsTowardProgress(t.status)); + const doneCount = countedTasks.filter((t) => t.status === "done").length; const progress_pct = - phaseTasks.length > 0 ? Math.round((doneCount / phaseTasks.length) * 100) : 0; + countedTasks.length > 0 ? Math.round((doneCount / countedTasks.length) * 100) : 0; return { ...phase, tasks: phaseTasks, progress_pct }; }); - const doneTasks = tasksRows.filter((t) => t.status === "done").length; + const countedTasks = tasksRows.filter((t) => countsTowardProgress(t.status)); + const doneTasks = countedTasks.filter((t) => t.status === "done").length; const global_progress_pct = - tasksRows.length > 0 ? Math.round((doneTasks / tasksRows.length) * 100) : 0; + countedTasks.length > 0 ? Math.round((doneTasks / countedTasks.length) * 100) : 0; return { project: { diff --git a/src/lib/delivery-queries.ts b/src/lib/delivery-queries.ts index 70004bc..ae0a85a 100644 --- a/src/lib/delivery-queries.ts +++ b/src/lib/delivery-queries.ts @@ -9,6 +9,7 @@ import { offer_macros, } from "@/db/schema"; import { eq, inArray } from "drizzle-orm"; +import { countsTowardProgress } from "@/lib/task-status"; export type DeliveryStatus = "consegnato" | "in_anticipo" | "in_linea" | "in_ritardo"; @@ -131,6 +132,9 @@ export async function getDeliveryBoard(): Promise { for (const task of taskRows) { const projectId = phaseToProject.get(task.phase_id); if (!projectId) continue; + // Le cancellate non entrano nel totale: il board di consegna misura quanto + // manca, e quello che è stato tolto dal lavoro non manca più. + if (!countsTowardProgress(task.status)) continue; const tally = taskTally.get(projectId) ?? { done: 0, total: 0 }; tally.total += 1; if (task.status === "done") tally.done += 1; diff --git a/src/lib/payment-dates.ts b/src/lib/payment-dates.ts new file mode 100644 index 0000000..b6536c7 --- /dev/null +++ b/src/lib/payment-dates.ts @@ -0,0 +1,100 @@ +// ── Date dei pagamenti, lette come le legge il cliente ─────────────────────── +// Un pagamento ha due date: quella attesa (`due_date`) e quella incassata +// (`paid_at`). Entrambe sono timestamptz, ma al cliente interessa il giorno sul +// calendario, non l'istante: "manca una settimana" non deve cambiare risposta +// perché il container gira a UTC e chi legge sta a Roma. +// +// Da qui passeranno anche i promemoria email ("la scadenza si avvicina"): il +// conteggio dei giorni che decide se mandare la mail deve essere lo stesso +// numero che il cliente vede nel portale, altrimenti la mail arriva a dire +// "mancano 3 giorni" mentre il portale ne mostra 2. + +/** Il fuso in cui il cliente guarda il calendario. */ +const TZ = "Europe/Rome"; + +const dayKeyFormatter = new Intl.DateTimeFormat("en-CA", { + timeZone: TZ, + year: "numeric", + month: "2-digit", + day: "2-digit", +}); + +const longDateFormatter = new Intl.DateTimeFormat("it-IT", { + timeZone: TZ, + day: "numeric", + month: "long", + year: "numeric", +}); + +const shortDateFormatter = new Intl.DateTimeFormat("it-IT", { + timeZone: TZ, + day: "numeric", + month: "short", + year: "numeric", +}); + +function toDate(value: Date | string): Date { + return value instanceof Date ? value : new Date(value); +} + +/** "2026-03-15" — il giorno civile a Roma, qualunque sia il fuso del server. */ +function dayKey(value: Date | string): string { + return dayKeyFormatter.format(toDate(value)); +} + +/** + * Giorni interi che separano oggi dalla scadenza: 0 = oggi, 3 = fra tre giorni, + * -2 = scaduta da due giorni. + * + * Si confrontano i giorni civili, non gli istanti: una scadenza fissata a + * mezzogiorno non deve leggersi "fra 0 giorni" solo perché mancano 20 ore. + */ +export function daysUntil(due: Date | string, now: Date = new Date()): number { + const a = Date.parse(`${dayKey(now)}T00:00:00Z`); + const b = Date.parse(`${dayKey(due)}T00:00:00Z`); + return Math.round((b - a) / 86_400_000); +} + +/** "15 marzo 2026" */ +export function formatLongDate(value: Date | string): string { + return longDateFormatter.format(toDate(value)); +} + +/** "15 mar 2026" — per le righe strette della sidebar. */ +export function formatShortDate(value: Date | string): string { + return shortDateFormatter.format(toDate(value)); +} + +/** + * Il conto alla rovescia in parole. Il ritardo si dice, non si nasconde: una + * rata scaduta che continua a mostrare la sua data senza commento sembra a posto. + */ +export function formatCountdown(days: number): string { + if (days === 0) return "oggi"; + if (days === 1) return "domani"; + if (days === -1) return "scaduto da 1 giorno"; + if (days > 1) return `tra ${days} giorni`; + return `scaduto da ${Math.abs(days)} giorni`; +} + +/** Una rata è ancora aperta finché non è saldata — "inviata" non è incassata. */ +export function isOutstanding(status: string): boolean { + return status !== "saldato"; +} + +type Schedulable = { status: string; due_date: Date | string | null }; + +/** + * La prossima rata da pagare: la più vicina fra quelle aperte che hanno una data. + * + * Le scadute restano candidate e vincono sulle future — se c'è un arretrato è + * quello "il prossimo pagamento", non la rata del mese prossimo. + */ +export function findNextDue(payments: T[]): T | null { + const withDate = payments.filter((p) => isOutstanding(p.status) && p.due_date !== null); + if (withDate.length === 0) return null; + + return withDate.reduce((earliest, p) => + toDate(p.due_date!).getTime() < toDate(earliest.due_date!).getTime() ? p : earliest + ); +} diff --git a/src/lib/task-status.ts b/src/lib/task-status.ts index 3db6b65..ec1f0b1 100644 --- a/src/lib/task-status.ts +++ b/src/lib/task-status.ts @@ -6,7 +6,7 @@ // // No db import on purpose: client components import this file. -export const TASK_STATUSES = ["todo", "in_progress", "in_review", "done"] as const; +export const TASK_STATUSES = ["todo", "in_progress", "in_review", "done", "cancelled"] as const; export type TaskStatus = (typeof TASK_STATUSES)[number]; @@ -15,8 +15,19 @@ export const TASK_STATUS_LABELS: Record = { in_progress: "In corso", in_review: "In revisione", done: "Fatto", + cancelled: "Cancellata", }; +// Un task cancellato è chiuso, non fatto: resta visibile (barrato) perché il +// cliente ha il diritto di sapere che quella voce esisteva ed è stata tolta, ma +// esce dai conteggi. Contarlo nel denominatore bloccherebbe la fase sotto il +// 100% per sempre; contarlo come fatto racconterebbe una consegna mai avvenuta. +// Vive qui e non come `!== "cancelled"` sparso nei file: è la stessa disciplina +// che ha fatto nascere questo modulo. +export function countsTowardProgress(status: string): boolean { + return status !== "cancelled"; +} + export function isTaskStatus(value: string): value is TaskStatus { return (TASK_STATUSES as readonly string[]).includes(value); }