feat(catalog): Notion-style shared select fields (tag/pacchetto/categoria/fase)

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 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 21:40:34 +02:00
parent 42c16e1bab
commit e858a8f577
12 changed files with 834 additions and 249 deletions
+177 -49
View File
@@ -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<string | null>(null);
function saveField(
field: "name" | "description" | "category" | "unit_price" | "active",
value: string
) {
function run(fn: () => Promise<unknown>) {
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))}
/>
</td>
<td className="py-2 px-3 text-[#71717a] min-w-[220px]">
<td className="py-2 px-3 text-[#71717a] min-w-[200px]">
<EditableCell
value={service.description ?? ""}
type="textarea"
placeholder="Descrizione..."
onSave={(v) => saveField("description", v)}
onSave={(v) => run(() => updateServiceField(service.id, "description", v))}
/>
</td>
<td className="py-2 px-3 text-[#71717a] min-w-[120px]">
<EditableCell
value={service.category ?? ""}
type="text"
placeholder="Categoria..."
onSave={(v) => saveField("category", v)}
<td className="py-2 px-3 min-w-[140px]">
<OptionSelect
value={service.category}
options={options.categoria}
onChange={(v) => run(() => updateServiceField(service.id, "category", v ?? ""))}
onRename={(o, n) => run(() => renameServiceOption("categoria", o, n))}
/>
</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]">
@@ -69,27 +117,20 @@ function ServiceRow({ service }: { service: ServiceWithTags }) {
value={service.unit_price}
type="number"
formatDisplay={formatPrice}
onSave={(v) => saveField("unit_price", v)}
/>
</td>
<td className="py-2 px-3 min-w-[180px]">
<TagMultiSelect
tags={service.tags}
serviceId={service.id}
onTagsChanged={() => router.refresh()}
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) => saveField("active", v)}
onSave={(v) => run(() => updateServiceField(service.id, "active", v))}
/>
</td>
</tr>
{error && (
<tr>
<td className="py-1 px-3 text-xs text-red-600" colSpan={6}>
<td colSpan={COL_COUNT} className="py-1 px-3 text-xs text-red-600">
{error}
</td>
</tr>
@@ -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<string | null>(null);
const router = useRouter();
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement>) {
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<HTMLInputElement>) {
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 (
<>
<tr className="border-b border-[#e5e7eb] bg-[#f9f9f9]">
<td className="py-2 px-3">
<Input
value={name}
onChange={(e) => 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 && <p className="text-xs text-red-600 mt-1">{error}</p>}
</td>
<td colSpan={5}></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={category}
onChange={(e) => setCategory(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Categoria..."
list="catalog-categoria-pool"
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}>
Tag 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-categoria-pool">
{options.categoria.map((c) => (
<option key={c} value={c} />
))}
</datalist>
<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>
)}
</>
);
}
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[] }) {
</tr>
</thead>
<tbody>
{activeServices.map((s) => (
<ServiceRow key={s.id} service={s} />
{activeServices.map((service) => (
<ServiceRow key={service.id} service={service} options={options} />
))}
<QuickAddRow />
<QuickAddRow options={options} />
{inactiveServices.length > 0 && (
<>
<tr className="border-b border-t-2 border-t-[#e5e7eb] border-[#e5e7eb]">
<td colSpan={6} className="py-1.5 px-3 text-xs font-medium text-[#71717a] uppercase tracking-wide">
<tr className="bg-[#fafafa]">
<td
colSpan={COL_COUNT}
className="py-1.5 px-3 text-xs font-medium uppercase tracking-wide text-[#a1a1aa]"
>
Servizi disattivati
</td>
</tr>
{inactiveServices.map((s) => (
<ServiceRow key={s.id} service={s} />
{inactiveServices.map((service) => (
<ServiceRow key={service.id} service={service} options={options} />
))}
</>
)}
+21
View File
@@ -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];
}
+219
View File
@@ -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<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(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 (
<div ref={containerRef} className="relative w-full min-w-[140px]">
<div
onClick={() => !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 ? (
<span className="text-xs text-[#71717a]"></span>
) : (
values.map((v) => (
<Badge
key={v}
variant="outline"
className={cn(
getOptionColor(v),
"border-transparent text-xs px-2 py-0.5 gap-1 font-medium"
)}
>
{v}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onRemove(v);
}}
className="hover:opacity-70"
aria-label={`Rimuovi ${v}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))
)}
<Plus className="h-3.5 w-3.5 text-[#71717a]" />
</div>
{isOpen && (
<div className="absolute top-full left-0 mt-1 bg-white border border-[#e5e7eb] rounded-md shadow-md p-1.5 z-20 min-w-[220px] max-h-[280px] overflow-y-auto">
<Input
ref={inputRef}
type="text"
placeholder={placeholder}
value={query}
onChange={(e) => 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"
/>
<div className="flex flex-col gap-0.5">
{filtered.map((opt) =>
renaming === opt ? (
<Input
key={opt}
autoFocus
value={renameValue}
onChange={(e) => 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"
/>
) : (
<div
key={opt}
className="flex items-center justify-between gap-1 rounded px-1.5 py-1 hover:bg-[#f5f5f5] group"
>
<button
type="button"
onClick={() => toggle(opt)}
className="flex items-center gap-2 flex-1 min-w-0 text-left"
>
<span className="w-3.5 flex-shrink-0">
{selected.has(opt) && <Check className="h-3.5 w-3.5 text-[#1A463C]" />}
</span>
<Badge
variant="outline"
className={cn(
getOptionColor(opt),
"border-transparent text-xs px-2 py-0.5 font-medium truncate"
)}
>
{opt}
</Badge>
</button>
{onRename && (
<button
type="button"
onClick={() => {
setRenameValue(opt);
setRenaming(opt);
}}
className="opacity-0 group-hover:opacity-100 text-[#71717a] hover:text-[#1a1a1a] p-0.5"
aria-label={`Rinomina ${opt}`}
>
<Pencil className="h-3 w-3" />
</button>
)}
</div>
)
)}
{q && !exactMatch && (
<button
type="button"
onClick={createFromQuery}
className="flex items-center gap-2 rounded px-1.5 py-1.5 hover:bg-[#f5f5f5] text-left text-sm"
>
<Plus className="h-3.5 w-3.5 text-[#1A463C]" />
Crea <span className="font-medium">«{query.trim()}»</span>
</button>
)}
{filtered.length === 0 && !q && (
<p className="text-xs text-[#71717a] px-1.5 py-1">Nessuna opzione ancora</p>
)}
</div>
</div>
)}
</div>
);
}
+209
View File
@@ -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<string | null>(null);
const [renameValue, setRenameValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(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 (
<div ref={containerRef} className="relative w-full min-w-[120px]">
<div
onClick={() => !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 ? (
<Badge
variant="outline"
className={cn(
getOptionColor(value),
"border-transparent text-xs px-2 py-0.5 font-medium truncate"
)}
>
{value}
</Badge>
) : (
<span className="text-xs text-[#71717a]"></span>
)}
</div>
{isOpen && (
<div className="absolute top-full left-0 mt-1 bg-white border border-[#e5e7eb] rounded-md shadow-md p-1.5 z-20 min-w-[200px] max-h-[280px] overflow-y-auto">
<Input
ref={inputRef}
type="text"
placeholder={placeholder}
value={query}
onChange={(e) => 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"
/>
<div className="flex flex-col gap-0.5">
{value && (
<button
type="button"
onClick={() => {
onChange(null);
setIsOpen(false);
}}
className="flex items-center gap-2 rounded px-1.5 py-1 hover:bg-[#f5f5f5] text-left text-xs text-[#71717a]"
>
<X className="h-3.5 w-3.5" /> Rimuovi
</button>
)}
{filtered.map((opt) =>
renaming === opt ? (
<Input
key={opt}
autoFocus
value={renameValue}
onChange={(e) => 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"
/>
) : (
<div
key={opt}
className="flex items-center justify-between gap-1 rounded px-1.5 py-1 hover:bg-[#f5f5f5] group"
>
<button
type="button"
onClick={() => select(opt)}
className="flex items-center gap-2 flex-1 min-w-0 text-left"
>
<span className="w-3.5 flex-shrink-0">
{value === opt && <Check className="h-3.5 w-3.5 text-[#1A463C]" />}
</span>
<Badge
variant="outline"
className={cn(
getOptionColor(opt),
"border-transparent text-xs px-2 py-0.5 font-medium truncate"
)}
>
{opt}
</Badge>
</button>
{onRename && (
<button
type="button"
onClick={() => {
setRenameValue(opt);
setRenaming(opt);
}}
className="opacity-0 group-hover:opacity-100 text-[#71717a] hover:text-[#1a1a1a] p-0.5"
aria-label={`Rinomina ${opt}`}
>
<Pencil className="h-3 w-3" />
</button>
)}
</div>
)
)}
{q && !exactMatch && (
<button
type="button"
onClick={createFromQuery}
className="flex items-center gap-2 rounded px-1.5 py-1.5 hover:bg-[#f5f5f5] text-left text-sm"
>
<Plus className="h-3.5 w-3.5 text-[#1A463C]" />
Crea <span className="font-medium">«{query.trim()}»</span>
</button>
)}
</div>
</div>
)}
</div>
);
}
-162
View File
@@ -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<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(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 (
<div ref={containerRef} className="relative w-full min-w-[140px]">
<div
onClick={() => 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 ? (
<span className="text-xs text-[#71717a]"></span>
) : (
tags.map((tag) => (
<Badge
key={tag}
variant="outline"
className={cn(
TAG_COLORS[getTagColorIndex(tag)],
"border-transparent text-xs px-2 py-0.5 gap-1 font-medium"
)}
>
{tag}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleRemoveTag(tag);
}}
disabled={isPending}
className="hover:opacity-70 disabled:opacity-30"
aria-label={`Rimuovi tag ${tag}`}
>
<X className="h-3 w-3" />
</button>
</Badge>
))
)}
<Plus className="h-3.5 w-3.5 text-[#71717a]" />
</div>
{isOpen && (
<div className="absolute top-full left-0 mt-1 bg-white border border-[#e5e7eb] rounded-md shadow-md p-2 z-10 min-w-[200px]">
<div className="flex gap-1">
<Input
ref={inputRef}
type="text"
placeholder="Nome tag..."
value={newTag}
onChange={(e) => 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}
/>
<Button
type="button"
size="sm"
onClick={handleAddTag}
disabled={isPending || !newTag.trim()}
className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90 h-8 px-2"
>
+
</Button>
</div>
{error && <p className="text-xs text-red-600 mt-1">{error}</p>}
</div>
)}
</div>
);
}