c29fab8975
- 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>
402 lines
13 KiB
TypeScript
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>
|
|
);
|
|
}
|