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 ───────────────────────
+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 };
}
+8 -22
View File
@@ -5,9 +5,6 @@ import { revalidatePath } from "next/cache";
export const SETTINGS_KEYS = {
TARGET_HOURLY_RATE: "target_hourly_rate",
OFFER_TIPO_POOL: "offer_tipo_pool",
OFFER_OBIETTIVO_POOL: "offer_obiettivo_pool",
OFFER_CATEGORIA_POOL: "offer_categoria_pool",
} as const;
export async function getSetting(key: string): Promise<string | null> {
@@ -19,7 +16,10 @@ export async function getSetting(key: string): Promise<string | null> {
return rows[0]?.value ?? null;
}
export async function updateSetting(key: string, value: string): Promise<void> {
// Pure upsert — NO revalidatePath. Safe to call during a server-component render
// (e.g. lazy-seeding a taxonomy pool). Use updateSetting when revalidation is
// desired (form submissions).
export async function writeSetting(key: string, value: string): Promise<void> {
const existing = await getSetting(key);
if (existing !== null) {
await db
@@ -29,6 +29,10 @@ export async function updateSetting(key: string, value: string): Promise<void> {
} else {
await db.insert(settings).values({ key, value });
}
}
export async function updateSetting(key: string, value: string): Promise<void> {
await writeSetting(key, value);
revalidatePath("/admin/impostazioni");
}
@@ -36,21 +40,3 @@ export async function getTargetHourlyRate(): Promise<number> {
const value = await getSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE);
return value ? parseFloat(value) : 50;
}
export async function getJsonPool(key: string): Promise<string[]> {
const raw = await getSetting(key);
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as string[]) : [];
} catch {
return [];
}
}
export async function updateJsonPool(key: string, values: string[]): Promise<void> {
const sorted = [...new Set(values.filter((v) => v.trim()))].sort((a, b) =>
a.localeCompare(b, "it")
);
await updateSetting(key, JSON.stringify(sorted));
}
+199
View File
@@ -0,0 +1,199 @@
import { db } from "@/db";
import { tags, offer_macros, services } from "@/db/schema";
import { eq, sql } from "drizzle-orm";
import { getSetting, writeSetting } from "@/lib/settings";
// ── Centralized taxonomy (Notion-style select properties) ───────────────────
// Each taxonomy field has a PERSISTENT, independent option pool stored as a JSON
// array in the `settings` table (key `tax_pool_<fieldId>`). Pools survive
// unassignment: deselecting a value on a row never touches the pool — only an
// explicit delete from the settings panel does (cascade-strips it everywhere).
export type TaxonomyFieldId =
| "offer_categoria"
| "offer_ticket"
| "offer_tipo"
| "offer_obiettivo"
| "service_fase"
| "service_offerta"
| "service_pacchetto";
export type TaxonomyGroup = "offer" | "catalog";
// Where the field's REAL values live — used for seeding (distinct in-use),
// cascade-delete, and rename propagation.
type Storage =
| { kind: "column"; table: "offer_macros" | "services"; column: "category" | "ticket" | "fase" }
| { kind: "tag"; entityType: string };
type FieldConfig = {
label: string;
group: TaxonomyGroup;
settingsKey: string;
storage: Storage;
};
export const TAXONOMY_FIELDS: Record<TaxonomyFieldId, FieldConfig> = {
offer_categoria: {
label: "Categoria",
group: "offer",
settingsKey: "tax_pool_offer_categoria",
storage: { kind: "column", table: "offer_macros", column: "category" },
},
offer_ticket: {
label: "Ticket",
group: "offer",
settingsKey: "tax_pool_offer_ticket",
storage: { kind: "column", table: "offer_macros", column: "ticket" },
},
offer_tipo: {
label: "Tipo",
group: "offer",
settingsKey: "tax_pool_offer_tipo",
storage: { kind: "tag", entityType: "offer_macros.tipo" },
},
offer_obiettivo: {
label: "Obiettivo",
group: "offer",
settingsKey: "tax_pool_offer_obiettivo",
storage: { kind: "tag", entityType: "offer_macros.obiettivo" },
},
service_fase: {
label: "Fase",
group: "catalog",
settingsKey: "tax_pool_service_fase",
storage: { kind: "column", table: "services", column: "fase" },
},
service_offerta: {
label: "Offerta",
group: "catalog",
settingsKey: "tax_pool_service_offerta",
storage: { kind: "tag", entityType: "services" },
},
service_pacchetto: {
label: "Pacchetto",
group: "catalog",
settingsKey: "tax_pool_service_pacchetto",
storage: { kind: "tag", entityType: "services.pacchetto" },
},
};
export const TAXONOMY_FIELD_IDS = Object.keys(TAXONOMY_FIELDS) as TaxonomyFieldId[];
function sortUnique(values: string[]): string[] {
return Array.from(new Set(values.filter((v) => v && v.trim().length > 0))).sort((a, b) =>
a.localeCompare(b, "it")
);
}
// Resolves a column-storage descriptor to its drizzle column reference.
// Columns are distinct across the config (category/ticket/fase), so switching
// on `column` is unambiguous and keeps drizzle's typing intact.
function columnRef(storage: Extract<Storage, { kind: "column" }>) {
if (storage.column === "category") return offer_macros.category;
if (storage.column === "ticket") return offer_macros.ticket;
return services.fase;
}
// Distinct in-use values for the field — used to seed the pool on first read.
async function readInUseValues(storage: Storage): Promise<string[]> {
if (storage.kind === "tag") {
const rows = await db
.selectDistinct({ name: tags.name })
.from(tags)
.where(eq(tags.entity_type, storage.entityType));
return rows.map((r) => r.name);
}
const rows = await db.selectDistinct({ value: columnRef(storage) }).from(
storage.table === "offer_macros" ? offer_macros : services
);
return rows.map((r) => r.value).filter((v): v is string => v != null);
}
// Reads the pool. If the settings row is ABSENT (never seeded), lazy-seeds it
// from in-use values and persists — WITHOUT revalidatePath (safe in render).
// A persisted "[]" is respected (intentionally emptied → no reseed).
export async function getPool(fieldId: TaxonomyFieldId): Promise<string[]> {
const { settingsKey, storage } = TAXONOMY_FIELDS[fieldId];
const raw = await getSetting(settingsKey);
if (raw === null) {
const seeded = sortUnique(await readInUseValues(storage));
await writeSetting(settingsKey, JSON.stringify(seeded));
return seeded;
}
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? sortUnique(parsed as string[]) : [];
} catch {
return [];
}
}
export async function getAllPools(): Promise<Record<TaxonomyFieldId, string[]>> {
const entries = await Promise.all(
TAXONOMY_FIELD_IDS.map(async (id) => [id, await getPool(id)] as const)
);
return Object.fromEntries(entries) as Record<TaxonomyFieldId, string[]>;
}
// Registers a value into the pool (idempotent). Used by the settings "Aggiungi"
// and by inline-create sync across the offer editor / catalog. Never revalidates.
export async function addPoolValue(fieldId: TaxonomyFieldId, value: string): Promise<void> {
const trimmed = value.trim();
if (!trimmed) return;
const current = await getPool(fieldId);
if (current.includes(trimmed)) return;
await writeSetting(TAXONOMY_FIELDS[fieldId].settingsKey, JSON.stringify(sortUnique([...current, trimmed])));
}
// Removes a value from the pool AND cascade-strips it from every row using it
// (delete tag rows / null out the column). Settings-only (global delete).
export async function removePoolValue(fieldId: TaxonomyFieldId, value: string): Promise<void> {
const { settingsKey, storage } = TAXONOMY_FIELDS[fieldId];
const current = await getPool(fieldId);
await writeSetting(settingsKey, JSON.stringify(current.filter((v) => v !== value)));
if (storage.kind === "tag") {
await db
.delete(tags)
.where(sql`${tags.entity_type} = ${storage.entityType} and ${tags.name} = ${value}`);
} else if (storage.column === "category") {
await db.update(offer_macros).set({ category: null }).where(eq(offer_macros.category, value));
} else if (storage.column === "ticket") {
await db.update(offer_macros).set({ ticket: null }).where(eq(offer_macros.ticket, value));
} else {
await db.update(services).set({ fase: null }).where(eq(services.fase, value));
}
}
// Renames a value in the pool AND propagates to every row using it.
export async function renamePoolValue(
fieldId: TaxonomyFieldId,
oldValue: string,
newValue: string
): Promise<void> {
const next = newValue.trim();
if (!next || next === oldValue) return;
const { settingsKey, storage } = TAXONOMY_FIELDS[fieldId];
const current = await getPool(fieldId);
await writeSetting(
settingsKey,
JSON.stringify(sortUnique(current.map((v) => (v === oldValue ? next : v))))
);
if (storage.kind === "tag") {
await db
.update(tags)
.set({ name: next })
.where(sql`${tags.entity_type} = ${storage.entityType} and ${tags.name} = ${oldValue}`);
} else if (storage.column === "category") {
await db.update(offer_macros).set({ category: next }).where(eq(offer_macros.category, oldValue));
} else if (storage.column === "ticket") {
await db.update(offer_macros).set({ ticket: next }).where(eq(offer_macros.ticket, oldValue));
} else {
await db.update(services).set({ fase: next }).where(eq(services.fase, oldValue));
}
}