feat(catalog): Notion-style shared select fields (tag/pacchetto/categoria/fase)

Turn the catalog into Notion/Airtable select properties:
- Tag + Pacchetto: multi-select with a SHARED pool — created values persist and
  are selectable from a dropdown across all services (no more re-typing)
- Categoria + Fase: single-select chips with the same dropdown + create-on-the-fly
- Rename an option once -> propagates to every row (renameServiceOption)
- Deterministic colors per value (shared option-colors util)
- Quick-add row now sets name+description+categoria+fase+prezzo then Enter (active)
- Search broadened to name/categoria/fase/tag/pacchetto

Data model (additive): tag/pacchetto in polymorphic tags table (entity_type
services / services.pacchetto); categoria/fase as single-select columns on
services (new: services.fase). Pools derived from distinct values.

New: OptionSelect, OptionMultiSelect, option-colors. Removed tag-multi-select.
Migration 0007_add_services_fase.sql must be applied before runtime.

tsc + eslint + next build clean. CSV bulk import (OFFER-12) stays Phase 12.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 21:40:34 +02:00
parent 42c16e1bab
commit e858a8f577
12 changed files with 834 additions and 249 deletions
+97 -17
View File
@@ -68,7 +68,7 @@ export async function toggleServiceActive(serviceId: string, active: boolean) {
// ── Inline-edit, tag, and quick-add actions (Phase 11 database-view) ────────
const EDITABLE_FIELDS = ["name", "description", "category", "unit_price", "active"] as const;
const EDITABLE_FIELDS = ["name", "description", "category", "fase", "unit_price", "active"] as const;
type EditableField = (typeof EDITABLE_FIELDS)[number];
export async function updateServiceField(
@@ -92,6 +92,9 @@ export async function updateServiceField(
} else if (fieldName === "category") {
const s = String(value).trim();
await db.update(services).set({ category: s || null }).where(eq(services.id, serviceId));
} else if (fieldName === "fase") {
const s = String(value).trim();
await db.update(services).set({ fase: s || null }).where(eq(services.id, serviceId));
} 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
@@ -112,47 +115,124 @@ export async function updateServiceField(
revalidatePath("/admin/catalog");
}
export async function addTagToService(serviceId: string, tagName: string) {
// ── Multi-select option fields (Notion-style shared pools) ──────────────────
// "tag" and "pacchetto" are stored in the polymorphic `tags` table, scoped by
// entity_type so the two pools stay separate.
const MULTI_ENTITY: Record<MultiSelectField, string> = {
tag: "services",
pacchetto: "services.pacchetto",
};
const MULTI_FIELDS = ["tag", "pacchetto"] as const;
export type MultiSelectField = (typeof MULTI_FIELDS)[number];
type SingleSelectField = "categoria" | "fase";
export async function addServiceOption(
field: MultiSelectField,
serviceId: string,
value: string
) {
await requireAdmin();
const trimmed = tagName.trim();
if (trimmed.length === 0) throw new Error("Nome tag richiesto");
if (!MULTI_FIELDS.includes(field)) throw new Error(`Campo non valido: ${field}`);
const trimmed = value.trim();
if (trimmed.length === 0) throw new Error("Valore richiesto");
await db
.insert(tags)
.values({
entity_type: "services",
entity_id: serviceId,
name: trimmed,
})
.values({ entity_type: MULTI_ENTITY[field], entity_id: serviceId, name: trimmed })
.onConflictDoNothing();
revalidatePath("/admin/catalog");
}
export async function removeTagFromService(serviceId: string, tagName: string) {
export async function removeServiceOption(
field: MultiSelectField,
serviceId: string,
value: string
) {
await requireAdmin();
if (!MULTI_FIELDS.includes(field)) throw new Error(`Campo non valido: ${field}`);
await db
.delete(tags)
.where(
and(
eq(tags.entity_type, "services"),
eq(tags.entity_type, MULTI_ENTITY[field]),
eq(tags.entity_id, serviceId),
eq(tags.name, tagName)
eq(tags.name, value)
)
);
revalidatePath("/admin/catalog");
}
export async function quickAddService(name: string) {
// ── Rename an option everywhere it is used (propagates across all services) ───
// Works for both multi-select pools (tag/pacchetto) and single-select columns
// (categoria/fase). This is what makes the dropdown options feel persistent &
// editable like Notion select properties.
export async function renameServiceOption(
field: MultiSelectField | SingleSelectField,
oldValue: string,
newValue: string
) {
await requireAdmin();
const trimmed = name.trim();
if (trimmed.length === 0) throw new Error("Nome richiesto");
const next = newValue.trim();
if (next.length === 0) throw new Error("Nuovo nome richiesto");
if (next === oldValue) return;
if (field === "tag" || field === "pacchetto") {
await db
.update(tags)
.set({ name: next })
.where(and(eq(tags.entity_type, MULTI_ENTITY[field]), eq(tags.name, oldValue)));
} 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));
} else {
throw new Error(`Campo non valido: ${field}`);
}
revalidatePath("/admin/catalog");
}
// ── Quick-add: create a service from the bottom row with all scalar fields set
// and active=true in one Enter. Tags/pacchetto are assigned afterwards (they
// need the new row's id). Only `name` is required.
export type QuickAddPayload = {
name: string;
description?: string;
category?: string;
fase?: string;
unit_price?: string;
};
export async function quickAddService(payload: QuickAddPayload | string) {
await requireAdmin();
// Back-compat: accept a bare name string.
const data: QuickAddPayload = typeof payload === "string" ? { name: payload } : payload;
const name = data.name.trim();
if (name.length === 0) throw new Error("Nome richiesto");
let unit_price = "0.00";
if (data.unit_price && data.unit_price.trim().length > 0) {
const raw = data.unit_price.trim();
const normalized = raw.includes(",") ? raw.replace(/\./g, "").replace(",", ".") : raw;
const num = Number(normalized);
if (!Number.isFinite(num) || num < 0) throw new Error("Prezzo invalido");
unit_price = num.toFixed(2);
}
await db.insert(services).values({
name: trimmed,
unit_price: "0.00",
name,
description: data.description?.trim() || null,
category: data.category?.trim() || null,
fase: data.fase?.trim() || null,
unit_price,
active: true,
});