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