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
+14 -24
View File
@@ -24,6 +24,7 @@ import {
tags,
} from "@/db/schema";
import { eq, inArray, asc, desc, isNull, sql, and } from "drizzle-orm";
import { getPool } from "@/lib/taxonomy";
import { LEAD_STAGES } from "@/lib/lead-validators";
import type {
Client,
@@ -420,32 +421,21 @@ export type CatalogFieldOptions = {
};
export async function getCatalogFieldOptions(): Promise<CatalogFieldOptions> {
const tagRows = await db
.selectDistinct({ name: tags.name, type: tags.entity_type })
.from(tags)
.where(inArray(tags.entity_type, [TAG_ENTITY, PACCHETTO_ENTITY]));
// fase / Offerta (tag) / Pacchetto read from the centralized, persistent
// taxonomy pools (src/lib/taxonomy.ts). `categoria` (services.category) is
// hidden in the catalog UI and stays derived from in-use values.
const [tag, pacchetto, fase, catRows] = await Promise.all([
getPool("service_offerta"),
getPool("service_pacchetto"),
getPool("service_fase"),
db.selectDistinct({ value: services.category }).from(services),
]);
// Distinct category / fase values (single-select pools); nulls filtered below.
const catRows = await db
.selectDistinct({ value: services.category })
.from(services);
const faseRows = await db
.selectDistinct({ value: services.fase })
.from(services);
const categoria = Array.from(
new Set(catRows.map((r) => r.value).filter((v): v is string => !!v && v.trim().length > 0))
).sort((a, b) => a.localeCompare(b, "it"));
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 {
tag: sortUnique(tagRows.filter((r) => r.type === TAG_ENTITY).map((r) => r.name)),
pacchetto: sortUnique(
tagRows.filter((r) => r.type === PACCHETTO_ENTITY).map((r) => r.name)
),
categoria: sortUnique(catRows.map((r) => r.value)),
fase: sortUnique(faseRows.map((r) => r.value)),
};
return { tag, pacchetto, categoria, fase };
}
// ── ProjectWithPayments — used by /admin/projects list ───────────────────────