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,28 +4,50 @@ import { useState, useTransition } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { EditableCell } from "@/components/ui/editable-cell";
|
||||
import { TagMultiSelect } from "@/components/ui/tag-multi-select";
|
||||
import { updateServiceField, quickAddService } from "@/app/admin/catalog/actions";
|
||||
import type { ServiceWithTags } from "@/lib/admin-queries";
|
||||
import { OptionSelect } from "@/components/ui/option-select";
|
||||
import { OptionMultiSelect } from "@/components/ui/option-multi-select";
|
||||
import {
|
||||
updateServiceField,
|
||||
quickAddService,
|
||||
addServiceOption,
|
||||
removeServiceOption,
|
||||
renameServiceOption,
|
||||
} from "@/app/admin/catalog/actions";
|
||||
import type { ServiceWithTags, CatalogFieldOptions } from "@/lib/admin-queries";
|
||||
|
||||
function formatPrice(raw: string): string {
|
||||
const num = parseFloat(raw);
|
||||
return `€${(isNaN(num) ? 0 : num).toLocaleString("it-IT", { minimumFractionDigits: 2 })}`;
|
||||
}
|
||||
|
||||
function ServiceRow({ service }: { service: ServiceWithTags }) {
|
||||
const COLUMN_HEADERS = [
|
||||
"Nome",
|
||||
"Descrizione",
|
||||
"Categoria",
|
||||
"Fase",
|
||||
"Tag",
|
||||
"Pacchetto",
|
||||
"Prezzo",
|
||||
"Stato",
|
||||
];
|
||||
const COL_COUNT = COLUMN_HEADERS.length;
|
||||
|
||||
function ServiceRow({
|
||||
service,
|
||||
options,
|
||||
}: {
|
||||
service: ServiceWithTags;
|
||||
options: CatalogFieldOptions;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const [, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
function saveField(
|
||||
field: "name" | "description" | "category" | "unit_price" | "active",
|
||||
value: string
|
||||
) {
|
||||
function run(fn: () => Promise<unknown>) {
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await updateServiceField(service.id, field, value);
|
||||
await fn();
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "Errore nel salvataggio");
|
||||
@@ -45,23 +67,49 @@ function ServiceRow({ service }: { service: ServiceWithTags }) {
|
||||
value={service.name}
|
||||
type="text"
|
||||
required
|
||||
onSave={(v) => saveField("name", v)}
|
||||
onSave={(v) => run(() => updateServiceField(service.id, "name", v))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-[#71717a] min-w-[220px]">
|
||||
<td className="py-2 px-3 text-[#71717a] min-w-[200px]">
|
||||
<EditableCell
|
||||
value={service.description ?? ""}
|
||||
type="textarea"
|
||||
placeholder="Descrizione..."
|
||||
onSave={(v) => saveField("description", v)}
|
||||
onSave={(v) => run(() => updateServiceField(service.id, "description", v))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-[#71717a] min-w-[120px]">
|
||||
<EditableCell
|
||||
value={service.category ?? ""}
|
||||
type="text"
|
||||
placeholder="Categoria..."
|
||||
onSave={(v) => saveField("category", v)}
|
||||
<td className="py-2 px-3 min-w-[140px]">
|
||||
<OptionSelect
|
||||
value={service.category}
|
||||
options={options.categoria}
|
||||
onChange={(v) => run(() => updateServiceField(service.id, "category", v ?? ""))}
|
||||
onRename={(o, n) => run(() => renameServiceOption("categoria", o, n))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 min-w-[140px]">
|
||||
<OptionSelect
|
||||
value={service.fase}
|
||||
options={options.fase}
|
||||
onChange={(v) => run(() => updateServiceField(service.id, "fase", v ?? ""))}
|
||||
onRename={(o, n) => run(() => renameServiceOption("fase", o, n))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 min-w-[180px]">
|
||||
<OptionMultiSelect
|
||||
values={service.tags}
|
||||
options={options.tag}
|
||||
onAdd={(v) => run(() => addServiceOption("tag", service.id, v))}
|
||||
onRemove={(v) => run(() => removeServiceOption("tag", service.id, v))}
|
||||
onRename={(o, n) => run(() => renameServiceOption("tag", o, n))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 min-w-[180px]">
|
||||
<OptionMultiSelect
|
||||
values={service.pacchetto}
|
||||
options={options.pacchetto}
|
||||
onAdd={(v) => run(() => addServiceOption("pacchetto", service.id, v))}
|
||||
onRemove={(v) => run(() => removeServiceOption("pacchetto", service.id, v))}
|
||||
onRename={(o, n) => run(() => renameServiceOption("pacchetto", o, n))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 tabular-nums min-w-[100px]">
|
||||
@@ -69,27 +117,20 @@ function ServiceRow({ service }: { service: ServiceWithTags }) {
|
||||
value={service.unit_price}
|
||||
type="number"
|
||||
formatDisplay={formatPrice}
|
||||
onSave={(v) => saveField("unit_price", v)}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 min-w-[180px]">
|
||||
<TagMultiSelect
|
||||
tags={service.tags}
|
||||
serviceId={service.id}
|
||||
onTagsChanged={() => router.refresh()}
|
||||
onSave={(v) => run(() => updateServiceField(service.id, "unit_price", v))}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 min-w-[100px]">
|
||||
<EditableCell
|
||||
value={service.active ? "true" : "false"}
|
||||
type="toggle"
|
||||
onSave={(v) => saveField("active", v)}
|
||||
onSave={(v) => run(() => updateServiceField(service.id, "active", v))}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
{error && (
|
||||
<tr>
|
||||
<td className="py-1 px-3 text-xs text-red-600" colSpan={6}>
|
||||
<td colSpan={COL_COUNT} className="py-1 px-3 text-xs text-red-600">
|
||||
{error}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -98,22 +139,34 @@ function ServiceRow({ service }: { service: ServiceWithTags }) {
|
||||
);
|
||||
}
|
||||
|
||||
function QuickAddRow() {
|
||||
function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [category, setCategory] = useState("");
|
||||
const [fase, setFase] = useState("");
|
||||
const [price, setPrice] = useState("");
|
||||
const [, startTransition] = useTransition();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const router = useRouter();
|
||||
|
||||
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key !== "Enter") return;
|
||||
function submit() {
|
||||
const trimmed = name.trim();
|
||||
if (!trimmed) return;
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await quickAddService(trimmed);
|
||||
await quickAddService({
|
||||
name: trimmed,
|
||||
description: description || undefined,
|
||||
category: category || undefined,
|
||||
fase: fase || undefined,
|
||||
unit_price: price || undefined,
|
||||
});
|
||||
setName("");
|
||||
setDescription("");
|
||||
setCategory("");
|
||||
setFase("");
|
||||
setPrice("");
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Errore nel salvataggio");
|
||||
@@ -121,26 +174,100 @@ function QuickAddRow() {
|
||||
});
|
||||
}
|
||||
|
||||
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submit();
|
||||
}
|
||||
}
|
||||
|
||||
const cellInput =
|
||||
"border-0 bg-transparent shadow-none h-8 text-sm placeholder:text-[#71717a] focus-visible:ring-1 focus-visible:ring-primary";
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className="border-b border-[#e5e7eb] bg-[#f9f9f9]">
|
||||
<td className="py-2 px-3">
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="+ Aggiungi servizio"
|
||||
className="border-0 bg-transparent shadow-none h-8 text-sm placeholder:text-[#71717a] focus-visible:ring-1 focus-visible:ring-primary"
|
||||
className={cellInput}
|
||||
/>
|
||||
{error && <p className="text-xs text-red-600 mt-1">{error}</p>}
|
||||
</td>
|
||||
<td colSpan={5}></td>
|
||||
<td className="py-2 px-3">
|
||||
<Input
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Descrizione..."
|
||||
className={cellInput}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<Input
|
||||
value={category}
|
||||
onChange={(e) => setCategory(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Categoria..."
|
||||
list="catalog-categoria-pool"
|
||||
className={cellInput}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<Input
|
||||
value={fase}
|
||||
onChange={(e) => setFase(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="Fase..."
|
||||
list="catalog-fase-pool"
|
||||
className={cellInput}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-xs text-[#a1a1aa]" colSpan={2}>
|
||||
Tag e pacchetto dopo la creazione
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<Input
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder="0,00"
|
||||
inputMode="decimal"
|
||||
className={`${cellInput} tabular-nums`}
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-xs text-[#a1a1aa]">Invio ↵</td>
|
||||
<datalist id="catalog-categoria-pool">
|
||||
{options.categoria.map((c) => (
|
||||
<option key={c} value={c} />
|
||||
))}
|
||||
</datalist>
|
||||
<datalist id="catalog-fase-pool">
|
||||
{options.fase.map((f) => (
|
||||
<option key={f} value={f} />
|
||||
))}
|
||||
</datalist>
|
||||
</tr>
|
||||
{error && (
|
||||
<tr>
|
||||
<td colSpan={COL_COUNT} className="py-1 px-3 text-xs text-red-600">
|
||||
{error}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const COLUMN_HEADERS = ["Nome", "Descrizione", "Categoria", "Prezzo", "Tag", "Stato"];
|
||||
|
||||
export function ServiceTable({ services }: { services: ServiceWithTags[] }) {
|
||||
export function ServiceTable({
|
||||
services,
|
||||
options,
|
||||
}: {
|
||||
services: ServiceWithTags[];
|
||||
options: CatalogFieldOptions;
|
||||
}) {
|
||||
const activeServices = services.filter((s) => s.active);
|
||||
const inactiveServices = services.filter((s) => !s.active);
|
||||
|
||||
@@ -161,21 +288,22 @@ export function ServiceTable({ services }: { services: ServiceWithTags[] }) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{activeServices.map((s) => (
|
||||
<ServiceRow key={s.id} service={s} />
|
||||
{activeServices.map((service) => (
|
||||
<ServiceRow key={service.id} service={service} options={options} />
|
||||
))}
|
||||
|
||||
<QuickAddRow />
|
||||
|
||||
<QuickAddRow options={options} />
|
||||
{inactiveServices.length > 0 && (
|
||||
<>
|
||||
<tr className="border-b border-t-2 border-t-[#e5e7eb] border-[#e5e7eb]">
|
||||
<td colSpan={6} className="py-1.5 px-3 text-xs font-medium text-[#71717a] uppercase tracking-wide">
|
||||
<tr className="bg-[#fafafa]">
|
||||
<td
|
||||
colSpan={COL_COUNT}
|
||||
className="py-1.5 px-3 text-xs font-medium uppercase tracking-wide text-[#a1a1aa]"
|
||||
>
|
||||
Servizi disattivati
|
||||
</td>
|
||||
</tr>
|
||||
{inactiveServices.map((s) => (
|
||||
<ServiceRow key={s.id} service={s} />
|
||||
{inactiveServices.map((service) => (
|
||||
<ServiceRow key={service.id} service={service} options={options} />
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user