Files
clienthub/src/components/admin/catalog/ServiceTable.tsx
T
simone c29fab8975 feat: pagina Catalogo redesign Quiet Luxury — griglia servizi tokenizzata + footer conteggio
- ServiceTable: contenitore bg-card/shadow-card, header uppercase muted, righe hover:bg-muted/40, footer "Visualizzazione di N servizi"
- CatalogSearch: usa il primitivo SearchInput
- page: sottotitolo sezione
- tokenizza OptionSelect ed EditableCell (accent-primary) — erano rimasti con hex hardcoded

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 11:37:09 +02:00

402 lines
13 KiB
TypeScript

"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { ArrowUp, ArrowDown, ArrowUpDown, GripVertical } from "lucide-react";
import { Input } from "@/components/ui/input";
import { EditableCell } from "@/components/ui/editable-cell";
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";
export type ColumnKey =
| "nome"
| "descrizione"
| "fase"
| "offerta"
| "pacchetto"
| "prezzo"
| "stato";
export const COLUMN_DEFS: Record<ColumnKey, { header: string; sortable: boolean; minWidth: string }> = {
nome: { header: "Nome", sortable: true, minWidth: "160px" },
descrizione: { header: "Descrizione", sortable: false, minWidth: "200px" },
fase: { header: "Fase", sortable: true, minWidth: "140px" },
offerta: { header: "Offerta", sortable: true, minWidth: "180px" },
pacchetto: { header: "Pacchetto", sortable: true, minWidth: "180px" },
prezzo: { header: "Prezzo", sortable: true, minWidth: "100px" },
stato: { header: "Stato", sortable: false, minWidth: "100px" },
};
export const DEFAULT_COL_ORDER: ColumnKey[] = [
"nome", "descrizione", "fase", "offerta", "pacchetto", "prezzo", "stato",
];
function formatPrice(raw: string): string {
const num = parseFloat(raw);
return `${(isNaN(num) ? 0 : num).toLocaleString("it-IT", { minimumFractionDigits: 2 })}`;
}
function ServiceRow({
service,
options,
colOrder,
}: {
service: ServiceWithTags;
options: CatalogFieldOptions;
colOrder: ColumnKey[];
}) {
const router = useRouter();
const [, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function run(fn: () => Promise<unknown>) {
setError(null);
startTransition(async () => {
try {
await fn();
router.refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "Errore nel salvataggio");
}
});
}
function renderCell(key: ColumnKey) {
switch (key) {
case "nome":
return (
<td key={key} className="py-2 px-3 font-medium text-foreground min-w-[160px]">
<EditableCell
value={service.name}
type="text"
required
onSave={(v) => run(() => updateServiceField(service.id, "name", v))}
/>
</td>
);
case "descrizione":
return (
<td key={key} className="py-2 px-3 text-muted-foreground min-w-[200px]">
<EditableCell
value={service.description ?? ""}
type="textarea"
placeholder="Descrizione..."
onSave={(v) => run(() => updateServiceField(service.id, "description", v))}
/>
</td>
);
case "fase":
return (
<td key={key} 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>
);
case "offerta":
return (
<td key={key} 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>
);
case "pacchetto":
return (
<td key={key} 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>
);
case "prezzo":
return (
<td key={key} className="py-2 px-3 tabular-nums min-w-[100px]">
<EditableCell
value={service.unit_price}
type="number"
formatDisplay={formatPrice}
onSave={(v) => run(() => updateServiceField(service.id, "unit_price", v))}
/>
</td>
);
case "stato":
return (
<td key={key} className="py-2 px-3 min-w-[100px]">
<EditableCell
value={service.active ? "true" : "false"}
type="toggle"
onSave={(v) => run(() => updateServiceField(service.id, "active", v))}
/>
</td>
);
}
}
return (
<>
<tr
className={`border-b border-border hover:bg-muted/40 transition-colors duration-150 ${
!service.active ? "opacity-50" : ""
}`}
>
{colOrder.map((key) => renderCell(key))}
</tr>
{error && (
<tr>
<td colSpan={colOrder.length} className="py-1 px-3 text-xs text-red-600">
{error}
</td>
</tr>
)}
</>
);
}
function QuickAddRow({
options,
colOrder,
}: {
options: CatalogFieldOptions;
colOrder: ColumnKey[];
}) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [fase, setFase] = useState("");
const [price, setPrice] = useState("");
const [, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
const router = useRouter();
function submit() {
const trimmed = name.trim();
if (!trimmed) return;
setError(null);
startTransition(async () => {
try {
await quickAddService({
name: trimmed,
description: description || undefined,
fase: fase || undefined,
unit_price: price || undefined,
});
setName("");
setDescription("");
setFase("");
setPrice("");
router.refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "Errore nel salvataggio");
}
});
}
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-muted-foreground focus-visible:ring-1 focus-visible:ring-primary";
function renderCell(key: ColumnKey) {
switch (key) {
case "nome":
return (
<td key={key} className="py-2 px-3">
<Input
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={onKeyDown}
placeholder="+ Aggiungi servizio"
className={cellInput}
/>
</td>
);
case "descrizione":
return (
<td key={key} className="py-2 px-3">
<Input
value={description}
onChange={(e) => setDescription(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Descrizione..."
className={cellInput}
/>
</td>
);
case "fase":
return (
<td key={key} className="py-2 px-3">
<Input
value={fase}
onChange={(e) => setFase(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Fase..."
list="catalog-fase-pool"
className={cellInput}
/>
<datalist id="catalog-fase-pool">
{options.fase.map((f) => (
<option key={f} value={f} />
))}
</datalist>
</td>
);
case "prezzo":
return (
<td key={key} 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>
);
case "stato":
return (
<td key={key} className="py-2 px-3 text-xs text-muted-foreground">
Invio
</td>
);
default:
return <td key={key} className="py-2 px-3 text-xs text-muted-foreground" />;
}
}
return (
<>
<tr className="border-b border-border bg-muted/40">
{colOrder.map((key) => renderCell(key))}
</tr>
{error && (
<tr>
<td colSpan={colOrder.length} className="py-1 px-3 text-xs text-red-600">
{error}
</td>
</tr>
)}
</>
);
}
export function ServiceTable({
activeServices,
inactiveServices,
options,
colOrder,
sortKey,
sortDir,
onSortClick,
onColDragStart,
onColDragOver,
onColDrop,
}: {
activeServices: ServiceWithTags[];
inactiveServices: ServiceWithTags[];
options: CatalogFieldOptions;
colOrder: ColumnKey[];
sortKey: ColumnKey | null;
sortDir: "asc" | "desc";
onSortClick: (key: ColumnKey) => void;
onColDragStart: (key: ColumnKey) => void;
onColDragOver: (e: React.DragEvent, key: ColumnKey) => void;
onColDrop: (key: ColumnKey) => void;
}) {
function SortIcon({ colKey }: { colKey: ColumnKey }) {
if (!COLUMN_DEFS[colKey].sortable) return null;
if (sortKey !== colKey) return <ArrowUpDown className="inline ml-1 h-3 w-3 opacity-40" />;
return sortDir === "asc"
? <ArrowUp className="inline ml-1 h-3 w-3 text-primary" />
: <ArrowDown className="inline ml-1 h-3 w-3 text-primary" />;
}
const totalCount = activeServices.length + inactiveServices.length;
return (
<div className="bg-card rounded-xl border border-border shadow-card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="bg-muted/50 border-b border-border sticky top-0 z-[1]">
<tr>
{colOrder.map((key) => {
const def = COLUMN_DEFS[key];
return (
<th
key={key}
draggable
onDragStart={() => onColDragStart(key)}
onDragOver={(e) => onColDragOver(e, key)}
onDrop={() => onColDrop(key)}
onClick={() => def.sortable && onSortClick(key)}
className={`text-left py-3 px-3 text-[11px] font-semibold uppercase tracking-wider text-muted-foreground select-none whitespace-nowrap group ${
def.sortable ? "cursor-pointer hover:text-foreground" : "cursor-grab"
}`}
style={{ minWidth: def.minWidth }}
>
<span className="inline-flex items-center gap-1">
<GripVertical className="h-3 w-3 opacity-0 group-hover:opacity-30 transition-opacity shrink-0" />
{def.header}
<SortIcon colKey={key} />
</span>
</th>
);
})}
</tr>
</thead>
<tbody>
{activeServices.map((service) => (
<ServiceRow key={service.id} service={service} options={options} colOrder={colOrder} />
))}
<QuickAddRow options={options} colOrder={colOrder} />
{inactiveServices.length > 0 && (
<>
<tr className="bg-muted/40">
<td
colSpan={colOrder.length}
className="py-1.5 px-3 text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
Servizi disattivati
</td>
</tr>
{inactiveServices.map((service) => (
<ServiceRow key={service.id} service={service} options={options} colOrder={colOrder} />
))}
</>
)}
</tbody>
</table>
</div>
<div className="border-t border-border px-6 py-4 text-xs text-muted-foreground">
{totalCount === 1
? "Visualizzazione di 1 servizio"
: `Visualizzazione di ${totalCount} servizi`}
</div>
</div>
);
}