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");
}
+31 -22
View File
@@ -1,33 +1,42 @@
"use server";
import { getJsonPool, updateJsonPool, SETTINGS_KEYS } from "@/lib/settings";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { revalidatePath } from "next/cache";
import {
addPoolValue,
removePoolValue,
TAXONOMY_FIELDS,
type TaxonomyFieldId,
} from "@/lib/taxonomy";
type PoolName = "tipo" | "obiettivo" | "categoria";
function poolKey(pool: PoolName): string {
switch (pool) {
case "tipo": return SETTINGS_KEYS.OFFER_TIPO_POOL;
case "obiettivo": return SETTINGS_KEYS.OFFER_OBIETTIVO_POOL;
case "categoria": return SETTINGS_KEYS.OFFER_CATEGORIA_POOL;
}
async function requireAdmin() {
const session = await getServerSession(authOptions);
if (!session) throw new Error("Non autorizzato");
}
export async function addPoolValue(pool: PoolName, value: string): Promise<void> {
const trimmed = value.trim();
if (!trimmed) return;
const key = poolKey(pool);
const current = await getJsonPool(key);
if (current.includes(trimmed)) return;
await updateJsonPool(key, [...current, trimmed]);
function assertField(fieldId: string): asserts fieldId is TaxonomyFieldId {
if (!(fieldId in TAXONOMY_FIELDS)) throw new Error(`Tassonomia non valida: ${fieldId}`);
}
function revalidateAll() {
revalidatePath("/admin/impostazioni");
revalidatePath("/admin/offers");
revalidatePath("/admin/catalog");
}
export async function removePoolValue(pool: PoolName, value: string): Promise<void> {
const key = poolKey(pool);
const current = await getJsonPool(key);
await updateJsonPool(key, current.filter((v) => v !== value));
revalidatePath("/admin/impostazioni");
revalidatePath("/admin/offers");
export async function addTaxonomyValue(fieldId: string, value: string): Promise<void> {
await requireAdmin();
assertField(fieldId);
await addPoolValue(fieldId, value);
revalidateAll();
}
// Global delete: removes the value from the pool AND cascade-strips it from every
// offer/service using it.
export async function removeTaxonomyValue(fieldId: string, value: string): Promise<void> {
await requireAdmin();
assertField(fieldId);
await removePoolValue(fieldId, value);
revalidateAll();
}
+5 -13
View File
@@ -1,15 +1,11 @@
import { getTargetHourlyRate, getJsonPool, updateSetting, SETTINGS_KEYS } from "@/lib/settings";
import { OfferPoolsSection } from "@/components/admin/impostazioni/OfferPoolsSection";
import { getTargetHourlyRate, updateSetting, SETTINGS_KEYS } from "@/lib/settings";
import { getAllPools } from "@/lib/taxonomy";
import { TaxonomyManager } from "@/components/admin/impostazioni/TaxonomyManager";
export const revalidate = 0;
export default async function ImpostazioniPage() {
const [targetRate, tipoPool, obiettivoPool, categoriaPool] = await Promise.all([
getTargetHourlyRate(),
getJsonPool(SETTINGS_KEYS.OFFER_TIPO_POOL),
getJsonPool(SETTINGS_KEYS.OFFER_OBIETTIVO_POOL),
getJsonPool(SETTINGS_KEYS.OFFER_CATEGORIA_POOL),
]);
const [targetRate, pools] = await Promise.all([getTargetHourlyRate(), getAllPools()]);
async function handleSave(fd: FormData) {
"use server";
@@ -62,11 +58,7 @@ export default async function ImpostazioniPage() {
</form>
</div>
<OfferPoolsSection
tipoPool={tipoPool}
obiettivoPool={obiettivoPool}
categoriaPool={categoriaPool}
/>
<TaxonomyManager pools={pools} />
</div>
</div>
);
+14
View File
@@ -14,6 +14,7 @@ import { eq, and, inArray } from "drizzle-orm";
import { z } from "zod";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
import { addPoolValue, renamePoolValue } from "@/lib/taxonomy";
async function requireAdmin() {
const session = await getServerSession(authOptions);
@@ -259,6 +260,13 @@ export async function saveOfferEditor(macroId: string, payload: SaveOfferEditorP
await db.insert(tags).values(tagRows).onConflictDoNothing();
}
// 4. Register any newly-typed values into the persistent taxonomy pools so they
// survive unassignment and appear in settings + every dropdown.
if (data.category) await addPoolValue("offer_categoria", data.category);
if (data.ticket) await addPoolValue("offer_ticket", data.ticket);
for (const name of data.tipoTags) await addPoolValue("offer_tipo", name);
for (const name of data.obiettivoTags) await addPoolValue("offer_obiettivo", name);
revalidatePath("/admin/offers");
revalidatePath(`/admin/offers/${macroId}/edit`);
}
@@ -290,6 +298,8 @@ export async function addOfferTag(dimension: OfferTagDimension, macroId: string,
.values({ entity_type: OFFER_TAG_ENTITY[dimension], entity_id: macroId, name: trimmed })
.onConflictDoNothing();
await addPoolValue(dimension === "tipo" ? "offer_tipo" : "offer_obiettivo", trimmed);
revalidatePath("/admin/offers");
}
@@ -332,13 +342,16 @@ export async function renameOfferOption(
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}`);
}
@@ -371,6 +384,7 @@ export async function createOfferMacro(formData: FormData) {
description: parsed.data.description || null,
category: parsed.data.category || null,
});
if (parsed.data.category) await addPoolValue("offer_categoria", parsed.data.category);
revalidatePath("/admin/offers");
}