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:
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user