Compare commits
3 Commits
df4671236a
...
4e3907d382
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e3907d382 | |||
| 1fa8e1ab5e | |||
| b49d4bfaaa |
+6
-5
@@ -4,8 +4,8 @@ milestone: v2.5
|
|||||||
milestone_name: Audit
|
milestone_name: Audit
|
||||||
status: executing
|
status: executing
|
||||||
stopped_at: "v2.5 in PAUSA. Modifiche hub: A, B, C1 e le due rifiniture del 2026-08-20 in prod; C2 (TidyCal) bloccato sulle credenziali API."
|
stopped_at: "v2.5 in PAUSA. Modifiche hub: A, B, C1 e le due rifiniture del 2026-08-20 in prod; C2 (TidyCal) bloccato sulle credenziali API."
|
||||||
last_updated: "2026-08-20T15:40:00.000Z"
|
last_updated: "2026-08-20T22:45:00.000Z"
|
||||||
last_activity: 2026-08-20 -- modifiche hub: rinomina tassonomie e stato task "In revisione" (5547e55)
|
last_activity: 2026-08-20 -- tab pagamenti (ordine, rinomina, override) e riordino task (1fa8e1a)
|
||||||
progress:
|
progress:
|
||||||
total_phases: 4
|
total_phases: 4
|
||||||
completed_phases: 0
|
completed_phases: 0
|
||||||
@@ -38,6 +38,7 @@ Phase 27 resta a metà — schema e fonti in prod, resto da scrivere.
|
|||||||
| C2 — TidyCal | ⛔ **[BLOCCANTE]** vedi sotto |
|
| C2 — TidyCal | ⛔ **[BLOCCANTE]** vedi sotto |
|
||||||
| C3 — Alleggerire l'hub | ⏸️ senza perimetro |
|
| C3 — Alleggerire l'hub | ⏸️ senza perimetro |
|
||||||
| Rifiniture — rinomina tassonomie, stato task "In revisione" | ✅ in prod 2026-08-20 |
|
| Rifiniture — rinomina tassonomie, stato task "In revisione" | ✅ in prod 2026-08-20 |
|
||||||
|
| Tab pagamenti (ordine stabile, rinomina, override) + riordino task | ✅ in prod 2026-08-20, migration `0019` |
|
||||||
| D — Whop → audit | ⏸️ dipende dal motore v2.5 |
|
| D — Whop → audit | ⏸️ dipende dal motore v2.5 |
|
||||||
|
|
||||||
Progress: [███░░░░░░░] 25% (v2.5)
|
Progress: [███░░░░░░░] 25% (v2.5)
|
||||||
@@ -72,6 +73,7 @@ Passo per passo in `STATUS.md` e in `…radiant-valley.md`.
|
|||||||
|
|
||||||
Log completo in `PROJECT.md`. Vive per il lavoro corrente:
|
Log completo in `PROJECT.md`. Vive per il lavoro corrente:
|
||||||
|
|
||||||
|
- **[2026-08-20] Gli importi scritti a mano non si ricalcolano** — `amount_locked` esclude la riga da `rescalePayments`, e lo scarto fra somma rate e totale si dichiara invece di aggiustarlo. Il backfill dell'ordine rate va per `percent DESC`, non per `ctid`: 3 progetti su 5 erano già scombinati e l'ordine fisico avrebbe fissato l'errore.
|
||||||
- **[2026-08-20] Rinominare una fase rinomina anche le fasi dei progetti** — non c'è FK fra tassonomia e `phases`: `importOfferIntoProject` riconosce una fase solo dal titolo (`offer_phase_id` non viene mai popolata). Senza propagazione, il re-import di un'offerta crea una fase duplicata accanto a quella vecchia. È l'unico rename che scrive fuori dal catalogo, quindi l'unico con conferma.
|
- **[2026-08-20] Rinominare una fase rinomina anche le fasi dei progetti** — non c'è FK fra tassonomia e `phases`: `importOfferIntoProject` riconosce una fase solo dal titolo (`offer_phase_id` non viene mai popolata). Senza propagazione, il re-import di un'offerta crea una fase duplicata accanto a quella vecchia. È l'unico rename che scrive fuori dal catalogo, quindi l'unico con conferma.
|
||||||
- **[2026-08-19] Prima l'hub, poi il motore** — le modifiche all'hub sono indipendenti e a basso rischio, il motore no. Il Whop → audit resta ultimo perché dipende dal motore.
|
- **[2026-08-19] Prima l'hub, poi il motore** — le modifiche all'hub sono indipendenti e a basso rischio, il motore no. Il Whop → audit resta ultimo perché dipende dal motore.
|
||||||
- **[2026-08-19] L'incassato non attribuibile si mostra, non si spalma** — i pagamenti stanno sul progetto, non sull'offerta. Un progetto senza offerta finisce in una riga "Senza offerta" separata: spalmarlo darebbe un totale che quadra e righe che mentono.
|
- **[2026-08-19] L'incassato non attribuibile si mostra, non si spalma** — i pagamenti stanno sul progetto, non sull'offerta. Un progetto senza offerta finisce in una riga "Senza offerta" separata: spalmarlo darebbe un totale che quadra e righe che mentono.
|
||||||
@@ -84,9 +86,8 @@ Log completo in `PROJECT.md`. Vive per il lavoro corrente:
|
|||||||
- **[BLOCCANTE] `LEAD_WEBHOOK_SECRET` non è su Coolify**: finché manca, `/api/webhooks/lead` risponde 403 a tutti (fallimento chiuso voluto). Sblocca: l'utente la imposta.
|
- **[BLOCCANTE] `LEAD_WEBHOOK_SECRET` non è su Coolify**: finché manca, `/api/webhooks/lead` risponde 403 a tutti (fallimento chiuso voluto). Sblocca: l'utente la imposta.
|
||||||
- **Il 100% dell'incassato è "Senza offerta"** — Caruso Speaker e Protocollo Estetico: 5.300 € senza offerte assegnate. Si sistema assegnandole dai rispettivi progetti. Il payload Elementor, intanto, non è ancora verificato sul campo: gestito in modo difensivo, serve un invio vero.
|
- **Il 100% dell'incassato è "Senza offerta"** — Caruso Speaker e Protocollo Estetico: 5.300 € senza offerte assegnate. Si sistema assegnandole dai rispettivi progetti. Il payload Elementor, intanto, non è ancora verificato sul campo: gestito in modo difensivo, serve un invio vero.
|
||||||
- **Il copy fisso del template v1 non ha una fonte nel repo** — il prototipo Giojello non c'è: testi e gerarchia dei blocchi vanno recuperati prima di Phase 30.
|
- **Il copy fisso del template v1 non ha una fonte nel repo** — il prototipo Giojello non c'è: testi e gerarchia dei blocchi vanno recuperati prima di Phase 30.
|
||||||
- **Audit, da vedere sul campo:** il caso "zero dati CrUX" (test 5) e quanto del 52% di checklist non verificabile da HTML statico recuperino gli audit Lighthouse (test 3).
|
- **Audit, da vedere sul campo:** il caso "zero dati CrUX" (test 5) e quanto del 52% non verificabile da HTML statico recuperi Lighthouse (test 3). **Whitelist portale vuota per 3 clienti su 4** — si popola da `/admin/clients/<id>`.
|
||||||
- **Whitelist portale vuota per 3 clienti su 4** — si popola da `/admin/clients/<id>`.
|
- **`.env.local` punta al DB di PRODUZIONE** (non allineato a Coolify per `ADMIN_PASSWORD`/`NEXTAUTH_SECRET`), e la porta 54321 non è pubblica: per provare in locale serve il tunnel SSH.
|
||||||
- **`.env.local` punta al DB di PRODUZIONE**, non allineato a Coolify per `ADMIN_PASSWORD` / `NEXTAUTH_SECRET`.
|
|
||||||
- **Ogni fase con schema**: migration applicata a prod **prima** del push del codice.
|
- **Ogni fase con schema**: migration applicata a prod **prima** del push del codice.
|
||||||
- **Debito design (DEBT-01)** — ~40 file, ~450 occorrenze. Dettaglio in `STATUS.md`.
|
- **Debito design (DEBT-01)** — ~40 file, ~450 occorrenze. Dettaglio in `STATUS.md`.
|
||||||
|
|
||||||
|
|||||||
@@ -62,7 +62,32 @@ reale del pannello, nessuna migration (verificato: `tasks.status` è `text` senz
|
|||||||
lavoro vero non era il nuovo stato ma i tre letterali ricopiati a mano in otto file:
|
lavoro vero non era il nuovo stato ma i tre letterali ricopiati a mano in otto file:
|
||||||
ora tutto deriva da `TASK_STATUSES` in `src/lib/task-status.ts`.
|
ora tutto deriva da `TASK_STATUSES` in `src/lib/task-status.ts`.
|
||||||
|
|
||||||
⚠️ **Deployate e servite, non ancora provate a mano.** Build e lint verdi, immagine
|
**In produzione dal 2026-08-20**, secondo giro (`b49d4bf`, `1fa8e1a`, migration `0019`):
|
||||||
|
|
||||||
|
- **Tab pagamenti.** Mettere una rata su "saldato" la faceva saltare in fondo: `payments`
|
||||||
|
non aveva **nessuna** colonna d'ordine e nessuna delle 13 query che la leggono aveva un
|
||||||
|
`ORDER BY`, quindi Postgres restituiva l'ordine fisico e una `UPDATE` in MVCC riscrive la
|
||||||
|
tupla in coda. In produzione **3 progetti su 5 erano già scombinati**. Per questo il
|
||||||
|
backfill della `0019` **non** ordina per `ctid` (avrebbe fotografato lo scombinamento) ma
|
||||||
|
per `percent DESC` con tie su `label`. Ora le rate si rinominano, gli importi si
|
||||||
|
sovrascrivono a mano (`amount_locked` li esclude dal ricalcolo) e lo scarto fra somma
|
||||||
|
rate e totale viene **dichiarato**, non aggiustato di nascosto. Schema a 3 rate: 50/25/25.
|
||||||
|
- **Riordino dei task** dentro la fase, trascinando. `PhasesTab` è un Server Component con
|
||||||
|
closure `"use server"` inline, quindi non è stato convertito: le righe restano
|
||||||
|
server-renderizzate e `SortableTaskList` ci monta attorno solo la maniglia.
|
||||||
|
|
||||||
|
Due bug chiusi per strada, entrambi vivi in produzione: `rescalePayments` azzerava le righe
|
||||||
|
con `percent` NULL appena una riga del progetto ne aveva uno (ed è proprio quello che
|
||||||
|
produce `splitPayment`), e il selettore di schema cancellava `status`/`paid_at` senza
|
||||||
|
chiedere, con due rate già saldate a rischio.
|
||||||
|
|
||||||
|
⚠️ **Provato a runtime, non ancora cliccato.** Build di produzione contro il DB vero via
|
||||||
|
tunnel SSH, in sola lettura: pagina progetto 200, 27 maniglie di trascinamento (esattamente
|
||||||
|
i task delle fasi con più di un task), rate nell'ordine giusto. Restano da esercitare a mano
|
||||||
|
le tre scritture nuove — `reorderTasks`, `updatePaymentField`, `clearPaymentOverride` — e il
|
||||||
|
drag vero e proprio.
|
||||||
|
|
||||||
|
⚠️ **Il giro precedente resta deployato ma non provato a mano.** Build e lint verdi, immagine
|
||||||
`9a57e45` viva, `/admin/login` risponde 200. Restano da esercitare in UI le due
|
`9a57e45` viva, `/admin/login` risponde 200. Restano da esercitare in UI le due
|
||||||
interazioni: la matita di rinomina con il dialog di conferma, e il drag di un task in
|
interazioni: la matita di rinomina con il dialog di conferma, e il drag di un task in
|
||||||
"In revisione" con il controllo che la fase risulti *attiva*. Non sono state automatizzate
|
"In revisione" con il controllo che la fase risulti *attiva*. Non sono state automatizzate
|
||||||
|
|||||||
@@ -227,6 +227,40 @@ export async function deleteTask(taskId: string, id: string) {
|
|||||||
revalidatePath(path);
|
revalidatePath(path);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Persists a new task order inside one phase.
|
||||||
|
//
|
||||||
|
// Rewrites sort_order for EVERY task of the phase as 0..n-1 rather than swapping
|
||||||
|
// two rows. That is deliberate: sort_order is not a reliable index today —
|
||||||
|
// deleteTask leaves gaps (0,1,3), rows created outside addTask/importOfferIntoProject
|
||||||
|
// sit at the 0 default, and there is no unique index on (phase_id, sort_order) so
|
||||||
|
// duplicates are legal. A full rewrite normalizes all of that on every drop.
|
||||||
|
export async function reorderTasks(
|
||||||
|
phaseId: string,
|
||||||
|
id: string,
|
||||||
|
orderedTaskIds: string[]
|
||||||
|
): Promise<void> {
|
||||||
|
await requireAdmin();
|
||||||
|
|
||||||
|
// The array comes from the client: only trust ids that really belong to this phase.
|
||||||
|
const phaseTasks = await db
|
||||||
|
.select({ id: tasks.id })
|
||||||
|
.from(tasks)
|
||||||
|
.where(eq(tasks.phase_id, phaseId));
|
||||||
|
const belongs = new Set(phaseTasks.map((t) => t.id));
|
||||||
|
|
||||||
|
const ordered = orderedTaskIds.filter((taskId) => belongs.has(taskId));
|
||||||
|
if (ordered.length !== phaseTasks.length) {
|
||||||
|
throw new Error("Ordine non valido: elenco task incompleto");
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let i = 0; i < ordered.length; i++) {
|
||||||
|
await db.update(tasks).set({ sort_order: i }).where(eq(tasks.id, ordered[i]));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { path } = await resolveEntity(id);
|
||||||
|
revalidatePath(path);
|
||||||
|
}
|
||||||
|
|
||||||
// ── DELIVERABLES ──────────────────────────────────────────────────────────────
|
// ── DELIVERABLES ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export async function addDeliverable(taskId: string, id: string, formData: FormData) {
|
export async function addDeliverable(taskId: string, id: string, formData: FormData) {
|
||||||
@@ -312,34 +346,109 @@ export async function setPaymentPaidAt(paymentId: string, id: string, monthStr:
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Rescales payment amounts when the total changes.
|
// Rescales payment amounts when the total changes.
|
||||||
// If the payment has a `percent` field, use it (new plan rows).
|
//
|
||||||
// Legacy rows (percent null) fall back to equal split across all rows.
|
// The rule is PER ROW, not per project. The previous version decided with
|
||||||
|
// `some((p) => p.percent !== null)`: a single row carrying a percent dragged the
|
||||||
|
// whole project into the percentage branch, where every percent-NULL row was
|
||||||
|
// computed as `newTotal * 0` and silently written to 0.00. That was a live bug —
|
||||||
|
// splitPayment inserts its second instalment with percent NULL, so splitting a
|
||||||
|
// payment and then touching the total zeroed the remainder.
|
||||||
|
//
|
||||||
|
// Now: a row is rescaled only if it still carries a percent AND has not been
|
||||||
|
// manually overridden. Everything else is left exactly as it is. The equal-split
|
||||||
|
// fallback survives only for projects where NO row has a percent (legacy stubs).
|
||||||
async function rescalePayments(projectId: string, newTotal: number): Promise<void> {
|
async function rescalePayments(projectId: string, newTotal: number): Promise<void> {
|
||||||
const projectPayments = await db
|
const projectPayments = await db
|
||||||
.select({ id: payments.id, percent: payments.percent })
|
.select({ id: payments.id, percent: payments.percent, amount_locked: payments.amount_locked })
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq(payments.project_id, projectId));
|
.where(eq(payments.project_id, projectId));
|
||||||
|
|
||||||
if (projectPayments.length === 0) return;
|
if (projectPayments.length === 0) return;
|
||||||
|
|
||||||
const hasPercent = projectPayments.some((p) => p.percent !== null);
|
const anyPercent = projectPayments.some((p) => p.percent !== null);
|
||||||
|
|
||||||
if (hasPercent) {
|
if (anyPercent) {
|
||||||
// New plan: rescale each row by its stored percent
|
|
||||||
for (const p of projectPayments) {
|
for (const p of projectPayments) {
|
||||||
const pct = p.percent !== null ? parseFloat(String(p.percent)) : 0;
|
if (p.percent === null || p.amount_locked) continue;
|
||||||
const newAmount = ((newTotal * pct) / 100).toFixed(2);
|
const newAmount = ((newTotal * parseFloat(String(p.percent))) / 100).toFixed(2);
|
||||||
await db.update(payments).set({ amount: newAmount }).where(eq(payments.id, p.id));
|
await db.update(payments).set({ amount: newAmount }).where(eq(payments.id, p.id));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Legacy: equal split across all rows (backward compat)
|
// Legacy: equal split across the rows that are still automatic.
|
||||||
const share = (newTotal / projectPayments.length).toFixed(2);
|
const auto = projectPayments.filter((p) => !p.amount_locked);
|
||||||
for (const p of projectPayments) {
|
if (auto.length === 0) return;
|
||||||
|
const share = (newTotal / auto.length).toFixed(2);
|
||||||
|
for (const p of auto) {
|
||||||
await db.update(payments).set({ amount: share }).where(eq(payments.id, p.id));
|
await db.update(payments).set({ amount: share }).where(eq(payments.id, p.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Per-row edit of a payment's label or amount, mirroring updateServiceField in
|
||||||
|
// src/app/admin/catalog/actions.ts. Writing an amount marks the row as manually
|
||||||
|
// overridden so rescalePayments stops touching it.
|
||||||
|
const EDITABLE_PAYMENT_FIELDS = ["label", "amount"] as const;
|
||||||
|
type EditablePaymentField = (typeof EDITABLE_PAYMENT_FIELDS)[number];
|
||||||
|
|
||||||
|
export async function updatePaymentField(
|
||||||
|
paymentId: string,
|
||||||
|
id: string,
|
||||||
|
fieldName: string,
|
||||||
|
value: string
|
||||||
|
): Promise<void> {
|
||||||
|
await requireAdmin();
|
||||||
|
if (!(EDITABLE_PAYMENT_FIELDS as readonly string[]).includes(fieldName)) {
|
||||||
|
throw new Error(`Campo non editabile: ${fieldName}`);
|
||||||
|
}
|
||||||
|
const field = fieldName as EditablePaymentField;
|
||||||
|
|
||||||
|
if (field === "label") {
|
||||||
|
const label = value.trim();
|
||||||
|
if (!label) throw new Error("Nome rata richiesto");
|
||||||
|
await db.update(payments).set({ label }).where(eq(payments.id, paymentId));
|
||||||
|
} else {
|
||||||
|
// The cell renders it-IT (€ 1.234,50), so an admin may well type "1.234,50".
|
||||||
|
// When a comma is present treat "." as thousands separators and "," as the
|
||||||
|
// decimal mark; otherwise "." is the decimal mark. Number() (not parseFloat)
|
||||||
|
// rejects trailing garbage like "12abc".
|
||||||
|
const raw = value.trim();
|
||||||
|
const normalized = raw.includes(",") ? raw.replace(/\./g, "").replace(",", ".") : raw;
|
||||||
|
const num = Number(normalized);
|
||||||
|
if (!Number.isFinite(num) || num < 0) throw new Error("Importo non valido");
|
||||||
|
await db
|
||||||
|
.update(payments)
|
||||||
|
.set({ amount: num.toFixed(2), amount_locked: true })
|
||||||
|
.where(eq(payments.id, paymentId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const { path } = await resolveEntity(id);
|
||||||
|
revalidatePath(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Releases a manual override: the row goes back under automatic rescaling and is
|
||||||
|
// immediately recomputed from its percent, so the effect is visible at once.
|
||||||
|
export async function clearPaymentOverride(paymentId: string, id: string): Promise<void> {
|
||||||
|
await requireAdmin();
|
||||||
|
const rows = await db
|
||||||
|
.select({ project_id: payments.project_id })
|
||||||
|
.from(payments)
|
||||||
|
.where(eq(payments.id, paymentId))
|
||||||
|
.limit(1);
|
||||||
|
if (!rows[0]) throw new Error("Pagamento non trovato");
|
||||||
|
|
||||||
|
await db.update(payments).set({ amount_locked: false }).where(eq(payments.id, paymentId));
|
||||||
|
|
||||||
|
const proj = await db
|
||||||
|
.select({ accepted_total: projects.accepted_total })
|
||||||
|
.from(projects)
|
||||||
|
.where(eq(projects.id, rows[0].project_id))
|
||||||
|
.limit(1);
|
||||||
|
if (proj[0]) await rescalePayments(rows[0].project_id, parseFloat(proj[0].accepted_total ?? "0"));
|
||||||
|
|
||||||
|
const { path } = await resolveEntity(id);
|
||||||
|
revalidatePath(path);
|
||||||
|
}
|
||||||
|
|
||||||
export async function updateAcceptedTotal(id: string, formData: FormData) {
|
export async function updateAcceptedTotal(id: string, formData: FormData) {
|
||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
const raw = (formData.get("accepted_total") as string)?.trim();
|
const raw = (formData.get("accepted_total") as string)?.trim();
|
||||||
|
|||||||
@@ -86,9 +86,12 @@ export async function createClientCore(input: {
|
|||||||
})
|
})
|
||||||
.returning({ id: projects.id });
|
.returning({ id: projects.id });
|
||||||
|
|
||||||
|
// percent is seeded so these stubs rescale properly once a total is set —
|
||||||
|
// previously they were NULL, which put the project in the legacy equal-split
|
||||||
|
// branch and made them indistinguishable from a manually overridden amount.
|
||||||
await db.insert(payments).values([
|
await db.insert(payments).values([
|
||||||
{ project_id: newProject.id, label: "Acconto 50%", amount: "0", status: "da_saldare" },
|
{ project_id: newProject.id, label: "Acconto 50%", amount: "0", percent: "50.00", status: "da_saldare", sort_order: 0 },
|
||||||
{ project_id: newProject.id, label: "Saldo 50%", amount: "0", status: "da_saldare" },
|
{ project_id: newProject.id, label: "Saldo 50%", amount: "0", percent: "50.00", status: "da_saldare", sort_order: 1 },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { clientId: newClient.id, projectId: newProject.id };
|
return { clientId: newClient.id, projectId: newProject.id };
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
tasks,
|
tasks,
|
||||||
PROJECT_OFFER_STATUSES,
|
PROJECT_OFFER_STATUSES,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import { eq, asc, and } from "drizzle-orm";
|
import { eq, asc, and, gt, sql } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { nanoid } from "nanoid";
|
import { nanoid } from "nanoid";
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ export async function initProjectPayments(projectId: string): Promise<void> {
|
|||||||
// Modes:
|
// Modes:
|
||||||
// single → 1 row 100% "Pagamento unico (100%)"
|
// single → 1 row 100% "Pagamento unico (100%)"
|
||||||
// two → 2 rows 50%/50% "Acconto 50% (inizio lavori)" / "Saldo 50% (alla consegna)"
|
// two → 2 rows 50%/50% "Acconto 50% (inizio lavori)" / "Saldo 50% (alla consegna)"
|
||||||
// three → 3 rows 50%/30%/20%
|
// three → 3 rows 50%/25%/25%
|
||||||
export async function setPaymentPlan(
|
export async function setPaymentPlan(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
mode: "single" | "two" | "three",
|
mode: "single" | "two" | "three",
|
||||||
@@ -100,8 +100,8 @@ export async function setPaymentPlan(
|
|||||||
],
|
],
|
||||||
three: [
|
three: [
|
||||||
{ label: "Acconto 50% (inizio lavori)", percent: 50 },
|
{ label: "Acconto 50% (inizio lavori)", percent: 50 },
|
||||||
{ label: "30% (post revisioni)", percent: 30 },
|
{ label: "25% (in corso d'opera)", percent: 25 },
|
||||||
{ label: "Saldo 20% (alla consegna)", percent: 20 },
|
{ label: "Saldo 25% (alla consegna)", percent: 25 },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -109,14 +109,17 @@ export async function setPaymentPlan(
|
|||||||
if (!plan) throw new Error("Modalità pagamento non valida");
|
if (!plan) throw new Error("Modalità pagamento non valida");
|
||||||
|
|
||||||
// Delete existing payments for this project, then insert new ones.
|
// Delete existing payments for this project, then insert new ones.
|
||||||
|
// Destructive by design (the UI says so), and the tab asks for confirmation
|
||||||
|
// when a row has already been marked inviata/saldato.
|
||||||
await db.delete(payments).where(eq(payments.project_id, projectId));
|
await db.delete(payments).where(eq(payments.project_id, projectId));
|
||||||
await db.insert(payments).values(
|
await db.insert(payments).values(
|
||||||
plan.map((p) => ({
|
plan.map((p, i) => ({
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
label: p.label,
|
label: p.label,
|
||||||
percent: p.percent.toFixed(2),
|
percent: p.percent.toFixed(2),
|
||||||
amount: ((total * p.percent) / 100).toFixed(2),
|
amount: ((total * p.percent) / 100).toFixed(2),
|
||||||
status: "da_saldare" as const,
|
status: "da_saldare" as const,
|
||||||
|
sort_order: i,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -145,16 +148,29 @@ export async function splitPayment(
|
|||||||
// Strip any existing " – Rata N" suffix so re-splits stay clean.
|
// Strip any existing " – Rata N" suffix so re-splits stay clean.
|
||||||
const baseLabel = original.label.replace(/ – Rata \d+$/, "");
|
const baseLabel = original.label.replace(/ – Rata \d+$/, "");
|
||||||
|
|
||||||
|
// Both halves are manual amounts by definition, so they are locked: without
|
||||||
|
// this, the next change to the project total would recompute Rata 1 from its
|
||||||
|
// (now meaningless) percent and zero out Rata 2, which carries none.
|
||||||
await db
|
await db
|
||||||
.update(payments)
|
.update(payments)
|
||||||
.set({ amount: first.toFixed(2), label: `${baseLabel} – Rata 1` })
|
.set({ amount: first.toFixed(2), label: `${baseLabel} – Rata 1`, amount_locked: true })
|
||||||
.where(eq(payments.id, paymentId));
|
.where(eq(payments.id, paymentId));
|
||||||
|
|
||||||
|
// Rata 2 slots in right after Rata 1; everything below shifts down one.
|
||||||
|
await db
|
||||||
|
.update(payments)
|
||||||
|
.set({ sort_order: sql`${payments.sort_order} + 1` })
|
||||||
|
.where(
|
||||||
|
and(eq(payments.project_id, projectId), gt(payments.sort_order, original.sort_order))
|
||||||
|
);
|
||||||
|
|
||||||
await db.insert(payments).values({
|
await db.insert(payments).values({
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
label: `${baseLabel} – Rata 2`,
|
label: `${baseLabel} – Rata 2`,
|
||||||
amount: second.toFixed(2),
|
amount: second.toFixed(2),
|
||||||
status: original.status,
|
status: original.status,
|
||||||
|
sort_order: original.sort_order + 1,
|
||||||
|
amount_locked: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
revalidatePath(`/admin/projects/${projectId}`);
|
revalidatePath(`/admin/projects/${projectId}`);
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { type ReactNode, useState, useTransition } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
type DragEndEvent,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
} from "@dnd-kit/core";
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
arrayMove,
|
||||||
|
useSortable,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from "@dnd-kit/sortable";
|
||||||
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
|
import { GripVertical } from "lucide-react";
|
||||||
|
import { reorderTasks } from "@/app/admin/clients/[id]/actions";
|
||||||
|
|
||||||
|
export type SortableTaskItem = { id: string; node: ReactNode };
|
||||||
|
|
||||||
|
// PhasesTab is a Server Component holding inline "use server" form closures, which
|
||||||
|
// cannot live in a client module. So the task rows stay server-rendered and arrive
|
||||||
|
// here as opaque ReactNodes; this component only adds the handle and the ordering.
|
||||||
|
// Same shape as PhasesViewToggle, which already passes a server-rendered tab as a prop.
|
||||||
|
function SortableRow({ id, children }: { id: string; children: ReactNode }) {
|
||||||
|
const { attributes, listeners, setNodeRef, transform, transition, isDragging } =
|
||||||
|
useSortable({ id });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={setNodeRef}
|
||||||
|
// Vertical-only: zero the X component instead of pulling in
|
||||||
|
// @dnd-kit/modifiers just for restrictToVerticalAxis.
|
||||||
|
style={{
|
||||||
|
transform: CSS.Transform.toString(transform ? { ...transform, x: 0 } : null),
|
||||||
|
transition,
|
||||||
|
}}
|
||||||
|
className={`flex items-center gap-1 ${isDragging ? "relative z-10 opacity-80" : ""}`}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
aria-label="Trascina per riordinare"
|
||||||
|
className="shrink-0 cursor-grab touch-none rounded p-1 text-tertiary opacity-0 transition-opacity hover:bg-muted hover:text-foreground focus-visible:opacity-100 active:cursor-grabbing group-hover/tasks:opacity-100"
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<div className="min-w-0 flex-1">{children}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SortableTaskList({
|
||||||
|
phaseId,
|
||||||
|
entityId,
|
||||||
|
items,
|
||||||
|
}: {
|
||||||
|
phaseId: string;
|
||||||
|
entityId: string;
|
||||||
|
items: SortableTaskItem[];
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
|
const incomingIds = items.map((i) => i.id);
|
||||||
|
const [order, setOrder] = useState<string[]>(incomingIds);
|
||||||
|
|
||||||
|
// Re-sync when the server sends a different set or order. The two existing
|
||||||
|
// kanban boards seed state once and never resync; for a status that barely
|
||||||
|
// shows, for an ORDER it means a stale local array fights router.refresh().
|
||||||
|
// Adjusted during render (React's documented pattern), not in an effect.
|
||||||
|
const [prevIds, setPrevIds] = useState(incomingIds.join(","));
|
||||||
|
if (prevIds !== incomingIds.join(",")) {
|
||||||
|
setPrevIds(incomingIds.join(","));
|
||||||
|
setOrder(incomingIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||||
|
useSensor(KeyboardSensor)
|
||||||
|
);
|
||||||
|
|
||||||
|
const byId = new Map(items.map((i) => [i.id, i.node]));
|
||||||
|
|
||||||
|
function handleDragEnd(event: DragEndEvent) {
|
||||||
|
const { active, over } = event;
|
||||||
|
if (!over || active.id === over.id) return;
|
||||||
|
|
||||||
|
const from = order.indexOf(active.id as string);
|
||||||
|
const to = order.indexOf(over.id as string);
|
||||||
|
if (from === -1 || to === -1) return;
|
||||||
|
|
||||||
|
const next = arrayMove(order, from, to);
|
||||||
|
setOrder(next);
|
||||||
|
|
||||||
|
startTransition(async () => {
|
||||||
|
await reorderTasks(phaseId, entityId, next);
|
||||||
|
router.refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// One task can't be reordered — skip the DnD machinery entirely.
|
||||||
|
if (items.length < 2) {
|
||||||
|
return <div className="space-y-2">{items.map((i) => i.node)}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext items={order} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="group/tasks space-y-2">
|
||||||
|
{order.map((id) => (
|
||||||
|
<SortableRow key={id} id={id}>
|
||||||
|
{byId.get(id)}
|
||||||
|
</SortableRow>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,16 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
|
import { RotateCcw } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
updatePaymentStatus,
|
updatePaymentStatus,
|
||||||
updateAcceptedTotal,
|
updateAcceptedTotal,
|
||||||
setPaymentPaidAt,
|
setPaymentPaidAt,
|
||||||
|
updatePaymentField,
|
||||||
|
clearPaymentOverride,
|
||||||
} 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";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { EditableCell } from "@/components/ui/editable-cell";
|
||||||
import { SplitPaymentForm } from "@/components/admin/SplitPaymentForm";
|
import { SplitPaymentForm } from "@/components/admin/SplitPaymentForm";
|
||||||
import type { Payment } from "@/db/schema";
|
import type { Payment } from "@/db/schema";
|
||||||
|
|
||||||
@@ -28,6 +32,10 @@ const statusLabels: Record<string, string> = {
|
|||||||
saldato: "Saldato",
|
saldato: "Saldato",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function formatEuro(value: number): string {
|
||||||
|
return value.toLocaleString("it-IT", { minimumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
// paid_at (Date | string | null, serializzato sul confine RSC) → "YYYY-MM" per <input type="month">
|
// paid_at (Date | string | null, serializzato sul confine RSC) → "YYYY-MM" per <input type="month">
|
||||||
function toMonthValue(paidAt: Date | string | null | undefined): string {
|
function toMonthValue(paidAt: Date | string | null | undefined): string {
|
||||||
if (!paidAt) {
|
if (!paidAt) {
|
||||||
@@ -43,7 +51,7 @@ type PlanMode = "single" | "two" | "three";
|
|||||||
const planOptions: { mode: PlanMode; label: string; description: string }[] = [
|
const planOptions: { mode: PlanMode; label: string; description: string }[] = [
|
||||||
{ mode: "single", label: "Pagamento unico", description: "100% in un'unica soluzione" },
|
{ mode: "single", label: "Pagamento unico", description: "100% in un'unica soluzione" },
|
||||||
{ mode: "two", label: "2 step", description: "Acconto 50% + Saldo 50%" },
|
{ mode: "two", label: "2 step", description: "Acconto 50% + Saldo 50%" },
|
||||||
{ mode: "three", label: "3 step", description: "50% + 30% + 20%" },
|
{ mode: "three", label: "3 step", description: "50% + 25% + 25%" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function PaymentsTab({
|
export function PaymentsTab({
|
||||||
@@ -57,35 +65,81 @@ export function PaymentsTab({
|
|||||||
const [overrideValue, setOverrideValue] = useState(acceptedTotal);
|
const [overrideValue, setOverrideValue] = useState(acceptedTotal);
|
||||||
const [planLoading, setPlanLoading] = useState<PlanMode | null>(null);
|
const [planLoading, setPlanLoading] = useState<PlanMode | null>(null);
|
||||||
const [statusLoading, setStatusLoading] = useState<string | 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 currentTotal = parseFloat(acceptedTotal) || 0;
|
||||||
const offersTotal = offersAcceptedTotal;
|
const offersTotal = offersAcceptedTotal;
|
||||||
const hasOffers = offersTotal > 0;
|
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() {
|
async function handleUseOffersTotal() {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
setOverrideValue(offersTotal.toFixed(2));
|
setOverrideValue(offersTotal.toFixed(2));
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.set("accepted_total", offersTotal.toFixed(2));
|
fd.set("accepted_total", offersTotal.toFixed(2));
|
||||||
await updateAcceptedTotal(projectId, fd);
|
run(() => updateAcceptedTotal(projectId, fd));
|
||||||
router.refresh();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSaveTotal(e: React.FormEvent) {
|
async function handleSaveTotal(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const fd = new FormData();
|
const fd = new FormData();
|
||||||
fd.set("accepted_total", overrideValue);
|
fd.set("accepted_total", overrideValue);
|
||||||
await updateAcceptedTotal(clientId, fd);
|
run(() => updateAcceptedTotal(clientId, fd));
|
||||||
router.refresh();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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.
|
||||||
|
// Only worth interrupting when there is actually payment history to lose.
|
||||||
|
const tracked = payments.filter(
|
||||||
|
(p) => p.status === "saldato" || p.status === "inviata"
|
||||||
|
).length;
|
||||||
|
if (tracked > 0) {
|
||||||
|
const what =
|
||||||
|
tracked === 1 ? "1 rata già segnata" : `${tracked} rate già segnate`;
|
||||||
|
const ok = window.confirm(
|
||||||
|
`Cambiare schema cancella tutte le rate di questo progetto.\n\nCi sono ${what} come inviate o saldate: lo stato e il mese di incasso andranno persi.\n\nProcedere?`
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
|
}
|
||||||
|
|
||||||
setPlanLoading(mode);
|
setPlanLoading(mode);
|
||||||
|
setError(null);
|
||||||
try {
|
try {
|
||||||
const total = parseFloat(overrideValue) || currentTotal;
|
const total = parseFloat(overrideValue) || currentTotal;
|
||||||
await setPaymentPlan(projectId, mode, total);
|
await setPaymentPlan(projectId, mode, total);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : "Errore nel salvataggio");
|
||||||
} finally {
|
} finally {
|
||||||
setPlanLoading(null);
|
setPlanLoading(null);
|
||||||
}
|
}
|
||||||
@@ -114,6 +168,12 @@ export function PaymentsTab({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 max-w-md">
|
<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 */}
|
{/* Totale preventivo */}
|
||||||
<div className="bg-white border border-gray-200 rounded-lg p-4 space-y-3">
|
<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>
|
<h3 className="font-medium text-gray-900">Totale preventivo</h3>
|
||||||
@@ -124,8 +184,7 @@ export function PaymentsTab({
|
|||||||
<span className="text-sm text-[#71717a]">
|
<span className="text-sm text-[#71717a]">
|
||||||
Ereditato dalle offerte attive:{" "}
|
Ereditato dalle offerte attive:{" "}
|
||||||
<span className="font-semibold text-[#1a1a1a]">
|
<span className="font-semibold text-[#1a1a1a]">
|
||||||
€{" "}
|
€ {formatEuro(offersTotal)}
|
||||||
{offersTotal.toLocaleString("it-IT", { minimumFractionDigits: 2 })}
|
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
{projectId && (
|
{projectId && (
|
||||||
@@ -211,28 +270,48 @@ export function PaymentsTab({
|
|||||||
{/* Payment rows */}
|
{/* Payment rows */}
|
||||||
{payments.map((p) => {
|
{payments.map((p) => {
|
||||||
const amount = parseFloat(p.amount);
|
const amount = parseFloat(p.amount);
|
||||||
const pct = p.percent !== null && p.percent !== undefined
|
const pct =
|
||||||
? parseFloat(String(p.percent))
|
p.percent !== null && p.percent !== undefined ? parseFloat(String(p.percent)) : null;
|
||||||
: null;
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div key={p.id} className="bg-white border border-gray-200 rounded-lg p-4">
|
||||||
key={p.id}
|
<div className="flex items-start justify-between gap-2 mb-2">
|
||||||
className="bg-white border border-gray-200 rounded-lg p-4"
|
<div className="min-w-0 flex-1">
|
||||||
>
|
<div className="font-medium text-gray-900">
|
||||||
<div className="flex items-center justify-between mb-2">
|
<EditableCell
|
||||||
<div>
|
value={p.label}
|
||||||
<h3 className="font-medium text-gray-900">{p.label}</h3>
|
type="text"
|
||||||
{pct !== null && (
|
required
|
||||||
<p className="text-xs text-[#71717a]">{pct}% del totale</p>
|
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>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<span className="text-sm text-gray-600">
|
<div className="text-sm text-gray-600">
|
||||||
€{" "}
|
<EditableCell
|
||||||
{amount.toLocaleString("it-IT", {
|
value={p.amount}
|
||||||
minimumFractionDigits: 2,
|
type="number"
|
||||||
})}
|
required
|
||||||
</span>
|
formatDisplay={(raw) => `€ ${formatEuro(parseFloat(raw) || 0)}`}
|
||||||
|
onSave={(v) => run(() => updatePaymentField(p.id, clientId, "amount", v))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{projectId && amount > 0 && (
|
{projectId && amount > 0 && (
|
||||||
<SplitPaymentForm
|
<SplitPaymentForm
|
||||||
paymentId={p.id}
|
paymentId={p.id}
|
||||||
@@ -258,9 +337,7 @@ export function PaymentsTab({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
{statusLoading === p.id && (
|
{statusLoading === p.id && <span className="text-xs text-[#71717a]">...</span>}
|
||||||
<span className="text-xs text-[#71717a]">...</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</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">
|
||||||
@@ -280,6 +357,21 @@ export function PaymentsTab({
|
|||||||
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { DeletePhaseTaskButton } from "@/components/admin/DeletePhaseTaskButton"
|
|||||||
import { TimerCell } from "@/components/admin/TimerCell";
|
import { TimerCell } from "@/components/admin/TimerCell";
|
||||||
import type { ClientFullDetail } from "@/lib/admin-queries";
|
import type { ClientFullDetail } from "@/lib/admin-queries";
|
||||||
import { TASK_STATUS_LABELS, TASK_STATUSES } from "@/lib/task-status";
|
import { TASK_STATUS_LABELS, TASK_STATUSES } from "@/lib/task-status";
|
||||||
|
import { SortableTaskList } from "@/components/admin/SortableTaskList";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
phases: ClientFullDetail["phases"];
|
phases: ClientFullDetail["phases"];
|
||||||
@@ -102,7 +103,7 @@ export async function PhasesTab({
|
|||||||
await updatePhaseStatus(
|
await updatePhaseStatus(
|
||||||
phase.id,
|
phase.id,
|
||||||
clientId,
|
clientId,
|
||||||
fd.get("status") as string
|
fd.get("status") as string,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className="flex items-center gap-2"
|
className="flex items-center gap-2"
|
||||||
@@ -118,73 +119,98 @@ export async function PhasesTab({
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<Button type="submit" variant="ghost" size="sm" className="text-xs">
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
Salva
|
Salva
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
<DeletePhaseTaskButton type="phase" phaseId={phase.id} clientId={clientId} />
|
<DeletePhaseTaskButton
|
||||||
|
type="phase"
|
||||||
|
phaseId={phase.id}
|
||||||
|
clientId={clientId}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tasks */}
|
{/* Tasks — server-rendered rows handed to a client wrapper that adds the
|
||||||
<div className="space-y-2 mb-3">
|
drag handle. The rows keep their inline "use server" forms, which is
|
||||||
{phase.tasks.map((task) => (
|
only legal inside a Server Component. */}
|
||||||
<div
|
<div className="mb-3">
|
||||||
key={task.id}
|
<SortableTaskList
|
||||||
className="flex items-center justify-between gap-3 pl-3 border-l-2 border-border"
|
phaseId={phase.id}
|
||||||
>
|
entityId={clientId}
|
||||||
<span className="text-sm text-foreground">{task.title}</span>
|
items={phase.tasks.map((task) => ({
|
||||||
<div className="flex items-center gap-1">
|
id: task.id,
|
||||||
{projectId && (
|
node: (
|
||||||
<TimerCell
|
<div className="flex items-center justify-between gap-3 pl-3 border-l-2 border-border">
|
||||||
projectId={projectId}
|
<span className="text-sm text-foreground">
|
||||||
phaseId={phase.id}
|
{task.title}
|
||||||
taskId={task.id}
|
</span>
|
||||||
activeEntryId={
|
<div className="flex items-center gap-1">
|
||||||
activeTimerTaskId === task.id ? activeTimerEntryId : null
|
{projectId && (
|
||||||
}
|
<TimerCell
|
||||||
activeStartedAt={
|
projectId={projectId}
|
||||||
activeTimerTaskId === task.id ? activeTimerStartedAt : null
|
phaseId={phase.id}
|
||||||
}
|
taskId={task.id}
|
||||||
totalTrackedSeconds={taskSeconds[task.id] ?? 0}
|
activeEntryId={
|
||||||
compact
|
activeTimerTaskId === task.id
|
||||||
/>
|
? activeTimerEntryId
|
||||||
)}
|
: null
|
||||||
<form
|
}
|
||||||
action={async (fd: FormData) => {
|
activeStartedAt={
|
||||||
"use server";
|
activeTimerTaskId === task.id
|
||||||
await updateTaskStatus(
|
? activeTimerStartedAt
|
||||||
task.id,
|
: null
|
||||||
clientId,
|
}
|
||||||
fd.get("status") as string
|
totalTrackedSeconds={taskSeconds[task.id] ?? 0}
|
||||||
);
|
compact
|
||||||
}}
|
/>
|
||||||
className="flex items-center gap-1"
|
)}
|
||||||
>
|
<form
|
||||||
<select
|
action={async (fd: FormData) => {
|
||||||
name="status"
|
"use server";
|
||||||
defaultValue={task.status}
|
await updateTaskStatus(
|
||||||
className="text-xs border border-border rounded px-2 py-1 bg-background text-foreground"
|
task.id,
|
||||||
>
|
clientId,
|
||||||
{taskStatusOptions.map((o) => (
|
fd.get("status") as string,
|
||||||
<option key={o.value} value={o.value}>
|
);
|
||||||
{o.label}
|
}}
|
||||||
</option>
|
className="flex items-center gap-1"
|
||||||
))}
|
>
|
||||||
</select>
|
<select
|
||||||
<Button
|
name="status"
|
||||||
type="submit"
|
defaultValue={task.status}
|
||||||
variant="ghost"
|
className="text-xs border border-border rounded px-2 py-1 bg-background text-foreground"
|
||||||
size="sm"
|
>
|
||||||
className="text-xs px-1"
|
{taskStatusOptions.map((o) => (
|
||||||
>
|
<option key={o.value} value={o.value}>
|
||||||
✓
|
{o.label}
|
||||||
</Button>
|
</option>
|
||||||
</form>
|
))}
|
||||||
<DeletePhaseTaskButton type="task" taskId={task.id} clientId={clientId} />
|
</select>
|
||||||
</div>
|
<Button
|
||||||
</div>
|
type="submit"
|
||||||
))}
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-xs px-1"
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<DeletePhaseTaskButton
|
||||||
|
type="task"
|
||||||
|
taskId={task.id}
|
||||||
|
clientId={clientId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add task form */}
|
{/* Add task form */}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- Additive: give `payments` a stable display order and a manual-override flag.
|
||||||
|
--
|
||||||
|
-- Why: the table had NO ordering column at all (no sort_order, no created_at) and
|
||||||
|
-- none of the 13 queries reading it had an ORDER BY. Postgres seq-scans and returns
|
||||||
|
-- physical order; an MVCC UPDATE rewrites the tuple at the end, so marking a payment
|
||||||
|
-- "saldato" pushed it to the bottom of the list.
|
||||||
|
--
|
||||||
|
-- No drops, no truncates, no deletes — `payments` is LOCKED in CLAUDE.md.
|
||||||
|
|
||||||
|
ALTER TABLE payments ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE payments ADD COLUMN IF NOT EXISTS amount_locked boolean NOT NULL DEFAULT false;
|
||||||
|
|
||||||
|
-- Backfill. Deliberately NOT ordered by ctid: 3 of the 5 projects in production are
|
||||||
|
-- already displaying scrambled (one shows 30/20/50, one is fully reversed 20/30/50,
|
||||||
|
-- and the legacy pair shows Saldo before Acconto), so physical order would freeze the
|
||||||
|
-- bug in place instead of fixing it.
|
||||||
|
--
|
||||||
|
-- percent DESC reconstructs the intent of every plan shape that exists (100 / 50-50 /
|
||||||
|
-- 50-30-20). The label tiebreaker puts "Acconto…" before "Saldo…" in the legacy pairs
|
||||||
|
-- that carry percent NULL.
|
||||||
|
UPDATE payments p SET sort_order = s.rn - 1
|
||||||
|
FROM (
|
||||||
|
SELECT id, row_number() OVER (
|
||||||
|
PARTITION BY project_id ORDER BY percent DESC NULLS LAST, label ASC
|
||||||
|
) AS rn
|
||||||
|
FROM payments
|
||||||
|
) s
|
||||||
|
WHERE p.id = s.id;
|
||||||
|
|
||||||
|
-- A foreign key creates no index on the referencing side, so payments had no index
|
||||||
|
-- at all beyond its PK. This one serves both the FK lookups and the new ORDER BY.
|
||||||
|
CREATE INDEX IF NOT EXISTS payments_project_sort_idx ON payments(project_id, sort_order);
|
||||||
@@ -214,6 +214,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 }),
|
||||||
|
sort_order: integer("sort_order").notNull().default(0),
|
||||||
|
// true = l'importo è stato scritto a mano: rescalePayments non lo tocca più.
|
||||||
|
amount_locked: boolean("amount_locked").notNull().default(false),
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============ DOCUMENTS ============
|
// ============ DOCUMENTS ============
|
||||||
|
|||||||
@@ -120,7 +120,11 @@ export async function getAllClientsWithPayments(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const [allPayments, activeEntries, totals, offerTotals] = await Promise.all([
|
const [allPayments, activeEntries, totals, offerTotals] = await Promise.all([
|
||||||
db.select().from(payments).where(inArray(payments.project_id, projectIds)),
|
db
|
||||||
|
.select()
|
||||||
|
.from(payments)
|
||||||
|
.where(inArray(payments.project_id, projectIds))
|
||||||
|
.orderBy(asc(payments.sort_order)),
|
||||||
|
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
@@ -326,7 +330,8 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
|
|||||||
const paymentsRows = await db
|
const paymentsRows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(inArray(payments.project_id, projectIds));
|
.where(inArray(payments.project_id, projectIds))
|
||||||
|
.orderBy(asc(payments.sort_order));
|
||||||
|
|
||||||
const documentsRows = await db
|
const documentsRows = await db
|
||||||
.select()
|
.select()
|
||||||
@@ -510,7 +515,8 @@ export async function getAllProjectsWithPayments(
|
|||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(inArray(payments.project_id, projectIds)),
|
.where(inArray(payments.project_id, projectIds))
|
||||||
|
.orderBy(asc(payments.sort_order)),
|
||||||
|
|
||||||
db
|
db
|
||||||
.select({
|
.select({
|
||||||
@@ -641,7 +647,7 @@ export async function getProjectFullDetail(id: string): Promise<ProjectFullDetai
|
|||||||
|
|
||||||
const [paymentsRows, documentsRows, notesRows, quoteItemRows, activeServiceRows, activeEntryRows, totalRes, taskSecondsRows, phaseSecondsRows, projectOffersRows, availableMicrosRows, transcriptsRows] =
|
const [paymentsRows, documentsRows, notesRows, quoteItemRows, activeServiceRows, activeEntryRows, totalRes, taskSecondsRows, phaseSecondsRows, projectOffersRows, availableMicrosRows, transcriptsRows] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
db.select().from(payments).where(eq(payments.project_id, id)),
|
db.select().from(payments).where(eq(payments.project_id, id)).orderBy(asc(payments.sort_order)),
|
||||||
db.select().from(documents).where(eq(documents.project_id, id)).orderBy(asc(documents.created_at)),
|
db.select().from(documents).where(eq(documents.project_id, id)).orderBy(asc(documents.created_at)),
|
||||||
db.select().from(notes).where(eq(notes.project_id, id)).orderBy(asc(notes.created_at)),
|
db.select().from(notes).where(eq(notes.project_id, id)).orderBy(asc(notes.created_at)),
|
||||||
db
|
db
|
||||||
|
|||||||
@@ -319,7 +319,8 @@ export async function getProjectView(projectId: string): Promise<ProjectView | n
|
|||||||
// amount intentionally excluded — client API never exposes payment amounts
|
// amount intentionally excluded — client API never exposes payment amounts
|
||||||
})
|
})
|
||||||
.from(payments)
|
.from(payments)
|
||||||
.where(eq(payments.project_id, projectId));
|
.where(eq(payments.project_id, projectId))
|
||||||
|
.orderBy(asc(payments.sort_order));
|
||||||
|
|
||||||
const documentsRows = await db
|
const documentsRows = await db
|
||||||
.select({
|
.select({
|
||||||
|
|||||||
Reference in New Issue
Block a user