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 { z } from "zod";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { addPoolValue, renamePoolValue } from "@/lib/taxonomy";
|
||||||
|
|
||||||
const serviceSchema = z.object({
|
const serviceSchema = z.object({
|
||||||
name: z.string().min(1, "Nome richiesto"),
|
name: z.string().min(1, "Nome richiesto"),
|
||||||
@@ -95,6 +96,7 @@ export async function updateServiceField(
|
|||||||
} else if (fieldName === "fase") {
|
} else if (fieldName === "fase") {
|
||||||
const s = String(value).trim();
|
const s = String(value).trim();
|
||||||
await db.update(services).set({ fase: s || null }).where(eq(services.id, serviceId));
|
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") {
|
} else if (fieldName === "unit_price") {
|
||||||
// Normalize locale-formatted input (WR-04): the cell displays it-IT (€1.234,50),
|
// 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
|
// 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 })
|
.values({ entity_type: MULTI_ENTITY[field], entity_id: serviceId, name: trimmed })
|
||||||
.onConflictDoNothing();
|
.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");
|
revalidatePath("/admin/catalog");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,10 +192,12 @@ export async function renameServiceOption(
|
|||||||
.update(tags)
|
.update(tags)
|
||||||
.set({ name: next })
|
.set({ name: next })
|
||||||
.where(and(eq(tags.entity_type, MULTI_ENTITY[field]), eq(tags.name, oldValue)));
|
.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") {
|
} else if (field === "categoria") {
|
||||||
await db.update(services).set({ category: next }).where(eq(services.category, oldValue));
|
await db.update(services).set({ category: next }).where(eq(services.category, oldValue));
|
||||||
} else if (field === "fase") {
|
} else if (field === "fase") {
|
||||||
await db.update(services).set({ fase: next }).where(eq(services.fase, oldValue));
|
await db.update(services).set({ fase: next }).where(eq(services.fase, oldValue));
|
||||||
|
await renamePoolValue("service_fase", oldValue, next);
|
||||||
} else {
|
} else {
|
||||||
throw new Error(`Campo non valido: ${field}`);
|
throw new Error(`Campo non valido: ${field}`);
|
||||||
}
|
}
|
||||||
@@ -227,14 +234,16 @@ export async function quickAddService(payload: QuickAddPayload | string) {
|
|||||||
unit_price = num.toFixed(2);
|
unit_price = num.toFixed(2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fase = data.fase?.trim() || null;
|
||||||
await db.insert(services).values({
|
await db.insert(services).values({
|
||||||
name,
|
name,
|
||||||
description: data.description?.trim() || null,
|
description: data.description?.trim() || null,
|
||||||
category: data.category?.trim() || null,
|
category: data.category?.trim() || null,
|
||||||
fase: data.fase?.trim() || null,
|
fase,
|
||||||
unit_price,
|
unit_price,
|
||||||
active: true,
|
active: true,
|
||||||
});
|
});
|
||||||
|
if (fase) await addPoolValue("service_fase", fase);
|
||||||
|
|
||||||
revalidatePath("/admin/catalog");
|
revalidatePath("/admin/catalog");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,33 +1,42 @@
|
|||||||
"use server";
|
"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 { revalidatePath } from "next/cache";
|
||||||
|
import {
|
||||||
|
addPoolValue,
|
||||||
|
removePoolValue,
|
||||||
|
TAXONOMY_FIELDS,
|
||||||
|
type TaxonomyFieldId,
|
||||||
|
} from "@/lib/taxonomy";
|
||||||
|
|
||||||
type PoolName = "tipo" | "obiettivo" | "categoria";
|
async function requireAdmin() {
|
||||||
|
const session = await getServerSession(authOptions);
|
||||||
function poolKey(pool: PoolName): string {
|
if (!session) throw new Error("Non autorizzato");
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function addPoolValue(pool: PoolName, value: string): Promise<void> {
|
function assertField(fieldId: string): asserts fieldId is TaxonomyFieldId {
|
||||||
const trimmed = value.trim();
|
if (!(fieldId in TAXONOMY_FIELDS)) throw new Error(`Tassonomia non valida: ${fieldId}`);
|
||||||
if (!trimmed) return;
|
}
|
||||||
const key = poolKey(pool);
|
|
||||||
const current = await getJsonPool(key);
|
function revalidateAll() {
|
||||||
if (current.includes(trimmed)) return;
|
|
||||||
await updateJsonPool(key, [...current, trimmed]);
|
|
||||||
revalidatePath("/admin/impostazioni");
|
revalidatePath("/admin/impostazioni");
|
||||||
revalidatePath("/admin/offers");
|
revalidatePath("/admin/offers");
|
||||||
|
revalidatePath("/admin/catalog");
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removePoolValue(pool: PoolName, value: string): Promise<void> {
|
export async function addTaxonomyValue(fieldId: string, value: string): Promise<void> {
|
||||||
const key = poolKey(pool);
|
await requireAdmin();
|
||||||
const current = await getJsonPool(key);
|
assertField(fieldId);
|
||||||
await updateJsonPool(key, current.filter((v) => v !== value));
|
await addPoolValue(fieldId, value);
|
||||||
revalidatePath("/admin/impostazioni");
|
revalidateAll();
|
||||||
revalidatePath("/admin/offers");
|
}
|
||||||
|
|
||||||
|
// 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 { getTargetHourlyRate, updateSetting, SETTINGS_KEYS } from "@/lib/settings";
|
||||||
import { OfferPoolsSection } from "@/components/admin/impostazioni/OfferPoolsSection";
|
import { getAllPools } from "@/lib/taxonomy";
|
||||||
|
import { TaxonomyManager } from "@/components/admin/impostazioni/TaxonomyManager";
|
||||||
|
|
||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
|
|
||||||
export default async function ImpostazioniPage() {
|
export default async function ImpostazioniPage() {
|
||||||
const [targetRate, tipoPool, obiettivoPool, categoriaPool] = await Promise.all([
|
const [targetRate, pools] = await Promise.all([getTargetHourlyRate(), getAllPools()]);
|
||||||
getTargetHourlyRate(),
|
|
||||||
getJsonPool(SETTINGS_KEYS.OFFER_TIPO_POOL),
|
|
||||||
getJsonPool(SETTINGS_KEYS.OFFER_OBIETTIVO_POOL),
|
|
||||||
getJsonPool(SETTINGS_KEYS.OFFER_CATEGORIA_POOL),
|
|
||||||
]);
|
|
||||||
|
|
||||||
async function handleSave(fd: FormData) {
|
async function handleSave(fd: FormData) {
|
||||||
"use server";
|
"use server";
|
||||||
@@ -62,11 +58,7 @@ export default async function ImpostazioniPage() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<OfferPoolsSection
|
<TaxonomyManager pools={pools} />
|
||||||
tipoPool={tipoPool}
|
|
||||||
obiettivoPool={obiettivoPool}
|
|
||||||
categoriaPool={categoriaPool}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { eq, and, inArray } from "drizzle-orm";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { getServerSession } from "next-auth";
|
import { getServerSession } from "next-auth";
|
||||||
import { authOptions } from "@/lib/auth";
|
import { authOptions } from "@/lib/auth";
|
||||||
|
import { addPoolValue, renamePoolValue } from "@/lib/taxonomy";
|
||||||
|
|
||||||
async function requireAdmin() {
|
async function requireAdmin() {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
@@ -259,6 +260,13 @@ export async function saveOfferEditor(macroId: string, payload: SaveOfferEditorP
|
|||||||
await db.insert(tags).values(tagRows).onConflictDoNothing();
|
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");
|
||||||
revalidatePath(`/admin/offers/${macroId}/edit`);
|
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 })
|
.values({ entity_type: OFFER_TAG_ENTITY[dimension], entity_id: macroId, name: trimmed })
|
||||||
.onConflictDoNothing();
|
.onConflictDoNothing();
|
||||||
|
|
||||||
|
await addPoolValue(dimension === "tipo" ? "offer_tipo" : "offer_obiettivo", trimmed);
|
||||||
|
|
||||||
revalidatePath("/admin/offers");
|
revalidatePath("/admin/offers");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,13 +342,16 @@ export async function renameOfferOption(
|
|||||||
|
|
||||||
if (field === "categoria") {
|
if (field === "categoria") {
|
||||||
await db.update(offer_macros).set({ category: next }).where(eq(offer_macros.category, oldValue));
|
await db.update(offer_macros).set({ category: next }).where(eq(offer_macros.category, oldValue));
|
||||||
|
await renamePoolValue("offer_categoria", oldValue, next);
|
||||||
} else if (field === "ticket") {
|
} else if (field === "ticket") {
|
||||||
await db.update(offer_macros).set({ ticket: next }).where(eq(offer_macros.ticket, oldValue));
|
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") {
|
} else if (field === "tipo" || field === "obiettivo") {
|
||||||
await db
|
await db
|
||||||
.update(tags)
|
.update(tags)
|
||||||
.set({ name: next })
|
.set({ name: next })
|
||||||
.where(and(eq(tags.entity_type, OFFER_TAG_ENTITY[field]), eq(tags.name, oldValue)));
|
.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 {
|
} else {
|
||||||
throw new Error(`Campo non valido: ${field}`);
|
throw new Error(`Campo non valido: ${field}`);
|
||||||
}
|
}
|
||||||
@@ -371,6 +384,7 @@ export async function createOfferMacro(formData: FormData) {
|
|||||||
description: parsed.data.description || null,
|
description: parsed.data.description || null,
|
||||||
category: parsed.data.category || null,
|
category: parsed.data.category || null,
|
||||||
});
|
});
|
||||||
|
if (parsed.data.category) await addPoolValue("offer_categoria", parsed.data.category);
|
||||||
|
|
||||||
revalidatePath("/admin/offers");
|
revalidatePath("/admin/offers");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { PoolManager } from "./PoolManager";
|
|
||||||
import { addPoolValue, removePoolValue } from "@/app/admin/impostazioni/actions";
|
|
||||||
|
|
||||||
export function OfferPoolsSection({
|
|
||||||
tipoPool,
|
|
||||||
obiettivoPool,
|
|
||||||
categoriaPool,
|
|
||||||
}: {
|
|
||||||
tipoPool: string[];
|
|
||||||
obiettivoPool: string[];
|
|
||||||
categoriaPool: string[];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6">
|
|
||||||
<h2 className="text-base font-semibold text-[#1a1a1a] mb-1">Categorie Offerta</h2>
|
|
||||||
<p className="text-xs text-[#71717a] mb-5">
|
|
||||||
Valori disponibili nei campi Tipo, Obiettivo e Categoria dell'editor offerte.
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-6">
|
|
||||||
<PoolManager
|
|
||||||
label="Tipo offerta"
|
|
||||||
pool={tipoPool}
|
|
||||||
onAdd={(v) => addPoolValue("tipo", v)}
|
|
||||||
onRemove={(v) => removePoolValue("tipo", v)}
|
|
||||||
/>
|
|
||||||
<PoolManager
|
|
||||||
label="Obiettivo"
|
|
||||||
pool={obiettivoPool}
|
|
||||||
onAdd={(v) => addPoolValue("obiettivo", v)}
|
|
||||||
onRemove={(v) => removePoolValue("obiettivo", v)}
|
|
||||||
/>
|
|
||||||
<PoolManager
|
|
||||||
label="Categoria"
|
|
||||||
pool={categoriaPool}
|
|
||||||
onAdd={(v) => addPoolValue("categoria", v)}
|
|
||||||
onRemove={(v) => removePoolValue("categoria", v)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -33,6 +33,10 @@ export function PoolManager({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleRemove(value: string) {
|
function handleRemove(value: string) {
|
||||||
|
const ok = window.confirm(
|
||||||
|
`Eliminare "${value}" da «${label}»?\n\nVerrà rimosso ovunque: sparirà dal menu e da tutte le offerte/servizi che lo usano.`
|
||||||
|
);
|
||||||
|
if (!ok) return;
|
||||||
startTransition(() => onRemove(value));
|
startTransition(() => onRemove(value));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { PoolManager } from "./PoolManager";
|
||||||
|
import { addTaxonomyValue, removeTaxonomyValue } from "@/app/admin/impostazioni/actions";
|
||||||
|
|
||||||
|
type FieldDef = { id: string; label: string };
|
||||||
|
|
||||||
|
const OFFER_FIELDS: FieldDef[] = [
|
||||||
|
{ id: "offer_categoria", label: "Categoria" },
|
||||||
|
{ id: "offer_ticket", label: "Ticket" },
|
||||||
|
{ id: "offer_tipo", label: "Tipo" },
|
||||||
|
{ id: "offer_obiettivo", label: "Obiettivo" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const CATALOG_FIELDS: FieldDef[] = [
|
||||||
|
{ id: "service_fase", label: "Fase" },
|
||||||
|
{ id: "service_offerta", label: "Offerta" },
|
||||||
|
{ id: "service_pacchetto", label: "Pacchetto" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function Section({
|
||||||
|
title,
|
||||||
|
subtitle,
|
||||||
|
fields,
|
||||||
|
pools,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
fields: FieldDef[];
|
||||||
|
pools: Record<string, string[]>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6">
|
||||||
|
<h2 className="text-base font-semibold text-[#1a1a1a] mb-1">{title}</h2>
|
||||||
|
<p className="text-xs text-[#71717a] mb-5">{subtitle}</p>
|
||||||
|
<div className="flex flex-wrap gap-6">
|
||||||
|
{fields.map((f) => (
|
||||||
|
<PoolManager
|
||||||
|
key={f.id}
|
||||||
|
label={f.label}
|
||||||
|
pool={pools[f.id] ?? []}
|
||||||
|
onAdd={(v) => addTaxonomyValue(f.id, v)}
|
||||||
|
onRemove={(v) => removeTaxonomyValue(f.id, v)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TaxonomyManager({ pools }: { pools: Record<string, string[]> }) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Section
|
||||||
|
title="Tassonomie Offerte"
|
||||||
|
subtitle="Valori disponibili nei campi Categoria, Ticket, Tipo e Obiettivo dell'editor offerte."
|
||||||
|
fields={OFFER_FIELDS}
|
||||||
|
pools={pools}
|
||||||
|
/>
|
||||||
|
<Section
|
||||||
|
title="Tassonomie Catalogo"
|
||||||
|
subtitle="Valori disponibili nei campi Fase, Offerta e Pacchetto del catalogo servizi."
|
||||||
|
fields={CATALOG_FIELDS}
|
||||||
|
pools={pools}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
+14
-24
@@ -24,6 +24,7 @@ import {
|
|||||||
tags,
|
tags,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import { eq, inArray, asc, desc, isNull, sql, and } from "drizzle-orm";
|
import { eq, inArray, asc, desc, isNull, sql, and } from "drizzle-orm";
|
||||||
|
import { getPool } from "@/lib/taxonomy";
|
||||||
import { LEAD_STAGES } from "@/lib/lead-validators";
|
import { LEAD_STAGES } from "@/lib/lead-validators";
|
||||||
import type {
|
import type {
|
||||||
Client,
|
Client,
|
||||||
@@ -420,32 +421,21 @@ export type CatalogFieldOptions = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export async function getCatalogFieldOptions(): Promise<CatalogFieldOptions> {
|
export async function getCatalogFieldOptions(): Promise<CatalogFieldOptions> {
|
||||||
const tagRows = await db
|
// fase / Offerta (tag) / Pacchetto read from the centralized, persistent
|
||||||
.selectDistinct({ name: tags.name, type: tags.entity_type })
|
// taxonomy pools (src/lib/taxonomy.ts). `categoria` (services.category) is
|
||||||
.from(tags)
|
// hidden in the catalog UI and stays derived from in-use values.
|
||||||
.where(inArray(tags.entity_type, [TAG_ENTITY, PACCHETTO_ENTITY]));
|
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 categoria = Array.from(
|
||||||
const catRows = await db
|
new Set(catRows.map((r) => r.value).filter((v): v is string => !!v && v.trim().length > 0))
|
||||||
.selectDistinct({ value: services.category })
|
).sort((a, b) => a.localeCompare(b, "it"));
|
||||||
.from(services);
|
|
||||||
const faseRows = await db
|
|
||||||
.selectDistinct({ value: services.fase })
|
|
||||||
.from(services);
|
|
||||||
|
|
||||||
const sortUnique = (arr: (string | null)[]) =>
|
return { tag, pacchetto, categoria, fase };
|
||||||
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)),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── ProjectWithPayments — used by /admin/projects list ───────────────────────
|
// ── ProjectWithPayments — used by /admin/projects list ───────────────────────
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
services,
|
services,
|
||||||
tags,
|
tags,
|
||||||
} from "@/db/schema";
|
} from "@/db/schema";
|
||||||
import { getJsonPool, SETTINGS_KEYS } from "@/lib/settings";
|
import { getPool } from "@/lib/taxonomy";
|
||||||
import type { OfferMacro, OfferMicro, OfferService } from "@/db/schema";
|
import type { OfferMacro, OfferMicro, OfferService } from "@/db/schema";
|
||||||
import { eq, and, asc, inArray, sql } from "drizzle-orm";
|
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
|
// Shared option pools for the offer editor's select fields (Notion-style
|
||||||
// dropdowns). Tipo, Obiettivo, and Categoria are merged from the managed
|
// dropdowns). All four read from the centralized, persistent taxonomy pools
|
||||||
// settings pools + any values already in use (retrocompatibility).
|
// (src/lib/taxonomy.ts), seeded from existing values on first access.
|
||||||
export async function getOfferFieldOptions(): Promise<OfferFieldOptions> {
|
export async function getOfferFieldOptions(): Promise<OfferFieldOptions> {
|
||||||
const [categoriaRows, ticketRows, tagRows, tipoPool, obiettivoPool, categoriaPool] =
|
const [categoria, ticket, tipo, obiettivo] = await Promise.all([
|
||||||
await Promise.all([
|
getPool("offer_categoria"),
|
||||||
db.selectDistinct({ value: offer_macros.category }).from(offer_macros),
|
getPool("offer_ticket"),
|
||||||
db.selectDistinct({ value: offer_macros.ticket }).from(offer_macros),
|
getPool("offer_tipo"),
|
||||||
db
|
getPool("offer_obiettivo"),
|
||||||
.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),
|
|
||||||
]);
|
]);
|
||||||
|
return { categoria, ticket, tipo, obiettivo };
|
||||||
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),
|
|
||||||
]),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
+8
-22
@@ -5,9 +5,6 @@ import { revalidatePath } from "next/cache";
|
|||||||
|
|
||||||
export const SETTINGS_KEYS = {
|
export const SETTINGS_KEYS = {
|
||||||
TARGET_HOURLY_RATE: "target_hourly_rate",
|
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;
|
} as const;
|
||||||
|
|
||||||
export async function getSetting(key: string): Promise<string | null> {
|
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;
|
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);
|
const existing = await getSetting(key);
|
||||||
if (existing !== null) {
|
if (existing !== null) {
|
||||||
await db
|
await db
|
||||||
@@ -29,6 +29,10 @@ export async function updateSetting(key: string, value: string): Promise<void> {
|
|||||||
} else {
|
} else {
|
||||||
await db.insert(settings).values({ key, value });
|
await db.insert(settings).values({ key, value });
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateSetting(key: string, value: string): Promise<void> {
|
||||||
|
await writeSetting(key, value);
|
||||||
revalidatePath("/admin/impostazioni");
|
revalidatePath("/admin/impostazioni");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,21 +40,3 @@ export async function getTargetHourlyRate(): Promise<number> {
|
|||||||
const value = await getSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE);
|
const value = await getSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE);
|
||||||
return value ? parseFloat(value) : 50;
|
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));
|
|
||||||
}
|
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user