From b49d4bfaaa938d4f2e5d02ae53d0992636a162a0 Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Thu, 20 Aug 2026 22:35:41 +0200 Subject: [PATCH] feat(pagamenti): ordine stabile delle rate, label e importi modificabili Mettere una rata su "saldato" la faceva saltare in fondo. Non era un'impressione: payments non aveva NESSUNA colonna d'ordine (ne' sort_order ne' created_at) e nessuna delle 13 query che la leggono aveva un ORDER BY. Postgres fa seq-scan e restituisce l'ordine fisico; una UPDATE in MVCC riscrive la tupla in coda, quindi la riga aggiornata tornava ultima. In produzione 3 progetti su 5 mostravano gia' l'ordine sbagliato (uno 30/20/50, uno del tutto rovesciato 20/30/50, e la coppia legacy con Saldo prima di Acconto). Per questo la migration 0019 NON fa il backfill per ctid, che avrebbe fotografato lo scombinamento: ordina per percent DESC con tie su label, che ricostruisce l'intento di tutti gli schemi esistenti. Verificato: rimette a posto tutti e 5. Aggiunto anche l'indice su (project_id, sort_order): una FK non crea indice sul lato referenziante, e payments non ne aveva alcuno oltre alla PK. Ora le rate si rinominano e gli importi si sovrascrivono a mano (EditableCell + updatePaymentField, con la stessa normalizzazione it-IT di updateServiceField). L'importo scritto a mano e' legge: amount_locked lo esclude dal ricalcolo. Quando la somma delle rate non corrisponde al totale il tab lo dice, con la cifra esatta, invece di aggiustare di nascosto. Lo schema a 3 rate passa da 50/30/20 a 50/25/25 (le righe gia' esistenti non cambiano: vale solo quando lo si riseleziona). Due bug trovati per strada e chiusi: - rescalePayments decideva con some(percent !== null): bastava UNA riga con percent per far entrare tutto il progetto nel ramo percentuale, che calcolava newTotal * 0 e azzerava ogni riga con percent NULL. splitPayment inserisce la Rata 2 proprio cosi', quindi splittare una rata e poi toccare il totale la portava a zero. Ora la regola e' per riga, non per progetto. - Il selettore di schema fa DELETE+INSERT e cancellava in silenzio anche status e paid_at, con due rate gia' saldate in produzione. Ora chiede conferma, ma solo quando c'e' davvero storico da perdere. Migration 0019 applicata a prod prima del push. Co-Authored-By: Claude Opus 5 --- src/app/admin/clients/[id]/actions.ts | 97 +++++++++-- src/app/admin/clients/new/actions.ts | 7 +- src/app/admin/projects/project-actions.ts | 28 +++- src/components/admin/tabs/PaymentsTab.tsx | 152 ++++++++++++++---- .../migrations/0019_payments_sort_order.sql | 32 ++++ src/db/schema.ts | 3 + src/lib/admin-queries.ts | 14 +- src/lib/client-view.ts | 3 +- 8 files changed, 282 insertions(+), 54 deletions(-) create mode 100644 src/db/migrations/0019_payments_sort_order.sql diff --git a/src/app/admin/clients/[id]/actions.ts b/src/app/admin/clients/[id]/actions.ts index e85ddbb..a9c26bb 100644 --- a/src/app/admin/clients/[id]/actions.ts +++ b/src/app/admin/clients/[id]/actions.ts @@ -312,34 +312,109 @@ export async function setPaymentPaidAt(paymentId: string, id: string, monthStr: } // Rescales payment amounts when the total changes. -// If the payment has a `percent` field, use it (new plan rows). -// Legacy rows (percent null) fall back to equal split across all rows. +// +// The rule is PER ROW, not per project. The previous version decided with +// `some((p) => p.percent !== null)`: a single row carrying a percent dragged the +// whole project into the percentage branch, where every percent-NULL row was +// computed as `newTotal * 0` and silently written to 0.00. That was a live bug — +// splitPayment inserts its second instalment with percent NULL, so splitting a +// payment and then touching the total zeroed the remainder. +// +// Now: a row is rescaled only if it still carries a percent AND has not been +// manually overridden. Everything else is left exactly as it is. The equal-split +// fallback survives only for projects where NO row has a percent (legacy stubs). async function rescalePayments(projectId: string, newTotal: number): Promise { const projectPayments = await db - .select({ id: payments.id, percent: payments.percent }) + .select({ id: payments.id, percent: payments.percent, amount_locked: payments.amount_locked }) .from(payments) .where(eq(payments.project_id, projectId)); if (projectPayments.length === 0) return; - const hasPercent = projectPayments.some((p) => p.percent !== null); + const anyPercent = projectPayments.some((p) => p.percent !== null); - if (hasPercent) { - // New plan: rescale each row by its stored percent + if (anyPercent) { for (const p of projectPayments) { - const pct = p.percent !== null ? parseFloat(String(p.percent)) : 0; - const newAmount = ((newTotal * pct) / 100).toFixed(2); + if (p.percent === null || p.amount_locked) continue; + const newAmount = ((newTotal * parseFloat(String(p.percent))) / 100).toFixed(2); await db.update(payments).set({ amount: newAmount }).where(eq(payments.id, p.id)); } } else { - // Legacy: equal split across all rows (backward compat) - const share = (newTotal / projectPayments.length).toFixed(2); - for (const p of projectPayments) { + // Legacy: equal split across the rows that are still automatic. + const auto = projectPayments.filter((p) => !p.amount_locked); + if (auto.length === 0) return; + const share = (newTotal / auto.length).toFixed(2); + for (const p of auto) { await db.update(payments).set({ amount: share }).where(eq(payments.id, p.id)); } } } +// Per-row edit of a payment's label or amount, mirroring updateServiceField in +// src/app/admin/catalog/actions.ts. Writing an amount marks the row as manually +// overridden so rescalePayments stops touching it. +const EDITABLE_PAYMENT_FIELDS = ["label", "amount"] as const; +type EditablePaymentField = (typeof EDITABLE_PAYMENT_FIELDS)[number]; + +export async function updatePaymentField( + paymentId: string, + id: string, + fieldName: string, + value: string +): Promise { + await requireAdmin(); + if (!(EDITABLE_PAYMENT_FIELDS as readonly string[]).includes(fieldName)) { + throw new Error(`Campo non editabile: ${fieldName}`); + } + const field = fieldName as EditablePaymentField; + + if (field === "label") { + const label = value.trim(); + if (!label) throw new Error("Nome rata richiesto"); + await db.update(payments).set({ label }).where(eq(payments.id, paymentId)); + } else { + // The cell renders it-IT (€ 1.234,50), so an admin may well type "1.234,50". + // When a comma is present treat "." as thousands separators and "," as the + // decimal mark; otherwise "." is the decimal mark. Number() (not parseFloat) + // rejects trailing garbage like "12abc". + const raw = value.trim(); + const normalized = raw.includes(",") ? raw.replace(/\./g, "").replace(",", ".") : raw; + const num = Number(normalized); + if (!Number.isFinite(num) || num < 0) throw new Error("Importo non valido"); + await db + .update(payments) + .set({ amount: num.toFixed(2), amount_locked: true }) + .where(eq(payments.id, paymentId)); + } + + const { path } = await resolveEntity(id); + revalidatePath(path); +} + +// Releases a manual override: the row goes back under automatic rescaling and is +// immediately recomputed from its percent, so the effect is visible at once. +export async function clearPaymentOverride(paymentId: string, id: string): Promise { + await requireAdmin(); + const rows = await db + .select({ project_id: payments.project_id }) + .from(payments) + .where(eq(payments.id, paymentId)) + .limit(1); + if (!rows[0]) throw new Error("Pagamento non trovato"); + + await db.update(payments).set({ amount_locked: false }).where(eq(payments.id, paymentId)); + + const proj = await db + .select({ accepted_total: projects.accepted_total }) + .from(projects) + .where(eq(projects.id, rows[0].project_id)) + .limit(1); + if (proj[0]) await rescalePayments(rows[0].project_id, parseFloat(proj[0].accepted_total ?? "0")); + + const { path } = await resolveEntity(id); + revalidatePath(path); +} + export async function updateAcceptedTotal(id: string, formData: FormData) { await requireAdmin(); const raw = (formData.get("accepted_total") as string)?.trim(); diff --git a/src/app/admin/clients/new/actions.ts b/src/app/admin/clients/new/actions.ts index 34d1b84..da96cf9 100644 --- a/src/app/admin/clients/new/actions.ts +++ b/src/app/admin/clients/new/actions.ts @@ -86,9 +86,12 @@ export async function createClientCore(input: { }) .returning({ id: projects.id }); + // percent is seeded so these stubs rescale properly once a total is set — + // previously they were NULL, which put the project in the legacy equal-split + // branch and made them indistinguishable from a manually overridden amount. await db.insert(payments).values([ - { project_id: newProject.id, label: "Acconto 50%", amount: "0", status: "da_saldare" }, - { project_id: newProject.id, label: "Saldo 50%", amount: "0", status: "da_saldare" }, + { project_id: newProject.id, label: "Acconto 50%", amount: "0", percent: "50.00", status: "da_saldare", sort_order: 0 }, + { project_id: newProject.id, label: "Saldo 50%", amount: "0", percent: "50.00", status: "da_saldare", sort_order: 1 }, ]); return { clientId: newClient.id, projectId: newProject.id }; diff --git a/src/app/admin/projects/project-actions.ts b/src/app/admin/projects/project-actions.ts index 2e8add8..1cbb6bc 100644 --- a/src/app/admin/projects/project-actions.ts +++ b/src/app/admin/projects/project-actions.ts @@ -15,7 +15,7 @@ import { tasks, PROJECT_OFFER_STATUSES, } from "@/db/schema"; -import { eq, asc, and } from "drizzle-orm"; +import { eq, asc, and, gt, sql } from "drizzle-orm"; import { z } from "zod"; import { nanoid } from "nanoid"; @@ -83,7 +83,7 @@ export async function initProjectPayments(projectId: string): Promise { // Modes: // single → 1 row 100% "Pagamento unico (100%)" // two → 2 rows 50%/50% "Acconto 50% (inizio lavori)" / "Saldo 50% (alla consegna)" -// three → 3 rows 50%/30%/20% +// three → 3 rows 50%/25%/25% export async function setPaymentPlan( projectId: string, mode: "single" | "two" | "three", @@ -100,8 +100,8 @@ export async function setPaymentPlan( ], three: [ { label: "Acconto 50% (inizio lavori)", percent: 50 }, - { label: "30% (post revisioni)", percent: 30 }, - { label: "Saldo 20% (alla consegna)", percent: 20 }, + { label: "25% (in corso d'opera)", percent: 25 }, + { label: "Saldo 25% (alla consegna)", percent: 25 }, ], }; @@ -109,14 +109,17 @@ export async function setPaymentPlan( if (!plan) throw new Error("Modalità pagamento non valida"); // Delete existing payments for this project, then insert new ones. + // Destructive by design (the UI says so), and the tab asks for confirmation + // when a row has already been marked inviata/saldato. await db.delete(payments).where(eq(payments.project_id, projectId)); await db.insert(payments).values( - plan.map((p) => ({ + plan.map((p, i) => ({ project_id: projectId, label: p.label, percent: p.percent.toFixed(2), amount: ((total * p.percent) / 100).toFixed(2), status: "da_saldare" as const, + sort_order: i, })) ); @@ -145,16 +148,29 @@ export async function splitPayment( // Strip any existing " – Rata N" suffix so re-splits stay clean. const baseLabel = original.label.replace(/ – Rata \d+$/, ""); + // Both halves are manual amounts by definition, so they are locked: without + // this, the next change to the project total would recompute Rata 1 from its + // (now meaningless) percent and zero out Rata 2, which carries none. await db .update(payments) - .set({ amount: first.toFixed(2), label: `${baseLabel} – Rata 1` }) + .set({ amount: first.toFixed(2), label: `${baseLabel} – Rata 1`, amount_locked: true }) .where(eq(payments.id, paymentId)); + // Rata 2 slots in right after Rata 1; everything below shifts down one. + await db + .update(payments) + .set({ sort_order: sql`${payments.sort_order} + 1` }) + .where( + and(eq(payments.project_id, projectId), gt(payments.sort_order, original.sort_order)) + ); + await db.insert(payments).values({ project_id: projectId, label: `${baseLabel} – Rata 2`, amount: second.toFixed(2), status: original.status, + sort_order: original.sort_order + 1, + amount_locked: true, }); revalidatePath(`/admin/projects/${projectId}`); diff --git a/src/components/admin/tabs/PaymentsTab.tsx b/src/components/admin/tabs/PaymentsTab.tsx index e5d7bf0..d57e76d 100644 --- a/src/components/admin/tabs/PaymentsTab.tsx +++ b/src/components/admin/tabs/PaymentsTab.tsx @@ -1,16 +1,20 @@ "use client"; -import { useState } from "react"; +import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; +import { RotateCcw } from "lucide-react"; import { updatePaymentStatus, updateAcceptedTotal, setPaymentPaidAt, + updatePaymentField, + clearPaymentOverride, } from "@/app/admin/clients/[id]/actions"; import { setPaymentPlan } from "@/app/admin/projects/project-actions"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; +import { EditableCell } from "@/components/ui/editable-cell"; import { SplitPaymentForm } from "@/components/admin/SplitPaymentForm"; import type { Payment } from "@/db/schema"; @@ -28,6 +32,10 @@ const statusLabels: Record = { saldato: "Saldato", }; +function formatEuro(value: number): string { + return value.toLocaleString("it-IT", { minimumFractionDigits: 2 }); +} + // paid_at (Date | string | null, serializzato sul confine RSC) → "YYYY-MM" per function toMonthValue(paidAt: Date | string | null | undefined): string { if (!paidAt) { @@ -43,7 +51,7 @@ type PlanMode = "single" | "two" | "three"; const planOptions: { mode: PlanMode; label: string; description: string }[] = [ { mode: "single", label: "Pagamento unico", description: "100% in un'unica soluzione" }, { mode: "two", label: "2 step", description: "Acconto 50% + Saldo 50%" }, - { mode: "three", label: "3 step", description: "50% + 30% + 20%" }, + { mode: "three", label: "3 step", description: "50% + 25% + 25%" }, ]; export function PaymentsTab({ @@ -57,35 +65,81 @@ export function PaymentsTab({ const [overrideValue, setOverrideValue] = useState(acceptedTotal); const [planLoading, setPlanLoading] = useState(null); const [statusLoading, setStatusLoading] = useState(null); + const [, startTransition] = useTransition(); + const [error, setError] = useState(null); + + // The input is seeded from a prop, so it has to follow the prop after a + // router.refresh() — otherwise it keeps showing the pre-save value. Adjusted + // during render rather than in an effect (React's documented pattern for + // "resetting state when a prop changes"): no extra pass, no flash of stale value. + const [prevAcceptedTotal, setPrevAcceptedTotal] = useState(acceptedTotal); + if (prevAcceptedTotal !== acceptedTotal) { + setPrevAcceptedTotal(acceptedTotal); + setOverrideValue(acceptedTotal); + } const currentTotal = parseFloat(acceptedTotal) || 0; const offersTotal = offersAcceptedTotal; const hasOffers = offersTotal > 0; + const rowsTotal = payments.reduce((sum, p) => sum + (parseFloat(p.amount) || 0), 0); + const drift = rowsTotal - currentTotal; + const hasDrift = payments.length > 0 && Math.abs(drift) >= 0.01; + + // Shared runner: every mutation goes through here so a failure is visible + // instead of vanishing into an unhandled rejection. + function run(fn: () => Promise) { + setError(null); + startTransition(async () => { + try { + await fn(); + router.refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Errore nel salvataggio"); + } + }); + } + async function handleUseOffersTotal() { if (!projectId) return; setOverrideValue(offersTotal.toFixed(2)); const fd = new FormData(); fd.set("accepted_total", offersTotal.toFixed(2)); - await updateAcceptedTotal(projectId, fd); - router.refresh(); + run(() => updateAcceptedTotal(projectId, fd)); } async function handleSaveTotal(e: React.FormEvent) { e.preventDefault(); const fd = new FormData(); fd.set("accepted_total", overrideValue); - await updateAcceptedTotal(clientId, fd); - router.refresh(); + run(() => updateAcceptedTotal(clientId, fd)); } 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. + const tracked = payments.filter( + (p) => p.status === "saldato" || p.status === "inviata" + ).length; + if (tracked > 0) { + const what = + tracked === 1 ? "1 rata già segnata" : `${tracked} rate già segnate`; + 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?` + ); + if (!ok) return; + } + setPlanLoading(mode); + setError(null); try { const total = parseFloat(overrideValue) || currentTotal; await setPaymentPlan(projectId, mode, total); router.refresh(); + } catch (e) { + setError(e instanceof Error ? e.message : "Errore nel salvataggio"); } finally { setPlanLoading(null); } @@ -114,6 +168,12 @@ export function PaymentsTab({ return (
+ {error && ( +

+ {error} +

+ )} + {/* Totale preventivo */}

Totale preventivo

@@ -124,8 +184,7 @@ export function PaymentsTab({ Ereditato dalle offerte attive:{" "} - €{" "} - {offersTotal.toLocaleString("it-IT", { minimumFractionDigits: 2 })} + € {formatEuro(offersTotal)} {projectId && ( @@ -211,28 +270,48 @@ export function PaymentsTab({ {/* Payment rows */} {payments.map((p) => { const amount = parseFloat(p.amount); - const pct = p.percent !== null && p.percent !== undefined - ? parseFloat(String(p.percent)) - : null; + const pct = + p.percent !== null && p.percent !== undefined ? parseFloat(String(p.percent)) : null; return ( -
-
-
-

{p.label}

- {pct !== null && ( -

{pct}% del totale

+
+
+
+
+ run(() => updatePaymentField(p.id, clientId, "label", v))} + /> +
+ {pct !== null && !p.amount_locked && ( +

{pct}% del totale

+ )} + {p.amount_locked && ( +

+ Importo scritto a mano + +

)}
-
- - €{" "} - {amount.toLocaleString("it-IT", { - minimumFractionDigits: 2, - })} - +
+
+ `€ ${formatEuro(parseFloat(raw) || 0)}`} + onSave={(v) => run(() => updatePaymentField(p.id, clientId, "amount", v))} + /> +
{projectId && amount > 0 && ( ))} - {statusLoading === p.id && ( - ... - )} + {statusLoading === p.id && ...}
{p.status === "saldato" && (
@@ -280,6 +357,21 @@ export function PaymentsTab({
); })} + + {/* Scarto fra la somma delle rate e il totale del progetto. Si dice, non si + sistema di nascosto: un importo scritto a mano è una scelta, non un errore. */} + {hasDrift && ( +
+

+ {drift > 0 + ? `Le rate superano il totale di € ${formatEuro(drift)}` + : `Le rate coprono € ${formatEuro(-drift)} in meno del totale`} +

+

+ Somma rate € {formatEuro(rowsTotal)} · Totale € {formatEuro(currentTotal)} +

+
+ )}
); } diff --git a/src/db/migrations/0019_payments_sort_order.sql b/src/db/migrations/0019_payments_sort_order.sql new file mode 100644 index 0000000..585dab3 --- /dev/null +++ b/src/db/migrations/0019_payments_sort_order.sql @@ -0,0 +1,32 @@ +-- Additive: give `payments` a stable display order and a manual-override flag. +-- +-- Why: the table had NO ordering column at all (no sort_order, no created_at) and +-- none of the 13 queries reading it had an ORDER BY. Postgres seq-scans and returns +-- physical order; an MVCC UPDATE rewrites the tuple at the end, so marking a payment +-- "saldato" pushed it to the bottom of the list. +-- +-- No drops, no truncates, no deletes — `payments` is LOCKED in CLAUDE.md. + +ALTER TABLE payments ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0; +ALTER TABLE payments ADD COLUMN IF NOT EXISTS amount_locked boolean NOT NULL DEFAULT false; + +-- Backfill. Deliberately NOT ordered by ctid: 3 of the 5 projects in production are +-- already displaying scrambled (one shows 30/20/50, one is fully reversed 20/30/50, +-- and the legacy pair shows Saldo before Acconto), so physical order would freeze the +-- bug in place instead of fixing it. +-- +-- percent DESC reconstructs the intent of every plan shape that exists (100 / 50-50 / +-- 50-30-20). The label tiebreaker puts "Acconto…" before "Saldo…" in the legacy pairs +-- that carry percent NULL. +UPDATE payments p SET sort_order = s.rn - 1 +FROM ( + SELECT id, row_number() OVER ( + PARTITION BY project_id ORDER BY percent DESC NULLS LAST, label ASC + ) AS rn + FROM payments +) s +WHERE p.id = s.id; + +-- A foreign key creates no index on the referencing side, so payments had no index +-- at all beyond its PK. This one serves both the FK lookups and the new ORDER BY. +CREATE INDEX IF NOT EXISTS payments_project_sort_idx ON payments(project_id, sort_order); diff --git a/src/db/schema.ts b/src/db/schema.ts index fa1bc59..35c5f85 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -214,6 +214,9 @@ export const payments = pgTable("payments", { percent: numeric("percent", { precision: 5, scale: 2 }), // nullable — % of total (for rescaling); null = legacy row status: text("status").notNull().default("da_saldare"), // da_saldare | inviata | saldato paid_at: timestamp("paid_at", { withTimezone: true }), + sort_order: integer("sort_order").notNull().default(0), + // true = l'importo è stato scritto a mano: rescalePayments non lo tocca più. + amount_locked: boolean("amount_locked").notNull().default(false), }); // ============ DOCUMENTS ============ diff --git a/src/lib/admin-queries.ts b/src/lib/admin-queries.ts index 4c10b27..ccdb9e7 100644 --- a/src/lib/admin-queries.ts +++ b/src/lib/admin-queries.ts @@ -120,7 +120,11 @@ export async function getAllClientsWithPayments( } const [allPayments, activeEntries, totals, offerTotals] = await Promise.all([ - db.select().from(payments).where(inArray(payments.project_id, projectIds)), + db + .select() + .from(payments) + .where(inArray(payments.project_id, projectIds)) + .orderBy(asc(payments.sort_order)), db .select({ @@ -326,7 +330,8 @@ export async function getClientFullDetail(id: string): Promise