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:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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,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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user