Files
clienthub/src/components/admin/tabs/PaymentsTab.tsx
T
simone fe767899b9 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>
2026-08-22 14:49:11 +02:00

417 lines
16 KiB
TypeScript

"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { RotateCcw } from "lucide-react";
import {
updatePaymentStatus,
updateAcceptedTotal,
setPaymentPaidAt,
updatePaymentField,
clearPaymentOverride,
setPaymentDueDate,
} 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";
type Props = {
payments: Payment[];
acceptedTotal: string;
clientId: string;
projectId?: string; // set only from project detail page — enables init & split
offersAcceptedTotal?: number; // sum of accepted_total from active offers
};
const statusLabels: Record<string, string> = {
da_saldare: "Da saldare",
inviata: "Inviata",
saldato: "Saldato",
};
function formatEuro(value: number): string {
return value.toLocaleString("it-IT", { minimumFractionDigits: 2 });
}
// 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";
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% + 25% + 25%" },
];
export function PaymentsTab({
payments,
acceptedTotal,
clientId,
projectId,
offersAcceptedTotal = 0,
}: Props) {
const router = useRouter();
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));
run(() => updateAcceptedTotal(projectId, fd));
}
async function handleSaveTotal(e: React.FormEvent) {
e.preventDefault();
const fd = new FormData();
fd.set("accepted_total", overrideValue);
run(() => updateAcceptedTotal(clientId, fd));
}
async function handleSetPlan(mode: PlanMode) {
if (!projectId) return;
// 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.due_date !== null
).length;
if (tracked > 0) {
const what =
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} (stato, scadenza o data 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);
}
}
async function handleStatusUpdate(paymentId: string, status: string) {
setStatusLoading(paymentId);
try {
await updatePaymentStatus(paymentId, clientId, status);
router.refresh();
} finally {
setStatusLoading(null);
}
}
async function handlePaidDateUpdate(paymentId: string, dateStr: string) {
if (!dateStr) return;
setStatusLoading(paymentId);
try {
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);
}
}
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>
{/* Totale ereditato dalle offerte */}
{hasOffers && (
<div className="flex items-center justify-between bg-[#f9f9f9] border border-[#e5e7eb] rounded-md px-3 py-2 gap-3">
<span className="text-sm text-[#71717a]">
Ereditato dalle offerte attive:{" "}
<span className="font-semibold text-[#1a1a1a]">
{formatEuro(offersTotal)}
</span>
</span>
{projectId && (
<Button
type="button"
size="sm"
variant="outline"
onClick={handleUseOffersTotal}
className="shrink-0 text-xs"
>
Usa questo totale
</Button>
)}
</div>
)}
{/* Override manuale */}
<form onSubmit={handleSaveTotal} className="flex items-end gap-3">
<div className="space-y-1 flex-1">
<Label htmlFor="accepted_total">Importo override ()</Label>
<Input
id="accepted_total"
name="accepted_total"
type="number"
step="0.01"
min="0"
value={overrideValue}
onChange={(e) => setOverrideValue(e.target.value)}
className="max-w-xs"
/>
</div>
<Button type="submit" size="sm">
Salva
</Button>
</form>
</div>
{/* Selettore schema rate */}
{projectId && (
<div className="bg-white border border-gray-200 rounded-lg p-4 space-y-3">
<h3 className="font-medium text-gray-900">Schema di pagamento</h3>
<div className="flex gap-2 flex-wrap">
{planOptions.map(({ mode, label, description }) => (
<button
key={mode}
type="button"
onClick={() => handleSetPlan(mode)}
disabled={planLoading !== null}
className={`flex-1 min-w-[120px] border rounded-lg px-3 py-2.5 text-left transition-colors ${
planLoading === mode
? "border-[#1A463C] bg-[#1A463C]/5 opacity-70"
: "border-[#e5e7eb] hover:border-[#1A463C] hover:bg-[#1A463C]/5"
}`}
>
<p className="text-sm font-medium text-[#1a1a1a]">{label}</p>
<p className="text-xs text-[#71717a] mt-0.5">{description}</p>
</button>
))}
</div>
<p className="text-xs text-[#71717a]">
Selezionare uno schema cancella le rate esistenti e crea le nuove in base al totale corrente.
</p>
</div>
)}
{/* Empty state — project has no payments yet */}
{projectId && payments.length === 0 && (
<div className="bg-white border border-dashed border-gray-300 rounded-lg p-6 text-center">
<p className="text-sm text-[#71717a] mb-4">
Nessun pagamento configurato. Scegli uno schema sopra o crea le rate standard.
</p>
<Button
type="button"
size="sm"
onClick={() => handleSetPlan("two")}
disabled={planLoading !== null}
>
Crea Acconto &amp; Saldo (50/50)
</Button>
</div>
)}
{/* Payment rows */}
{payments.map((p) => {
const amount = parseFloat(p.amount);
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-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 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}
projectId={projectId}
currentAmount={amount}
/>
)}
</div>
</div>
<div className="flex items-center gap-2">
<select
name="status"
defaultValue={p.status}
disabled={statusLoading === p.id}
onChange={async (e) => {
await handleStatusUpdate(p.id, e.target.value);
}}
className="text-sm border border-gray-200 rounded px-2 py-1.5 bg-white flex-1"
>
{Object.entries(statusLabels).map(([val, label]) => (
<option key={val} value={val}>
{label}
</option>
))}
</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 il
</Label>
<input
id={`paid-${p.id}`}
type="date"
defaultValue={toDateValue(p.paid_at) || todayValue()}
disabled={statusLoading === p.id}
onChange={(e) => handlePaidDateUpdate(p.id, e.target.value)}
className="text-sm border border-gray-200 rounded px-2 py-1 bg-white"
/>
</div>
)}
</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>
);
}