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 -36
View File
@@ -8,7 +8,7 @@ import {
services,
tags,
} from "@/db/schema";
import { getJsonPool, SETTINGS_KEYS } from "@/lib/settings";
import { getPool } from "@/lib/taxonomy";
import type { OfferMacro, OfferMicro, OfferService } from "@/db/schema";
import { eq, and, asc, inArray, sql } from "drizzle-orm";
@@ -273,40 +273,14 @@ export type OfferFieldOptions = {
};
// Shared option pools for the offer editor's select fields (Notion-style
// dropdowns). Tipo, Obiettivo, and Categoria are merged from the managed
// settings pools + any values already in use (retrocompatibility).
// dropdowns). All four read from the centralized, persistent taxonomy pools
// (src/lib/taxonomy.ts), seeded from existing values on first access.
export async function getOfferFieldOptions(): Promise<OfferFieldOptions> {
const [categoriaRows, ticketRows, tagRows, tipoPool, obiettivoPool, categoriaPool] =
await Promise.all([
db.selectDistinct({ value: offer_macros.category }).from(offer_macros),
db.selectDistinct({ value: offer_macros.ticket }).from(offer_macros),
db
.selectDistinct({ name: tags.name, type: tags.entity_type })
.from(tags)
.where(inArray(tags.entity_type, [OFFER_TIPO_ENTITY, OFFER_OBIETTIVO_ENTITY])),
getJsonPool(SETTINGS_KEYS.OFFER_TIPO_POOL),
getJsonPool(SETTINGS_KEYS.OFFER_OBIETTIVO_POOL),
getJsonPool(SETTINGS_KEYS.OFFER_CATEGORIA_POOL),
]);
const sortUnique = (arr: (string | null)[]) =>
Array.from(new Set(arr.filter((v): v is string => !!v && v.trim().length > 0))).sort(
(a, b) => a.localeCompare(b, "it")
);
return {
categoria: sortUnique([
...categoriaPool,
...categoriaRows.map((r) => r.value),
]),
ticket: sortUnique(ticketRows.map((r) => r.value)),
tipo: sortUnique([
...tipoPool,
...tagRows.filter((r) => r.type === OFFER_TIPO_ENTITY).map((r) => r.name),
]),
obiettivo: sortUnique([
...obiettivoPool,
...tagRows.filter((r) => r.type === OFFER_OBIETTIVO_ENTITY).map((r) => r.name),
]),
};
const [categoria, ticket, tipo, obiettivo] = await Promise.all([
getPool("offer_categoria"),
getPool("offer_ticket"),
getPool("offer_tipo"),
getPool("offer_obiettivo"),
]);
return { categoria, ticket, tipo, obiettivo };
}