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:
@@ -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'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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user