fe767899b9
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>
417 lines
16 KiB
TypeScript
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 & 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>
|
|
);
|
|
}
|