86e1499e8f
- Design system foundations: Plus Jakarta Sans font, shadow-card/radius tokens in @theme, design-reference/DESIGN-SYSTEM.md with component inventory - Collapsible admin shell (AdminShell): smooth w-64<->w-20 sidebar, new top header with "Admin" + avatar placeholder, localStorage-persisted state - Rename route /admin/leads -> /admin/pipeline (redirect stubs preserved), nav label Lead -> Pipeline; DB table unchanged - Reusable primitives: StatusBadge, SearchInput, SegmentedToggle (dual-theme) - Luxury restyle of lead table, kanban (6 stages + @dnd-kit intact), PageHeader - Tokenize editable-cell / option-multi-select for dark-mode legibility Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
220 lines
7.3 KiB
TypeScript
220 lines
7.3 KiB
TypeScript
"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-muted"
|
|
)}
|
|
>
|
|
{values.length === 0 ? (
|
|
<span className="text-xs text-muted-foreground">—</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-muted-foreground" />
|
|
</div>
|
|
|
|
{isOpen && (
|
|
<div className="absolute top-full left-0 mt-1 bg-popover border border-border 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-muted 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-primary" />}
|
|
</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-muted-foreground hover:text-foreground 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-muted text-left text-sm"
|
|
>
|
|
<Plus className="h-3.5 w-3.5 text-primary" />
|
|
Crea <span className="font-medium">«{query.trim()}»</span>
|
|
</button>
|
|
)}
|
|
|
|
{filtered.length === 0 && !q && (
|
|
<p className="text-xs text-muted-foreground px-1.5 py-1">Nessuna opzione ancora</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|