feat: centralized Notion-style taxonomy management in settings

Single persistent option pool per taxonomy (7 fields: offer
categoria/ticket/tipo/obiettivo + catalog fase/offerta/pacchetto),
stored as JSON in the settings table (no DB migration).

- src/lib/taxonomy.ts: pools with lazy seed from in-use values,
  add/remove(cascade)/rename helpers
- Inline creation anywhere registers into the pool (save offer,
  addServiceOption, updateServiceField fase, quickAdd, create macro)
- Deselecting on a row never touches the pool; global delete only
  from settings (cascade-strips tags / nulls columns)
- getOfferFieldOptions + getCatalogFieldOptions read from pools
- Settings: TaxonomyManager (offer + catalog groups), confirm on delete

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 14:24:47 +02:00
parent 320827e13a
commit e80c95f838
11 changed files with 363 additions and 161 deletions
+10 -1
View File
@@ -7,6 +7,7 @@ import { eq, and } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { addPoolValue, renamePoolValue } from "@/lib/taxonomy";
const serviceSchema = z.object({
name: z.string().min(1, "Nome richiesto"),
@@ -95,6 +96,7 @@ export async function updateServiceField(
} else if (fieldName === "fase") {
const s = String(value).trim();
await db.update(services).set({ fase: s || null }).where(eq(services.id, serviceId));
if (s) await addPoolValue("service_fase", s);
} else if (fieldName === "unit_price") {
// Normalize locale-formatted input (WR-04): the cell displays it-IT (€1.234,50),
// so an admin may type "1.234,50". When a comma is present, treat "." as thousands
@@ -143,6 +145,9 @@ export async function addServiceOption(
.values({ entity_type: MULTI_ENTITY[field], entity_id: serviceId, name: trimmed })
.onConflictDoNothing();
// Register into the persistent pool so it survives unassignment / shows in settings.
await addPoolValue(field === "tag" ? "service_offerta" : "service_pacchetto", trimmed);
revalidatePath("/admin/catalog");
}
@@ -187,10 +192,12 @@ export async function renameServiceOption(
.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") {
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}`);
}
@@ -227,14 +234,16 @@ export async function quickAddService(payload: QuickAddPayload | string) {
unit_price = num.toFixed(2);
}
const fase = data.fase?.trim() || null;
await db.insert(services).values({
name,
description: data.description?.trim() || null,
category: data.category?.trim() || null,
fase: data.fase?.trim() || null,
fase,
unit_price,
active: true,
});
if (fase) await addPoolValue("service_fase", fase);
revalidatePath("/admin/catalog");
}