fix: flag A/B/C persistence, catalog sort+reorder, offer category pools

- Fix duplicate tier INSERT bug (useState not syncing after router.refresh)
- Add column sort by clicking headers in service catalog
- Add drag-and-drop column reordering (persisted in localStorage)
- Add Categorie Offerta section in Impostazioni (tipo/obiettivo/categoria pools)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-21 12:12:49 +02:00
parent ba3e824157
commit 320827e13a
9 changed files with 654 additions and 216 deletions
+258 -152
View File
@@ -2,6 +2,7 @@
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";
@@ -15,28 +16,42 @@ import {
} 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 })}`;
}
const COLUMN_HEADERS = [
"Nome",
"Descrizione",
"Fase",
"Offerta",
"Pacchetto",
"Prezzo",
"Stato",
];
const COL_COUNT = COLUMN_HEADERS.length;
function ServiceRow({
service,
options,
colOrder,
}: {
service: ServiceWithTags;
options: CatalogFieldOptions;
colOrder: ColumnKey[];
}) {
const router = useRouter();
const [, startTransition] = useTransition();
@@ -54,83 +69,116 @@ function ServiceRow({
});
}
function renderCell(key: ColumnKey) {
switch (key) {
case "nome":
return (
<td key={key} className="py-2 px-3 font-medium text-[#1a1a1a] 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-[#71717a] 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-[#e5e7eb] hover:bg-[#f9f9f9] transition-colors duration-150 ${
!service.active ? "opacity-50" : ""
}`}
>
<td className="py-2 px-3 font-medium text-[#1a1a1a] min-w-[160px]">
<EditableCell
value={service.name}
type="text"
required
onSave={(v) => run(() => updateServiceField(service.id, "name", v))}
/>
</td>
<td className="py-2 px-3 text-[#71717a] min-w-[200px]">
<EditableCell
value={service.description ?? ""}
type="textarea"
placeholder="Descrizione..."
onSave={(v) => run(() => updateServiceField(service.id, "description", v))}
/>
</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]">
<EditableCell
value={service.unit_price}
type="number"
formatDisplay={formatPrice}
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) => run(() => updateServiceField(service.id, "active", v))}
/>
</td>
</tr>
{error && (
<tr>
<td colSpan={COL_COUNT} className="py-1 px-3 text-xs text-red-600">
{error}
</td>
<tr
className={`border-b border-[#e5e7eb] hover:bg-[#f9f9f9] 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 }: { options: CatalogFieldOptions }) {
function QuickAddRow({
options,
colOrder,
}: {
options: CatalogFieldOptions;
colOrder: ColumnKey[];
}) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [fase, setFase] = useState("");
@@ -172,77 +220,120 @@ function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
const cellInput =
"border-0 bg-transparent shadow-none h-8 text-sm placeholder:text-[#71717a] 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-[#a1a1aa]">
Invio
</td>
);
default:
return <td key={key} className="py-2 px-3 text-xs text-[#a1a1aa]" />;
}
}
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={onKeyDown}
placeholder="+ Aggiungi servizio"
className={cellInput}
/>
</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={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}>
Offerta 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-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 className="border-b border-[#e5e7eb] bg-[#f9f9f9]">
{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({
services,
activeServices,
inactiveServices,
options,
colOrder,
sortKey,
sortDir,
onSortClick,
onColDragStart,
onColDragOver,
onColDrop,
}: {
services: ServiceWithTags[];
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;
}) {
const activeServices = services.filter((s) => s.active);
const inactiveServices = services.filter((s) => !s.active);
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-[#1A463C]" />
: <ArrowDown className="inline ml-1 h-3 w-3 text-[#1A463C]" />;
}
return (
<div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
@@ -250,33 +341,48 @@ export function ServiceTable({
<table className="w-full text-sm">
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb] sticky top-0 z-[1]">
<tr>
{COLUMN_HEADERS.map((header) => (
<th
key={header}
className="text-left py-2 px-3 font-semibold text-[#71717a]"
>
{header}
</th>
))}
{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-2 px-3 font-semibold text-[#71717a] select-none whitespace-nowrap group ${
def.sortable ? "cursor-pointer hover:text-[#1a1a1a]" : "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} />
<ServiceRow key={service.id} service={service} options={options} colOrder={colOrder} />
))}
<QuickAddRow options={options} />
<QuickAddRow options={options} colOrder={colOrder} />
{inactiveServices.length > 0 && (
<>
<tr className="bg-[#fafafa]">
<td
colSpan={COL_COUNT}
colSpan={colOrder.length}
className="py-1.5 px-3 text-xs font-medium uppercase tracking-wide text-[#a1a1aa]"
>
Servizi disattivati
</td>
</tr>
{inactiveServices.map((service) => (
<ServiceRow key={service.id} service={service} options={options} />
<ServiceRow key={service.id} service={service} options={options} colOrder={colOrder} />
))}
</>
)}
@@ -0,0 +1,43 @@
"use client";
import { PoolManager } from "./PoolManager";
import { addPoolValue, removePoolValue } from "@/app/admin/impostazioni/actions";
export function OfferPoolsSection({
tipoPool,
obiettivoPool,
categoriaPool,
}: {
tipoPool: string[];
obiettivoPool: string[];
categoriaPool: string[];
}) {
return (
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6">
<h2 className="text-base font-semibold text-[#1a1a1a] mb-1">Categorie Offerta</h2>
<p className="text-xs text-[#71717a] mb-5">
Valori disponibili nei campi Tipo, Obiettivo e Categoria dell&apos;editor offerte.
</p>
<div className="flex flex-wrap gap-6">
<PoolManager
label="Tipo offerta"
pool={tipoPool}
onAdd={(v) => addPoolValue("tipo", v)}
onRemove={(v) => removePoolValue("tipo", v)}
/>
<PoolManager
label="Obiettivo"
pool={obiettivoPool}
onAdd={(v) => addPoolValue("obiettivo", v)}
onRemove={(v) => removePoolValue("obiettivo", v)}
/>
<PoolManager
label="Categoria"
pool={categoriaPool}
onAdd={(v) => addPoolValue("categoria", v)}
onRemove={(v) => removePoolValue("categoria", v)}
/>
</div>
</div>
);
}
@@ -0,0 +1,95 @@
"use client";
import { useState, useTransition } from "react";
import { X } from "lucide-react";
export function PoolManager({
label,
pool,
onAdd,
onRemove,
}: {
label: string;
pool: string[];
onAdd: (value: string) => Promise<void>;
onRemove: (value: string) => Promise<void>;
}) {
const [input, setInput] = useState("");
const [isPending, startTransition] = useTransition();
const [error, setError] = useState<string | null>(null);
function handleAdd() {
const trimmed = input.trim();
if (!trimmed) return;
if (pool.includes(trimmed)) {
setError("Valore già presente");
return;
}
setError(null);
startTransition(async () => {
await onAdd(trimmed);
setInput("");
});
}
function handleRemove(value: string) {
startTransition(() => onRemove(value));
}
function onKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
e.preventDefault();
handleAdd();
}
}
return (
<div className="flex-1 min-w-[180px]">
<p className="text-sm font-medium text-[#1a1a1a] mb-2">{label}</p>
<div className="flex flex-wrap gap-1.5 mb-3 min-h-[28px]">
{pool.length === 0 && (
<span className="text-xs text-[#a1a1aa]">Nessun valore</span>
)}
{pool.map((v) => (
<span
key={v}
className="inline-flex items-center gap-1 bg-[#f4f4f5] text-[#1a1a1a] text-xs px-2 py-0.5 rounded-full"
>
{v}
<button
type="button"
onClick={() => handleRemove(v)}
disabled={isPending}
className="text-[#71717a] hover:text-red-500 transition-colors disabled:opacity-50"
aria-label={`Rimuovi ${v}`}
>
<X className="h-3 w-3" />
</button>
</span>
))}
</div>
<div className="flex gap-1.5">
<input
type="text"
value={input}
onChange={(e) => { setInput(e.target.value); setError(null); }}
onKeyDown={onKeyDown}
placeholder="Aggiungi..."
disabled={isPending}
className="border border-[#e5e7eb] rounded-lg px-3 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-[#1A463C]/20 flex-1 min-w-0 disabled:opacity-50"
/>
<button
type="button"
onClick={handleAdd}
disabled={isPending || !input.trim()}
className="bg-[#1A463C] text-white px-3 py-1.5 rounded-lg text-sm font-medium hover:bg-[#1A463C]/90 transition-colors disabled:opacity-40"
>
+
</button>
</div>
{error && <p className="text-xs text-red-500 mt-1">{error}</p>}
</div>
);
}
@@ -1,6 +1,6 @@
"use client";
import { useState, useMemo, useTransition } from "react";
import { useState, useMemo, useTransition, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
@@ -86,6 +86,13 @@ export function OfferEditorClient({
const [tipoTags, setTipoTags] = useState<string[]>(data.tipoTags);
const [obiettivoTags, setObiettivoTags] = useState<string[]>(data.obiettivoTags);
// After first draft save router.refresh() assigns real DB IDs to new tiers.
// Re-sync tiers state when those IDs arrive so subsequent saves UPDATE instead of INSERT.
const tierIdKey = data.tiers.map((t) => t.id).sort().join(",");
useEffect(() => {
setTiers(padTiers(data.tiers));
}, [tierIdKey]); // eslint-disable-line react-hooks/exhaustive-deps
const [categoriaOptions, setCategoriaOptions] = useState<string[]>(fieldOptions.categoria);
const [ticketOptions, setTicketOptions] = useState<string[]>(fieldOptions.ticket);
const [tipoOptions, setTipoOptions] = useState<string[]>(fieldOptions.tipo);