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
+119 -14
View File
@@ -1,11 +1,59 @@
"use client"; "use client";
import { useState, useMemo } from "react"; import { useState, useMemo, useRef } from "react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Search } from "lucide-react"; import { Search } from "lucide-react";
import { ServiceTable } from "@/components/admin/catalog/ServiceTable"; import { ServiceTable, DEFAULT_COL_ORDER } from "@/components/admin/catalog/ServiceTable";
import type { ColumnKey } from "@/components/admin/catalog/ServiceTable";
import type { ServiceWithTags, CatalogFieldOptions } from "@/lib/admin-queries"; import type { ServiceWithTags, CatalogFieldOptions } from "@/lib/admin-queries";
function loadColOrder(): ColumnKey[] {
if (typeof window === "undefined") return DEFAULT_COL_ORDER;
try {
const saved = localStorage.getItem("catalog_col_order");
if (saved) {
const parsed = JSON.parse(saved) as ColumnKey[];
// Validate: must contain exactly the same keys
if (
parsed.length === DEFAULT_COL_ORDER.length &&
DEFAULT_COL_ORDER.every((k) => parsed.includes(k))
) {
return parsed;
}
}
} catch {}
return DEFAULT_COL_ORDER;
}
function buildSortFn(
sortKey: ColumnKey | null,
sortDir: "asc" | "desc"
): (a: ServiceWithTags, b: ServiceWithTags) => number {
if (!sortKey) return () => 0;
const dir = sortDir === "asc" ? 1 : -1;
return (a, b) => {
switch (sortKey) {
case "nome":
return dir * a.name.localeCompare(b.name, "it");
case "descrizione":
return dir * (a.description ?? "").localeCompare(b.description ?? "", "it");
case "fase":
return dir * (a.fase ?? "").localeCompare(b.fase ?? "", "it");
case "offerta":
return dir * a.tags.join(",").localeCompare(b.tags.join(","), "it");
case "pacchetto":
return dir * a.pacchetto.join(",").localeCompare(b.pacchetto.join(","), "it");
case "prezzo": {
const na = parseFloat(a.unit_price ?? "0");
const nb = parseFloat(b.unit_price ?? "0");
return dir * (na - nb);
}
default:
return 0;
}
};
}
export function CatalogSearch({ export function CatalogSearch({
services, services,
options, options,
@@ -14,19 +62,65 @@ export function CatalogSearch({
options: CatalogFieldOptions; options: CatalogFieldOptions;
}) { }) {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [colOrder, setColOrder] = useState<ColumnKey[]>(() => loadColOrder());
const [sortKey, setSortKey] = useState<ColumnKey | null>(null);
const [sortDir, setSortDir] = useState<"asc" | "desc">("asc");
const dragCol = useRef<ColumnKey | null>(null);
const filtered = useMemo(() => { function handleSortClick(key: ColumnKey) {
const q = query.trim().toLowerCase(); if (sortKey === key) {
if (!q) return services; setSortDir((d) => (d === "asc" ? "desc" : "asc"));
return services.filter((s) => { } else {
if (s.name.toLowerCase().includes(q)) return true; setSortKey(key);
if (s.category?.toLowerCase().includes(q)) return true; setSortDir("asc");
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; function handleColDragStart(key: ColumnKey) {
dragCol.current = key;
}
function handleColDragOver(e: React.DragEvent, key: ColumnKey) {
e.preventDefault();
}
function handleColDrop(targetKey: ColumnKey) {
const from = dragCol.current;
dragCol.current = null;
if (!from || from === targetKey) return;
setColOrder((prev) => {
const next = [...prev];
const fromIdx = next.indexOf(from);
const toIdx = next.indexOf(targetKey);
next.splice(fromIdx, 1);
next.splice(toIdx, 0, from);
try {
localStorage.setItem("catalog_col_order", JSON.stringify(next));
} catch {}
return next;
}); });
}, [services, query]); }
const sortFn = useMemo(() => buildSortFn(sortKey, sortDir), [sortKey, sortDir]);
const { activeServices, inactiveServices } = useMemo(() => {
const q = query.trim().toLowerCase();
const filtered = q
? services.filter((s) => {
if (s.name.toLowerCase().includes(q)) return true;
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;
return {
activeServices: [...filtered.filter((s) => s.active)].sort(sortFn),
inactiveServices: [...filtered.filter((s) => !s.active)].sort(sortFn),
};
}, [services, query, sortFn]);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
@@ -40,7 +134,18 @@ export function CatalogSearch({
className="pl-9 h-9" className="pl-9 h-9"
/> />
</div> </div>
<ServiceTable services={filtered} options={options} /> <ServiceTable
activeServices={activeServices}
inactiveServices={inactiveServices}
options={options}
colOrder={colOrder}
sortKey={sortKey}
sortDir={sortDir}
onSortClick={handleSortClick}
onColDragStart={handleColDragStart}
onColDragOver={handleColDragOver}
onColDrop={handleColDrop}
/>
</div> </div>
); );
} }
+33
View File
@@ -0,0 +1,33 @@
"use server";
import { getJsonPool, updateJsonPool, SETTINGS_KEYS } from "@/lib/settings";
import { revalidatePath } from "next/cache";
type PoolName = "tipo" | "obiettivo" | "categoria";
function poolKey(pool: PoolName): string {
switch (pool) {
case "tipo": return SETTINGS_KEYS.OFFER_TIPO_POOL;
case "obiettivo": return SETTINGS_KEYS.OFFER_OBIETTIVO_POOL;
case "categoria": return SETTINGS_KEYS.OFFER_CATEGORIA_POOL;
}
}
export async function addPoolValue(pool: PoolName, value: string): Promise<void> {
const trimmed = value.trim();
if (!trimmed) return;
const key = poolKey(pool);
const current = await getJsonPool(key);
if (current.includes(trimmed)) return;
await updateJsonPool(key, [...current, trimmed]);
revalidatePath("/admin/impostazioni");
revalidatePath("/admin/offers");
}
export async function removePoolValue(pool: PoolName, value: string): Promise<void> {
const key = poolKey(pool);
const current = await getJsonPool(key);
await updateJsonPool(key, current.filter((v) => v !== value));
revalidatePath("/admin/impostazioni");
revalidatePath("/admin/offers");
}
+50 -36
View File
@@ -1,9 +1,15 @@
import { getTargetHourlyRate, updateSetting, SETTINGS_KEYS } from "@/lib/settings"; import { getTargetHourlyRate, getJsonPool, updateSetting, SETTINGS_KEYS } from "@/lib/settings";
import { OfferPoolsSection } from "@/components/admin/impostazioni/OfferPoolsSection";
export const revalidate = 0; export const revalidate = 0;
export default async function ImpostazioniPage() { export default async function ImpostazioniPage() {
const targetRate = await getTargetHourlyRate(); const [targetRate, tipoPool, obiettivoPool, categoriaPool] = await Promise.all([
getTargetHourlyRate(),
getJsonPool(SETTINGS_KEYS.OFFER_TIPO_POOL),
getJsonPool(SETTINGS_KEYS.OFFER_OBIETTIVO_POOL),
getJsonPool(SETTINGS_KEYS.OFFER_CATEGORIA_POOL),
]);
async function handleSave(fd: FormData) { async function handleSave(fd: FormData) {
"use server"; "use server";
@@ -17,43 +23,51 @@ export default async function ImpostazioniPage() {
<div> <div>
<h1 className="text-2xl font-bold text-[#1a1a1a] mb-6">Impostazioni</h1> <h1 className="text-2xl font-bold text-[#1a1a1a] mb-6">Impostazioni</h1>
<div className="bg-white rounded-xl border border-[#e5e7eb] p-6 max-w-md"> <div className="space-y-6 max-w-2xl">
<h2 className="text-base font-semibold text-[#1a1a1a] mb-4">Analytics Profittabilità</h2> <div className="bg-white rounded-xl border border-[#e5e7eb] p-6">
<h2 className="text-base font-semibold text-[#1a1a1a] mb-4">Analytics Profittabilità</h2>
<form action={handleSave} className="space-y-4"> <form action={handleSave} className="space-y-4">
<div> <div>
<label <label
htmlFor="target_hourly_rate" htmlFor="target_hourly_rate"
className="block text-sm font-medium text-[#1a1a1a] mb-1" className="block text-sm font-medium text-[#1a1a1a] mb-1"
> >
Tariffa oraria target (/h) Tariffa oraria target (/h)
</label> </label>
<p className="text-xs text-[#71717a] mb-2"> <p className="text-xs text-[#71717a] mb-2">
Usata per calcolare il costo ideale e il delta profitto/perdita per ogni progetto. Usata per calcolare il costo ideale e il delta profitto/perdita per ogni progetto.
</p> </p>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-[#71717a]"></span> <span className="text-sm text-[#71717a]"></span>
<input <input
id="target_hourly_rate" id="target_hourly_rate"
name="target_hourly_rate" name="target_hourly_rate"
type="number" type="number"
step="0.01" step="0.01"
min="0" min="0"
defaultValue={targetRate.toFixed(2)} defaultValue={targetRate.toFixed(2)}
className="border border-[#e5e7eb] rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#1A463C]/20 w-32" className="border border-[#e5e7eb] rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[#1A463C]/20 w-32"
/> />
<span className="text-sm text-[#71717a]">/h</span> <span className="text-sm text-[#71717a]">/h</span>
</div>
</div> </div>
</div>
<button <button
type="submit" type="submit"
className="bg-[#1A463C] text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-[#1A463C]/90 transition-colors" className="bg-[#1A463C] text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-[#1A463C]/90 transition-colors"
> >
Salva Salva
</button> </button>
</form> </form>
</div>
<OfferPoolsSection
tipoPool={tipoPool}
obiettivoPool={obiettivoPool}
categoriaPool={categoriaPool}
/>
</div> </div>
</div> </div>
); );
} }
+258 -152
View File
@@ -2,6 +2,7 @@
import { useState, useTransition } from "react"; import { useState, useTransition } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { ArrowUp, ArrowDown, ArrowUpDown, GripVertical } from "lucide-react";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { EditableCell } from "@/components/ui/editable-cell"; import { EditableCell } from "@/components/ui/editable-cell";
import { OptionSelect } from "@/components/ui/option-select"; import { OptionSelect } from "@/components/ui/option-select";
@@ -15,28 +16,42 @@ import {
} from "@/app/admin/catalog/actions"; } from "@/app/admin/catalog/actions";
import type { ServiceWithTags, CatalogFieldOptions } from "@/lib/admin-queries"; 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 { function formatPrice(raw: string): string {
const num = parseFloat(raw); const num = parseFloat(raw);
return `${(isNaN(num) ? 0 : num).toLocaleString("it-IT", { minimumFractionDigits: 2 })}`; 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({ function ServiceRow({
service, service,
options, options,
colOrder,
}: { }: {
service: ServiceWithTags; service: ServiceWithTags;
options: CatalogFieldOptions; options: CatalogFieldOptions;
colOrder: ColumnKey[];
}) { }) {
const router = useRouter(); const router = useRouter();
const [, startTransition] = useTransition(); 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 ( return (
<> <>
<tr <tr
className={`border-b border-[#e5e7eb] hover:bg-[#f9f9f9] transition-colors duration-150 ${ className={`border-b border-[#e5e7eb] hover:bg-[#f9f9f9] transition-colors duration-150 ${
!service.active ? "opacity-50" : "" !service.active ? "opacity-50" : ""
}`} }`}
> >
<td className="py-2 px-3 font-medium text-[#1a1a1a] min-w-[160px]"> {colOrder.map((key) => renderCell(key))}
<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> </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 [name, setName] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [fase, setFase] = useState(""); const [fase, setFase] = useState("");
@@ -172,77 +220,120 @@ function QuickAddRow({ options }: { options: CatalogFieldOptions }) {
const cellInput = const cellInput =
"border-0 bg-transparent shadow-none h-8 text-sm placeholder:text-[#71717a] focus-visible:ring-1 focus-visible:ring-primary"; "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 ( return (
<> <>
<tr className="border-b border-[#e5e7eb] bg-[#f9f9f9]"> <tr className="border-b border-[#e5e7eb] bg-[#f9f9f9]">
<td className="py-2 px-3"> {colOrder.map((key) => renderCell(key))}
<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> </tr>
)} {error && (
<tr>
<td colSpan={colOrder.length} className="py-1 px-3 text-xs text-red-600">
{error}
</td>
</tr>
)}
</> </>
); );
} }
export function ServiceTable({ export function ServiceTable({
services, activeServices,
inactiveServices,
options, options,
colOrder,
sortKey,
sortDir,
onSortClick,
onColDragStart,
onColDragOver,
onColDrop,
}: { }: {
services: ServiceWithTags[]; activeServices: ServiceWithTags[];
inactiveServices: ServiceWithTags[];
options: CatalogFieldOptions; 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); function SortIcon({ colKey }: { colKey: ColumnKey }) {
const inactiveServices = services.filter((s) => !s.active); 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 ( return (
<div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden"> <div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
@@ -250,33 +341,48 @@ export function ServiceTable({
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb] sticky top-0 z-[1]"> <thead className="bg-[#f9f9f9] border-b border-[#e5e7eb] sticky top-0 z-[1]">
<tr> <tr>
{COLUMN_HEADERS.map((header) => ( {colOrder.map((key) => {
<th const def = COLUMN_DEFS[key];
key={header} return (
className="text-left py-2 px-3 font-semibold text-[#71717a]" <th
> key={key}
{header} draggable
</th> 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> </tr>
</thead> </thead>
<tbody> <tbody>
{activeServices.map((service) => ( {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 && ( {inactiveServices.length > 0 && (
<> <>
<tr className="bg-[#fafafa]"> <tr className="bg-[#fafafa]">
<td <td
colSpan={COL_COUNT} colSpan={colOrder.length}
className="py-1.5 px-3 text-xs font-medium uppercase tracking-wide text-[#a1a1aa]" className="py-1.5 px-3 text-xs font-medium uppercase tracking-wide text-[#a1a1aa]"
> >
Servizi disattivati Servizi disattivati
</td> </td>
</tr> </tr>
{inactiveServices.map((service) => ( {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"; "use client";
import { useState, useMemo, useTransition } from "react"; import { useState, useMemo, useTransition, useEffect } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import { import {
@@ -86,6 +86,13 @@ export function OfferEditorClient({
const [tipoTags, setTipoTags] = useState<string[]>(data.tipoTags); const [tipoTags, setTipoTags] = useState<string[]>(data.tipoTags);
const [obiettivoTags, setObiettivoTags] = useState<string[]>(data.obiettivoTags); 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 [categoriaOptions, setCategoriaOptions] = useState<string[]>(fieldOptions.categoria);
const [ticketOptions, setTicketOptions] = useState<string[]>(fieldOptions.ticket); const [ticketOptions, setTicketOptions] = useState<string[]>(fieldOptions.ticket);
const [tipoOptions, setTipoOptions] = useState<string[]>(fieldOptions.tipo); const [tipoOptions, setTipoOptions] = useState<string[]>(fieldOptions.tipo);
+27 -13
View File
@@ -8,6 +8,7 @@ import {
services, services,
tags, tags,
} from "@/db/schema"; } from "@/db/schema";
import { getJsonPool, SETTINGS_KEYS } from "@/lib/settings";
import type { OfferMacro, OfferMicro, OfferService } from "@/db/schema"; import type { OfferMacro, OfferMicro, OfferService } from "@/db/schema";
import { eq, and, asc, inArray, sql } from "drizzle-orm"; import { eq, and, asc, inArray, sql } from "drizzle-orm";
@@ -272,15 +273,21 @@ export type OfferFieldOptions = {
}; };
// Shared option pools for the offer editor's select fields (Notion-style // Shared option pools for the offer editor's select fields (Notion-style
// dropdowns), mirroring getCatalogFieldOptions from admin-queries.ts. // dropdowns). Tipo, Obiettivo, and Categoria are merged from the managed
// settings pools + any values already in use (retrocompatibility).
export async function getOfferFieldOptions(): Promise<OfferFieldOptions> { export async function getOfferFieldOptions(): Promise<OfferFieldOptions> {
const categoriaRows = await db.selectDistinct({ value: offer_macros.category }).from(offer_macros); const [categoriaRows, ticketRows, tagRows, tipoPool, obiettivoPool, categoriaPool] =
const ticketRows = await db.selectDistinct({ value: offer_macros.ticket }).from(offer_macros); await Promise.all([
db.selectDistinct({ value: offer_macros.category }).from(offer_macros),
const tagRows = await db db.selectDistinct({ value: offer_macros.ticket }).from(offer_macros),
.selectDistinct({ name: tags.name, type: tags.entity_type }) db
.from(tags) .selectDistinct({ name: tags.name, type: tags.entity_type })
.where(inArray(tags.entity_type, [OFFER_TIPO_ENTITY, OFFER_OBIETTIVO_ENTITY])); .from(tags)
.where(inArray(tags.entity_type, [OFFER_TIPO_ENTITY, OFFER_OBIETTIVO_ENTITY])),
getJsonPool(SETTINGS_KEYS.OFFER_TIPO_POOL),
getJsonPool(SETTINGS_KEYS.OFFER_OBIETTIVO_POOL),
getJsonPool(SETTINGS_KEYS.OFFER_CATEGORIA_POOL),
]);
const sortUnique = (arr: (string | null)[]) => const sortUnique = (arr: (string | null)[]) =>
Array.from(new Set(arr.filter((v): v is string => !!v && v.trim().length > 0))).sort( Array.from(new Set(arr.filter((v): v is string => !!v && v.trim().length > 0))).sort(
@@ -288,11 +295,18 @@ export async function getOfferFieldOptions(): Promise<OfferFieldOptions> {
); );
return { return {
categoria: sortUnique(categoriaRows.map((r) => r.value)), categoria: sortUnique([
...categoriaPool,
...categoriaRows.map((r) => r.value),
]),
ticket: sortUnique(ticketRows.map((r) => r.value)), ticket: sortUnique(ticketRows.map((r) => r.value)),
tipo: sortUnique(tagRows.filter((r) => r.type === OFFER_TIPO_ENTITY).map((r) => r.name)), tipo: sortUnique([
obiettivo: sortUnique( ...tipoPool,
tagRows.filter((r) => r.type === OFFER_OBIETTIVO_ENTITY).map((r) => r.name) ...tagRows.filter((r) => r.type === OFFER_TIPO_ENTITY).map((r) => r.name),
), ]),
obiettivo: sortUnique([
...obiettivoPool,
...tagRows.filter((r) => r.type === OFFER_OBIETTIVO_ENTITY).map((r) => r.name),
]),
}; };
} }
+21
View File
@@ -5,6 +5,9 @@ import { revalidatePath } from "next/cache";
export const SETTINGS_KEYS = { export const SETTINGS_KEYS = {
TARGET_HOURLY_RATE: "target_hourly_rate", TARGET_HOURLY_RATE: "target_hourly_rate",
OFFER_TIPO_POOL: "offer_tipo_pool",
OFFER_OBIETTIVO_POOL: "offer_obiettivo_pool",
OFFER_CATEGORIA_POOL: "offer_categoria_pool",
} as const; } as const;
export async function getSetting(key: string): Promise<string | null> { export async function getSetting(key: string): Promise<string | null> {
@@ -32,4 +35,22 @@ export async function updateSetting(key: string, value: string): Promise<void> {
export async function getTargetHourlyRate(): Promise<number> { export async function getTargetHourlyRate(): Promise<number> {
const value = await getSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE); const value = await getSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE);
return value ? parseFloat(value) : 50; return value ? parseFloat(value) : 50;
}
export async function getJsonPool(key: string): Promise<string[]> {
const raw = await getSetting(key);
if (!raw) return [];
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? (parsed as string[]) : [];
} catch {
return [];
}
}
export async function updateJsonPool(key: string, values: string[]): Promise<void> {
const sorted = [...new Set(values.filter((v) => v.trim()))].sort((a, b) =>
a.localeCompare(b, "it")
);
await updateSetting(key, JSON.stringify(sorted));
} }