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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user