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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user