feat(portale): task cancellati e le date dei pagamenti
Due cose che il cliente non poteva sapere guardando il portale.
**Task cancellati.** Fino a ieri un'attività tolta dal lavoro poteva solo
sparire (cancellata dal DB) o restare lì a far finta di essere ancora da
fare. Ora `cancelled` è il quinto stato: X dentro il cerchio, titolo barrato,
pill "Cancellata" — l'unico stato chiuso che la porta, perché "fatto" e
"cancellato" sono entrambi barrati e confonderli significa credere consegnato
qualcosa che non esiste.
Esce da tutti i denominatori — fase, progetto, board di consegna, riepilogo
admin — con un unico `countsTowardProgress()` in task-status.ts invece di
cinque `!== "cancelled"` sparsi. Contarlo terrebbe la fase sotto il 100% per
un lavoro che nessuno farà; contarlo come fatto racconterebbe una consegna
mai avvenuta. `recomputePhaseStatus` lo ignora allo stesso modo: senza questo,
cancellare l'ultima voce lasciava la fase "in corso" per sempre.
Nel kanban cliente la colonna compare solo se ha dentro qualcosa — le quattro
che raccontano il lavoro si tengono la larghezza — ma mai se è piena, quindi
nessun task sparisce dalla board. Nell'admin la colonna c'è sempre: è così che
si cancella un task, trascinandocelo.
**Date dei pagamenti** (migration 0023, già applicata in produzione).
`payments` sapeva solo quando una rata era stata incassata, mai quando era
attesa: il portale non poteva rispondere a "quando devo pagare?" e non c'era
niente su cui agganciare il promemoria email. Ora c'è `due_date`, nullable —
una rata senza data concordata è normale, e il portale la mostra solo se c'è.
Il cliente vede in cima al box la prossima scadenza col conto alla rovescia
("tra 12 giorni", "domani", "scaduto da 3 giorni" in rosso), e su ogni riga
la data: "Scade il…" se aperta, "Pagato il…" se saldata. Nessun importo per
riga — LOCKED #2 resta dov'era, le date non sono cifre.
I giorni si contano in `src/lib/payment-dates.ts`, sui giorni civili a Roma e
non sugli istanti: il container gira a UTC e "manca una settimana" non deve
cambiare risposta a seconda del fuso. È lo stesso modulo che userà il
promemoria email, così la mail e il portale non si contraddicono.
Lato admin ogni rata ha il campo Scadenza, e "Incassato nel mese" diventa
"Incassato il" — precisione al giorno, che le analytics (raggruppate per mese)
non notano. Il warning sul cambio schema ora conta anche le scadenze: una rata
"da saldare" con una data è già sotto gli occhi del cliente, e sparirebbe in
silenzio.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -38,6 +38,7 @@ const COLUMN_STYLES: Record<Status, { headerClass: string; dotClass: string }> =
|
||||
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({
|
||||
<p className="text-[10px] font-medium text-[#71717a] uppercase tracking-wide mb-1 truncate">
|
||||
{task.phaseTitle}
|
||||
</p>
|
||||
<p className="text-sm font-medium text-[#1a1a1a] leading-snug">{task.title}</p>
|
||||
<p
|
||||
className={`text-sm font-medium leading-snug ${
|
||||
status === "cancelled" ? "text-[#a1a1aa] line-through" : "text-[#1a1a1a]"
|
||||
}`}
|
||||
>
|
||||
{task.title}
|
||||
</p>
|
||||
{task.description && (
|
||||
<p className="text-xs text-[#71717a] mt-1 leading-snug line-clamp-2">
|
||||
{task.description}
|
||||
@@ -211,7 +221,7 @@ export function KanbanBoard({
|
||||
onDragStart={(e) => setActiveId(e.active.id as string)}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4 xl:grid-cols-4">
|
||||
<div className="grid grid-cols-2 gap-4 xl:grid-cols-5">
|
||||
{COLUMNS.map((col) => (
|
||||
<DroppableColumn
|
||||
key={col.id}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
setPaymentPaidAt,
|
||||
updatePaymentField,
|
||||
clearPaymentOverride,
|
||||
setPaymentDueDate,
|
||||
} from "@/app/admin/clients/[id]/actions";
|
||||
import { setPaymentPlan } from "@/app/admin/projects/project-actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -36,14 +37,19 @@ function formatEuro(value: number): string {
|
||||
return value.toLocaleString("it-IT", { minimumFractionDigits: 2 });
|
||||
}
|
||||
|
||||
// paid_at (Date | string | null, serializzato sul confine RSC) → "YYYY-MM" per <input type="month">
|
||||
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 <input type="date">.
|
||||
// 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({
|
||||
</select>
|
||||
{statusLoading === p.id && <span className="text-xs text-[#71717a]">...</span>}
|
||||
</div>
|
||||
{/* 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. */}
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Label htmlFor={`due-${p.id}`} className="text-xs text-[#71717a] shrink-0">
|
||||
Scadenza
|
||||
</Label>
|
||||
<input
|
||||
id={`due-${p.id}`}
|
||||
type="date"
|
||||
defaultValue={toDateValue(p.due_date)}
|
||||
disabled={statusLoading === p.id}
|
||||
onChange={(e) => handleDueDateUpdate(p.id, e.target.value)}
|
||||
className="text-sm border border-gray-200 rounded px-2 py-1 bg-white"
|
||||
/>
|
||||
{!p.due_date && (
|
||||
<span className="text-xs text-[#a1a1aa]">non mostrata al cliente</span>
|
||||
)}
|
||||
</div>
|
||||
{p.status === "saldato" && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<Label htmlFor={`paid-${p.id}`} className="text-xs text-[#71717a] shrink-0">
|
||||
Incassato nel mese
|
||||
Incassato il
|
||||
</Label>
|
||||
<input
|
||||
id={`paid-${p.id}`}
|
||||
type="month"
|
||||
defaultValue={toMonthValue(p.paid_at)}
|
||||
type="date"
|
||||
defaultValue={toDateValue(p.paid_at) || todayValue()}
|
||||
disabled={statusLoading === p.id}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -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({
|
||||
<div className="mb-1">
|
||||
<div className="flex justify-between items-center gap-3 mb-1.5">
|
||||
<p className="text-xs text-muted-foreground font-medium min-w-0">
|
||||
{doneCount} di {phase.tasks.length} task
|
||||
{doneCount} di {countedTotal} task
|
||||
{reviewCount > 0 && (
|
||||
<span className="text-violet-600 dark:text-violet-400">
|
||||
{" · "}
|
||||
{reviewCount} in attesa di riscontro
|
||||
</span>
|
||||
)}
|
||||
{cancelledCount > 0 && (
|
||||
<span>
|
||||
{" · "}
|
||||
{cancelledCount} {cancelledCount === 1 ? "cancellata" : "cancellate"}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="text-xs font-semibold text-foreground shrink-0">{phase.progress_pct}%</p>
|
||||
</div>
|
||||
@@ -121,7 +136,10 @@ export function PhaseCard({
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{phase.tasks.map((task) => (
|
||||
<li key={task.id} className="flex items-start gap-3">
|
||||
<li
|
||||
key={task.id}
|
||||
className={`flex items-start gap-3 ${task.status === "cancelled" ? "opacity-70" : ""}`}
|
||||
>
|
||||
<TaskStatusIcon status={task.status} />
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* flex-wrap: su un titolo lungo la pill va a capo invece di
|
||||
@@ -129,7 +147,7 @@ export function PhaseCard({
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||
<p
|
||||
className={`text-xs leading-snug ${
|
||||
task.status === "done"
|
||||
isClosedTaskStatus(task.status)
|
||||
? "line-through text-muted-foreground"
|
||||
: "text-foreground"
|
||||
}`}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
// sono lo stesso oggetto, e il design system chiede il contrario — colore + testo,
|
||||
// mai colore da solo. Ora le quattro forme si distinguono anche in bianco e nero:
|
||||
// vuoto → punto → spunta vuota → spunta piena. La spunta compare quando il lavoro
|
||||
// è finito e si riempie quando è confermato.
|
||||
// è finito e si riempie quando è confermato. La cancellata è l'unica che non è
|
||||
// un passo avanti: X e testo barrato, tinta spenta, fuori da ogni conteggio.
|
||||
|
||||
import { TASK_STATUSES, TASK_STATUS_LABELS, type TaskStatus } from "@/lib/task-status";
|
||||
|
||||
@@ -65,8 +66,24 @@ export const TASK_STATUS_VISUALS: Record<TaskStatus, StatusVisual> = {
|
||||
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 (
|
||||
<svg
|
||||
@@ -82,12 +99,27 @@ function CheckGlyph({ className }: { className: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function CrossGlyph({ className }: { className: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={3}
|
||||
viewBox="0 0 24 24"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6l12 12M18 6L6 18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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" && <CheckGlyph className="h-3 w-3" />}
|
||||
{status === "cancelled" && <CrossGlyph className="h-2.5 w-2.5" />}
|
||||
{status === "in_review" && (
|
||||
<CheckGlyph className="h-2.5 w-2.5 text-violet-600 dark:text-violet-400" />
|
||||
)}
|
||||
@@ -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];
|
||||
|
||||
@@ -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 (
|
||||
<div className="bg-card rounded-lg border border-border-light px-4 py-4 shadow-sm hover:border-primary/30 hover:shadow-card-hover transition-all">
|
||||
<div
|
||||
className={`bg-card rounded-lg border border-border-light px-4 py-4 shadow-sm hover:border-primary/30 hover:shadow-card-hover transition-all ${
|
||||
task.status === "cancelled" ? "opacity-70" : ""
|
||||
}`}
|
||||
>
|
||||
<p className="text-[9px] font-semibold text-muted-foreground uppercase tracking-wide mb-1 truncate">
|
||||
{task.phaseTitle}
|
||||
</p>
|
||||
<p className={`text-xs font-medium leading-snug ${task.status === "done" ? "line-through text-muted-foreground" : "text-foreground"}`}>
|
||||
<p className={`text-xs font-medium leading-snug ${isClosedTaskStatus(task.status) ? "line-through text-muted-foreground" : "text-foreground"}`}>
|
||||
{task.title}
|
||||
</p>
|
||||
{task.description && (
|
||||
@@ -58,9 +62,21 @@ export function ClientKanban({ phases, token }: { phases: ClientView["phases"];
|
||||
{} as Record<TaskStatus, Task[]>
|
||||
);
|
||||
|
||||
// "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 (
|
||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-4">
|
||||
{COLUMNS.map((col) => {
|
||||
<div
|
||||
className={`grid grid-cols-1 gap-6 sm:grid-cols-2 ${
|
||||
visibleColumns.length > 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];
|
||||
|
||||
@@ -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 (
|
||||
<div className="rounded-xl border border-border-light bg-card shadow-card overflow-hidden">
|
||||
{/* Totale — unico importo visibile al cliente (LOCKED) */}
|
||||
@@ -61,7 +78,34 @@ export function PaymentStatus({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 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 && (
|
||||
<div
|
||||
className={`px-5 py-4 border-b border-border-light ${
|
||||
isLate
|
||||
? 'bg-red-50 dark:bg-red-500/10'
|
||||
: 'bg-amber-50/60 dark:bg-amber-500/10'
|
||||
}`}
|
||||
>
|
||||
<p
|
||||
className={`text-[10px] font-bold uppercase tracking-wider mb-1 ${
|
||||
isLate
|
||||
? 'text-red-700 dark:text-red-400'
|
||||
: 'text-amber-700 dark:text-amber-400'
|
||||
}`}
|
||||
>
|
||||
{isLate ? 'Pagamento scaduto' : 'Prossimo pagamento'}
|
||||
</p>
|
||||
<p className="text-sm font-bold text-foreground leading-snug">
|
||||
{formatLongDate(nextDue.due_date)}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
{nextDue.label} · {formatCountdown(nextDueDays)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Righe pagamento: solo etichetta, stato e date — MAI importo singolo */}
|
||||
{!hideRows && (
|
||||
<div className="px-5 py-4 space-y-2">
|
||||
{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 (
|
||||
<div
|
||||
key={payment.id}
|
||||
className="flex items-center justify-between gap-4 rounded-lg border border-border-light p-3"
|
||||
className="flex items-start justify-between gap-4 rounded-lg border border-border-light p-3"
|
||||
>
|
||||
<p className="text-xs font-semibold text-foreground">
|
||||
{payment.label}
|
||||
</p>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold text-foreground">
|
||||
{payment.label}
|
||||
</p>
|
||||
{(paidOn || dueOn) && (
|
||||
<p
|
||||
className={`text-[11px] mt-0.5 leading-snug ${
|
||||
overdue
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{paidOn ?? dueOn}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Pill stato — nessun importo */}
|
||||
<span
|
||||
|
||||
Reference in New Issue
Block a user