Compare commits

...

3 Commits

Author SHA1 Message Date
simone 9a57e450fc docs: registra rinomina tassonomie e stato task "In revisione"
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:11:47 +02:00
simone 5547e555bd feat(tasks): stato "In revisione", con gli stati finalmente in un posto solo
Mancava il modo di dire "finito, ma da controllare prima di consegnarlo".
Il nuovo stato sta fra "In corso" e "Fatto" ed e' visibile anche al cliente:
il lavoro c'e' ed e' in controllo qualita', non e' fermo.

Il costo non era la logica ma la dispersione: tre letterali ricopiati a mano in
otto file, in tre forme diverse (allow-list a runtime, union TS, colonne kanban,
opzioni della select) e nessun CHECK in DB a tenerli insieme. Invece di
modificarne quattordici occorrenze, tutto deriva da TASK_STATUSES in
src/lib/task-status.ts: la prossima aggiunta costa una riga.

Due punti perdevano dati in silenzio, ed erano il vero motivo per centralizzare:

- recomputePhaseStatus considerava "iniziato" solo in_progress|done, come lista.
  Una fase con tutti i task in revisione non rientrava ne' in allDone ne' in
  anyActive e retrocedeva a "upcoming": si leggeva "non iniziata" quando era
  quasi finita. Ora e' la negazione di "todo", e regge anche il prossimo stato.
- ClientKanban ripartiva i task con un oggetto a tre chiavi fisse, non derivato
  dalle colonne: un task fuori da quelle spariva da ogni colonna e da ogni
  contatore, e il cliente ne vedeva meno di quanti ce n'erano, senza errore.

Chiuso anche il cast non verificato al confine del portale (page.tsx), che era
la causa a monte di entrambi: ora ci passa normalizeTaskStatus.

Nessuna migration: tasks.status e' text senza CHECK, le righe esistenti valgono.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:10:52 +02:00
simone 3fcb10dac6 feat(impostazioni): rinomina di un valore di tassonomia, fasi dei progetti incluse
Dalle impostazioni si poteva solo aggiungere o eliminare un valore: per
cambiargli nome bisognava cancellarlo — strappandolo via da ogni servizio che
lo usava — e ricrearlo a mano. La matita nel chip fa il rename in un passo.

renamePoolValue esisteva gia' e propagava ovunque, tranne in un punto:
importOfferIntoProject copia services.fase dentro phases.title e poi ritrova
la fase confrontando i titoli (phases.offer_phase_id non viene mai popolata,
quindi il titolo e' l'unico legame). Un rename fermo al catalogo lasciava le
fasi dei progetti col vecchio nome e al re-import ne nasceva una duplicata.
Ora propaga anche li', con lo stesso match trim+lowercase del merge.

E' l'unico rename di tassonomia che scrive fuori dal dominio catalogo/offerte,
quindi e' l'unico che chiede conferma, dicendo quante fasi e quanti progetti
sta per toccare.

Tolte anche le UPDATE manuali in renameServiceOption/renameOfferOption: erano
la stessa scrittura che renamePoolValue faceva subito dopo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 16:08:20 +02:00
17 changed files with 312 additions and 91 deletions
+8 -6
View File
@@ -3,9 +3,9 @@ gsd_state_version: 1.0
milestone: v2.5
milestone_name: Audit
status: executing
stopped_at: "v2.5 in PAUSA. In corso le modifiche hub: A e B in prod, C1 in prod, C2 (TidyCal) bloccato sulle credenziali API."
last_updated: "2026-08-19T21:15:00.000Z"
last_activity: 2026-08-19 -- modifiche hub: progetti, dashboard e ingresso lead in produzione (19ed377)
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_activity: 2026-08-20 -- modifiche hub: rinomina tassonomie e stato task "In revisione" (5547e55)
progress:
total_phases: 4
completed_phases: 0
@@ -27,8 +27,8 @@ il suo progetto, senza scrivere email. · **Current focus:** modifiche hub (v2.5
## Current Position
**v2.5 è in pausa per scelta** (2026-08-19): prima le modifiche all'hub chieste il
2026-08-18, poi il motore. Phase 27 resta a metà — schema e fonti in prod, resto da scrivere.
**v2.5 è in pausa per scelta** (2026-08-19): prima le modifiche all'hub, poi il motore.
Phase 27 resta a metà — schema e fonti in prod, resto da scrivere.
| Blocco (modifiche hub) | Stato |
|---|---|
@@ -36,7 +36,8 @@ il suo progetto, senza scrivere email. · **Current focus:** modifiche hub (v2.5
| B — Dashboard (inbox, linee di prodotto, timeline consegne) | ✅ in produzione 2026-08-19 |
| C1 — `POST /api/webhooks/lead` | ✅ in produzione, provato contro il DB vero |
| C2 — TidyCal | ⛔ **[BLOCCANTE]** vedi sotto |
| C3 — Alleggerire l'hub | ⏸️ senza perimetro, da definire guardando i dati d'uso |
| C3 — Alleggerire l'hub | ⏸️ senza perimetro |
| Rifiniture — rinomina tassonomie, stato task "In revisione" | ✅ in prod 2026-08-20 |
| D — Whop → audit | ⏸️ dipende dal motore v2.5 |
Progress: [███░░░░░░░] 25% (v2.5)
@@ -71,6 +72,7 @@ Passo per passo in `STATUS.md` e in `…radiant-valley.md`.
Log completo in `PROJECT.md`. Vive per il lavoro corrente:
- **[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] 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] Il tempo lavorato sopravvive alla cancellazione del task** — `ON DELETE SET NULL`, mai cascade: con cascade, ripulire una fase abbasserebbe in silenzio il fatturato tracciato.
+28 -1
View File
@@ -1,6 +1,6 @@
# ClientHub (IAMCAVALLI) — Status
_Ultimo aggiornamento: 2026-08-19_
_Ultimo aggiornamento: 2026-08-20_
Questo è **l'unico documento narrativo** del progetto: a che punto siamo, cosa manca,
cosa abbiamo imparato. `.planning/STATE.md` è il digest che leggono i comandi
@@ -46,6 +46,22 @@ Tre aree: dashboard, progetti, pipeline. Piano in
regge payload piatto, `fields` annidati di Elementor e urlencoded, e non duplica chi
compila due volte. Provato contro il DB di produzione via tunnel, poi ripulito.
**In produzione dal 2026-08-20** (`3fcb10d`, `5547e55`) — due modifiche uscite dall'uso
reale del pannello, nessuna migration (verificato: `tasks.status` è `text` senza `CHECK`):
- **Rinomina di un valore di tassonomia** da `/admin/impostazioni`. Prima si poteva solo
aggiungere o eliminare: per cambiare nome a una fase bisognava cancellarla — il che la
strappava via da ogni servizio — e ricrearla a mano. `renamePoolValue` c'era già e
propagava ovunque tranne in un punto: `importOfferIntoProject` copia `services.fase`
dentro `phases.title` e poi ritrova la fase **confrontando i titoli**, perché
`phases.offer_phase_id` esiste in schema ma non viene mai popolata. Un rename fermo al
catalogo lasciava le fasi dei progetti col vecchio nome e al re-import ne nasceva una
duplicata. Ora propaga anche lì, con lo stesso match `trim`+`lowercase` del merge, ed
è l'unico rename che chiede conferma — dicendo quante fasi e quanti progetti tocca.
- **Stato task "In revisione"**, fra "In corso" e "Fatto", visibile anche al cliente. Il
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`.
Cosa manca, e perché:
- **[BLOCCANTE] TidyCal.** Non ha webhook — è scritto nella loro FAQ, la strada
@@ -167,6 +183,17 @@ cui si ricasca.
costruire firme o hash validi in prod vanno letti da Coolify, non da `.env.local`.
- **L'API Coolify rifiuta `is_build_time`** con 422 sul POST a
`/api/v1/applications/<uuid>/envs`: mandare solo `key`, `value`, `is_preview`.
- **Una lista di valori validi dimentica in silenzio, una negazione no.**
`recomputePhaseStatus` decideva "fase iniziata" con `status === "in_progress" ||
status === "done"`. Aggiungendo "In revisione", una fase con tutti i task in revisione
non rientrava né in `allDone` né in `anyActive` e **retrocedeva a "upcoming"**: si
leggeva "non iniziata" quando era quasi finita. Stesso schema in `ClientKanban`, dove
i task erano ripartiti da un oggetto a tre chiavi fisse invece che dalle colonne: un
task fuori da quelle spariva da ogni colonna e da ogni contatore, e il cliente ne
vedeva meno di quanti ce n'erano, **senza errore**. La causa a monte di entrambi era
lo stesso `as` al confine del portale, che TypeScript non verifica. Quando un insieme
di stati può crescere: derivare le colonne dalla costante, scrivere il predicato come
negazione, e normalizzare al confine invece di castare.
- **Playwright non funziona contro `npm run dev`**: la CSP blocca `eval` e i client
component non si idratano. Serve il build di produzione.
- **Le API di Google cambiano forma sotto i piedi, e in silenzio.** Scrivendo
+3 -5
View File
@@ -187,16 +187,14 @@ export async function renameServiceOption(
if (next.length === 0) throw new Error("Nuovo nome richiesto");
if (next === oldValue) return;
// renamePoolValue owns the propagation (tags / services.fase / project phases);
// duplicating the UPDATE here would just run it twice.
if (field === "tag" || field === "pacchetto") {
await db
.update(tags)
.set({ name: next })
.where(and(eq(tags.entity_type, MULTI_ENTITY[field]), eq(tags.name, oldValue)));
await renamePoolValue(field === "tag" ? "service_offerta" : "service_pacchetto", oldValue, next);
} else if (field === "categoria") {
// No taxonomy pool backs services.category — this one propagates by hand.
await db.update(services).set({ category: next }).where(eq(services.category, oldValue));
} else if (field === "fase") {
await db.update(services).set({ fase: next }).where(eq(services.fase, oldValue));
await renamePoolValue("service_fase", oldValue, next);
} else {
throw new Error(`Campo non valido: ${field}`);
+10 -8
View File
@@ -23,6 +23,7 @@ import {
} from "@/db/schema";
import { eq, asc, and, isNull } from "drizzle-orm";
import { z } from "zod";
import { isTaskStatus } from "@/lib/task-status";
// ── ENTITY RESOLUTION ────────────────────────────────────────────────────────
// Both clientId and projectId are passed as "clientId" by tab components.
@@ -179,9 +180,13 @@ export async function addTask(phaseId: string, id: string, formData: FormData) {
// ── PHASE STATUS CASCADE ──────────────────────────────────────────────────────
// Recomputes phase status from its tasks:
// all done → done
// any in_progress or done (but not all done) → active
// all todo (or no tasks) → upcoming
// all done → done
// any task moved off "todo" → active
// all todo (or no tasks) → upcoming
// "any moved off todo" is deliberately a negation, not a list of the statuses
// 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
// not-started when it was nearly finished.
export async function recomputePhaseStatus(phaseId: string): Promise<void> {
const phaseTasks = await db
.select({ status: tasks.status })
@@ -191,9 +196,7 @@ export async function recomputePhaseStatus(phaseId: string): Promise<void> {
let newStatus: "upcoming" | "active" | "done" = "upcoming";
if (phaseTasks.length > 0) {
const allDone = phaseTasks.every((t) => t.status === "done");
const anyActive = phaseTasks.some(
(t) => t.status === "in_progress" || t.status === "done"
);
const anyActive = phaseTasks.some((t) => t.status !== "todo");
if (allDone) newStatus = "done";
else if (anyActive) newStatus = "active";
}
@@ -202,8 +205,7 @@ export async function recomputePhaseStatus(phaseId: string): Promise<void> {
export async function updateTaskStatus(taskId: string, id: string, status: string) {
await requireAdmin();
const allowed = ["todo", "in_progress", "done"];
if (!allowed.includes(status)) throw new Error("Stato non valido");
if (!isTaskStatus(status)) throw new Error("Stato non valido");
await db.update(tasks).set({ status }).where(eq(tasks.id, taskId));
// Cascade: recompute parent phase status from all its tasks
+33
View File
@@ -5,7 +5,9 @@ import { authOptions } from "@/lib/auth";
import { revalidatePath } from "next/cache";
import {
addPoolValue,
countProjectPhasesByTitle,
removePoolValue,
renamePoolValue,
TAXONOMY_FIELDS,
type TaxonomyFieldId,
} from "@/lib/taxonomy";
@@ -23,6 +25,10 @@ function revalidateAll() {
revalidatePath("/admin/impostazioni");
revalidatePath("/admin/offers");
revalidatePath("/admin/catalog");
// A `service_fase` rename rewrites phase titles inside projects too, so the
// project/client trees have to drop their cached copies of the old name.
revalidatePath("/admin/projects", "layout");
revalidatePath("/admin/clients", "layout");
}
export async function addTaxonomyValue(fieldId: string, value: string): Promise<void> {
@@ -40,3 +46,30 @@ export async function removeTaxonomyValue(fieldId: string, value: string): Promi
await removePoolValue(fieldId, value);
revalidateAll();
}
// Global rename: renames the value in the pool AND propagates it to every row
// using it — including, for `service_fase`, the phases already materialized in
// projects (see renamePoolValue).
export async function renameTaxonomyValue(
fieldId: string,
oldValue: string,
newValue: string
): Promise<void> {
await requireAdmin();
assertField(fieldId);
const next = newValue.trim();
if (!next) throw new Error("Nuovo nome richiesto");
if (next === oldValue) return;
await renamePoolValue(fieldId, oldValue, next);
revalidateAll();
}
// Read-only: how many project phases a `service_fase` rename would rewrite.
// Feeds the confirmation dialog — this is the one taxonomy rename that writes
// outside the catalog/offer domain.
export async function getFaseRenameImpact(
value: string
): Promise<{ phases: number; projects: number }> {
await requireAdmin();
return countProjectPhasesByTitle(value);
}
+1 -6
View File
@@ -342,17 +342,12 @@ export async function renameOfferOption(
if (next.length === 0) throw new Error("Nuovo nome richiesto");
if (next === oldValue) return;
// renamePoolValue already propagates to offer_macros / tags — no manual UPDATE.
if (field === "categoria") {
await db.update(offer_macros).set({ category: next }).where(eq(offer_macros.category, oldValue));
await renamePoolValue("offer_categoria", oldValue, next);
} else if (field === "ticket") {
await db.update(offer_macros).set({ ticket: next }).where(eq(offer_macros.ticket, oldValue));
await renamePoolValue("offer_ticket", oldValue, next);
} else if (field === "tipo" || field === "obiettivo") {
await db
.update(tags)
.set({ name: next })
.where(and(eq(tags.entity_type, OFFER_TAG_ENTITY[field]), eq(tags.name, oldValue)));
await renamePoolValue(field === "tipo" ? "offer_tipo" : "offer_obiettivo", oldValue, next);
} else {
throw new Error(`Campo non valido: ${field}`);
+2 -1
View File
@@ -13,6 +13,7 @@ import { OtpGate } from "@/components/client/OtpGate";
import { PreviewBanner } from "@/components/client/PreviewBanner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import type { Comment } from "@/db/schema";
import { normalizeTaskStatus } from "@/lib/task-status";
export const revalidate = 0;
@@ -40,7 +41,7 @@ function projectViewToClientView(
id: task.id,
title: task.title,
description: task.description,
status: task.status as "todo" | "in_progress" | "done",
status: normalizeTaskStatus(task.status),
sort_order: task.sort_order,
deliverables: task.deliverables.map((d) => ({
id: d.id,
@@ -1,22 +1,31 @@
"use client";
import { useState, useTransition } from "react";
import { X } from "lucide-react";
import { Pencil, X } from "lucide-react";
export function PoolManager({
label,
pool,
onAdd,
onRemove,
onRename,
getRenameWarning,
}: {
label: string;
pool: string[];
onAdd: (value: string) => Promise<void>;
onRemove: (value: string) => Promise<void>;
onRename?: (oldValue: string, newValue: string) => Promise<void>;
// Returns a message to confirm before the rename lands, or null to go straight
// through. Only wired for the one field whose rename writes outside the
// catalog (Fase → phases.title).
getRenameWarning?: (oldValue: string, newValue: string) => Promise<string | null>;
}) {
const [input, setInput] = useState("");
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const [renaming, setRenaming] = useState<string | null>(null);
const [renameValue, setRenameValue] = useState("");
function handleAdd() {
const trimmed = input.trim();
@@ -40,6 +49,22 @@ export function PoolManager({
startTransition(() => onRemove(value));
}
function commitRename(oldValue: string) {
const next = renameValue.trim();
setRenaming(null);
if (!next || next === oldValue) return;
if (pool.includes(next)) {
setError("Valore già presente");
return;
}
setError(null);
startTransition(async () => {
const warning = await getRenameWarning?.(oldValue, next);
if (warning && !window.confirm(warning)) return;
await onRename?.(oldValue, next);
});
}
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
e.preventDefault();
@@ -69,24 +94,63 @@ export function PoolManager({
Nessun valore inserito
</div>
) : (
pool.map((v) => (
<span
key={v}
className="inline-flex items-center gap-1 rounded-md border border-border/60 bg-card py-1 pl-2.5 pr-1.5 text-[11px] font-semibold text-foreground shadow-sm"
>
{v}
<button
type="button"
onClick={() => handleRemove(v)}
disabled={isPending}
className="rounded p-0.5 text-tertiary transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
aria-label={`Elimina ${v}`}
title={`Elimina "${v}" ovunque`}
pool.map((v) =>
renaming === v ? (
<input
key={v}
autoFocus
type="text"
value={renameValue}
onChange={(e) => setRenameValue(e.target.value)}
onBlur={() => commitRename(v)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
commitRename(v);
}
if (e.key === "Escape") {
e.preventDefault();
setRenaming(null);
}
}}
aria-label={`Nuovo nome per ${v}`}
className="w-28 rounded-md border border-primary bg-card px-2 py-1 text-[11px] font-semibold text-foreground focus:outline-none"
/>
) : (
<span
key={v}
className="group inline-flex items-center gap-1 rounded-md border border-border/60 bg-card py-1 pl-2.5 pr-1.5 text-[11px] font-semibold text-foreground shadow-sm"
>
<X className="h-3 w-3" />
</button>
</span>
))
{v}
{onRename && (
<button
type="button"
onClick={() => {
setRenameValue(v);
setRenaming(v);
setError(null);
}}
disabled={isPending}
className="rounded p-0.5 text-tertiary opacity-0 transition-all hover:bg-muted hover:text-foreground focus:opacity-100 group-hover:opacity-100 disabled:opacity-50"
aria-label={`Rinomina ${v}`}
title={`Rinomina "${v}" ovunque`}
>
<Pencil className="h-3 w-3" />
</button>
)}
<button
type="button"
onClick={() => handleRemove(v)}
disabled={isPending}
className="rounded p-0.5 text-tertiary transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
aria-label={`Elimina ${v}`}
title={`Elimina "${v}" ovunque`}
>
<X className="h-3 w-3" />
</button>
</span>
)
)
)}
</div>
</div>
@@ -2,7 +2,12 @@
import { Tag, BookOpen } from "lucide-react";
import { PoolManager } from "./PoolManager";
import { addTaxonomyValue, removeTaxonomyValue } from "@/app/admin/impostazioni/actions";
import {
addTaxonomyValue,
getFaseRenameImpact,
removeTaxonomyValue,
renameTaxonomyValue,
} from "@/app/admin/impostazioni/actions";
type FieldDef = { id: string; label: string };
@@ -19,6 +24,17 @@ const CATALOG_FIELDS: FieldDef[] = [
{ id: "service_pacchetto", label: "Pacchetto" },
];
// Renaming a Fase also rewrites `phases.title` in projects that already imported
// it — the only taxonomy rename that touches delivery data, so it asks first and
// says exactly how much it will touch.
async function faseRenameWarning(oldValue: string, newValue: string): Promise<string | null> {
const { phases, projects } = await getFaseRenameImpact(oldValue);
if (phases === 0) return null;
const fasi = phases === 1 ? "1 fase" : `${phases} fasi`;
const prog = projects === 1 ? "1 progetto" : `${projects} progetti`;
return `Rinominare "${oldValue}" in "${newValue}"?\n\nVerrà aggiornato anche il titolo di ${fasi} in ${prog} già avviati.`;
}
function Section({
icon,
title,
@@ -54,6 +70,8 @@ function Section({
pool={pools[f.id] ?? []}
onAdd={(v) => addTaxonomyValue(f.id, v)}
onRemove={(v) => removeTaxonomyValue(f.id, v)}
onRename={(o, n) => renameTaxonomyValue(f.id, o, n)}
getRenameWarning={f.id === "service_fase" ? faseRenameWarning : undefined}
/>
))}
</div>
@@ -82,7 +100,8 @@ export function TaxonomyManager({ pools }: { pools: Record<string, string[]> })
/>
<p className="max-w-4xl px-2 text-xs leading-relaxed text-tertiary">
I valori si sincronizzano ovunque: creandone uno dall&apos;editor offerte o dal catalogo
comparirà qui automaticamente. Eliminando un valore da qui viene rimosso ovunque sia usato.
comparirà qui automaticamente. Rinominando un valore da qui il nuovo nome sostituisce il
vecchio ovunque sia usato; eliminandolo viene rimosso ovunque.
</p>
</div>
);
+31 -24
View File
@@ -15,32 +15,40 @@ import {
} from "@dnd-kit/core";
import { updateTaskStatus } from "@/app/admin/clients/[id]/actions";
import type { ClientFullDetail } from "@/lib/admin-queries";
import {
normalizeTaskStatus,
TASK_STATUS_LABELS,
TASK_STATUSES,
type TaskStatus,
} from "@/lib/task-status";
type Task = ClientFullDetail["phases"][number]["tasks"][number] & {
phaseTitle: string;
};
type Status = "todo" | "in_progress" | "done";
type Status = TaskStatus;
const COLUMNS: { id: Status; label: string; headerClass: string; dotClass: string }[] = [
{
id: "todo",
label: "Da fare",
headerClass: "text-[#71717a]",
dotClass: "bg-[#d4d4d8]",
// Colours are status semantics — the sanctioned exception to the token rule.
// The hex values are pre-existing design debt (DEBT-01); "in revisione" uses
// violet rather than amber, which PhaseCard already spends on "in corso".
const COLUMN_STYLES: Record<Status, { headerClass: string; dotClass: string }> = {
todo: { headerClass: "text-[#71717a]", dotClass: "bg-[#d4d4d8]" },
in_progress: { headerClass: "text-[#1A463C]", dotClass: "bg-[#DEF168]" },
in_review: {
headerClass: "text-violet-600 dark:text-violet-400",
dotClass: "bg-violet-500",
},
{
id: "in_progress",
label: "In corso",
headerClass: "text-[#1A463C]",
dotClass: "bg-[#DEF168]",
},
{
id: "done",
label: "Fatto",
headerClass: "text-[#1A463C]",
dotClass: "bg-[#1A463C]",
},
];
done: { headerClass: "text-[#1A463C]", dotClass: "bg-[#1A463C]" },
};
const COLUMNS: { id: Status; label: string; headerClass: string; dotClass: string }[] =
TASK_STATUSES.map((id) => ({
id,
label: TASK_STATUS_LABELS[id],
...COLUMN_STYLES[id],
}));
// Derived from the columns, never a second hand-written list.
const VALID_STATUSES: string[] = COLUMNS.map((c) => c.id);
function DroppableColumn({
id,
@@ -149,7 +157,7 @@ export function KanbanBoard({
const map: Record<string, Status> = {};
for (const phase of phases) {
for (const task of phase.tasks) {
map[task.id] = task.status as Status;
map[task.id] = normalizeTaskStatus(task.status);
}
}
return map;
@@ -187,8 +195,7 @@ export function KanbanBoard({
const currentStatus = taskStatuses[taskId];
if (newStatus === currentStatus) return;
if (!(["todo", "in_progress", "done"] as string[]).includes(newStatus))
return;
if (!VALID_STATUSES.includes(newStatus)) return;
setTaskStatuses((prev) => ({ ...prev, [taskId]: newStatus }));
@@ -204,7 +211,7 @@ export function KanbanBoard({
onDragStart={(e) => setActiveId(e.active.id as string)}
onDragEnd={handleDragEnd}
>
<div className="grid grid-cols-3 gap-4">
<div className="grid grid-cols-2 gap-4 xl:grid-cols-4">
{COLUMNS.map((col) => (
<DroppableColumn
key={col.id}
+5 -5
View File
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
import { DeletePhaseTaskButton } from "@/components/admin/DeletePhaseTaskButton";
import { TimerCell } from "@/components/admin/TimerCell";
import type { ClientFullDetail } from "@/lib/admin-queries";
import { TASK_STATUS_LABELS, TASK_STATUSES } from "@/lib/task-status";
type Props = {
phases: ClientFullDetail["phases"];
@@ -22,11 +23,10 @@ type Props = {
phaseSeconds?: Record<string, number>;
};
const taskStatusOptions = [
{ value: "todo", label: "Da fare" },
{ value: "in_progress", label: "In corso" },
{ value: "done", label: "Fatto" },
];
const taskStatusOptions = TASK_STATUSES.map((value) => ({
value,
label: TASK_STATUS_LABELS[value],
}));
const phaseStatusOptions = [
{ value: "upcoming", label: "Da iniziare" },
+12 -1
View File
@@ -4,6 +4,7 @@ import { useState } from "react";
import { ApproveButton } from "@/components/client/ApproveButton";
import { useChatContext } from "@/components/client/ChatProvider";
import type { ClientView } from "@/lib/client-view";
import { TASK_STATUS_LABELS, type TaskStatus } from "@/lib/task-status";
type Phase = ClientView["phases"][number];
@@ -28,7 +29,7 @@ const phaseBarColor: Record<"upcoming" | "active" | "done", string> = {
done: "bg-emerald-600",
};
function TaskStatusIcon({ status }: { status: "todo" | "in_progress" | "done" }) {
function TaskStatusIcon({ status }: { status: TaskStatus }) {
if (status === "done") {
return (
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full bg-emerald-50 text-[10px] font-bold text-emerald-600 dark:bg-emerald-500/10 dark:text-emerald-400">
@@ -36,6 +37,16 @@ function TaskStatusIcon({ status }: { status: "todo" | "in_progress" | "done" })
</span>
);
}
if (status === "in_review") {
return (
<span
className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 border-violet-400 bg-card dark:border-violet-500"
title={TASK_STATUS_LABELS.in_review}
>
<span className="h-2 w-2 rounded-full bg-violet-500 dark:bg-violet-400" />
</span>
);
}
if (status === "in_progress") {
return (
<span className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border-2 border-amber-400 bg-card">
+16 -11
View File
@@ -2,16 +2,16 @@
import type { ClientView } from "@/lib/client-view";
import { ApproveButton } from "@/components/client/ApproveButton";
import { TASK_STATUS_LABELS, TASK_STATUSES, type TaskStatus } from "@/lib/task-status";
type Task = ClientView["phases"][number]["tasks"][number] & {
phaseTitle: string;
};
const COLUMNS: { id: "todo" | "in_progress" | "done"; label: string }[] = [
{ id: "todo", label: "Da fare" },
{ id: "in_progress", label: "In corso" },
{ id: "done", label: "Fatto" },
];
const COLUMNS: { id: TaskStatus; label: string }[] = TASK_STATUSES.map((id) => ({
id,
label: TASK_STATUS_LABELS[id],
}));
function TaskCard({ task, token }: { task: Task; token: string }) {
return (
@@ -46,14 +46,19 @@ export function ClientKanban({ phases, token }: { phases: ClientView["phases"];
phase.tasks.map((task) => ({ ...task, phaseTitle: phase.title }))
);
const tasksByStatus = {
todo: allTasks.filter((t) => t.status === "todo"),
in_progress: allTasks.filter((t) => t.status === "in_progress"),
done: allTasks.filter((t) => t.status === "done"),
};
// Derived from the statuses, not a hand-written object: as three fixed keys it
// dropped any task outside them from every column AND every counter, so the
// client silently saw fewer tasks than the project had.
const tasksByStatus = COLUMNS.reduce(
(acc, col) => {
acc[col.id] = allTasks.filter((t) => t.status === col.id);
return acc;
},
{} as Record<TaskStatus, Task[]>
);
return (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6">
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-4">
{COLUMNS.map((col) => (
<div key={col.id} className="flex flex-col gap-4 rounded-xl border border-border-light bg-muted/60 p-4 min-h-[300px]">
<div className="flex items-center justify-between border-b border-border-light pb-2">
+1 -1
View File
@@ -141,7 +141,7 @@ export const tasks = pgTable("tasks", {
.references(() => phases.id, { onDelete: "cascade" }),
title: text("title").notNull(),
description: text("description"),
status: text("status").notNull().default("todo"), // todo | in_progress | done
status: text("status").notNull().default("todo"), // TASK_STATUSES in src/lib/task-status.ts
sort_order: integer("sort_order").notNull().default(0),
});
+2 -1
View File
@@ -1,6 +1,7 @@
import { eq, ne, and, inArray, asc, desc } from "drizzle-orm";
import { db } from "@/db";
import { clients, projects, phases, tasks, deliverables, payments, documents, notes, comments, project_offers, offer_micros, offer_micro_services, offer_services, offer_macros, offer_tier_services, services, clientTranscripts } from "@/db/schema";
import type { TaskStatus } from "@/lib/task-status";
/**
* ClientView: Legacy shape used by ClientDashboard component.
@@ -24,7 +25,7 @@ export interface ClientView {
id: string;
title: string;
description: string | null;
status: "todo" | "in_progress" | "done";
status: TaskStatus;
sort_order: number;
deliverables: Array<{
id: string;
+29
View File
@@ -0,0 +1,29 @@
// ── Task statuses, in one place ───────────────────────────────────────────────
// `tasks.status` is a plain text column with no CHECK constraint, and until now
// the three values were retyped by hand in eight files — as a runtime allow-list,
// as a TS union, as kanban columns, as <select> options. Two of those copies
// dropped unknown values silently. Everything derives from here instead.
//
// No db import on purpose: client components import this file.
export const TASK_STATUSES = ["todo", "in_progress", "in_review", "done"] as const;
export type TaskStatus = (typeof TASK_STATUSES)[number];
export const TASK_STATUS_LABELS: Record<TaskStatus, string> = {
todo: "Da fare",
in_progress: "In corso",
in_review: "In revisione",
done: "Fatto",
};
export function isTaskStatus(value: string): value is TaskStatus {
return (TASK_STATUSES as readonly string[]).includes(value);
}
// Guards the boundary where a raw DB string enters a typed union. Previously an
// `as` cast, which TypeScript does not check: a row holding an unknown status
// slipped through and then vanished from whichever board failed to match it.
export function normalizeTaskStatus(value: string): TaskStatus {
return isTaskStatus(value) ? value : "todo";
}
+28 -1
View File
@@ -1,5 +1,5 @@
import { db } from "@/db";
import { tags, offer_macros, services } from "@/db/schema";
import { tags, offer_macros, services, phases } from "@/db/schema";
import { eq, sql } from "drizzle-orm";
import { getSetting, writeSetting } from "@/lib/settings";
@@ -195,5 +195,32 @@ export async function renamePoolValue(
await db.update(offer_macros).set({ ticket: next }).where(eq(offer_macros.ticket, oldValue));
} else {
await db.update(services).set({ fase: next }).where(eq(services.fase, oldValue));
await renameProjectPhasesByTitle(oldValue, next);
}
}
// `importOfferIntoProject` materializes project phases by COPYING the service's
// `fase` into `phases.title`, and later re-imports match an existing phase by that
// title alone (there is no FK: `phases.offer_phase_id` is never populated). So a
// fase rename that stops at the catalog leaves every project phase stranded under
// the old name, and the next re-import creates a duplicate phase beside it.
// The match mirrors the import's merge key exactly (trim + lowercase) — an exact
// match would miss the casing variants the import already treats as one phase.
async function renameProjectPhasesByTitle(oldValue: string, next: string): Promise<void> {
await db
.update(phases)
.set({ title: next })
.where(sql`lower(trim(${phases.title})) = ${oldValue.trim().toLowerCase()}`);
}
// How many project phases a `service_fase` rename would rewrite — powers the
// confirmation dialog in the settings panel. Read-only.
export async function countProjectPhasesByTitle(
value: string
): Promise<{ phases: number; projects: number }> {
const rows = await db
.select({ project_id: phases.project_id })
.from(phases)
.where(sql`lower(trim(${phases.title})) = ${value.trim().toLowerCase()}`);
return { phases: rows.length, projects: new Set(rows.map((r) => r.project_id)).size };
}