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:
@@ -23,7 +23,7 @@ import {
|
|||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import { eq, asc, and, isNull } from "drizzle-orm";
|
import { eq, asc, and, isNull } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { isTaskStatus } from "@/lib/task-status";
|
import { countsTowardProgress, isTaskStatus } from "@/lib/task-status";
|
||||||
|
|
||||||
// ── ENTITY RESOLUTION ────────────────────────────────────────────────────────
|
// ── ENTITY RESOLUTION ────────────────────────────────────────────────────────
|
||||||
// Both clientId and projectId are passed as "clientId" by tab components.
|
// Both clientId and projectId are passed as "clientId" by tab components.
|
||||||
@@ -187,16 +187,24 @@ export async function addTask(phaseId: string, id: string, formData: FormData) {
|
|||||||
// that count as started: as a list it silently forgot new statuses, and a phase
|
// that count as started: as a list it silently forgot new statuses, and a phase
|
||||||
// whose tasks were all in review fell back to "upcoming" — reading as
|
// whose tasks were all in review fell back to "upcoming" — reading as
|
||||||
// not-started when it was nearly finished.
|
// not-started when it was nearly finished.
|
||||||
|
//
|
||||||
|
// I task cancellati sono invisibili a questo calcolo: non tengono la fase aperta
|
||||||
|
// (senza questo, cancellare l'ultima voce la lascerebbe "in corso" per sempre) e
|
||||||
|
// da soli non la fanno partire. Se restano SOLO cancellati la fase torna
|
||||||
|
// "Da iniziare": è degenere, ma è meglio di "Completata", che al cliente
|
||||||
|
// racconterebbe una consegna che non c'è stata.
|
||||||
export async function recomputePhaseStatus(phaseId: string): Promise<void> {
|
export async function recomputePhaseStatus(phaseId: string): Promise<void> {
|
||||||
const phaseTasks = await db
|
const phaseTasks = await db
|
||||||
.select({ status: tasks.status })
|
.select({ status: tasks.status })
|
||||||
.from(tasks)
|
.from(tasks)
|
||||||
.where(eq(tasks.phase_id, phaseId));
|
.where(eq(tasks.phase_id, phaseId));
|
||||||
|
|
||||||
|
const countedTasks = phaseTasks.filter((t) => countsTowardProgress(t.status));
|
||||||
|
|
||||||
let newStatus: "upcoming" | "active" | "done" = "upcoming";
|
let newStatus: "upcoming" | "active" | "done" = "upcoming";
|
||||||
if (phaseTasks.length > 0) {
|
if (countedTasks.length > 0) {
|
||||||
const allDone = phaseTasks.every((t) => t.status === "done");
|
const allDone = countedTasks.every((t) => t.status === "done");
|
||||||
const anyActive = phaseTasks.some((t) => t.status !== "todo");
|
const anyActive = countedTasks.some((t) => t.status !== "todo");
|
||||||
if (allDone) newStatus = "done";
|
if (allDone) newStatus = "done";
|
||||||
else if (anyActive) newStatus = "active";
|
else if (anyActive) newStatus = "active";
|
||||||
}
|
}
|
||||||
@@ -326,17 +334,42 @@ export async function updatePaymentStatus(paymentId: string, id: string, status:
|
|||||||
revalidatePath(path);
|
revalidatePath(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Imposta il mese in cui un pagamento è stato incassato (formato "YYYY-MM").
|
// Parsing di una data inserita dall'admin, in due formati:
|
||||||
// Mappa al primo giorno del mese (mezzogiorno UTC per evitare drift di fuso) e
|
// "YYYY-MM-DD" → quel giorno (quello che scrivono i campi di oggi)
|
||||||
// porta lo stato a "saldato" così l'incasso viene attribuito a quel mese nelle analytics.
|
// "YYYY-MM" → primo del mese (formato storico di paid_at, campo <input type="month">)
|
||||||
export async function setPaymentPaidAt(paymentId: string, id: string, monthStr: string) {
|
//
|
||||||
|
// Mezzogiorno UTC, non mezzanotte: a mezzanotte UTC il giorno civile a Roma è già
|
||||||
|
// quello dopo per due ore in estate, e la data tornerebbe indietro di un giorno
|
||||||
|
// una volta riletta. A mezzogiorno nessun fuso reale sposta il giorno.
|
||||||
|
function parseAdminDate(value: string): Date {
|
||||||
|
const day = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
|
||||||
|
if (day) {
|
||||||
|
const [, y, m, d] = day;
|
||||||
|
const year = parseInt(y, 10);
|
||||||
|
const month = parseInt(m, 10);
|
||||||
|
const date = parseInt(d, 10);
|
||||||
|
if (month < 1 || month > 12 || date < 1 || date > 31) throw new Error("Data non valida");
|
||||||
|
return new Date(Date.UTC(year, month - 1, date, 12, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
const month = /^(\d{4})-(\d{2})$/.exec(value);
|
||||||
|
if (month) {
|
||||||
|
const [, y, m] = month;
|
||||||
|
const monthNum = parseInt(m, 10);
|
||||||
|
if (monthNum < 1 || monthNum > 12) throw new Error("Data non valida");
|
||||||
|
return new Date(Date.UTC(parseInt(y, 10), monthNum - 1, 1, 12, 0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error("Data non valida");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Imposta il giorno in cui un pagamento è stato incassato e porta lo stato a
|
||||||
|
// "saldato", così l'incasso viene attribuito a quel mese nelle analytics (che
|
||||||
|
// raggruppano con `extract(month from paid_at)`: la precisione al giorno non le
|
||||||
|
// tocca). Accetta anche il vecchio "YYYY-MM" delle righe storiche.
|
||||||
|
export async function setPaymentPaidAt(paymentId: string, id: string, dateStr: string) {
|
||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
const m = /^(\d{4})-(\d{2})$/.exec(monthStr);
|
const paid_at = parseAdminDate(dateStr);
|
||||||
if (!m) throw new Error("Mese non valido");
|
|
||||||
const year = parseInt(m[1], 10);
|
|
||||||
const month = parseInt(m[2], 10);
|
|
||||||
if (month < 1 || month > 12) throw new Error("Mese non valido");
|
|
||||||
const paid_at = new Date(Date.UTC(year, month - 1, 1, 12, 0, 0));
|
|
||||||
await db
|
await db
|
||||||
.update(payments)
|
.update(payments)
|
||||||
.set({ paid_at, status: "saldato" })
|
.set({ paid_at, status: "saldato" })
|
||||||
@@ -345,6 +378,17 @@ export async function setPaymentPaidAt(paymentId: string, id: string, monthStr:
|
|||||||
revalidatePath(path);
|
revalidatePath(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Scadenza concordata della rata. Stringa vuota = la scadenza si toglie: era
|
||||||
|
// stata messa e non vale più, e cancellarla deve essere possibile senza dover
|
||||||
|
// cancellare la rata.
|
||||||
|
export async function setPaymentDueDate(paymentId: string, id: string, dateStr: string) {
|
||||||
|
await requireAdmin();
|
||||||
|
const due_date = dateStr.trim() === "" ? null : parseAdminDate(dateStr);
|
||||||
|
await db.update(payments).set({ due_date }).where(eq(payments.id, paymentId));
|
||||||
|
const { path } = await resolveEntity(id);
|
||||||
|
revalidatePath(path);
|
||||||
|
}
|
||||||
|
|
||||||
// Rescales payment amounts when the total changes.
|
// Rescales payment amounts when the total changes.
|
||||||
//
|
//
|
||||||
// The rule is PER ROW, not per project. The previous version decided with
|
// The rule is PER ROW, not per project. The previous version decided with
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ function projectViewToClientView(
|
|||||||
id: p.id,
|
id: p.id,
|
||||||
label: p.label,
|
label: p.label,
|
||||||
status: p.status as "da_saldare" | "inviata" | "saldato",
|
status: p.status as "da_saldare" | "inviata" | "saldato",
|
||||||
|
due_date: p.due_date instanceof Date ? p.due_date.toISOString() : null,
|
||||||
|
paid_at: p.paid_at instanceof Date ? p.paid_at.toISOString() : null,
|
||||||
})),
|
})),
|
||||||
documents: view.documents.map((d) => ({
|
documents: view.documents.map((d) => ({
|
||||||
id: d.id,
|
id: d.id,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { MetricCard, fmtEur0 } from "@/components/admin/MetricCard";
|
import { MetricCard, fmtEur0 } from "@/components/admin/MetricCard";
|
||||||
import type { ProjectFullDetail } from "@/lib/admin-queries";
|
import type { ProjectFullDetail } from "@/lib/admin-queries";
|
||||||
|
import { countsTowardProgress } from "@/lib/task-status";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
acceptedTotal: string;
|
acceptedTotal: string;
|
||||||
@@ -38,7 +39,9 @@ export function ProjectSummary({
|
|||||||
|
|
||||||
const collectedPct = contracted > 0 ? Math.round((collected / contracted) * 100) : 0;
|
const collectedPct = contracted > 0 ? Math.round((collected / contracted) * 100) : 0;
|
||||||
|
|
||||||
const allTasks = phases.flatMap((p) => p.tasks);
|
// Le cancellate escono dal denominatore, come nel portale cliente: le due
|
||||||
|
// percentuali devono raccontare la stessa cosa.
|
||||||
|
const allTasks = phases.flatMap((p) => p.tasks).filter((t) => countsTowardProgress(t.status));
|
||||||
const doneTasks = allTasks.filter((t) => t.status === "done").length;
|
const doneTasks = allTasks.filter((t) => t.status === "done").length;
|
||||||
const progressPct =
|
const progressPct =
|
||||||
allTasks.length > 0 ? Math.round((doneTasks / allTasks.length) * 100) : 0;
|
allTasks.length > 0 ? Math.round((doneTasks / allTasks.length) * 100) : 0;
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const COLUMN_STYLES: Record<Status, { headerClass: string; dotClass: string }> =
|
|||||||
dotClass: "bg-violet-500",
|
dotClass: "bg-violet-500",
|
||||||
},
|
},
|
||||||
done: { headerClass: "text-[#1A463C]", dotClass: "bg-[#1A463C]" },
|
done: { headerClass: "text-[#1A463C]", dotClass: "bg-[#1A463C]" },
|
||||||
|
cancelled: { headerClass: "text-[#a1a1aa]", dotClass: "bg-[#a1a1aa]" },
|
||||||
};
|
};
|
||||||
|
|
||||||
const COLUMNS: { id: Status; label: string; headerClass: string; dotClass: string }[] =
|
const COLUMNS: { id: Status; label: string; headerClass: string; dotClass: string }[] =
|
||||||
@@ -92,6 +93,7 @@ function DroppableColumn({
|
|||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
isActive={activeId === task.id}
|
isActive={activeId === task.id}
|
||||||
|
status={id}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
{tasks.length === 0 && (
|
{tasks.length === 0 && (
|
||||||
@@ -107,9 +109,11 @@ function DroppableColumn({
|
|||||||
function DraggableCard({
|
function DraggableCard({
|
||||||
task,
|
task,
|
||||||
isActive,
|
isActive,
|
||||||
|
status,
|
||||||
}: {
|
}: {
|
||||||
task: Task;
|
task: Task;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
|
status: Status;
|
||||||
}) {
|
}) {
|
||||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
||||||
useDraggable({ id: task.id });
|
useDraggable({ id: task.id });
|
||||||
@@ -131,7 +135,13 @@ function DraggableCard({
|
|||||||
<p className="text-[10px] font-medium text-[#71717a] uppercase tracking-wide mb-1 truncate">
|
<p className="text-[10px] font-medium text-[#71717a] uppercase tracking-wide mb-1 truncate">
|
||||||
{task.phaseTitle}
|
{task.phaseTitle}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm font-medium text-[#1a1a1a] leading-snug">{task.title}</p>
|
<p
|
||||||
|
className={`text-sm font-medium leading-snug ${
|
||||||
|
status === "cancelled" ? "text-[#a1a1aa] line-through" : "text-[#1a1a1a]"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</p>
|
||||||
{task.description && (
|
{task.description && (
|
||||||
<p className="text-xs text-[#71717a] mt-1 leading-snug line-clamp-2">
|
<p className="text-xs text-[#71717a] mt-1 leading-snug line-clamp-2">
|
||||||
{task.description}
|
{task.description}
|
||||||
@@ -211,7 +221,7 @@ export function KanbanBoard({
|
|||||||
onDragStart={(e) => setActiveId(e.active.id as string)}
|
onDragStart={(e) => setActiveId(e.active.id as string)}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-2 gap-4 xl:grid-cols-4">
|
<div className="grid grid-cols-2 gap-4 xl:grid-cols-5">
|
||||||
{COLUMNS.map((col) => (
|
{COLUMNS.map((col) => (
|
||||||
<DroppableColumn
|
<DroppableColumn
|
||||||
key={col.id}
|
key={col.id}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
setPaymentPaidAt,
|
setPaymentPaidAt,
|
||||||
updatePaymentField,
|
updatePaymentField,
|
||||||
clearPaymentOverride,
|
clearPaymentOverride,
|
||||||
|
setPaymentDueDate,
|
||||||
} from "@/app/admin/clients/[id]/actions";
|
} from "@/app/admin/clients/[id]/actions";
|
||||||
import { setPaymentPlan } from "@/app/admin/projects/project-actions";
|
import { setPaymentPlan } from "@/app/admin/projects/project-actions";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -36,14 +37,19 @@ function formatEuro(value: number): string {
|
|||||||
return value.toLocaleString("it-IT", { minimumFractionDigits: 2 });
|
return value.toLocaleString("it-IT", { minimumFractionDigits: 2 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// paid_at (Date | string | null, serializzato sul confine RSC) → "YYYY-MM" per <input type="month">
|
// Date | string | null (serializzato sul confine RSC) → "YYYY-MM-DD" per <input type="date">.
|
||||||
function toMonthValue(paidAt: Date | string | null | undefined): string {
|
// Le date sono salvate a mezzogiorno UTC apposta, quindi i getter locali leggono
|
||||||
if (!paidAt) {
|
// 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();
|
const now = new Date();
|
||||||
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||||
}
|
|
||||||
const d = paidAt instanceof Date ? paidAt : new Date(paidAt);
|
|
||||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type PlanMode = "single" | "two" | "three";
|
type PlanMode = "single" | "two" | "three";
|
||||||
@@ -118,16 +124,19 @@ export function PaymentsTab({
|
|||||||
async function handleSetPlan(mode: PlanMode) {
|
async function handleSetPlan(mode: PlanMode) {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
|
|
||||||
// Picking a plan deletes every existing row — including status and paid_at.
|
// Picking a plan deletes every existing row — including status, paid_at and
|
||||||
// Only worth interrupting when there is actually payment history to lose.
|
// 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(
|
const tracked = payments.filter(
|
||||||
(p) => p.status === "saldato" || p.status === "inviata"
|
(p) => p.status === "saldato" || p.status === "inviata" || p.due_date !== null
|
||||||
).length;
|
).length;
|
||||||
if (tracked > 0) {
|
if (tracked > 0) {
|
||||||
const what =
|
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(
|
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;
|
if (!ok) return;
|
||||||
}
|
}
|
||||||
@@ -155,11 +164,22 @@ export function PaymentsTab({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handlePaidMonthUpdate(paymentId: string, monthStr: string) {
|
async function handlePaidDateUpdate(paymentId: string, dateStr: string) {
|
||||||
if (!monthStr) return;
|
if (!dateStr) return;
|
||||||
setStatusLoading(paymentId);
|
setStatusLoading(paymentId);
|
||||||
try {
|
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();
|
router.refresh();
|
||||||
} finally {
|
} finally {
|
||||||
setStatusLoading(null);
|
setStatusLoading(null);
|
||||||
@@ -339,17 +359,36 @@ export function PaymentsTab({
|
|||||||
</select>
|
</select>
|
||||||
{statusLoading === p.id && <span className="text-xs text-[#71717a]">...</span>}
|
{statusLoading === p.id && <span className="text-xs text-[#71717a]">...</span>}
|
||||||
</div>
|
</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" && (
|
{p.status === "saldato" && (
|
||||||
<div className="flex items-center gap-2 mt-2">
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<Label htmlFor={`paid-${p.id}`} className="text-xs text-[#71717a] shrink-0">
|
<Label htmlFor={`paid-${p.id}`} className="text-xs text-[#71717a] shrink-0">
|
||||||
Incassato nel mese
|
Incassato il
|
||||||
</Label>
|
</Label>
|
||||||
<input
|
<input
|
||||||
id={`paid-${p.id}`}
|
id={`paid-${p.id}`}
|
||||||
type="month"
|
type="date"
|
||||||
defaultValue={toMonthValue(p.paid_at)}
|
defaultValue={toDateValue(p.paid_at) || todayValue()}
|
||||||
disabled={statusLoading === p.id}
|
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"
|
className="text-sm border border-gray-200 rounded px-2 py-1 bg-white"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,12 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { ApproveButton } from "@/components/client/ApproveButton";
|
import { ApproveButton } from "@/components/client/ApproveButton";
|
||||||
import { useChatContext } from "@/components/client/ChatProvider";
|
import { useChatContext } from "@/components/client/ChatProvider";
|
||||||
import { TaskStatusIcon, TaskStatusPill } from "@/components/client/TaskStatusIndicator";
|
import {
|
||||||
|
isClosedTaskStatus,
|
||||||
|
TaskStatusIcon,
|
||||||
|
TaskStatusPill,
|
||||||
|
} from "@/components/client/TaskStatusIndicator";
|
||||||
|
import { countsTowardProgress } from "@/lib/task-status";
|
||||||
import type { ClientView } from "@/lib/client-view";
|
import type { ClientView } from "@/lib/client-view";
|
||||||
|
|
||||||
type Phase = ClientView["phases"][number];
|
type Phase = ClientView["phases"][number];
|
||||||
@@ -41,6 +46,10 @@ export function PhaseCard({
|
|||||||
const [open, setOpen] = useState(defaultOpen);
|
const [open, setOpen] = useState(defaultOpen);
|
||||||
const { openChat } = useChatContext();
|
const { openChat } = useChatContext();
|
||||||
const doneCount = phase.tasks.filter((t) => t.status === "done").length;
|
const doneCount = phase.tasks.filter((t) => t.status === "done").length;
|
||||||
|
// Il denominatore esclude le cancellate: "3 di 4" con una quarta voce annullata
|
||||||
|
// resterebbe fermo lì per sempre, e la barra non toccherebbe mai il 100%.
|
||||||
|
const countedTotal = phase.tasks.filter((t) => countsTowardProgress(t.status)).length;
|
||||||
|
const cancelledCount = phase.tasks.length - countedTotal;
|
||||||
// I task in revisione aspettano il cliente, non noi. Il conteggio sta nell'header,
|
// I task in revisione aspettano il cliente, non noi. Il conteggio sta nell'header,
|
||||||
// che resta visibile anche a card chiusa: altrimenti la richiesta si nasconde
|
// che resta visibile anche a card chiusa: altrimenti la richiesta si nasconde
|
||||||
// dentro una fase collassata e il cliente non sa che tocca a lui.
|
// dentro una fase collassata e il cliente non sa che tocca a lui.
|
||||||
@@ -94,13 +103,19 @@ export function PhaseCard({
|
|||||||
<div className="mb-1">
|
<div className="mb-1">
|
||||||
<div className="flex justify-between items-center gap-3 mb-1.5">
|
<div className="flex justify-between items-center gap-3 mb-1.5">
|
||||||
<p className="text-xs text-muted-foreground font-medium min-w-0">
|
<p className="text-xs text-muted-foreground font-medium min-w-0">
|
||||||
{doneCount} di {phase.tasks.length} task
|
{doneCount} di {countedTotal} task
|
||||||
{reviewCount > 0 && (
|
{reviewCount > 0 && (
|
||||||
<span className="text-violet-600 dark:text-violet-400">
|
<span className="text-violet-600 dark:text-violet-400">
|
||||||
{" · "}
|
{" · "}
|
||||||
{reviewCount} in attesa di riscontro
|
{reviewCount} in attesa di riscontro
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{cancelledCount > 0 && (
|
||||||
|
<span>
|
||||||
|
{" · "}
|
||||||
|
{cancelledCount} {cancelledCount === 1 ? "cancellata" : "cancellate"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs font-semibold text-foreground shrink-0">{phase.progress_pct}%</p>
|
<p className="text-xs font-semibold text-foreground shrink-0">{phase.progress_pct}%</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -121,7 +136,10 @@ export function PhaseCard({
|
|||||||
) : (
|
) : (
|
||||||
<ul className="space-y-3">
|
<ul className="space-y-3">
|
||||||
{phase.tasks.map((task) => (
|
{phase.tasks.map((task) => (
|
||||||
<li key={task.id} className="flex items-start gap-3">
|
<li
|
||||||
|
key={task.id}
|
||||||
|
className={`flex items-start gap-3 ${task.status === "cancelled" ? "opacity-70" : ""}`}
|
||||||
|
>
|
||||||
<TaskStatusIcon status={task.status} />
|
<TaskStatusIcon status={task.status} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
{/* flex-wrap: su un titolo lungo la pill va a capo invece di
|
{/* flex-wrap: su un titolo lungo la pill va a capo invece di
|
||||||
@@ -129,7 +147,7 @@ export function PhaseCard({
|
|||||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
<p
|
<p
|
||||||
className={`text-xs leading-snug ${
|
className={`text-xs leading-snug ${
|
||||||
task.status === "done"
|
isClosedTaskStatus(task.status)
|
||||||
? "line-through text-muted-foreground"
|
? "line-through text-muted-foreground"
|
||||||
: "text-foreground"
|
: "text-foreground"
|
||||||
}`}
|
}`}
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
// sono lo stesso oggetto, e il design system chiede il contrario — colore + testo,
|
// sono lo stesso oggetto, e il design system chiede il contrario — colore + testo,
|
||||||
// mai colore da solo. Ora le quattro forme si distinguono anche in bianco e nero:
|
// mai colore da solo. Ora le quattro forme si distinguono anche in bianco e nero:
|
||||||
// vuoto → punto → spunta vuota → spunta piena. La spunta compare quando il lavoro
|
// vuoto → punto → spunta vuota → spunta piena. La spunta compare quando il lavoro
|
||||||
// è finito e si riempie quando è confermato.
|
// è finito e si riempie quando è confermato. La cancellata è l'unica che non è
|
||||||
|
// un passo avanti: X e testo barrato, tinta spenta, fuori da ogni conteggio.
|
||||||
|
|
||||||
import { TASK_STATUSES, TASK_STATUS_LABELS, type TaskStatus } from "@/lib/task-status";
|
import { TASK_STATUSES, TASK_STATUS_LABELS, type TaskStatus } from "@/lib/task-status";
|
||||||
|
|
||||||
@@ -65,8 +66,24 @@ export const TASK_STATUS_VISUALS: Record<TaskStatus, StatusVisual> = {
|
|||||||
count: "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400",
|
count: "bg-emerald-50 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400",
|
||||||
hint: "Fatto — completato.",
|
hint: "Fatto — completato.",
|
||||||
},
|
},
|
||||||
|
cancelled: {
|
||||||
|
ring: "border-2 border-border bg-muted text-muted-foreground",
|
||||||
|
// Unica pill fra gli stati chiusi: "fatto" e "cancellata" sono entrambi
|
||||||
|
// barrati, e la differenza fra i due — consegnato o tolto dal lavoro — è
|
||||||
|
// troppo importante per affidarla al solo disegno dentro il cerchio.
|
||||||
|
pill: "bg-muted text-muted-foreground border border-border",
|
||||||
|
dot: "bg-muted-foreground/40",
|
||||||
|
label: "text-muted-foreground",
|
||||||
|
count: "bg-muted text-muted-foreground",
|
||||||
|
hint: "Cancellata — non verrà realizzata.",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Stati chiusi: il titolo si legge barrato, il lavoro non è più in corso. */
|
||||||
|
export function isClosedTaskStatus(status: TaskStatus): boolean {
|
||||||
|
return status === "done" || status === "cancelled";
|
||||||
|
}
|
||||||
|
|
||||||
function CheckGlyph({ className }: { className: string }) {
|
function CheckGlyph({ className }: { className: string }) {
|
||||||
return (
|
return (
|
||||||
<svg
|
<svg
|
||||||
@@ -82,12 +99,27 @@ function CheckGlyph({ className }: { className: string }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CrossGlyph({ className }: { className: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={3}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 6l12 12M18 6L6 18" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Il cerchio di stato accanto al titolo del task.
|
* Il cerchio di stato accanto al titolo del task.
|
||||||
*
|
*
|
||||||
* L'icona è decorativa (`aria-hidden`) e lo stato viaggia in uno `sr-only`: così
|
* L'icona è decorativa (`aria-hidden`) e lo stato viaggia in uno `sr-only`: così
|
||||||
* lo screen reader lo annuncia su tutti e quattro gli stati, mentre a schermo solo
|
* lo screen reader lo annuncia su tutti gli stati, mentre a schermo solo quelli
|
||||||
* i due ambigui portano la pill.
|
* ambigui portano la pill.
|
||||||
*/
|
*/
|
||||||
export function TaskStatusIcon({ status }: { status: TaskStatus }) {
|
export function TaskStatusIcon({ status }: { status: TaskStatus }) {
|
||||||
const v = TASK_STATUS_VISUALS[status];
|
const v = TASK_STATUS_VISUALS[status];
|
||||||
@@ -99,6 +131,7 @@ export function TaskStatusIcon({ status }: { status: TaskStatus }) {
|
|||||||
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full ${v.ring}`}
|
className={`mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full ${v.ring}`}
|
||||||
>
|
>
|
||||||
{status === "done" && <CheckGlyph className="h-3 w-3" />}
|
{status === "done" && <CheckGlyph className="h-3 w-3" />}
|
||||||
|
{status === "cancelled" && <CrossGlyph className="h-2.5 w-2.5" />}
|
||||||
{status === "in_review" && (
|
{status === "in_review" && (
|
||||||
<CheckGlyph className="h-2.5 w-2.5 text-violet-600 dark:text-violet-400" />
|
<CheckGlyph className="h-2.5 w-2.5 text-violet-600 dark:text-violet-400" />
|
||||||
)}
|
)}
|
||||||
@@ -115,8 +148,8 @@ export function TaskStatusIcon({ status }: { status: TaskStatus }) {
|
|||||||
* Etichetta di testo. Nulla per "da fare" e "fatto", che si leggono da soli.
|
* Etichetta di testo. Nulla per "da fare" e "fatto", che si leggono da soli.
|
||||||
*
|
*
|
||||||
* `aria-hidden` perché è ridondanza *visiva*: allo screen reader lo stato lo dice già
|
* `aria-hidden` perché è ridondanza *visiva*: allo screen reader lo stato lo dice già
|
||||||
* lo `sr-only` dell'icona, su tutti e quattro gli stati. Senza questo, i due che hanno
|
* lo `sr-only` dell'icona, su tutti gli stati. Senza questo, quelli che hanno la pill
|
||||||
* la pill verrebbero annunciati due volte.
|
* verrebbero annunciati due volte.
|
||||||
*/
|
*/
|
||||||
export function TaskStatusPill({ status }: { status: TaskStatus }) {
|
export function TaskStatusPill({ status }: { status: TaskStatus }) {
|
||||||
const v = TASK_STATUS_VISUALS[status];
|
const v = TASK_STATUS_VISUALS[status];
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import type { ClientView } from "@/lib/client-view";
|
import type { ClientView } from "@/lib/client-view";
|
||||||
import { ApproveButton } from "@/components/client/ApproveButton";
|
import { ApproveButton } from "@/components/client/ApproveButton";
|
||||||
import { TASK_STATUS_VISUALS } from "@/components/client/TaskStatusIndicator";
|
import { isClosedTaskStatus, TASK_STATUS_VISUALS } from "@/components/client/TaskStatusIndicator";
|
||||||
import { TASK_STATUS_LABELS, TASK_STATUSES, type TaskStatus } from "@/lib/task-status";
|
import { TASK_STATUS_LABELS, TASK_STATUSES, type TaskStatus } from "@/lib/task-status";
|
||||||
|
|
||||||
type Task = ClientView["phases"][number]["tasks"][number] & {
|
type Task = ClientView["phases"][number]["tasks"][number] & {
|
||||||
@@ -16,11 +16,15 @@ const COLUMNS: { id: TaskStatus; label: string }[] = TASK_STATUSES.map((id) => (
|
|||||||
|
|
||||||
function TaskCard({ task, token }: { task: Task; token: string }) {
|
function TaskCard({ task, token }: { task: Task; token: string }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-card rounded-lg border border-border-light px-4 py-4 shadow-sm hover:border-primary/30 hover:shadow-card-hover transition-all">
|
<div
|
||||||
|
className={`bg-card rounded-lg border border-border-light px-4 py-4 shadow-sm hover:border-primary/30 hover:shadow-card-hover transition-all ${
|
||||||
|
task.status === "cancelled" ? "opacity-70" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<p className="text-[9px] font-semibold text-muted-foreground uppercase tracking-wide mb-1 truncate">
|
<p className="text-[9px] font-semibold text-muted-foreground uppercase tracking-wide mb-1 truncate">
|
||||||
{task.phaseTitle}
|
{task.phaseTitle}
|
||||||
</p>
|
</p>
|
||||||
<p className={`text-xs font-medium leading-snug ${task.status === "done" ? "line-through text-muted-foreground" : "text-foreground"}`}>
|
<p className={`text-xs font-medium leading-snug ${isClosedTaskStatus(task.status) ? "line-through text-muted-foreground" : "text-foreground"}`}>
|
||||||
{task.title}
|
{task.title}
|
||||||
</p>
|
</p>
|
||||||
{task.description && (
|
{task.description && (
|
||||||
@@ -58,9 +62,21 @@ export function ClientKanban({ phases, token }: { phases: ClientView["phases"];
|
|||||||
{} as Record<TaskStatus, Task[]>
|
{} as Record<TaskStatus, Task[]>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// "Cancellate" è l'unica colonna che si nasconde da vuota: nella maggior parte
|
||||||
|
// dei progetti non ce n'è nessuna, e una quinta colonna vuota ruberebbe spazio
|
||||||
|
// alle quattro che raccontano il lavoro. Si nasconde solo se è vuota, quindi
|
||||||
|
// nessun task sparisce mai dalla board — era proprio quello il bug di prima.
|
||||||
|
const visibleColumns = COLUMNS.filter(
|
||||||
|
(col) => col.id !== "cancelled" || tasksByStatus.cancelled.length > 0
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-4">
|
<div
|
||||||
{COLUMNS.map((col) => {
|
className={`grid grid-cols-1 gap-6 sm:grid-cols-2 ${
|
||||||
|
visibleColumns.length > 4 ? "xl:grid-cols-5" : "xl:grid-cols-4"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{visibleColumns.map((col) => {
|
||||||
// Stessa tinta dell'icona in Timeline: passando da una vista all'altra
|
// Stessa tinta dell'icona in Timeline: passando da una vista all'altra
|
||||||
// "in corso" e "in revisione" restano riconoscibili senza rileggere.
|
// "in corso" e "in revisione" restano riconoscibili senza rileggere.
|
||||||
const v = TASK_STATUS_VISUALS[col.id];
|
const v = TASK_STATUS_VISUALS[col.id];
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import type { ClientView } from '@/lib/client-view';
|
import type { ClientView } from '@/lib/client-view';
|
||||||
|
import {
|
||||||
|
daysUntil,
|
||||||
|
findNextDue,
|
||||||
|
formatCountdown,
|
||||||
|
formatLongDate,
|
||||||
|
formatShortDate,
|
||||||
|
} from '@/lib/payment-dates';
|
||||||
|
|
||||||
interface PaymentStatusProps {
|
interface PaymentStatusProps {
|
||||||
accepted_total: string;
|
accepted_total: string;
|
||||||
@@ -49,6 +56,16 @@ export function PaymentStatus({
|
|||||||
maximumFractionDigits: 2,
|
maximumFractionDigits: 2,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Il conto alla rovescia si calcola qui, sul server: la pagina è dinamica
|
||||||
|
// (`revalidate = 0`), quindi il numero è fresco a ogni apertura del portale.
|
||||||
|
//
|
||||||
|
// Il riquadro compare anche quando le righe sono nascoste (retainer): non
|
||||||
|
// contiene importi, e una scadenza esiste solo se l'admin l'ha scritta — se
|
||||||
|
// l'ha scritta, è perché il cliente la deve sapere.
|
||||||
|
const nextDue = findNextDue(payments);
|
||||||
|
const nextDueDays = nextDue?.due_date ? daysUntil(nextDue.due_date) : null;
|
||||||
|
const isLate = nextDueDays !== null && nextDueDays < 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-border-light bg-card shadow-card overflow-hidden">
|
<div className="rounded-xl border border-border-light bg-card shadow-card overflow-hidden">
|
||||||
{/* Totale — unico importo visibile al cliente (LOCKED) */}
|
{/* Totale — unico importo visibile al cliente (LOCKED) */}
|
||||||
@@ -61,7 +78,34 @@ export function PaymentStatus({
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Righe pagamento: solo etichetta + stato, MAI importo singolo — omesse se hideRows */}
|
{/* Prossima scadenza — la domanda più ovvia del cliente, in cima e senza cifre */}
|
||||||
|
{nextDue && nextDue.due_date && nextDueDays !== null && (
|
||||||
|
<div
|
||||||
|
className={`px-5 py-4 border-b border-border-light ${
|
||||||
|
isLate
|
||||||
|
? 'bg-red-50 dark:bg-red-500/10'
|
||||||
|
: 'bg-amber-50/60 dark:bg-amber-500/10'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p
|
||||||
|
className={`text-[10px] font-bold uppercase tracking-wider mb-1 ${
|
||||||
|
isLate
|
||||||
|
? 'text-red-700 dark:text-red-400'
|
||||||
|
: 'text-amber-700 dark:text-amber-400'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{isLate ? 'Pagamento scaduto' : 'Prossimo pagamento'}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm font-bold text-foreground leading-snug">
|
||||||
|
{formatLongDate(nextDue.due_date)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
{nextDue.label} · {formatCountdown(nextDueDays)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Righe pagamento: solo etichetta, stato e date — MAI importo singolo */}
|
||||||
{!hideRows && (
|
{!hideRows && (
|
||||||
<div className="px-5 py-4 space-y-2">
|
<div className="px-5 py-4 space-y-2">
|
||||||
{payments.length === 0 ? (
|
{payments.length === 0 ? (
|
||||||
@@ -73,14 +117,42 @@ export function PaymentStatus({
|
|||||||
const status = payment.status as PaymentStatusValue;
|
const status = payment.status as PaymentStatusValue;
|
||||||
const config = statusConfig[status] ?? statusConfig.da_saldare;
|
const config = statusConfig[status] ?? statusConfig.da_saldare;
|
||||||
|
|
||||||
|
// Una riga saldata mostra quando è stata incassata; una ancora
|
||||||
|
// aperta mostra quando scade. Nessuna delle due inventa la data:
|
||||||
|
// se in gestionale non c'è, sotto l'etichetta non compare nulla.
|
||||||
|
const paidOn =
|
||||||
|
status === 'saldato' && payment.paid_at
|
||||||
|
? `Pagato il ${formatShortDate(payment.paid_at)}`
|
||||||
|
: null;
|
||||||
|
const dueOn =
|
||||||
|
status !== 'saldato' && payment.due_date
|
||||||
|
? `Scade il ${formatShortDate(payment.due_date)} · ${formatCountdown(
|
||||||
|
daysUntil(payment.due_date)
|
||||||
|
)}`
|
||||||
|
: null;
|
||||||
|
const overdue = dueOn !== null && daysUntil(payment.due_date!) < 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={payment.id}
|
key={payment.id}
|
||||||
className="flex items-center justify-between gap-4 rounded-lg border border-border-light p-3"
|
className="flex items-start justify-between gap-4 rounded-lg border border-border-light p-3"
|
||||||
>
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
<p className="text-xs font-semibold text-foreground">
|
<p className="text-xs font-semibold text-foreground">
|
||||||
{payment.label}
|
{payment.label}
|
||||||
</p>
|
</p>
|
||||||
|
{(paidOn || dueOn) && (
|
||||||
|
<p
|
||||||
|
className={`text-[11px] mt-0.5 leading-snug ${
|
||||||
|
overdue
|
||||||
|
? 'text-red-600 dark:text-red-400'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{paidOn ?? dueOn}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Pill stato — nessun importo */}
|
{/* Pill stato — nessun importo */}
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Additive: la data in cui una rata è attesa.
|
||||||
|
--
|
||||||
|
-- Perché: `payments` sapeva solo QUANDO una rata è stata incassata (paid_at) e mai
|
||||||
|
-- quando ci si aspetta che lo sia. Il portale non poteva quindi rispondere alla
|
||||||
|
-- domanda più ovvia del cliente — "quando devo pagare?" — e non c'era nessun campo
|
||||||
|
-- su cui agganciare il promemoria via email prima della scadenza.
|
||||||
|
--
|
||||||
|
-- Nullable senza default: una rata senza data concordata è normale, e un default
|
||||||
|
-- (per esempio "oggi") inventerebbe una scadenza che nessuno ha pattuito. Il
|
||||||
|
-- portale mostra la scadenza solo se c'è.
|
||||||
|
--
|
||||||
|
-- Nessun DROP, nessun TRUNCATE, nessuna DELETE — `payments` è LOCKED in CLAUDE.md.
|
||||||
|
|
||||||
|
ALTER TABLE payments ADD COLUMN IF NOT EXISTS due_date timestamptz;
|
||||||
|
|
||||||
|
-- Il promemoria email cercherà "le rate non saldate in scadenza entro N giorni":
|
||||||
|
-- è una scansione su tutta la tabella filtrata per data, non per progetto, quindi
|
||||||
|
-- l'indice esistente (project_id, sort_order) non la copre.
|
||||||
|
CREATE INDEX IF NOT EXISTS payments_due_date_idx ON payments(due_date) WHERE due_date IS NOT NULL;
|
||||||
@@ -249,6 +249,9 @@ export const payments = pgTable("payments", {
|
|||||||
percent: numeric("percent", { precision: 5, scale: 2 }), // nullable — % of total (for rescaling); null = legacy row
|
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
|
status: text("status").notNull().default("da_saldare"), // da_saldare | inviata | saldato
|
||||||
paid_at: timestamp("paid_at", { withTimezone: true }),
|
paid_at: timestamp("paid_at", { withTimezone: true }),
|
||||||
|
// Scadenza concordata. Nullable: non tutte le rate ne hanno una, e il portale
|
||||||
|
// la mostra al cliente solo quando c'è. È anche l'aggancio del promemoria email.
|
||||||
|
due_date: timestamp("due_date", { withTimezone: true }),
|
||||||
sort_order: integer("sort_order").notNull().default(0),
|
sort_order: integer("sort_order").notNull().default(0),
|
||||||
// true = l'importo è stato scritto a mano: rescalePayments non lo tocca più.
|
// true = l'importo è stato scritto a mano: rescalePayments non lo tocca più.
|
||||||
amount_locked: boolean("amount_locked").notNull().default(false),
|
amount_locked: boolean("amount_locked").notNull().default(false),
|
||||||
|
|||||||
+19
-5
@@ -1,7 +1,7 @@
|
|||||||
import { eq, ne, and, inArray, asc, desc } from "drizzle-orm";
|
import { eq, ne, and, inArray, asc, desc } from "drizzle-orm";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, client_channel_reads, project_offers, offer_micros, offer_macros, clientTranscripts } from "@/db/schema";
|
import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, client_channel_reads, project_offers, offer_micros, offer_macros, clientTranscripts } from "@/db/schema";
|
||||||
import type { TaskStatus } from "@/lib/task-status";
|
import { countsTowardProgress, type TaskStatus } from "@/lib/task-status";
|
||||||
import { getComputedOfferValues } from "@/lib/offer-value";
|
import { getComputedOfferValues } from "@/lib/offer-value";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -42,6 +42,10 @@ export interface ClientView {
|
|||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
status: "da_saldare" | "inviata" | "saldato";
|
status: "da_saldare" | "inviata" | "saldato";
|
||||||
|
// Le date sì, gli importi no: il cliente deve sapere quando paga e quando ha
|
||||||
|
// pagato — quanto, per singola rata, resta fuori dall'API cliente (LOCKED #2).
|
||||||
|
due_date: string | null; // ISO
|
||||||
|
paid_at: string | null; // ISO
|
||||||
}>;
|
}>;
|
||||||
documents: Array<{
|
documents: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -108,6 +112,8 @@ export interface ProjectView {
|
|||||||
label: string;
|
label: string;
|
||||||
// amount intentionally excluded — client API never exposes payment amounts (CLAUDE.md + DASH-07)
|
// amount intentionally excluded — client API never exposes payment amounts (CLAUDE.md + DASH-07)
|
||||||
status: string;
|
status: string;
|
||||||
|
due_date: Date | null;
|
||||||
|
paid_at: Date | null;
|
||||||
}>;
|
}>;
|
||||||
documents: Array<{
|
documents: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
@@ -326,6 +332,10 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
|
|||||||
id: payments.id,
|
id: payments.id,
|
||||||
label: payments.label,
|
label: payments.label,
|
||||||
status: payments.status,
|
status: payments.status,
|
||||||
|
// Date sì, importo no: la scadenza e l'incasso servono al cliente per
|
||||||
|
// sapere quando paga; l'importo della singola rata resta escluso.
|
||||||
|
due_date: payments.due_date,
|
||||||
|
paid_at: payments.paid_at,
|
||||||
// amount intentionally excluded — client API never exposes payment amounts
|
// amount intentionally excluded — client API never exposes payment amounts
|
||||||
})
|
})
|
||||||
.from(payments)
|
.from(payments)
|
||||||
@@ -446,16 +456,20 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
|
|||||||
deliverables: deliverablesRows.filter((d) => d.task_id === task.id),
|
deliverables: deliverablesRows.filter((d) => d.task_id === task.id),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const doneCount = phaseTasks.filter((t) => t.status === "done").length;
|
// I task cancellati escono dal denominatore: restano in lista, barrati, ma
|
||||||
|
// non tengono la fase sotto il 100% per un lavoro che nessuno farà più.
|
||||||
|
const countedTasks = phaseTasks.filter((t) => countsTowardProgress(t.status));
|
||||||
|
const doneCount = countedTasks.filter((t) => t.status === "done").length;
|
||||||
const progress_pct =
|
const progress_pct =
|
||||||
phaseTasks.length > 0 ? Math.round((doneCount / phaseTasks.length) * 100) : 0;
|
countedTasks.length > 0 ? Math.round((doneCount / countedTasks.length) * 100) : 0;
|
||||||
|
|
||||||
return { ...phase, tasks: phaseTasks, progress_pct };
|
return { ...phase, tasks: phaseTasks, progress_pct };
|
||||||
});
|
});
|
||||||
|
|
||||||
const doneTasks = tasksRows.filter((t) => t.status === "done").length;
|
const countedTasks = tasksRows.filter((t) => countsTowardProgress(t.status));
|
||||||
|
const doneTasks = countedTasks.filter((t) => t.status === "done").length;
|
||||||
const global_progress_pct =
|
const global_progress_pct =
|
||||||
tasksRows.length > 0 ? Math.round((doneTasks / tasksRows.length) * 100) : 0;
|
countedTasks.length > 0 ? Math.round((doneTasks / countedTasks.length) * 100) : 0;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
project: {
|
project: {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
offer_macros,
|
offer_macros,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import { eq, inArray } from "drizzle-orm";
|
import { eq, inArray } from "drizzle-orm";
|
||||||
|
import { countsTowardProgress } from "@/lib/task-status";
|
||||||
|
|
||||||
export type DeliveryStatus = "consegnato" | "in_anticipo" | "in_linea" | "in_ritardo";
|
export type DeliveryStatus = "consegnato" | "in_anticipo" | "in_linea" | "in_ritardo";
|
||||||
|
|
||||||
@@ -131,6 +132,9 @@ export async function getDeliveryBoard(): Promise<DeliveryBoard> {
|
|||||||
for (const task of taskRows) {
|
for (const task of taskRows) {
|
||||||
const projectId = phaseToProject.get(task.phase_id);
|
const projectId = phaseToProject.get(task.phase_id);
|
||||||
if (!projectId) continue;
|
if (!projectId) continue;
|
||||||
|
// Le cancellate non entrano nel totale: il board di consegna misura quanto
|
||||||
|
// manca, e quello che è stato tolto dal lavoro non manca più.
|
||||||
|
if (!countsTowardProgress(task.status)) continue;
|
||||||
const tally = taskTally.get(projectId) ?? { done: 0, total: 0 };
|
const tally = taskTally.get(projectId) ?? { done: 0, total: 0 };
|
||||||
tally.total += 1;
|
tally.total += 1;
|
||||||
if (task.status === "done") tally.done += 1;
|
if (task.status === "done") tally.done += 1;
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// ── Date dei pagamenti, lette come le legge il cliente ───────────────────────
|
||||||
|
// Un pagamento ha due date: quella attesa (`due_date`) e quella incassata
|
||||||
|
// (`paid_at`). Entrambe sono timestamptz, ma al cliente interessa il giorno sul
|
||||||
|
// calendario, non l'istante: "manca una settimana" non deve cambiare risposta
|
||||||
|
// perché il container gira a UTC e chi legge sta a Roma.
|
||||||
|
//
|
||||||
|
// Da qui passeranno anche i promemoria email ("la scadenza si avvicina"): il
|
||||||
|
// conteggio dei giorni che decide se mandare la mail deve essere lo stesso
|
||||||
|
// numero che il cliente vede nel portale, altrimenti la mail arriva a dire
|
||||||
|
// "mancano 3 giorni" mentre il portale ne mostra 2.
|
||||||
|
|
||||||
|
/** Il fuso in cui il cliente guarda il calendario. */
|
||||||
|
const TZ = "Europe/Rome";
|
||||||
|
|
||||||
|
const dayKeyFormatter = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: TZ,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
const longDateFormatter = new Intl.DateTimeFormat("it-IT", {
|
||||||
|
timeZone: TZ,
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
const shortDateFormatter = new Intl.DateTimeFormat("it-IT", {
|
||||||
|
timeZone: TZ,
|
||||||
|
day: "numeric",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
});
|
||||||
|
|
||||||
|
function toDate(value: Date | string): Date {
|
||||||
|
return value instanceof Date ? value : new Date(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "2026-03-15" — il giorno civile a Roma, qualunque sia il fuso del server. */
|
||||||
|
function dayKey(value: Date | string): string {
|
||||||
|
return dayKeyFormatter.format(toDate(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Giorni interi che separano oggi dalla scadenza: 0 = oggi, 3 = fra tre giorni,
|
||||||
|
* -2 = scaduta da due giorni.
|
||||||
|
*
|
||||||
|
* Si confrontano i giorni civili, non gli istanti: una scadenza fissata a
|
||||||
|
* mezzogiorno non deve leggersi "fra 0 giorni" solo perché mancano 20 ore.
|
||||||
|
*/
|
||||||
|
export function daysUntil(due: Date | string, now: Date = new Date()): number {
|
||||||
|
const a = Date.parse(`${dayKey(now)}T00:00:00Z`);
|
||||||
|
const b = Date.parse(`${dayKey(due)}T00:00:00Z`);
|
||||||
|
return Math.round((b - a) / 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "15 marzo 2026" */
|
||||||
|
export function formatLongDate(value: Date | string): string {
|
||||||
|
return longDateFormatter.format(toDate(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "15 mar 2026" — per le righe strette della sidebar. */
|
||||||
|
export function formatShortDate(value: Date | string): string {
|
||||||
|
return shortDateFormatter.format(toDate(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Il conto alla rovescia in parole. Il ritardo si dice, non si nasconde: una
|
||||||
|
* rata scaduta che continua a mostrare la sua data senza commento sembra a posto.
|
||||||
|
*/
|
||||||
|
export function formatCountdown(days: number): string {
|
||||||
|
if (days === 0) return "oggi";
|
||||||
|
if (days === 1) return "domani";
|
||||||
|
if (days === -1) return "scaduto da 1 giorno";
|
||||||
|
if (days > 1) return `tra ${days} giorni`;
|
||||||
|
return `scaduto da ${Math.abs(days)} giorni`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Una rata è ancora aperta finché non è saldata — "inviata" non è incassata. */
|
||||||
|
export function isOutstanding(status: string): boolean {
|
||||||
|
return status !== "saldato";
|
||||||
|
}
|
||||||
|
|
||||||
|
type Schedulable = { status: string; due_date: Date | string | null };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La prossima rata da pagare: la più vicina fra quelle aperte che hanno una data.
|
||||||
|
*
|
||||||
|
* Le scadute restano candidate e vincono sulle future — se c'è un arretrato è
|
||||||
|
* quello "il prossimo pagamento", non la rata del mese prossimo.
|
||||||
|
*/
|
||||||
|
export function findNextDue<T extends Schedulable>(payments: T[]): T | null {
|
||||||
|
const withDate = payments.filter((p) => isOutstanding(p.status) && p.due_date !== null);
|
||||||
|
if (withDate.length === 0) return null;
|
||||||
|
|
||||||
|
return withDate.reduce((earliest, p) =>
|
||||||
|
toDate(p.due_date!).getTime() < toDate(earliest.due_date!).getTime() ? p : earliest
|
||||||
|
);
|
||||||
|
}
|
||||||
+12
-1
@@ -6,7 +6,7 @@
|
|||||||
//
|
//
|
||||||
// No db import on purpose: client components import this file.
|
// No db import on purpose: client components import this file.
|
||||||
|
|
||||||
export const TASK_STATUSES = ["todo", "in_progress", "in_review", "done"] as const;
|
export const TASK_STATUSES = ["todo", "in_progress", "in_review", "done", "cancelled"] as const;
|
||||||
|
|
||||||
export type TaskStatus = (typeof TASK_STATUSES)[number];
|
export type TaskStatus = (typeof TASK_STATUSES)[number];
|
||||||
|
|
||||||
@@ -15,8 +15,19 @@ export const TASK_STATUS_LABELS: Record<TaskStatus, string> = {
|
|||||||
in_progress: "In corso",
|
in_progress: "In corso",
|
||||||
in_review: "In revisione",
|
in_review: "In revisione",
|
||||||
done: "Fatto",
|
done: "Fatto",
|
||||||
|
cancelled: "Cancellata",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Un task cancellato è chiuso, non fatto: resta visibile (barrato) perché il
|
||||||
|
// cliente ha il diritto di sapere che quella voce esisteva ed è stata tolta, ma
|
||||||
|
// esce dai conteggi. Contarlo nel denominatore bloccherebbe la fase sotto il
|
||||||
|
// 100% per sempre; contarlo come fatto racconterebbe una consegna mai avvenuta.
|
||||||
|
// Vive qui e non come `!== "cancelled"` sparso nei file: è la stessa disciplina
|
||||||
|
// che ha fatto nascere questo modulo.
|
||||||
|
export function countsTowardProgress(status: string): boolean {
|
||||||
|
return status !== "cancelled";
|
||||||
|
}
|
||||||
|
|
||||||
export function isTaskStatus(value: string): value is TaskStatus {
|
export function isTaskStatus(value: string): value is TaskStatus {
|
||||||
return (TASK_STATUSES as readonly string[]).includes(value);
|
return (TASK_STATUSES as readonly string[]).includes(value);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user