diff --git a/src/app/admin/catalog/CatalogSearch.tsx b/src/app/admin/catalog/CatalogSearch.tsx index 6d017d7..e31c08a 100644 --- a/src/app/admin/catalog/CatalogSearch.tsx +++ b/src/app/admin/catalog/CatalogSearch.tsx @@ -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(() => loadColOrder()); + const [sortKey, setSortKey] = useState(null); + const [sortDir, setSortDir] = useState<"asc" | "desc">("asc"); + const dragCol = useRef(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 (
@@ -40,7 +134,18 @@ export function CatalogSearch({ className="pl-9 h-9" />
- + ); } diff --git a/src/app/admin/impostazioni/actions.ts b/src/app/admin/impostazioni/actions.ts new file mode 100644 index 0000000..261587e --- /dev/null +++ b/src/app/admin/impostazioni/actions.ts @@ -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 { + 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 { + const key = poolKey(pool); + const current = await getJsonPool(key); + await updateJsonPool(key, current.filter((v) => v !== value)); + revalidatePath("/admin/impostazioni"); + revalidatePath("/admin/offers"); +} diff --git a/src/app/admin/impostazioni/page.tsx b/src/app/admin/impostazioni/page.tsx index 57aa4e8..a54d3ef 100644 --- a/src/app/admin/impostazioni/page.tsx +++ b/src/app/admin/impostazioni/page.tsx @@ -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() {

Impostazioni

-
-

Analytics Profittabilità

+
+
+

Analytics Profittabilità

-
-
- -

- Usata per calcolare il costo ideale e il delta profitto/perdita per ogni progetto. -

-
- - - /h + +
+ +

+ Usata per calcolare il costo ideale e il delta profitto/perdita per ogni progetto. +

+
+ + + /h +
-
- - + + +
+ +
); -} \ No newline at end of file +} diff --git a/src/components/admin/catalog/ServiceTable.tsx b/src/components/admin/catalog/ServiceTable.tsx index ce78456..9521c0d 100644 --- a/src/components/admin/catalog/ServiceTable.tsx +++ b/src/components/admin/catalog/ServiceTable.tsx @@ -2,6 +2,7 @@ import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; +import { ArrowUp, ArrowDown, ArrowUpDown, GripVertical } from "lucide-react"; import { Input } from "@/components/ui/input"; import { EditableCell } from "@/components/ui/editable-cell"; import { OptionSelect } from "@/components/ui/option-select"; @@ -15,28 +16,42 @@ import { } from "@/app/admin/catalog/actions"; import type { ServiceWithTags, CatalogFieldOptions } from "@/lib/admin-queries"; +export type ColumnKey = + | "nome" + | "descrizione" + | "fase" + | "offerta" + | "pacchetto" + | "prezzo" + | "stato"; + +export const COLUMN_DEFS: Record = { + nome: { header: "Nome", sortable: true, minWidth: "160px" }, + descrizione: { header: "Descrizione", sortable: false, minWidth: "200px" }, + fase: { header: "Fase", sortable: true, minWidth: "140px" }, + offerta: { header: "Offerta", sortable: true, minWidth: "180px" }, + pacchetto: { header: "Pacchetto", sortable: true, minWidth: "180px" }, + prezzo: { header: "Prezzo", sortable: true, minWidth: "100px" }, + stato: { header: "Stato", sortable: false, minWidth: "100px" }, +}; + +export const DEFAULT_COL_ORDER: ColumnKey[] = [ + "nome", "descrizione", "fase", "offerta", "pacchetto", "prezzo", "stato", +]; + function formatPrice(raw: string): string { const num = parseFloat(raw); return `€${(isNaN(num) ? 0 : num).toLocaleString("it-IT", { minimumFractionDigits: 2 })}`; } -const COLUMN_HEADERS = [ - "Nome", - "Descrizione", - "Fase", - "Offerta", - "Pacchetto", - "Prezzo", - "Stato", -]; -const COL_COUNT = COLUMN_HEADERS.length; - function ServiceRow({ service, options, + colOrder, }: { service: ServiceWithTags; options: CatalogFieldOptions; + colOrder: ColumnKey[]; }) { const router = useRouter(); const [, startTransition] = useTransition(); @@ -54,83 +69,116 @@ function ServiceRow({ }); } + function renderCell(key: ColumnKey) { + switch (key) { + case "nome": + return ( + + run(() => updateServiceField(service.id, "name", v))} + /> + + ); + case "descrizione": + return ( + + run(() => updateServiceField(service.id, "description", v))} + /> + + ); + case "fase": + return ( + + run(() => updateServiceField(service.id, "fase", v ?? ""))} + onRename={(o, n) => run(() => renameServiceOption("fase", o, n))} + /> + + ); + case "offerta": + return ( + + run(() => addServiceOption("tag", service.id, v))} + onRemove={(v) => run(() => removeServiceOption("tag", service.id, v))} + onRename={(o, n) => run(() => renameServiceOption("tag", o, n))} + /> + + ); + case "pacchetto": + return ( + + run(() => addServiceOption("pacchetto", service.id, v))} + onRemove={(v) => run(() => removeServiceOption("pacchetto", service.id, v))} + onRename={(o, n) => run(() => renameServiceOption("pacchetto", o, n))} + /> + + ); + case "prezzo": + return ( + + run(() => updateServiceField(service.id, "unit_price", v))} + /> + + ); + case "stato": + return ( + + run(() => updateServiceField(service.id, "active", v))} + /> + + ); + } + } + return ( <> - - - run(() => updateServiceField(service.id, "name", v))} - /> - - - run(() => updateServiceField(service.id, "description", v))} - /> - - - run(() => updateServiceField(service.id, "fase", v ?? ""))} - onRename={(o, n) => run(() => renameServiceOption("fase", o, n))} - /> - - - run(() => addServiceOption("tag", service.id, v))} - onRemove={(v) => run(() => removeServiceOption("tag", service.id, v))} - onRename={(o, n) => run(() => renameServiceOption("tag", o, n))} - /> - - - run(() => addServiceOption("pacchetto", service.id, v))} - onRemove={(v) => run(() => removeServiceOption("pacchetto", service.id, v))} - onRename={(o, n) => run(() => renameServiceOption("pacchetto", o, n))} - /> - - - run(() => updateServiceField(service.id, "unit_price", v))} - /> - - - run(() => updateServiceField(service.id, "active", v))} - /> - - - {error && ( - - - {error} - + + {colOrder.map((key) => renderCell(key))} - )} + {error && ( + + + {error} + + + )} ); } -function QuickAddRow({ options }: { options: CatalogFieldOptions }) { +function QuickAddRow({ + options, + colOrder, +}: { + options: CatalogFieldOptions; + colOrder: ColumnKey[]; +}) { const [name, setName] = useState(""); const [description, setDescription] = useState(""); const [fase, setFase] = useState(""); @@ -172,77 +220,120 @@ function QuickAddRow({ options }: { options: CatalogFieldOptions }) { const cellInput = "border-0 bg-transparent shadow-none h-8 text-sm placeholder:text-[#71717a] focus-visible:ring-1 focus-visible:ring-primary"; + function renderCell(key: ColumnKey) { + switch (key) { + case "nome": + return ( + + setName(e.target.value)} + onKeyDown={onKeyDown} + placeholder="+ Aggiungi servizio" + className={cellInput} + /> + + ); + case "descrizione": + return ( + + setDescription(e.target.value)} + onKeyDown={onKeyDown} + placeholder="Descrizione..." + className={cellInput} + /> + + ); + case "fase": + return ( + + setFase(e.target.value)} + onKeyDown={onKeyDown} + placeholder="Fase..." + list="catalog-fase-pool" + className={cellInput} + /> + + {options.fase.map((f) => ( + + + ); + case "prezzo": + return ( + + setPrice(e.target.value)} + onKeyDown={onKeyDown} + placeholder="0,00" + inputMode="decimal" + className={`${cellInput} tabular-nums`} + /> + + ); + case "stato": + return ( + + Invio ↵ + + ); + default: + return ; + } + } + return ( <> - - - setName(e.target.value)} - onKeyDown={onKeyDown} - placeholder="+ Aggiungi servizio" - className={cellInput} - /> - - - setDescription(e.target.value)} - onKeyDown={onKeyDown} - placeholder="Descrizione..." - className={cellInput} - /> - - - setFase(e.target.value)} - onKeyDown={onKeyDown} - placeholder="Fase..." - list="catalog-fase-pool" - className={cellInput} - /> - - - Offerta e pacchetto dopo la creazione - - - setPrice(e.target.value)} - onKeyDown={onKeyDown} - placeholder="0,00" - inputMode="decimal" - className={`${cellInput} tabular-nums`} - /> - - Invio ↵ - - {options.fase.map((f) => ( - - - {error && ( - - - {error} - + + {colOrder.map((key) => renderCell(key))} - )} + {error && ( + + + {error} + + + )} ); } export function ServiceTable({ - services, + activeServices, + inactiveServices, options, + colOrder, + sortKey, + sortDir, + onSortClick, + onColDragStart, + onColDragOver, + onColDrop, }: { - services: ServiceWithTags[]; + activeServices: ServiceWithTags[]; + inactiveServices: ServiceWithTags[]; options: CatalogFieldOptions; + colOrder: ColumnKey[]; + sortKey: ColumnKey | null; + sortDir: "asc" | "desc"; + onSortClick: (key: ColumnKey) => void; + onColDragStart: (key: ColumnKey) => void; + onColDragOver: (e: React.DragEvent, key: ColumnKey) => void; + onColDrop: (key: ColumnKey) => void; }) { - const activeServices = services.filter((s) => s.active); - const inactiveServices = services.filter((s) => !s.active); + function SortIcon({ colKey }: { colKey: ColumnKey }) { + if (!COLUMN_DEFS[colKey].sortable) return null; + if (sortKey !== colKey) return ; + return sortDir === "asc" + ? + : ; + } return (
@@ -250,33 +341,48 @@ export function ServiceTable({ - {COLUMN_HEADERS.map((header) => ( - - ))} + {colOrder.map((key) => { + const def = COLUMN_DEFS[key]; + return ( + + ); + })} {activeServices.map((service) => ( - + ))} - + {inactiveServices.length > 0 && ( <> {inactiveServices.map((service) => ( - + ))} )} diff --git a/src/components/admin/impostazioni/OfferPoolsSection.tsx b/src/components/admin/impostazioni/OfferPoolsSection.tsx new file mode 100644 index 0000000..0bf3232 --- /dev/null +++ b/src/components/admin/impostazioni/OfferPoolsSection.tsx @@ -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 ( +
+

Categorie Offerta

+

+ Valori disponibili nei campi Tipo, Obiettivo e Categoria dell'editor offerte. +

+
+ addPoolValue("tipo", v)} + onRemove={(v) => removePoolValue("tipo", v)} + /> + addPoolValue("obiettivo", v)} + onRemove={(v) => removePoolValue("obiettivo", v)} + /> + addPoolValue("categoria", v)} + onRemove={(v) => removePoolValue("categoria", v)} + /> +
+
+ ); +} diff --git a/src/components/admin/impostazioni/PoolManager.tsx b/src/components/admin/impostazioni/PoolManager.tsx new file mode 100644 index 0000000..ec0e534 --- /dev/null +++ b/src/components/admin/impostazioni/PoolManager.tsx @@ -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; + onRemove: (value: string) => Promise; +}) { + const [input, setInput] = useState(""); + const [isPending, startTransition] = useTransition(); + const [error, setError] = useState(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) { + if (e.key === "Enter") { + e.preventDefault(); + handleAdd(); + } + } + + return ( +
+

{label}

+ +
+ {pool.length === 0 && ( + Nessun valore + )} + {pool.map((v) => ( + + {v} + + + ))} +
+ +
+ { 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" + /> + +
+ {error &&

{error}

} +
+ ); +} diff --git a/src/components/admin/offers/OfferEditorClient.tsx b/src/components/admin/offers/OfferEditorClient.tsx index 44cd443..44574c4 100644 --- a/src/components/admin/offers/OfferEditorClient.tsx +++ b/src/components/admin/offers/OfferEditorClient.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useMemo, useTransition } from "react"; +import { useState, useMemo, useTransition, useEffect } from "react"; import { useRouter } from "next/navigation"; import Link from "next/link"; import { @@ -86,6 +86,13 @@ export function OfferEditorClient({ const [tipoTags, setTipoTags] = useState(data.tipoTags); const [obiettivoTags, setObiettivoTags] = useState(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(fieldOptions.categoria); const [ticketOptions, setTicketOptions] = useState(fieldOptions.ticket); const [tipoOptions, setTipoOptions] = useState(fieldOptions.tipo); diff --git a/src/lib/offer-queries.ts b/src/lib/offer-queries.ts index 057fb85..697edaf 100644 --- a/src/lib/offer-queries.ts +++ b/src/lib/offer-queries.ts @@ -8,6 +8,7 @@ import { services, tags, } from "@/db/schema"; +import { getJsonPool, SETTINGS_KEYS } from "@/lib/settings"; import type { OfferMacro, OfferMicro, OfferService } from "@/db/schema"; 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 -// 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 { - const categoriaRows = await db.selectDistinct({ value: offer_macros.category }).from(offer_macros); - const ticketRows = await db.selectDistinct({ value: offer_macros.ticket }).from(offer_macros); - - const tagRows = await db - .selectDistinct({ name: tags.name, type: tags.entity_type }) - .from(tags) - .where(inArray(tags.entity_type, [OFFER_TIPO_ENTITY, OFFER_OBIETTIVO_ENTITY])); + const [categoriaRows, ticketRows, tagRows, tipoPool, obiettivoPool, categoriaPool] = + await Promise.all([ + db.selectDistinct({ value: offer_macros.category }).from(offer_macros), + db.selectDistinct({ value: offer_macros.ticket }).from(offer_macros), + db + .selectDistinct({ name: tags.name, type: tags.entity_type }) + .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)[]) => 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 { ); return { - categoria: sortUnique(categoriaRows.map((r) => r.value)), + categoria: sortUnique([ + ...categoriaPool, + ...categoriaRows.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)), - obiettivo: sortUnique( - tagRows.filter((r) => r.type === OFFER_OBIETTIVO_ENTITY).map((r) => r.name) - ), + tipo: sortUnique([ + ...tipoPool, + ...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), + ]), }; } diff --git a/src/lib/settings.ts b/src/lib/settings.ts index 46a4779..d58a1e2 100644 --- a/src/lib/settings.ts +++ b/src/lib/settings.ts @@ -5,6 +5,9 @@ import { revalidatePath } from "next/cache"; export const SETTINGS_KEYS = { 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; export async function getSetting(key: string): Promise { @@ -32,4 +35,22 @@ export async function updateSetting(key: string, value: string): Promise { export async function getTargetHourlyRate(): Promise { const value = await getSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE); return value ? parseFloat(value) : 50; +} + +export async function getJsonPool(key: string): Promise { + 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 { + const sorted = [...new Set(values.filter((v) => v.trim()))].sort((a, b) => + a.localeCompare(b, "it") + ); + await updateSetting(key, JSON.stringify(sorted)); } \ No newline at end of file
- {header} - 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 }} + > + + + {def.header} + + +
Servizi disattivati