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";
import { useState, useMemo } from "react";
import { useState, useMemo, useRef } from "react";
import { Input } from "@/components/ui/input";
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";
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({
services,
options,
@@ -14,19 +62,65 @@ export function CatalogSearch({
options: CatalogFieldOptions;
}) {
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(() => {
const q = query.trim().toLowerCase();
if (!q) return services;
return 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;
function handleSortClick(key: ColumnKey) {
if (sortKey === key) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir("asc");
}
}
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 (
<div className="space-y-4">
@@ -40,7 +134,18 @@ export function CatalogSearch({
className="pl-9 h-9"
/>
</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>
);
}
+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 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) {
"use server";
@@ -17,43 +23,51 @@ export default async function ImpostazioniPage() {
<div>
<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">
<h2 className="text-base font-semibold text-[#1a1a1a] mb-4">Analytics Profittabilità</h2>
<div className="space-y-6 max-w-2xl">
<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">
<div>
<label
htmlFor="target_hourly_rate"
className="block text-sm font-medium text-[#1a1a1a] mb-1"
>
Tariffa oraria target (/h)
</label>
<p className="text-xs text-[#71717a] mb-2">
Usata per calcolare il costo ideale e il delta profitto/perdita per ogni progetto.
</p>
<div className="flex items-center gap-2">
<span className="text-sm text-[#71717a]"></span>
<input
id="target_hourly_rate"
name="target_hourly_rate"
type="number"
step="0.01"
min="0"
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"
/>
<span className="text-sm text-[#71717a]">/h</span>
<form action={handleSave} className="space-y-4">
<div>
<label
htmlFor="target_hourly_rate"
className="block text-sm font-medium text-[#1a1a1a] mb-1"
>
Tariffa oraria target (/h)
</label>
<p className="text-xs text-[#71717a] mb-2">
Usata per calcolare il costo ideale e il delta profitto/perdita per ogni progetto.
</p>
<div className="flex items-center gap-2">
<span className="text-sm text-[#71717a]"></span>
<input
id="target_hourly_rate"
name="target_hourly_rate"
type="number"
step="0.01"
min="0"
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"
/>
<span className="text-sm text-[#71717a]">/h</span>
</div>
</div>
</div>
<button
type="submit"
className="bg-[#1A463C] text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-[#1A463C]/90 transition-colors"
>
Salva
</button>
</form>
<button
type="submit"
className="bg-[#1A463C] text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-[#1A463C]/90 transition-colors"
>
Salva
</button>
</form>
</div>
<OfferPoolsSection
tipoPool={tipoPool}
obiettivoPool={obiettivoPool}
categoriaPool={categoriaPool}
/>
</div>
</div>
);
}
}