Compare commits
3 Commits
97cc6460a0
...
9a57e450fc
| Author | SHA1 | Date | |
|---|---|---|---|
| 9a57e450fc | |||
| 5547e555bd | |||
| 3fcb10dac6 |
+8
-6
@@ -3,9 +3,9 @@ gsd_state_version: 1.0
|
|||||||
milestone: v2.5
|
milestone: v2.5
|
||||||
milestone_name: Audit
|
milestone_name: Audit
|
||||||
status: executing
|
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."
|
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-19T21:15:00.000Z"
|
last_updated: "2026-08-20T15:40:00.000Z"
|
||||||
last_activity: 2026-08-19 -- modifiche hub: progetti, dashboard e ingresso lead in produzione (19ed377)
|
last_activity: 2026-08-20 -- modifiche hub: rinomina tassonomie e stato task "In revisione" (5547e55)
|
||||||
progress:
|
progress:
|
||||||
total_phases: 4
|
total_phases: 4
|
||||||
completed_phases: 0
|
completed_phases: 0
|
||||||
@@ -27,8 +27,8 @@ il suo progetto, senza scrivere email. · **Current focus:** modifiche hub (v2.5
|
|||||||
|
|
||||||
## Current Position
|
## Current Position
|
||||||
|
|
||||||
**v2.5 è in pausa per scelta** (2026-08-19): prima le modifiche all'hub chieste il
|
**v2.5 è in pausa per scelta** (2026-08-19): prima le modifiche all'hub, poi il motore.
|
||||||
2026-08-18, poi il motore. Phase 27 resta a metà — schema e fonti in prod, resto da scrivere.
|
Phase 27 resta a metà — schema e fonti in prod, resto da scrivere.
|
||||||
|
|
||||||
| Blocco (modifiche hub) | Stato |
|
| 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 |
|
| 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 |
|
| C1 — `POST /api/webhooks/lead` | ✅ in produzione, provato contro il DB vero |
|
||||||
| C2 — TidyCal | ⛔ **[BLOCCANTE]** vedi sotto |
|
| 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 |
|
| D — Whop → audit | ⏸️ dipende dal motore v2.5 |
|
||||||
|
|
||||||
Progress: [███░░░░░░░] 25% (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:
|
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] 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.
|
||||||
- **[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.
|
- **[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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ClientHub (IAMCAVALLI) — Status
|
# 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,
|
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
|
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
|
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.
|
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é:
|
Cosa manca, e perché:
|
||||||
|
|
||||||
- **[BLOCCANTE] TidyCal.** Non ha webhook — è scritto nella loro FAQ, la strada
|
- **[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`.
|
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
|
- **L'API Coolify rifiuta `is_build_time`** con 422 sul POST a
|
||||||
`/api/v1/applications/<uuid>/envs`: mandare solo `key`, `value`, `is_preview`.
|
`/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
|
- **Playwright non funziona contro `npm run dev`**: la CSP blocca `eval` e i client
|
||||||
component non si idratano. Serve il build di produzione.
|
component non si idratano. Serve il build di produzione.
|
||||||
- **Le API di Google cambiano forma sotto i piedi, e in silenzio.** Scrivendo
|
- **Le API di Google cambiano forma sotto i piedi, e in silenzio.** Scrivendo
|
||||||
|
|||||||
@@ -187,16 +187,14 @@ export async function renameServiceOption(
|
|||||||
if (next.length === 0) throw new Error("Nuovo nome richiesto");
|
if (next.length === 0) throw new Error("Nuovo nome richiesto");
|
||||||
if (next === oldValue) return;
|
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") {
|
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);
|
await renamePoolValue(field === "tag" ? "service_offerta" : "service_pacchetto", oldValue, next);
|
||||||
} else if (field === "categoria") {
|
} 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));
|
await db.update(services).set({ category: next }).where(eq(services.category, oldValue));
|
||||||
} else if (field === "fase") {
|
} else if (field === "fase") {
|
||||||
await db.update(services).set({ fase: next }).where(eq(services.fase, oldValue));
|
|
||||||
await renamePoolValue("service_fase", oldValue, next);
|
await renamePoolValue("service_fase", oldValue, next);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Campo non valido: ${field}`);
|
throw new Error(`Campo non valido: ${field}`);
|
||||||
|
|||||||
@@ -23,6 +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";
|
||||||
|
|
||||||
// ── 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.
|
||||||
@@ -179,9 +180,13 @@ export async function addTask(phaseId: string, id: string, formData: FormData) {
|
|||||||
|
|
||||||
// ── PHASE STATUS CASCADE ──────────────────────────────────────────────────────
|
// ── PHASE STATUS CASCADE ──────────────────────────────────────────────────────
|
||||||
// Recomputes phase status from its tasks:
|
// Recomputes phase status from its tasks:
|
||||||
// all done → done
|
// all done → done
|
||||||
// any in_progress or done (but not all done) → active
|
// any task moved off "todo" → active
|
||||||
// all todo (or no tasks) → upcoming
|
// 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> {
|
export async function recomputePhaseStatus(phaseId: string): Promise<void> {
|
||||||
const phaseTasks = await db
|
const phaseTasks = await db
|
||||||
.select({ status: tasks.status })
|
.select({ status: tasks.status })
|
||||||
@@ -191,9 +196,7 @@ export async function recomputePhaseStatus(phaseId: string): Promise<void> {
|
|||||||
let newStatus: "upcoming" | "active" | "done" = "upcoming";
|
let newStatus: "upcoming" | "active" | "done" = "upcoming";
|
||||||
if (phaseTasks.length > 0) {
|
if (phaseTasks.length > 0) {
|
||||||
const allDone = phaseTasks.every((t) => t.status === "done");
|
const allDone = phaseTasks.every((t) => t.status === "done");
|
||||||
const anyActive = phaseTasks.some(
|
const anyActive = phaseTasks.some((t) => t.status !== "todo");
|
||||||
(t) => t.status === "in_progress" || t.status === "done"
|
|
||||||
);
|
|
||||||
if (allDone) newStatus = "done";
|
if (allDone) newStatus = "done";
|
||||||
else if (anyActive) newStatus = "active";
|
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) {
|
export async function updateTaskStatus(taskId: string, id: string, status: string) {
|
||||||
await requireAdmin();
|
await requireAdmin();
|
||||||
const allowed = ["todo", "in_progress", "done"];
|
if (!isTaskStatus(status)) throw new Error("Stato non valido");
|
||||||
if (!allowed.includes(status)) throw new Error("Stato non valido");
|
|
||||||
await db.update(tasks).set({ status }).where(eq(tasks.id, taskId));
|
await db.update(tasks).set({ status }).where(eq(tasks.id, taskId));
|
||||||
|
|
||||||
// Cascade: recompute parent phase status from all its tasks
|
// Cascade: recompute parent phase status from all its tasks
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import { authOptions } from "@/lib/auth";
|
|||||||
import { revalidatePath } from "next/cache";
|
import { revalidatePath } from "next/cache";
|
||||||
import {
|
import {
|
||||||
addPoolValue,
|
addPoolValue,
|
||||||
|
countProjectPhasesByTitle,
|
||||||
removePoolValue,
|
removePoolValue,
|
||||||
|
renamePoolValue,
|
||||||
TAXONOMY_FIELDS,
|
TAXONOMY_FIELDS,
|
||||||
type TaxonomyFieldId,
|
type TaxonomyFieldId,
|
||||||
} from "@/lib/taxonomy";
|
} from "@/lib/taxonomy";
|
||||||
@@ -23,6 +25,10 @@ function revalidateAll() {
|
|||||||
revalidatePath("/admin/impostazioni");
|
revalidatePath("/admin/impostazioni");
|
||||||
revalidatePath("/admin/offers");
|
revalidatePath("/admin/offers");
|
||||||
revalidatePath("/admin/catalog");
|
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> {
|
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);
|
await removePoolValue(fieldId, value);
|
||||||
revalidateAll();
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -342,17 +342,12 @@ export async function renameOfferOption(
|
|||||||
if (next.length === 0) throw new Error("Nuovo nome richiesto");
|
if (next.length === 0) throw new Error("Nuovo nome richiesto");
|
||||||
if (next === oldValue) return;
|
if (next === oldValue) return;
|
||||||
|
|
||||||
|
// renamePoolValue already propagates to offer_macros / tags — no manual UPDATE.
|
||||||
if (field === "categoria") {
|
if (field === "categoria") {
|
||||||
await db.update(offer_macros).set({ category: next }).where(eq(offer_macros.category, oldValue));
|
|
||||||
await renamePoolValue("offer_categoria", oldValue, next);
|
await renamePoolValue("offer_categoria", oldValue, next);
|
||||||
} else if (field === "ticket") {
|
} else if (field === "ticket") {
|
||||||
await db.update(offer_macros).set({ ticket: next }).where(eq(offer_macros.ticket, oldValue));
|
|
||||||
await renamePoolValue("offer_ticket", oldValue, next);
|
await renamePoolValue("offer_ticket", oldValue, next);
|
||||||
} else if (field === "tipo" || field === "obiettivo") {
|
} 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);
|
await renamePoolValue(field === "tipo" ? "offer_tipo" : "offer_obiettivo", oldValue, next);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Campo non valido: ${field}`);
|
throw new Error(`Campo non valido: ${field}`);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { OtpGate } from "@/components/client/OtpGate";
|
|||||||
import { PreviewBanner } from "@/components/client/PreviewBanner";
|
import { PreviewBanner } from "@/components/client/PreviewBanner";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import type { Comment } from "@/db/schema";
|
import type { Comment } from "@/db/schema";
|
||||||
|
import { normalizeTaskStatus } from "@/lib/task-status";
|
||||||
|
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
|
|
||||||
@@ -40,7 +41,7 @@ function projectViewToClientView(
|
|||||||
id: task.id,
|
id: task.id,
|
||||||
title: task.title,
|
title: task.title,
|
||||||
description: task.description,
|
description: task.description,
|
||||||
status: task.status as "todo" | "in_progress" | "done",
|
status: normalizeTaskStatus(task.status),
|
||||||
sort_order: task.sort_order,
|
sort_order: task.sort_order,
|
||||||
deliverables: task.deliverables.map((d) => ({
|
deliverables: task.deliverables.map((d) => ({
|
||||||
id: d.id,
|
id: d.id,
|
||||||
|
|||||||
@@ -1,22 +1,31 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState, useTransition } from "react";
|
import { useState, useTransition } from "react";
|
||||||
import { X } from "lucide-react";
|
import { Pencil, X } from "lucide-react";
|
||||||
|
|
||||||
export function PoolManager({
|
export function PoolManager({
|
||||||
label,
|
label,
|
||||||
pool,
|
pool,
|
||||||
onAdd,
|
onAdd,
|
||||||
onRemove,
|
onRemove,
|
||||||
|
onRename,
|
||||||
|
getRenameWarning,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
pool: string[];
|
pool: string[];
|
||||||
onAdd: (value: string) => Promise<void>;
|
onAdd: (value: string) => Promise<void>;
|
||||||
onRemove: (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 [input, setInput] = useState("");
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [renaming, setRenaming] = useState<string | null>(null);
|
||||||
|
const [renameValue, setRenameValue] = useState("");
|
||||||
|
|
||||||
function handleAdd() {
|
function handleAdd() {
|
||||||
const trimmed = input.trim();
|
const trimmed = input.trim();
|
||||||
@@ -40,6 +49,22 @@ export function PoolManager({
|
|||||||
startTransition(() => onRemove(value));
|
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>) {
|
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||||
if (e.key === "Enter") {
|
if (e.key === "Enter") {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -69,24 +94,63 @@ export function PoolManager({
|
|||||||
Nessun valore inserito
|
Nessun valore inserito
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
pool.map((v) => (
|
pool.map((v) =>
|
||||||
<span
|
renaming === v ? (
|
||||||
key={v}
|
<input
|
||||||
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"
|
key={v}
|
||||||
>
|
autoFocus
|
||||||
{v}
|
type="text"
|
||||||
<button
|
value={renameValue}
|
||||||
type="button"
|
onChange={(e) => setRenameValue(e.target.value)}
|
||||||
onClick={() => handleRemove(v)}
|
onBlur={() => commitRename(v)}
|
||||||
disabled={isPending}
|
onKeyDown={(e) => {
|
||||||
className="rounded p-0.5 text-tertiary transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
|
if (e.key === "Enter") {
|
||||||
aria-label={`Elimina ${v}`}
|
e.preventDefault();
|
||||||
title={`Elimina "${v}" ovunque`}
|
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" />
|
{v}
|
||||||
</button>
|
{onRename && (
|
||||||
</span>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,7 +2,12 @@
|
|||||||
|
|
||||||
import { Tag, BookOpen } from "lucide-react";
|
import { Tag, BookOpen } from "lucide-react";
|
||||||
import { PoolManager } from "./PoolManager";
|
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 };
|
type FieldDef = { id: string; label: string };
|
||||||
|
|
||||||
@@ -19,6 +24,17 @@ const CATALOG_FIELDS: FieldDef[] = [
|
|||||||
{ id: "service_pacchetto", label: "Pacchetto" },
|
{ 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({
|
function Section({
|
||||||
icon,
|
icon,
|
||||||
title,
|
title,
|
||||||
@@ -54,6 +70,8 @@ function Section({
|
|||||||
pool={pools[f.id] ?? []}
|
pool={pools[f.id] ?? []}
|
||||||
onAdd={(v) => addTaxonomyValue(f.id, v)}
|
onAdd={(v) => addTaxonomyValue(f.id, v)}
|
||||||
onRemove={(v) => removeTaxonomyValue(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>
|
</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">
|
<p className="max-w-4xl px-2 text-xs leading-relaxed text-tertiary">
|
||||||
I valori si sincronizzano ovunque: creandone uno dall'editor offerte o dal catalogo
|
I valori si sincronizzano ovunque: creandone uno dall'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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -15,32 +15,40 @@ import {
|
|||||||
} from "@dnd-kit/core";
|
} from "@dnd-kit/core";
|
||||||
import { updateTaskStatus } from "@/app/admin/clients/[id]/actions";
|
import { updateTaskStatus } from "@/app/admin/clients/[id]/actions";
|
||||||
import type { ClientFullDetail } from "@/lib/admin-queries";
|
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] & {
|
type Task = ClientFullDetail["phases"][number]["tasks"][number] & {
|
||||||
phaseTitle: string;
|
phaseTitle: string;
|
||||||
};
|
};
|
||||||
type Status = "todo" | "in_progress" | "done";
|
type Status = TaskStatus;
|
||||||
|
|
||||||
const COLUMNS: { id: Status; label: string; headerClass: string; dotClass: string }[] = [
|
// Colours are status semantics — the sanctioned exception to the token rule.
|
||||||
{
|
// The hex values are pre-existing design debt (DEBT-01); "in revisione" uses
|
||||||
id: "todo",
|
// violet rather than amber, which PhaseCard already spends on "in corso".
|
||||||
label: "Da fare",
|
const COLUMN_STYLES: Record<Status, { headerClass: string; dotClass: string }> = {
|
||||||
headerClass: "text-[#71717a]",
|
todo: { headerClass: "text-[#71717a]", dotClass: "bg-[#d4d4d8]" },
|
||||||
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",
|
||||||
},
|
},
|
||||||
{
|
done: { headerClass: "text-[#1A463C]", dotClass: "bg-[#1A463C]" },
|
||||||
id: "in_progress",
|
};
|
||||||
label: "In corso",
|
|
||||||
headerClass: "text-[#1A463C]",
|
const COLUMNS: { id: Status; label: string; headerClass: string; dotClass: string }[] =
|
||||||
dotClass: "bg-[#DEF168]",
|
TASK_STATUSES.map((id) => ({
|
||||||
},
|
id,
|
||||||
{
|
label: TASK_STATUS_LABELS[id],
|
||||||
id: "done",
|
...COLUMN_STYLES[id],
|
||||||
label: "Fatto",
|
}));
|
||||||
headerClass: "text-[#1A463C]",
|
|
||||||
dotClass: "bg-[#1A463C]",
|
// Derived from the columns, never a second hand-written list.
|
||||||
},
|
const VALID_STATUSES: string[] = COLUMNS.map((c) => c.id);
|
||||||
];
|
|
||||||
|
|
||||||
function DroppableColumn({
|
function DroppableColumn({
|
||||||
id,
|
id,
|
||||||
@@ -149,7 +157,7 @@ export function KanbanBoard({
|
|||||||
const map: Record<string, Status> = {};
|
const map: Record<string, Status> = {};
|
||||||
for (const phase of phases) {
|
for (const phase of phases) {
|
||||||
for (const task of phase.tasks) {
|
for (const task of phase.tasks) {
|
||||||
map[task.id] = task.status as Status;
|
map[task.id] = normalizeTaskStatus(task.status);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
@@ -187,8 +195,7 @@ export function KanbanBoard({
|
|||||||
const currentStatus = taskStatuses[taskId];
|
const currentStatus = taskStatuses[taskId];
|
||||||
|
|
||||||
if (newStatus === currentStatus) return;
|
if (newStatus === currentStatus) return;
|
||||||
if (!(["todo", "in_progress", "done"] as string[]).includes(newStatus))
|
if (!VALID_STATUSES.includes(newStatus)) return;
|
||||||
return;
|
|
||||||
|
|
||||||
setTaskStatuses((prev) => ({ ...prev, [taskId]: newStatus }));
|
setTaskStatuses((prev) => ({ ...prev, [taskId]: newStatus }));
|
||||||
|
|
||||||
@@ -204,7 +211,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-3 gap-4">
|
<div className="grid grid-cols-2 gap-4 xl:grid-cols-4">
|
||||||
{COLUMNS.map((col) => (
|
{COLUMNS.map((col) => (
|
||||||
<DroppableColumn
|
<DroppableColumn
|
||||||
key={col.id}
|
key={col.id}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { DeletePhaseTaskButton } from "@/components/admin/DeletePhaseTaskButton";
|
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";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
phases: ClientFullDetail["phases"];
|
phases: ClientFullDetail["phases"];
|
||||||
@@ -22,11 +23,10 @@ type Props = {
|
|||||||
phaseSeconds?: Record<string, number>;
|
phaseSeconds?: Record<string, number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
const taskStatusOptions = [
|
const taskStatusOptions = TASK_STATUSES.map((value) => ({
|
||||||
{ value: "todo", label: "Da fare" },
|
value,
|
||||||
{ value: "in_progress", label: "In corso" },
|
label: TASK_STATUS_LABELS[value],
|
||||||
{ value: "done", label: "Fatto" },
|
}));
|
||||||
];
|
|
||||||
|
|
||||||
const phaseStatusOptions = [
|
const phaseStatusOptions = [
|
||||||
{ value: "upcoming", label: "Da iniziare" },
|
{ value: "upcoming", label: "Da iniziare" },
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 type { ClientView } from "@/lib/client-view";
|
import type { ClientView } from "@/lib/client-view";
|
||||||
|
import { TASK_STATUS_LABELS, type TaskStatus } from "@/lib/task-status";
|
||||||
|
|
||||||
type Phase = ClientView["phases"][number];
|
type Phase = ClientView["phases"][number];
|
||||||
|
|
||||||
@@ -28,7 +29,7 @@ const phaseBarColor: Record<"upcoming" | "active" | "done", string> = {
|
|||||||
done: "bg-emerald-600",
|
done: "bg-emerald-600",
|
||||||
};
|
};
|
||||||
|
|
||||||
function TaskStatusIcon({ status }: { status: "todo" | "in_progress" | "done" }) {
|
function TaskStatusIcon({ status }: { status: TaskStatus }) {
|
||||||
if (status === "done") {
|
if (status === "done") {
|
||||||
return (
|
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">
|
<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>
|
</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") {
|
if (status === "in_progress") {
|
||||||
return (
|
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">
|
<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">
|
||||||
|
|||||||
@@ -2,16 +2,16 @@
|
|||||||
|
|
||||||
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_LABELS, TASK_STATUSES, type TaskStatus } from "@/lib/task-status";
|
||||||
|
|
||||||
type Task = ClientView["phases"][number]["tasks"][number] & {
|
type Task = ClientView["phases"][number]["tasks"][number] & {
|
||||||
phaseTitle: string;
|
phaseTitle: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
const COLUMNS: { id: "todo" | "in_progress" | "done"; label: string }[] = [
|
const COLUMNS: { id: TaskStatus; label: string }[] = TASK_STATUSES.map((id) => ({
|
||||||
{ id: "todo", label: "Da fare" },
|
id,
|
||||||
{ id: "in_progress", label: "In corso" },
|
label: TASK_STATUS_LABELS[id],
|
||||||
{ id: "done", label: "Fatto" },
|
}));
|
||||||
];
|
|
||||||
|
|
||||||
function TaskCard({ task, token }: { task: Task; token: string }) {
|
function TaskCard({ task, token }: { task: Task; token: string }) {
|
||||||
return (
|
return (
|
||||||
@@ -46,14 +46,19 @@ export function ClientKanban({ phases, token }: { phases: ClientView["phases"];
|
|||||||
phase.tasks.map((task) => ({ ...task, phaseTitle: phase.title }))
|
phase.tasks.map((task) => ({ ...task, phaseTitle: phase.title }))
|
||||||
);
|
);
|
||||||
|
|
||||||
const tasksByStatus = {
|
// Derived from the statuses, not a hand-written object: as three fixed keys it
|
||||||
todo: allTasks.filter((t) => t.status === "todo"),
|
// dropped any task outside them from every column AND every counter, so the
|
||||||
in_progress: allTasks.filter((t) => t.status === "in_progress"),
|
// client silently saw fewer tasks than the project had.
|
||||||
done: allTasks.filter((t) => t.status === "done"),
|
const tasksByStatus = COLUMNS.reduce(
|
||||||
};
|
(acc, col) => {
|
||||||
|
acc[col.id] = allTasks.filter((t) => t.status === col.id);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<TaskStatus, Task[]>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
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) => (
|
{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 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">
|
<div className="flex items-center justify-between border-b border-border-light pb-2">
|
||||||
|
|||||||
+1
-1
@@ -141,7 +141,7 @@ export const tasks = pgTable("tasks", {
|
|||||||
.references(() => phases.id, { onDelete: "cascade" }),
|
.references(() => phases.id, { onDelete: "cascade" }),
|
||||||
title: text("title").notNull(),
|
title: text("title").notNull(),
|
||||||
description: text("description"),
|
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),
|
sort_order: integer("sort_order").notNull().default(0),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +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, project_offers, offer_micros, offer_micro_services, offer_services, offer_macros, offer_tier_services, services, clientTranscripts } from "@/db/schema";
|
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.
|
* ClientView: Legacy shape used by ClientDashboard component.
|
||||||
@@ -24,7 +25,7 @@ export interface ClientView {
|
|||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
status: "todo" | "in_progress" | "done";
|
status: TaskStatus;
|
||||||
sort_order: number;
|
sort_order: number;
|
||||||
deliverables: Array<{
|
deliverables: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -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
@@ -1,5 +1,5 @@
|
|||||||
import { db } from "@/db";
|
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 { eq, sql } from "drizzle-orm";
|
||||||
import { getSetting, writeSetting } from "@/lib/settings";
|
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));
|
await db.update(offer_macros).set({ ticket: next }).where(eq(offer_macros.ticket, oldValue));
|
||||||
} else {
|
} else {
|
||||||
await db.update(services).set({ fase: next }).where(eq(services.fase, oldValue));
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user