) {
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 ↵ |
+
+
+ {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 = {
|