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
+58 -6
View File
@@ -357,9 +357,15 @@ export async function getClientFullDetail(id: string): Promise<ClientFullDetail
};
}
// ── ServiceWithTags — services + assigned tag names (Phase 11 database-view) ─
// ── ServiceWithTags — services + multi-select options (Phase 11 database-view) ─
// "tag" and "pacchetto" are multi-select pools stored in the polymorphic `tags`
// table, distinguished by entity_type: "services" (tag) | "services.pacchetto".
// "category" and "fase" are single-select values stored directly on the row.
export type ServiceWithTags = Service & { tags: string[] };
const TAG_ENTITY = "services";
const PACCHETTO_ENTITY = "services.pacchetto";
export type ServiceWithTags = Service & { tags: string[]; pacchetto: string[] };
export async function getAllServices(): Promise<ServiceWithTags[]> {
const rows = await db
@@ -369,32 +375,78 @@ export async function getAllServices(): Promise<ServiceWithTags[]> {
description: services.description,
unit_price: services.unit_price,
category: services.category,
fase: services.fase,
active: services.active,
migrated_from: services.migrated_from,
migrated_id: services.migrated_id,
created_at: services.created_at,
tag_name: tags.name,
tag_type: tags.entity_type,
})
.from(services)
.leftJoin(
tags,
and(eq(tags.entity_type, "services"), eq(tags.entity_id, services.id))
and(
eq(tags.entity_id, services.id),
inArray(tags.entity_type, [TAG_ENTITY, PACCHETTO_ENTITY])
)
)
.orderBy(asc(services.name), asc(tags.name));
const serviceMap = new Map<string, ServiceWithTags>();
for (const row of rows) {
const { tag_name, ...serviceFields } = row;
const { tag_name, tag_type, ...serviceFields } = row;
if (!serviceMap.has(row.id)) {
serviceMap.set(row.id, { ...serviceFields, tags: [] });
serviceMap.set(row.id, { ...serviceFields, tags: [], pacchetto: [] });
}
if (tag_name) {
serviceMap.get(row.id)!.tags.push(tag_name);
const target = serviceMap.get(row.id)!;
if (tag_type === PACCHETTO_ENTITY) target.pacchetto.push(tag_name);
else target.tags.push(tag_name);
}
}
return Array.from(serviceMap.values());
}
// ── Shared option pools for the catalog select fields (Notion-style dropdowns) ─
// Each pool is the set of distinct values currently in use, sorted alphabetically.
export type CatalogFieldOptions = {
tag: string[];
pacchetto: string[];
categoria: string[];
fase: string[];
};
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]));
// 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 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)),
};
}
// ── ProjectWithPayments — used by /admin/projects list ───────────────────────
export type ProjectWithPayments = {