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
@@ -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>
);