From e858a8f577305b61eeb4d2948a0b58521c152ffa Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Sat, 13 Jun 2026 21:40:34 +0200 Subject: [PATCH] feat(catalog): Notion-style shared select fields (tag/pacchetto/categoria/fase) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turn the catalog into Notion/Airtable select properties: - Tag + Pacchetto: multi-select with a SHARED pool — created values persist and are selectable from a dropdown across all services (no more re-typing) - Categoria + Fase: single-select chips with the same dropdown + create-on-the-fly - Rename an option once -> propagates to every row (renameServiceOption) - Deterministic colors per value (shared option-colors util) - Quick-add row now sets name+description+categoria+fase+prezzo then Enter (active) - Search broadened to name/categoria/fase/tag/pacchetto Data model (additive): tag/pacchetto in polymorphic tags table (entity_type services / services.pacchetto); categoria/fase as single-select columns on services (new: services.fase). Pools derived from distinct values. New: OptionSelect, OptionMultiSelect, option-colors. Removed tag-multi-select. Migration 0007_add_services_fase.sql must be applied before runtime. tsc + eslint + next build clean. CSV bulk import (OFFER-12) stays Phase 12. Co-Authored-By: Claude Opus 4.8 --- scripts/push-11b-fase-column.ts | 27 +++ src/app/admin/catalog/CatalogSearch.tsx | 20 +- src/app/admin/catalog/actions.ts | 114 +++++++-- src/app/admin/catalog/page.tsx | 15 +- src/components/admin/catalog/ServiceTable.tsx | 226 ++++++++++++++---- src/components/ui/option-colors.ts | 21 ++ src/components/ui/option-multi-select.tsx | 219 +++++++++++++++++ src/components/ui/option-select.tsx | 209 ++++++++++++++++ src/components/ui/tag-multi-select.tsx | 162 ------------- src/db/migrations/0007_add_services_fase.sql | 3 + src/db/schema.ts | 3 +- src/lib/admin-queries.ts | 64 ++++- 12 files changed, 834 insertions(+), 249 deletions(-) create mode 100644 scripts/push-11b-fase-column.ts create mode 100644 src/components/ui/option-colors.ts create mode 100644 src/components/ui/option-multi-select.tsx create mode 100644 src/components/ui/option-select.tsx delete mode 100644 src/components/ui/tag-multi-select.tsx create mode 100644 src/db/migrations/0007_add_services_fase.sql diff --git a/scripts/push-11b-fase-column.ts b/scripts/push-11b-fase-column.ts new file mode 100644 index 0000000..2754328 --- /dev/null +++ b/scripts/push-11b-fase-column.ts @@ -0,0 +1,27 @@ +// PRODUCTION DEPLOY NOTE: additive-only (ADD COLUMN IF NOT EXISTS, no drops/truncates). +// Per CLAUDE.md Data Safety, apply to the live DB via SSH tunnel / docker exec BEFORE +// deploying the schema-dependent catalog code. Idempotent — safe to re-run. +// Run: npx tsx scripts/push-11b-fase-column.ts (with DATABASE_URL pointing at the DB) +import postgres from "postgres"; + +async function push() { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + console.error("DATABASE_URL environment variable is required"); + process.exit(1); + } + + const client = postgres(databaseUrl); + + try { + console.log("Adding services.fase column..."); + await client`ALTER TABLE services ADD COLUMN IF NOT EXISTS fase text`; + console.log("✓ services.fase column ready"); + process.exit(0); + } catch (err: unknown) { + console.error("Error pushing migration:", err instanceof Error ? err.message : err); + process.exit(1); + } +} + +push(); diff --git a/src/app/admin/catalog/CatalogSearch.tsx b/src/app/admin/catalog/CatalogSearch.tsx index 1d8db30..6d017d7 100644 --- a/src/app/admin/catalog/CatalogSearch.tsx +++ b/src/app/admin/catalog/CatalogSearch.tsx @@ -4,9 +4,15 @@ import { useState, useMemo } from "react"; import { Input } from "@/components/ui/input"; import { Search } from "lucide-react"; import { ServiceTable } from "@/components/admin/catalog/ServiceTable"; -import type { ServiceWithTags } from "@/lib/admin-queries"; +import type { ServiceWithTags, CatalogFieldOptions } from "@/lib/admin-queries"; -export function CatalogSearch({ services }: { services: ServiceWithTags[] }) { +export function CatalogSearch({ + services, + options, +}: { + services: ServiceWithTags[]; + options: CatalogFieldOptions; +}) { const [query, setQuery] = useState(""); const filtered = useMemo(() => { @@ -14,7 +20,11 @@ export function CatalogSearch({ services }: { services: ServiceWithTags[] }) { if (!q) return services; return services.filter((s) => { if (s.name.toLowerCase().includes(q)) return true; - return s.tags.some((tag) => tag.toLowerCase().includes(q)); + 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, query]); @@ -24,13 +34,13 @@ export function CatalogSearch({ services }: { services: ServiceWithTags[] }) { setQuery(e.target.value)} className="pl-9 h-9" /> - + ); } diff --git a/src/app/admin/catalog/actions.ts b/src/app/admin/catalog/actions.ts index 079588b..0c9f01f 100644 --- a/src/app/admin/catalog/actions.ts +++ b/src/app/admin/catalog/actions.ts @@ -68,7 +68,7 @@ export async function toggleServiceActive(serviceId: string, active: boolean) { // ── Inline-edit, tag, and quick-add actions (Phase 11 database-view) ──────── -const EDITABLE_FIELDS = ["name", "description", "category", "unit_price", "active"] as const; +const EDITABLE_FIELDS = ["name", "description", "category", "fase", "unit_price", "active"] as const; type EditableField = (typeof EDITABLE_FIELDS)[number]; export async function updateServiceField( @@ -92,6 +92,9 @@ export async function updateServiceField( } else if (fieldName === "category") { const s = String(value).trim(); await db.update(services).set({ category: s || null }).where(eq(services.id, serviceId)); + } else if (fieldName === "fase") { + const s = String(value).trim(); + await db.update(services).set({ fase: s || null }).where(eq(services.id, serviceId)); } else if (fieldName === "unit_price") { // Normalize locale-formatted input (WR-04): the cell displays it-IT (€1.234,50), // so an admin may type "1.234,50". When a comma is present, treat "." as thousands @@ -112,47 +115,124 @@ export async function updateServiceField( revalidatePath("/admin/catalog"); } -export async function addTagToService(serviceId: string, tagName: string) { +// ── Multi-select option fields (Notion-style shared pools) ────────────────── +// "tag" and "pacchetto" are stored in the polymorphic `tags` table, scoped by +// entity_type so the two pools stay separate. + +const MULTI_ENTITY: Record = { + tag: "services", + pacchetto: "services.pacchetto", +}; +const MULTI_FIELDS = ["tag", "pacchetto"] as const; +export type MultiSelectField = (typeof MULTI_FIELDS)[number]; + +type SingleSelectField = "categoria" | "fase"; + +export async function addServiceOption( + field: MultiSelectField, + serviceId: string, + value: string +) { await requireAdmin(); - const trimmed = tagName.trim(); - if (trimmed.length === 0) throw new Error("Nome tag richiesto"); + if (!MULTI_FIELDS.includes(field)) throw new Error(`Campo non valido: ${field}`); + const trimmed = value.trim(); + if (trimmed.length === 0) throw new Error("Valore richiesto"); await db .insert(tags) - .values({ - entity_type: "services", - entity_id: serviceId, - name: trimmed, - }) + .values({ entity_type: MULTI_ENTITY[field], entity_id: serviceId, name: trimmed }) .onConflictDoNothing(); revalidatePath("/admin/catalog"); } -export async function removeTagFromService(serviceId: string, tagName: string) { +export async function removeServiceOption( + field: MultiSelectField, + serviceId: string, + value: string +) { await requireAdmin(); + if (!MULTI_FIELDS.includes(field)) throw new Error(`Campo non valido: ${field}`); await db .delete(tags) .where( and( - eq(tags.entity_type, "services"), + eq(tags.entity_type, MULTI_ENTITY[field]), eq(tags.entity_id, serviceId), - eq(tags.name, tagName) + eq(tags.name, value) ) ); revalidatePath("/admin/catalog"); } -export async function quickAddService(name: string) { +// ── Rename an option everywhere it is used (propagates across all services) ─── +// Works for both multi-select pools (tag/pacchetto) and single-select columns +// (categoria/fase). This is what makes the dropdown options feel persistent & +// editable like Notion select properties. + +export async function renameServiceOption( + field: MultiSelectField | SingleSelectField, + oldValue: string, + newValue: string +) { await requireAdmin(); - const trimmed = name.trim(); - if (trimmed.length === 0) throw new Error("Nome richiesto"); + const next = newValue.trim(); + if (next.length === 0) throw new Error("Nuovo nome richiesto"); + if (next === oldValue) return; + + if (field === "tag" || field === "pacchetto") { + await db + .update(tags) + .set({ name: next }) + .where(and(eq(tags.entity_type, MULTI_ENTITY[field]), eq(tags.name, oldValue))); + } else if (field === "categoria") { + await db.update(services).set({ category: next }).where(eq(services.category, oldValue)); + } else if (field === "fase") { + await db.update(services).set({ fase: next }).where(eq(services.fase, oldValue)); + } else { + throw new Error(`Campo non valido: ${field}`); + } + + revalidatePath("/admin/catalog"); +} + +// ── Quick-add: create a service from the bottom row with all scalar fields set +// and active=true in one Enter. Tags/pacchetto are assigned afterwards (they +// need the new row's id). Only `name` is required. + +export type QuickAddPayload = { + name: string; + description?: string; + category?: string; + fase?: string; + unit_price?: string; +}; + +export async function quickAddService(payload: QuickAddPayload | string) { + await requireAdmin(); + // Back-compat: accept a bare name string. + const data: QuickAddPayload = typeof payload === "string" ? { name: payload } : payload; + + const name = data.name.trim(); + if (name.length === 0) throw new Error("Nome richiesto"); + + let unit_price = "0.00"; + if (data.unit_price && data.unit_price.trim().length > 0) { + const raw = data.unit_price.trim(); + const normalized = raw.includes(",") ? raw.replace(/\./g, "").replace(",", ".") : raw; + const num = Number(normalized); + if (!Number.isFinite(num) || num < 0) throw new Error("Prezzo invalido"); + unit_price = num.toFixed(2); + } await db.insert(services).values({ - name: trimmed, - unit_price: "0.00", + name, + description: data.description?.trim() || null, + category: data.category?.trim() || null, + fase: data.fase?.trim() || null, + unit_price, active: true, }); diff --git a/src/app/admin/catalog/page.tsx b/src/app/admin/catalog/page.tsx index dcdf6e9..a64548d 100644 --- a/src/app/admin/catalog/page.tsx +++ b/src/app/admin/catalog/page.tsx @@ -1,10 +1,13 @@ -import { getAllServices } from "@/lib/admin-queries"; +import { getAllServices, getCatalogFieldOptions } from "@/lib/admin-queries"; import { CatalogSearch } from "./CatalogSearch"; export const revalidate = 0; export default async function CatalogPage() { - const services = await getAllServices(); + const [services, options] = await Promise.all([ + getAllServices(), + getCatalogFieldOptions(), + ]); return (
@@ -12,13 +15,7 @@ export default async function CatalogPage() {

Catalogo Servizi

- {services.length === 0 ? ( -

- Nessun servizio nel catalogo. Scrivi un nome nella riga in fondo alla tabella e premi invio per crearne uno. -

- ) : ( - - )} + ); } diff --git a/src/components/admin/catalog/ServiceTable.tsx b/src/components/admin/catalog/ServiceTable.tsx index 4dd276d..43681a8 100644 --- a/src/components/admin/catalog/ServiceTable.tsx +++ b/src/components/admin/catalog/ServiceTable.tsx @@ -4,28 +4,50 @@ import { useState, useTransition } from "react"; import { useRouter } from "next/navigation"; import { Input } from "@/components/ui/input"; import { EditableCell } from "@/components/ui/editable-cell"; -import { TagMultiSelect } from "@/components/ui/tag-multi-select"; -import { updateServiceField, quickAddService } from "@/app/admin/catalog/actions"; -import type { ServiceWithTags } from "@/lib/admin-queries"; +import { OptionSelect } from "@/components/ui/option-select"; +import { OptionMultiSelect } from "@/components/ui/option-multi-select"; +import { + updateServiceField, + quickAddService, + addServiceOption, + removeServiceOption, + renameServiceOption, +} from "@/app/admin/catalog/actions"; +import type { ServiceWithTags, CatalogFieldOptions } from "@/lib/admin-queries"; function formatPrice(raw: string): string { const num = parseFloat(raw); return `€${(isNaN(num) ? 0 : num).toLocaleString("it-IT", { minimumFractionDigits: 2 })}`; } -function ServiceRow({ service }: { service: ServiceWithTags }) { +const COLUMN_HEADERS = [ + "Nome", + "Descrizione", + "Categoria", + "Fase", + "Tag", + "Pacchetto", + "Prezzo", + "Stato", +]; +const COL_COUNT = COLUMN_HEADERS.length; + +function ServiceRow({ + service, + options, +}: { + service: ServiceWithTags; + options: CatalogFieldOptions; +}) { const router = useRouter(); const [, startTransition] = useTransition(); const [error, setError] = useState(null); - function saveField( - field: "name" | "description" | "category" | "unit_price" | "active", - value: string - ) { + function run(fn: () => Promise) { setError(null); startTransition(async () => { try { - await updateServiceField(service.id, field, value); + await fn(); router.refresh(); } catch (e) { setError(e instanceof Error ? e.message : "Errore nel salvataggio"); @@ -45,23 +67,49 @@ function ServiceRow({ service }: { service: ServiceWithTags }) { value={service.name} type="text" required - onSave={(v) => saveField("name", v)} + onSave={(v) => run(() => updateServiceField(service.id, "name", v))} /> - + saveField("description", v)} + onSave={(v) => run(() => updateServiceField(service.id, "description", v))} /> - - saveField("category", v)} + + run(() => updateServiceField(service.id, "category", v ?? ""))} + onRename={(o, n) => run(() => renameServiceOption("categoria", o, n))} + /> + + + 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))} /> @@ -69,27 +117,20 @@ function ServiceRow({ service }: { service: ServiceWithTags }) { value={service.unit_price} type="number" formatDisplay={formatPrice} - onSave={(v) => saveField("unit_price", v)} - /> - - - router.refresh()} + onSave={(v) => run(() => updateServiceField(service.id, "unit_price", v))} /> saveField("active", v)} + onSave={(v) => run(() => updateServiceField(service.id, "active", v))} /> {error && ( - + {error} @@ -98,22 +139,34 @@ function ServiceRow({ service }: { service: ServiceWithTags }) { ); } -function QuickAddRow() { +function QuickAddRow({ options }: { options: CatalogFieldOptions }) { const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [category, setCategory] = useState(""); + const [fase, setFase] = useState(""); + const [price, setPrice] = useState(""); const [, startTransition] = useTransition(); const [error, setError] = useState(null); const router = useRouter(); - function handleKeyDown(e: React.KeyboardEvent) { - if (e.key !== "Enter") return; + function submit() { const trimmed = name.trim(); if (!trimmed) return; - e.preventDefault(); setError(null); startTransition(async () => { try { - await quickAddService(trimmed); + await quickAddService({ + name: trimmed, + description: description || undefined, + category: category || undefined, + fase: fase || undefined, + unit_price: price || undefined, + }); setName(""); + setDescription(""); + setCategory(""); + setFase(""); + setPrice(""); router.refresh(); } catch (err) { setError(err instanceof Error ? err.message : "Errore nel salvataggio"); @@ -121,26 +174,100 @@ function QuickAddRow() { }); } + function onKeyDown(e: React.KeyboardEvent) { + if (e.key === "Enter") { + e.preventDefault(); + submit(); + } + } + + const cellInput = + "border-0 bg-transparent shadow-none h-8 text-sm placeholder:text-[#71717a] focus-visible:ring-1 focus-visible:ring-primary"; + return ( + <> setName(e.target.value)} - onKeyDown={handleKeyDown} + onKeyDown={onKeyDown} placeholder="+ Aggiungi servizio" - className="border-0 bg-transparent shadow-none h-8 text-sm placeholder:text-[#71717a] focus-visible:ring-1 focus-visible:ring-primary" + className={cellInput} /> - {error &&

{error}

} - + + setDescription(e.target.value)} + onKeyDown={onKeyDown} + placeholder="Descrizione..." + className={cellInput} + /> + + + setCategory(e.target.value)} + onKeyDown={onKeyDown} + placeholder="Categoria..." + list="catalog-categoria-pool" + className={cellInput} + /> + + + setFase(e.target.value)} + onKeyDown={onKeyDown} + placeholder="Fase..." + list="catalog-fase-pool" + className={cellInput} + /> + + + Tag e pacchetto dopo la creazione + + + setPrice(e.target.value)} + onKeyDown={onKeyDown} + placeholder="0,00" + inputMode="decimal" + className={`${cellInput} tabular-nums`} + /> + + Invio ↵ + + {options.categoria.map((c) => ( + + + {options.fase.map((f) => ( + + {error && ( + + + {error} + + + )} + ); } -const COLUMN_HEADERS = ["Nome", "Descrizione", "Categoria", "Prezzo", "Tag", "Stato"]; - -export function ServiceTable({ services }: { services: ServiceWithTags[] }) { +export function ServiceTable({ + services, + options, +}: { + services: ServiceWithTags[]; + options: CatalogFieldOptions; +}) { const activeServices = services.filter((s) => s.active); const inactiveServices = services.filter((s) => !s.active); @@ -161,21 +288,22 @@ export function ServiceTable({ services }: { services: ServiceWithTags[] }) { - {activeServices.map((s) => ( - + {activeServices.map((service) => ( + ))} - - - + {inactiveServices.length > 0 && ( <> - - + + Servizi disattivati - {inactiveServices.map((s) => ( - + {inactiveServices.map((service) => ( + ))} )} diff --git a/src/components/ui/option-colors.ts b/src/components/ui/option-colors.ts new file mode 100644 index 0000000..d8d877a --- /dev/null +++ b/src/components/ui/option-colors.ts @@ -0,0 +1,21 @@ +// Deterministic option-name -> color, shared by every select/multi-select field +// (D-07: no stored color column — colour is derived from the value's hash). +// 7-color pastel palette, bg-*-100/text-*-900 pairs for AA contrast on white. +export const OPTION_COLORS = [ + "bg-blue-100 text-blue-900", + "bg-green-100 text-green-900", + "bg-purple-100 text-purple-900", + "bg-pink-100 text-pink-900", + "bg-amber-100 text-amber-900", + "bg-teal-100 text-teal-900", + "bg-orange-100 text-orange-900", +]; + +export function getOptionColor(value: string): string { + let hash = 0; + for (let i = 0; i < value.length; i++) { + hash = (hash << 5) - hash + value.charCodeAt(i); + hash |= 0; // 32-bit int + } + return OPTION_COLORS[Math.abs(hash) % OPTION_COLORS.length]; +} diff --git a/src/components/ui/option-multi-select.tsx b/src/components/ui/option-multi-select.tsx new file mode 100644 index 0000000..6f6f5f3 --- /dev/null +++ b/src/components/ui/option-multi-select.tsx @@ -0,0 +1,219 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Check, X, Plus, Pencil } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { getOptionColor } from "@/components/ui/option-colors"; + +export interface OptionMultiSelectProps { + values: string[]; + options: string[]; + onAdd: (value: string) => void; + onRemove: (value: string) => void; + onRename?: (oldValue: string, newValue: string) => void; + placeholder?: string; + disabled?: boolean; +} + +export function OptionMultiSelect({ + values, + options, + onAdd, + onRemove, + onRename, + placeholder = "Cerca o crea...", + disabled, +}: OptionMultiSelectProps) { + const [isOpen, setIsOpen] = useState(false); + const [query, setQuery] = useState(""); + const [renaming, setRenaming] = useState(null); + const [renameValue, setRenameValue] = useState(""); + const inputRef = useRef(null); + const containerRef = useRef(null); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsOpen(false); + setRenaming(null); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + useEffect(() => { + if (isOpen && inputRef.current) inputRef.current.focus(); + }, [isOpen]); + + const selected = new Set(values); + // Union of the shared pool + anything already on this row, filtered by query. + const pool = Array.from(new Set([...options, ...values])).sort((a, b) => + a.localeCompare(b, "it") + ); + const q = query.trim().toLowerCase(); + const filtered = q ? pool.filter((o) => o.toLowerCase().includes(q)) : pool; + const exactMatch = pool.some((o) => o.toLowerCase() === q); + + function toggle(value: string) { + if (selected.has(value)) onRemove(value); + else onAdd(value); + } + + function createFromQuery() { + const trimmed = query.trim(); + if (!trimmed) return; + if (!selected.has(trimmed)) onAdd(trimmed); + setQuery(""); + } + + function commitRename(oldValue: string) { + const next = renameValue.trim(); + setRenaming(null); + if (next && next !== oldValue) onRename?.(oldValue, next); + } + + return ( +
+
!disabled && setIsOpen((v) => !v)} + className={cn( + "flex flex-wrap gap-1 items-center px-2 py-1 rounded transition-colors duration-150 min-h-[28px]", + disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-[#f0f0f0]" + )} + > + {values.length === 0 ? ( + + ) : ( + values.map((v) => ( + + {v} + + + )) + )} + +
+ + {isOpen && ( +
+ setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + if (!exactMatch) createFromQuery(); + else if (filtered.length === 1) toggle(filtered[0]); + } + if (e.key === "Escape") { + e.preventDefault(); + setIsOpen(false); + } + }} + className="text-sm h-8 mb-1.5 ring-1 ring-primary" + /> + +
+ {filtered.map((opt) => + renaming === opt ? ( + setRenameValue(e.target.value)} + onBlur={() => commitRename(opt)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitRename(opt); + } + if (e.key === "Escape") { + e.preventDefault(); + setRenaming(null); + } + }} + className="text-sm h-7" + /> + ) : ( +
+ + {onRename && ( + + )} +
+ ) + )} + + {q && !exactMatch && ( + + )} + + {filtered.length === 0 && !q && ( +

Nessuna opzione ancora

+ )} +
+
+ )} +
+ ); +} diff --git a/src/components/ui/option-select.tsx b/src/components/ui/option-select.tsx new file mode 100644 index 0000000..3a09b0f --- /dev/null +++ b/src/components/ui/option-select.tsx @@ -0,0 +1,209 @@ +"use client"; + +import { useState, useRef, useEffect } from "react"; +import { Badge } from "@/components/ui/badge"; +import { Input } from "@/components/ui/input"; +import { Check, Plus, Pencil, X } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { getOptionColor } from "@/components/ui/option-colors"; + +export interface OptionSelectProps { + value: string | null; + options: string[]; + onChange: (value: string | null) => void; + onRename?: (oldValue: string, newValue: string) => void; + placeholder?: string; + disabled?: boolean; +} + +export function OptionSelect({ + value, + options, + onChange, + onRename, + placeholder = "Cerca o crea...", + disabled, +}: OptionSelectProps) { + const [isOpen, setIsOpen] = useState(false); + const [query, setQuery] = useState(""); + const [renaming, setRenaming] = useState(null); + const [renameValue, setRenameValue] = useState(""); + const inputRef = useRef(null); + const containerRef = useRef(null); + + useEffect(() => { + function handleClickOutside(e: MouseEvent) { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setIsOpen(false); + setRenaming(null); + } + } + document.addEventListener("mousedown", handleClickOutside); + return () => document.removeEventListener("mousedown", handleClickOutside); + }, []); + + useEffect(() => { + if (isOpen && inputRef.current) inputRef.current.focus(); + }, [isOpen]); + + const pool = Array.from(new Set([...options, ...(value ? [value] : [])])).sort((a, b) => + a.localeCompare(b, "it") + ); + const q = query.trim().toLowerCase(); + const filtered = q ? pool.filter((o) => o.toLowerCase().includes(q)) : pool; + const exactMatch = pool.some((o) => o.toLowerCase() === q); + + function select(v: string) { + onChange(v); + setQuery(""); + setIsOpen(false); + } + + function createFromQuery() { + const trimmed = query.trim(); + if (!trimmed) return; + select(trimmed); + } + + function commitRename(oldValue: string) { + const next = renameValue.trim(); + setRenaming(null); + if (next && next !== oldValue) onRename?.(oldValue, next); + } + + return ( +
+
!disabled && setIsOpen((v) => !v)} + className={cn( + "flex items-center gap-1 px-2 py-1 rounded transition-colors duration-150 min-h-[28px]", + disabled ? "cursor-not-allowed opacity-50" : "cursor-pointer hover:bg-[#f0f0f0]" + )} + > + {value ? ( + + {value} + + ) : ( + + )} +
+ + {isOpen && ( +
+ setQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + if (!exactMatch) createFromQuery(); + else if (filtered.length === 1) select(filtered[0]); + } + if (e.key === "Escape") { + e.preventDefault(); + setIsOpen(false); + } + }} + className="text-sm h-8 mb-1.5 ring-1 ring-primary" + /> + +
+ {value && ( + + )} + + {filtered.map((opt) => + renaming === opt ? ( + setRenameValue(e.target.value)} + onBlur={() => commitRename(opt)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + commitRename(opt); + } + if (e.key === "Escape") { + e.preventDefault(); + setRenaming(null); + } + }} + className="text-sm h-7" + /> + ) : ( +
+ + {onRename && ( + + )} +
+ ) + )} + + {q && !exactMatch && ( + + )} +
+
+ )} +
+ ); +} diff --git a/src/components/ui/tag-multi-select.tsx b/src/components/ui/tag-multi-select.tsx deleted file mode 100644 index f1506c0..0000000 --- a/src/components/ui/tag-multi-select.tsx +++ /dev/null @@ -1,162 +0,0 @@ -"use client"; - -import { useState, useRef, useEffect, useTransition } from "react"; -import { Badge } from "@/components/ui/badge"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { X, Plus } from "lucide-react"; -import { cn } from "@/lib/utils"; -import { addTagToService, removeTagFromService } from "@/app/admin/catalog/actions"; - -// D-07: deterministic name -> color index, no stored `color` column. -// 7-color pastel palette, bg-*-100/text-*-900 pairs for AA contrast on white. -const TAG_COLORS = [ - "bg-blue-100 text-blue-900", - "bg-green-100 text-green-900", - "bg-purple-100 text-purple-900", - "bg-pink-100 text-pink-900", - "bg-amber-100 text-amber-900", - "bg-teal-100 text-teal-900", - "bg-orange-100 text-orange-900", -]; - -export function getTagColorIndex(name: string): number { - let hash = 0; - for (let i = 0; i < name.length; i++) { - hash = (hash << 5) - hash + name.charCodeAt(i); - hash |= 0; // 32-bit int - } - return Math.abs(hash) % TAG_COLORS.length; -} - -export interface TagMultiSelectProps { - tags: string[]; - serviceId: string; - onTagsChanged?: () => void; -} - -export function TagMultiSelect({ tags, serviceId, onTagsChanged }: TagMultiSelectProps) { - const [isOpen, setIsOpen] = useState(false); - const [newTag, setNewTag] = useState(""); - const [isPending, startTransition] = useTransition(); - const [error, setError] = useState(null); - const inputRef = useRef(null); - const containerRef = useRef(null); - - useEffect(() => { - function handleClickOutside(e: MouseEvent) { - if (containerRef.current && !containerRef.current.contains(e.target as Node)) { - setIsOpen(false); - } - } - document.addEventListener("mousedown", handleClickOutside); - return () => document.removeEventListener("mousedown", handleClickOutside); - }, []); - - useEffect(() => { - if (isOpen && inputRef.current) { - inputRef.current.focus(); - } - }, [isOpen]); - - function handleAddTag() { - const trimmed = newTag.trim(); - if (!trimmed) return; - setError(null); - startTransition(async () => { - try { - await addTagToService(serviceId, trimmed); - setNewTag(""); - onTagsChanged?.(); - } catch (e) { - setError(e instanceof Error ? e.message : "Errore nel salvataggio"); - } - }); - } - - function handleRemoveTag(tagName: string) { - startTransition(async () => { - try { - await removeTagFromService(serviceId, tagName); - onTagsChanged?.(); - } catch (e) { - setError(e instanceof Error ? e.message : "Errore nella rimozione"); - } - }); - } - - return ( -
-
setIsOpen((v) => !v)} - className="flex flex-wrap gap-1 items-center px-2 py-1 rounded cursor-pointer hover:bg-[#f0f0f0] transition-colors duration-150 min-h-[28px]" - > - {tags.length === 0 ? ( - - ) : ( - tags.map((tag) => ( - - {tag} - - - )) - )} - -
- - {isOpen && ( -
-
- setNewTag(e.target.value)} - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); - handleAddTag(); - } - if (e.key === "Escape") { - e.preventDefault(); - setIsOpen(false); - } - }} - className="text-sm h-8 ring-1 ring-primary" - disabled={isPending} - /> - -
- {error &&

{error}

} -
- )} -
- ); -} diff --git a/src/db/migrations/0007_add_services_fase.sql b/src/db/migrations/0007_add_services_fase.sql new file mode 100644 index 0000000..b11b703 --- /dev/null +++ b/src/db/migrations/0007_add_services_fase.sql @@ -0,0 +1,3 @@ +-- Additive: single-select "fase" option on services (Notion-style catalog). +-- No drops/truncates. Safe to re-run. +ALTER TABLE services ADD COLUMN IF NOT EXISTS fase text; diff --git a/src/db/schema.ts b/src/db/schema.ts index 5430e88..bde926b 100644 --- a/src/db/schema.ts +++ b/src/db/schema.ts @@ -216,7 +216,8 @@ export const services = pgTable("services", { name: text("name").notNull(), description: text("description"), unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(), - category: text("category"), + category: text("category"), // single-select option (shared pool derived from distinct values) + fase: text("fase"), // single-select option — offer lifecycle phase (Notion-style) active: boolean("active").notNull().default(true), migrated_from: text("migrated_from"), // "service_catalog" | "offer_services" | null (new rows after Phase 7) migrated_id: text("migrated_id"), // original id from source table, null for new rows diff --git a/src/lib/admin-queries.ts b/src/lib/admin-queries.ts index 9222168..0fb11a0 100644 --- a/src/lib/admin-queries.ts +++ b/src/lib/admin-queries.ts @@ -357,9 +357,15 @@ export async function getClientFullDetail(id: string): Promise { const rows = await db @@ -369,32 +375,78 @@ export async function getAllServices(): Promise { description: services.description, unit_price: services.unit_price, category: services.category, + fase: services.fase, active: services.active, migrated_from: services.migrated_from, migrated_id: services.migrated_id, created_at: services.created_at, tag_name: tags.name, + tag_type: tags.entity_type, }) .from(services) .leftJoin( tags, - and(eq(tags.entity_type, "services"), eq(tags.entity_id, services.id)) + and( + eq(tags.entity_id, services.id), + inArray(tags.entity_type, [TAG_ENTITY, PACCHETTO_ENTITY]) + ) ) .orderBy(asc(services.name), asc(tags.name)); const serviceMap = new Map(); for (const row of rows) { - const { tag_name, ...serviceFields } = row; + const { tag_name, tag_type, ...serviceFields } = row; if (!serviceMap.has(row.id)) { - serviceMap.set(row.id, { ...serviceFields, tags: [] }); + serviceMap.set(row.id, { ...serviceFields, tags: [], pacchetto: [] }); } if (tag_name) { - serviceMap.get(row.id)!.tags.push(tag_name); + const target = serviceMap.get(row.id)!; + if (tag_type === PACCHETTO_ENTITY) target.pacchetto.push(tag_name); + else target.tags.push(tag_name); } } return Array.from(serviceMap.values()); } +// ── Shared option pools for the catalog select fields (Notion-style dropdowns) ─ +// Each pool is the set of distinct values currently in use, sorted alphabetically. + +export type CatalogFieldOptions = { + tag: string[]; + pacchetto: string[]; + categoria: string[]; + fase: string[]; +}; + +export async function getCatalogFieldOptions(): Promise { + const tagRows = await db + .selectDistinct({ name: tags.name, type: tags.entity_type }) + .from(tags) + .where(inArray(tags.entity_type, [TAG_ENTITY, PACCHETTO_ENTITY])); + + // Distinct category / fase values (single-select pools); nulls filtered below. + const catRows = await db + .selectDistinct({ value: services.category }) + .from(services); + const faseRows = await db + .selectDistinct({ value: services.fase }) + .from(services); + + const sortUnique = (arr: (string | null)[]) => + Array.from(new Set(arr.filter((v): v is string => !!v && v.trim().length > 0))).sort( + (a, b) => a.localeCompare(b, "it") + ); + + return { + tag: sortUnique(tagRows.filter((r) => r.type === TAG_ENTITY).map((r) => r.name)), + pacchetto: sortUnique( + tagRows.filter((r) => r.type === PACCHETTO_ENTITY).map((r) => r.name) + ), + categoria: sortUnique(catRows.map((r) => r.value)), + fase: sortUnique(faseRows.map((r) => r.value)), + }; +} + // ── ProjectWithPayments — used by /admin/projects list ─────────────────────── export type ProjectWithPayments = {