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:
@@ -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}`);
|
||||||
|
|||||||
@@ -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}`);
|
||||||
|
|||||||
@@ -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,12 +94,50 @@ export function PoolManager({
|
|||||||
Nessun valore inserito
|
Nessun valore inserito
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
pool.map((v) => (
|
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
|
<span
|
||||||
key={v}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
{v}
|
{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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRemove(v)}
|
onClick={() => handleRemove(v)}
|
||||||
@@ -86,7 +149,8 @@ export function PoolManager({
|
|||||||
<X className="h-3 w-3" />
|
<X className="h-3 w-3" />
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</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>
|
||||||
);
|
);
|
||||||
|
|||||||
+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