From 3fcb10dac691339e8d7f5995d017252df9adf5f4 Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Thu, 20 Aug 2026 16:08:20 +0200 Subject: [PATCH] feat(impostazioni): rinomina di un valore di tassonomia, fasi dei progetti incluse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/app/admin/catalog/actions.ts | 8 +- src/app/admin/impostazioni/actions.ts | 33 ++++++ src/app/admin/offers/actions.ts | 7 +- .../admin/impostazioni/PoolManager.tsx | 100 ++++++++++++++---- .../admin/impostazioni/TaxonomyManager.tsx | 23 +++- src/lib/taxonomy.ts | 29 ++++- 6 files changed, 168 insertions(+), 32 deletions(-) diff --git a/src/app/admin/catalog/actions.ts b/src/app/admin/catalog/actions.ts index d81ce85..865250d 100644 --- a/src/app/admin/catalog/actions.ts +++ b/src/app/admin/catalog/actions.ts @@ -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}`); diff --git a/src/app/admin/impostazioni/actions.ts b/src/app/admin/impostazioni/actions.ts index 81b2031..d99ad0f 100644 --- a/src/app/admin/impostazioni/actions.ts +++ b/src/app/admin/impostazioni/actions.ts @@ -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 { @@ -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 { + 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); +} diff --git a/src/app/admin/offers/actions.ts b/src/app/admin/offers/actions.ts index ba6e20c..5c2a14b 100644 --- a/src/app/admin/offers/actions.ts +++ b/src/app/admin/offers/actions.ts @@ -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}`); diff --git a/src/components/admin/impostazioni/PoolManager.tsx b/src/components/admin/impostazioni/PoolManager.tsx index b57b552..86288a7 100644 --- a/src/components/admin/impostazioni/PoolManager.tsx +++ b/src/components/admin/impostazioni/PoolManager.tsx @@ -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; onRemove: (value: string) => Promise; + onRename?: (oldValue: string, newValue: string) => Promise; + // 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; }) { const [input, setInput] = useState(""); const [isPending, startTransition] = useTransition(); const [error, setError] = useState(null); + const [renaming, setRenaming] = useState(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) { if (e.key === "Enter") { e.preventDefault(); @@ -69,24 +94,63 @@ export function PoolManager({ Nessun valore inserito ) : ( - pool.map((v) => ( - - {v} - - - )) + {v} + {onRename && ( + + )} + + + ) + ) )} diff --git a/src/components/admin/impostazioni/TaxonomyManager.tsx b/src/components/admin/impostazioni/TaxonomyManager.tsx index 8d9cbbb..1036769 100644 --- a/src/components/admin/impostazioni/TaxonomyManager.tsx +++ b/src/components/admin/impostazioni/TaxonomyManager.tsx @@ -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 { + 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} /> ))} @@ -82,7 +100,8 @@ export function TaxonomyManager({ pools }: { pools: Record }) />

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.

); diff --git a/src/lib/taxonomy.ts b/src/lib/taxonomy.ts index 06fbab1..827350a 100644 --- a/src/lib/taxonomy.ts +++ b/src/lib/taxonomy.ts @@ -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 { + 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 }; +}