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:
@@ -4,9 +4,15 @@ import { useState, useMemo } from "react";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Search } from "lucide-react";
|
||||
import { ServiceTable } from "@/components/admin/catalog/ServiceTable";
|
||||
import type { ServiceWithTags } from "@/lib/admin-queries";
|
||||
import type { ServiceWithTags, CatalogFieldOptions } from "@/lib/admin-queries";
|
||||
|
||||
export function CatalogSearch({ services }: { services: ServiceWithTags[] }) {
|
||||
export function CatalogSearch({
|
||||
services,
|
||||
options,
|
||||
}: {
|
||||
services: ServiceWithTags[];
|
||||
options: CatalogFieldOptions;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
@@ -14,7 +20,11 @@ export function CatalogSearch({ services }: { services: ServiceWithTags[] }) {
|
||||
if (!q) return services;
|
||||
return services.filter((s) => {
|
||||
if (s.name.toLowerCase().includes(q)) return true;
|
||||
return s.tags.some((tag) => tag.toLowerCase().includes(q));
|
||||
if (s.category?.toLowerCase().includes(q)) return true;
|
||||
if (s.fase?.toLowerCase().includes(q)) return true;
|
||||
if (s.tags.some((t) => t.toLowerCase().includes(q))) return true;
|
||||
if (s.pacchetto.some((p) => p.toLowerCase().includes(q))) return true;
|
||||
return false;
|
||||
});
|
||||
}, [services, query]);
|
||||
|
||||
@@ -24,13 +34,13 @@ export function CatalogSearch({ services }: { services: ServiceWithTags[] }) {
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[#71717a]" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Cerca per nome o tag..."
|
||||
placeholder="Cerca per nome, categoria, fase, tag o pacchetto..."
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
<ServiceTable services={filtered} />
|
||||
<ServiceTable services={filtered} options={options} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import { getAllServices } from "@/lib/admin-queries";
|
||||
import { getAllServices, getCatalogFieldOptions } from "@/lib/admin-queries";
|
||||
import { CatalogSearch } from "./CatalogSearch";
|
||||
|
||||
export const revalidate = 0;
|
||||
|
||||
export default async function CatalogPage() {
|
||||
const services = await getAllServices();
|
||||
const [services, options] = await Promise.all([
|
||||
getAllServices(),
|
||||
getCatalogFieldOptions(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -12,13 +15,7 @@ export default async function CatalogPage() {
|
||||
<h1 className="text-2xl font-bold text-[#1a1a1a]">Catalogo Servizi</h1>
|
||||
</div>
|
||||
|
||||
{services.length === 0 ? (
|
||||
<p className="text-sm text-[#71717a]">
|
||||
Nessun servizio nel catalogo. Scrivi un nome nella riga in fondo alla tabella e premi invio per crearne uno.
|
||||
</p>
|
||||
) : (
|
||||
<CatalogSearch services={services} />
|
||||
)}
|
||||
<CatalogSearch services={services} options={options} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user