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 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string, string> = {
|
||||
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 <input type="month">
|
||||
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<PlanMode | null>(null);
|
||||
const [statusLoading, setStatusLoading] = useState<string | null>(null);
|
||||
const [, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(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<unknown>) {
|
||||
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 (
|
||||
<div className="space-y-6 max-w-md">
|
||||
{error && (
|
||||
<p className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Totale preventivo */}
|
||||
<div className="bg-white border border-gray-200 rounded-lg p-4 space-y-3">
|
||||
<h3 className="font-medium text-gray-900">Totale preventivo</h3>
|
||||
@@ -124,8 +184,7 @@ export function PaymentsTab({
|
||||
<span className="text-sm text-[#71717a]">
|
||||
Ereditato dalle offerte attive:{" "}
|
||||
<span className="font-semibold text-[#1a1a1a]">
|
||||
€{" "}
|
||||
{offersTotal.toLocaleString("it-IT", { minimumFractionDigits: 2 })}
|
||||
€ {formatEuro(offersTotal)}
|
||||
</span>
|
||||
</span>
|
||||
{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 (
|
||||
<div
|
||||
key={p.id}
|
||||
className="bg-white border border-gray-200 rounded-lg p-4"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{p.label}</h3>
|
||||
{pct !== null && (
|
||||
<p className="text-xs text-[#71717a]">{pct}% del totale</p>
|
||||
<div key={p.id} className="bg-white border border-gray-200 rounded-lg p-4">
|
||||
<div className="flex items-start justify-between gap-2 mb-2">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-medium text-gray-900">
|
||||
<EditableCell
|
||||
value={p.label}
|
||||
type="text"
|
||||
required
|
||||
onSave={(v) => run(() => updatePaymentField(p.id, clientId, "label", v))}
|
||||
/>
|
||||
</div>
|
||||
{pct !== null && !p.amount_locked && (
|
||||
<p className="text-xs text-[#71717a] px-2">{pct}% del totale</p>
|
||||
)}
|
||||
{p.amount_locked && (
|
||||
<p className="flex items-center gap-1 px-2 text-xs text-amber-700 dark:text-amber-500">
|
||||
Importo scritto a mano
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => run(() => clearPaymentOverride(p.id, clientId))}
|
||||
className="rounded p-0.5 hover:bg-amber-500/10"
|
||||
aria-label="Rimuovi l'override e torna al calcolo automatico"
|
||||
title="Torna al calcolo automatico dalla percentuale"
|
||||
>
|
||||
<RotateCcw className="h-3 w-3" />
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">
|
||||
€{" "}
|
||||
{amount.toLocaleString("it-IT", {
|
||||
minimumFractionDigits: 2,
|
||||
})}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="text-sm text-gray-600">
|
||||
<EditableCell
|
||||
value={p.amount}
|
||||
type="number"
|
||||
required
|
||||
formatDisplay={(raw) => `€ ${formatEuro(parseFloat(raw) || 0)}`}
|
||||
onSave={(v) => run(() => updatePaymentField(p.id, clientId, "amount", v))}
|
||||
/>
|
||||
</div>
|
||||
{projectId && amount > 0 && (
|
||||
<SplitPaymentForm
|
||||
paymentId={p.id}
|
||||
@@ -258,9 +337,7 @@ export function PaymentsTab({
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{statusLoading === p.id && (
|
||||
<span className="text-xs text-[#71717a]">...</span>
|
||||
)}
|
||||
{statusLoading === p.id && <span className="text-xs text-[#71717a]">...</span>}
|
||||
</div>
|
||||
{p.status === "saldato" && (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
@@ -280,6 +357,21 @@ export function PaymentsTab({
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* 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 && (
|
||||
<div className="rounded-lg border border-amber-300 bg-amber-50 p-4 dark:border-amber-500/40 dark:bg-amber-500/10">
|
||||
<p className="text-sm font-medium text-amber-800 dark:text-amber-300">
|
||||
{drift > 0
|
||||
? `Le rate superano il totale di € ${formatEuro(drift)}`
|
||||
: `Le rate coprono € ${formatEuro(-drift)} in meno del totale`}
|
||||
</p>
|
||||
<p className="mt-1 font-mono text-xs tabular-nums text-amber-700 dark:text-amber-400">
|
||||
Somma rate € {formatEuro(rowsTotal)} · Totale € {formatEuro(currentTotal)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user