d1b1047368
Catalog Database-View UX & Legacy Consolidation (OFFER-07/08/09/10/13) - 11-01: tags table + polymorphic junction + additive legacy migration - 11-02: getAllServices tag join + inline-edit/tag/quick-add server actions - 11-03: EditableCell + TagMultiSelect components - 11-04: database-view ServiceTable + client-side search Verified by gsd-plan-checker (VERIFICATION PASSED). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
25 KiB
25 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | must_haves | ||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 11-catalog-database-view-ux-legacy-consolidation | 03 | execute | 3 |
|
|
true |
|
|
Purpose: Interface-first — these are pure, reusable components with no ServiceTable-specific logic. Plan 04 imports and wires them into ServiceRow/ServiceTable without needing to touch their internals.
Output:
src/components/ui/editable-cell.tsx—EditableCellcomponent +EditableCellPropstypesrc/components/ui/tag-multi-select.tsx—TagMultiSelectcomponent + color-hash utility
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-CONTEXT.md @.planning/DESIGN-SYSTEM.md ```typescript export async function addTagToService(serviceId: string, tagName: string): Promise; export async function removeTagFromService(serviceId: string, tagName: string): Promise; ```import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps): JSX.Element;
```typescript
"use client";
import { useState, useRef, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
export interface EditableCellProps {
value: string | number | boolean;
type?: "text" | "number" | "textarea" | "toggle";
onSave: (value: string) => void;
required?: boolean;
placeholder?: string;
disabled?: boolean;
formatDisplay?: (value: string) => string;
}
export function EditableCell({
value,
type = "text",
onSave,
required,
placeholder,
disabled,
formatDisplay,
}: EditableCellProps) {
const [isEditing, setIsEditing] = useState(false);
const [tempValue, setTempValue] = useState(String(value));
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);
useEffect(() => {
setTempValue(String(value));
}, [value]);
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
if ("select" in inputRef.current) {
inputRef.current.select();
}
}
}, [isEditing]);
function startEdit() {
if (disabled) return;
setError(null);
setTempValue(String(value));
setIsEditing(true);
}
function commit() {
if (required && tempValue.trim().length === 0) {
setError("Campo richiesto");
return;
}
setError(null);
onSave(tempValue);
setIsEditing(false);
}
function cancel() {
setTempValue(String(value));
setError(null);
setIsEditing(false);
}
function handleKeyDown(e: React.KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) {
if (e.key === "Enter" && type !== "textarea") {
e.preventDefault();
commit();
} else if (e.key === "Escape") {
e.preventDefault();
cancel();
}
}
if (!isEditing) {
let display: string;
if (type === "toggle") {
display = tempValue === "true" ? "✓ Attivo" : "✗ Disattivato";
} else {
const raw = String(value);
display = formatDisplay ? formatDisplay(raw) : raw || "—";
}
return (
<div
onClick={startEdit}
className={cn(
"px-2 py-1 rounded transition-colors duration-150 text-sm",
disabled
? "cursor-not-allowed opacity-50"
: "cursor-pointer hover:bg-[#f0f0f0]",
type === "textarea" && "line-clamp-2 max-w-md"
)}
>
{display}
</div>
);
}
return (
<div className="flex flex-col gap-1">
{type === "textarea" ? (
<Textarea
ref={inputRef as React.Ref<HTMLTextAreaElement>}
value={tempValue}
onChange={(e) => setTempValue(e.target.value)}
onBlur={commit}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className={cn("ring-1 ring-primary resize-none text-sm", error && "ring-2 ring-red-500")}
rows={3}
/>
) : type === "toggle" ? (
<input
ref={inputRef as React.Ref<HTMLInputElement>}
type="checkbox"
checked={tempValue === "true"}
onChange={(e) => {
const next = e.target.checked ? "true" : "false";
setTempValue(next);
onSave(next);
setIsEditing(false);
}}
onBlur={commit}
onKeyDown={handleKeyDown}
className="h-4 w-4 cursor-pointer accent-[#1A463C]"
/>
) : (
<Input
ref={inputRef as React.Ref<HTMLInputElement>}
type={type}
value={tempValue}
onChange={(e) => setTempValue(e.target.value)}
onBlur={commit}
onKeyDown={handleKeyDown}
placeholder={placeholder}
step={type === "number" ? "0.01" : undefined}
min={type === "number" ? "0" : undefined}
className={cn("ring-1 ring-primary h-8 text-sm", error && "ring-2 ring-red-500")}
/>
)}
{error && <p className="text-xs text-red-600">{error}</p>}
</div>
);
}
```
Design notes implemented per DESIGN-SYSTEM.md:
- Display mode: `cursor-pointer hover:bg-[#f0f0f0] transition-colors duration-150`
- Edit mode: `ring-1 ring-primary`, borderless via `Input`/`Textarea`'s existing border being visually overridden by the ring (acceptable — `Input`/`Textarea` retain their `border-input` class; the ring sits alongside it, which is consistent with shadcn focus-ring conventions used elsewhere in this codebase)
- Toggle type saves immediately on change (no separate commit step) since a checkbox's `onChange` IS the user's deliberate action — this matches D-14 ("stato attivo/disattivo diventa una cella inline (toggle/checkbox cliccabile")
- `formatDisplay` prop allows the consumer (Plan 04's `ServiceRow`) to format prices as `€{...}` without baking currency logic into this generic component
npx tsc --noEmit 2>&1 | grep -i "editable-cell" ; echo "exit: $?"
- `grep -c "export function EditableCell" src/components/ui/editable-cell.tsx` returns `1`
- `grep -c "export interface EditableCellProps" src/components/ui/editable-cell.tsx` returns `1`
- `grep -c "ring-1 ring-primary" src/components/ui/editable-cell.tsx` returns >= 2 (Input and Textarea edit modes)
- `grep -c "case \"Escape\"\|e.key === \"Escape\"" src/components/ui/editable-cell.tsx` returns >= 1
- `grep -c "e.key === \"Enter\"" src/components/ui/editable-cell.tsx` returns >= 1
- `grep -c "hover:bg-\[#f0f0f0\]" src/components/ui/editable-cell.tsx` returns >= 1
- `npx tsc --noEmit` produces zero errors referencing `src/components/ui/editable-cell.tsx`
`EditableCell` renders display mode by default, enters edit mode on click (unless disabled), supports text/number/textarea/toggle types, saves on Enter (non-textarea) and blur, cancels on Escape without saving, and validates required fields with an inline error. Typecheck passes.
Task 2: Build TagMultiSelect component with deterministic color hashing
src/components/ui/tag-multi-select.tsx
src/components/ui/badge.tsx (Badge component + BadgeProps, cn-based className override)
src/app/admin/catalog/actions.ts (addTagToService/removeTagFromService signatures from Plan 02 — read after Plan 02 completes)
.planning/DESIGN-SYSTEM.md (tag pattern: "Badge con colori derivati da una palette fissa a rotazione (6-8 colori pastello su sfondo, testo scuro per contrasto AA) + pulsante + inline per creare un nuovo tag senza uscire dalla riga")
.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-CONTEXT.md (D-07: hash-derived color, no color column; D-08: "Offerta" is a normal tag)
- Test 1 (color determinism): `getTagColorIndex("Offerta")` always returns the same index across calls; `getTagColorIndex("Premium")` may return a different index (hash-based, not random)
- Test 2 (palette size): the color palette array has between 6 and 8 entries (per DESIGN-SYSTEM.md), all using `bg-*-100 text-*-900` pastel pairs for AA contrast
- Test 3 (display, no tags): renders a placeholder "—" when `tags=[]`
- Test 4 (display, with tags): renders one `Badge` per tag name, each colored via `TAG_COLORS[getTagColorIndex(tagName)]`
- Test 5 (open dropdown): clicking the cell container toggles `isOpen`, revealing an input + "+" button
- Test 6 (add tag on Enter): typing a name and pressing Enter in the dropdown input calls `addTagToService(serviceId, name)`, clears the input, and triggers `onTagsChanged` (revalidation)
- Test 7 (add tag, empty input): pressing Enter with an empty/whitespace input does NOT call `addTagToService`
- Test 8 (remove tag): clicking the "X" on a badge calls `removeTagFromService(serviceId, tagName)` and `onTagsChanged`, without opening the dropdown (event propagation stopped)
- Test 9 (click outside closes dropdown): clicking outside the component while `isOpen=true` sets `isOpen=false`
- Test 10 (Escape closes dropdown): pressing Escape in the dropdown input sets `isOpen=false` without calling `addTagToService`
Create `src/components/ui/tag-multi-select.tsx`:
```typescript
"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>
);
}
```
Design notes per DESIGN-SYSTEM.md / D-07 / D-08:
- 7-color palette (within the 6-8 range specified), `bg-*-100 text-*-900` for AA contrast on white backgrounds
- `getTagColorIndex` exported for potential reuse/testing — pure function, no I/O
- "Offerta" tag (D-08) is rendered identically to any other tag — no special-casing, no protection logic
- Dropdown closes on outside click and Escape; transition 150ms per checklist
npx tsc --noEmit 2>&1 | grep -i "tag-multi-select" ; echo "exit: $?"
- `grep -c "export function TagMultiSelect" src/components/ui/tag-multi-select.tsx` returns `1`
- `grep -c "export function getTagColorIndex" src/components/ui/tag-multi-select.tsx` returns `1`
- `grep -c "const TAG_COLORS" src/components/ui/tag-multi-select.tsx` returns `1`, and the array literal has between 6 and 8 string entries (verify with `grep -A8 "const TAG_COLORS" src/components/ui/tag-multi-select.tsx | grep -c "bg-.*-100 text-.*-900"` returns a number between 6 and 8)
- `grep -c "addTagToService\|removeTagFromService" src/components/ui/tag-multi-select.tsx` returns >= 2
- `grep -c "handleClickOutside" src/components/ui/tag-multi-select.tsx` returns >= 1
- `grep -c "e.key === \"Escape\"" src/components/ui/tag-multi-select.tsx` returns >= 1
- `npx tsc --noEmit` produces zero errors referencing `src/components/ui/tag-multi-select.tsx`
`TagMultiSelect` renders tag badges colored via deterministic hash (no DB color column), supports adding a new tag via Enter/+ button (calling `addTagToService`), removing a tag via the badge's X (calling `removeTagFromService`), closes its dropdown on outside-click/Escape, and surfaces server errors inline. Typecheck passes.
<threat_model>
Trust Boundaries
| Boundary | Description |
|---|---|
| Admin browser -> TagMultiSelect -> Server Actions | Client component invokes addTagToService/removeTagFromService (Plan 02, already admin-gated) directly |
STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|---|---|---|---|---|
| T-11-11 | Information Disclosure | EditableCell error messages |
accept | Errors shown are generic ("Campo richiesto", "Errore nel salvataggio") — no stack traces or internal details surfaced to the browser. |
| T-11-12 | Tampering | TagMultiSelect calling addTagToService/removeTagFromService with arbitrary serviceId |
accept | Authorization is enforced server-side in Plan 02's requireAdmin() — this plan's components are presentation-only and inherit that protection. A malicious client could theoretically call these actions with any serviceId, but the action itself does not leak cross-entity data (it only inserts/deletes a tags row scoped to entity_type="services"), and the admin session requirement limits this to authenticated admins (single-admin app per REQUIREMENTS.md "Out of Scope: Multi-utente"). |
| T-11-13 | Denial of Service | Rapid-fire tag add/remove clicks | mitigate | useTransition + isPending disables the input/button and remove-buttons during in-flight requests, preventing duplicate concurrent submissions from a single user action. |
No HIGH-severity unmitigated threats — both new components are presentation-layer, deferring authorization to Plan 02's server actions. </threat_model>
1. `npx tsc --noEmit` passes with zero errors in both new files 2. `EditableCell` exports `EditableCell` + `EditableCellProps` 3. `TagMultiSelect` exports `TagMultiSelect`, `TagMultiSelectProps`, `getTagColorIndex` 4. Both components are client components (`"use client"` directive present)<success_criteria>
EditableCellsupports text/number/textarea/toggle with click-to-edit, Enter/blur save, Escape cancel, required validation, disabled state (D-11, D-14)TagMultiSelectrenders deterministically-colored tag badges (D-07), supports add-on-the-fly and remove (D-08, OFFER-08), scoped via Plan 02's actions- Both components have zero ServiceTable-specific logic — pure, reusable primitives ready for Plan 04 </success_criteria>