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>
This commit is contained in:
2026-08-20 16:08:20 +02:00
parent 97cc6460a0
commit 3fcb10dac6
6 changed files with 168 additions and 32 deletions
+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}`);
+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}`);
@@ -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>
);
+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 };
}