Files
simone d1b1047368 docs(11): create phase plan — 4 plans, 4 waves
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>
2026-06-13 15:07:17 +02:00

534 lines
25 KiB
Markdown

---
phase: 11-catalog-database-view-ux-legacy-consolidation
plan: 03
type: execute
wave: 3
depends_on: ["11-02"]
files_modified:
- src/components/ui/editable-cell.tsx
- src/components/ui/tag-multi-select.tsx
autonomous: true
requirements: [OFFER-07, OFFER-08]
must_haves:
truths:
- "Clicking a cell in display mode transitions it to an editable input/textarea/checkbox with a visible focus ring"
- "Pressing Enter (non-textarea) or blurring the field saves the value via the onSave callback"
- "Pressing Escape reverts to the previous value without calling onSave"
- "Tag badges display with deterministic colors derived from tag name (same name = same color, no stored color column)"
- "Typing a new tag name and pressing Enter in the TagMultiSelect dropdown calls addTagToService and the badge appears without a full page reload (via revalidation)"
artifacts:
- path: "src/components/ui/editable-cell.tsx"
provides: "EditableCell component — click-to-edit text/number/textarea/toggle cell"
contains: "export function EditableCell"
- path: "src/components/ui/tag-multi-select.tsx"
provides: "TagMultiSelect component — tag badges + add/remove dropdown with deterministic color hashing"
contains: "export function TagMultiSelect"
key_links:
- from: "src/components/ui/tag-multi-select.tsx"
to: "src/app/admin/catalog/actions.ts"
via: "addTagToService / removeTagFromService server action calls"
pattern: "addTagToService|removeTagFromService"
- from: "src/components/ui/editable-cell.tsx"
to: "src/components/ui/input.tsx, src/components/ui/textarea.tsx"
via: "renders Input/Textarea in edit mode with ring-1 ring-primary"
pattern: "ring-1 ring-primary"
---
<objective>
Build the two new shared UI primitives the database-view table (Plan 04) depends on: `EditableCell` (generic click-to-edit cell for text/number/textarea/toggle, per D-11/D-14 and DESIGN-SYSTEM.md inline-edit pattern) and `TagMultiSelect` (tag badges with deterministic color hashing per D-07, inline "+" dropdown to add/remove tags per D-06/D-08).
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``EditableCell` component + `EditableCellProps` type
- `src/components/ui/tag-multi-select.tsx``TagMultiSelect` component + color-hash utility
</objective>
<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>
<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
</context>
<interfaces>
<!-- From src/app/admin/catalog/actions.ts (Plan 02) — TagMultiSelect calls these directly -->
```typescript
export async function addTagToService(serviceId: string, tagName: string): Promise<void>;
export async function removeTagFromService(serviceId: string, tagName: string): Promise<void>;
```
<!-- src/lib/utils.ts — cn() helper, used by both new components -->
```typescript
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
```
<!-- src/components/ui/input.tsx — forwardRef<HTMLInputElement, ComponentProps<"input">> -->
<!-- src/components/ui/textarea.tsx — forwardRef<HTMLTextAreaElement, ComponentProps<"textarea">> -->
<!-- src/components/ui/badge.tsx — Badge component, accepts className override via cn() -->
```typescript
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps): JSX.Element;
```
<!-- lucide-react v1.14.0 is installed — X and Plus icons available -->
</interfaces>
<tasks>
<task type="auto" tdd="true">
<name>Task 1: Build EditableCell component (text/number/textarea/toggle)</name>
<files>src/components/ui/editable-cell.tsx</files>
<read_first>
src/components/admin/catalog/ServiceTable.tsx (current ServiceRow inline-edit state pattern, lines 11-35, for the useState/useTransition conventions to mirror — NOT the form-based editing, but the local state + save/cancel idiom)
src/components/ui/input.tsx (Input forwardRef signature)
src/components/ui/textarea.tsx (Textarea forwardRef signature)
.planning/DESIGN-SYSTEM.md (inline edit pattern: "click su cella -> diventa input/select borderless con ring-1 ring-primary on focus -> Enter salva, Esc annulla, blur salva")
</read_first>
<behavior>
- Test 1 (display mode): renders `value` as plain text (or "—" if empty/falsy for text/textarea; "✓ Attivo"/"✗ Disattivato" for toggle)
- Test 2 (click to edit): clicking the display div sets `isEditing=true` and renders the appropriate input (`Input` for text/number, `Textarea` for textarea, `<input type="checkbox">` for toggle), auto-focused
- Test 3 (Enter saves, non-textarea): pressing Enter in a text/number input calls `onSave(tempValue)` and exits edit mode
- Test 4 (Escape cancels): pressing Escape reverts `tempValue` to the original `value` and exits edit mode WITHOUT calling `onSave`
- Test 5 (blur saves): blurring any input calls `onSave(tempValue)` and exits edit mode
- Test 6 (required validation): if `required=true` and `tempValue.trim() === ""`, `onSave` is NOT called and an inline error message renders
- Test 7 (disabled): if `disabled=true`, clicking the display div does NOT enter edit mode (no `cursor-pointer`, has `cursor-not-allowed opacity-50`)
- Test 8 (toggle type): for `type="toggle"`, `onSave` receives `"true"` or `"false"` as a string based on checkbox state
</behavior>
<action>
Create `src/components/ui/editable-cell.tsx`:
```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
</action>
<verify>
<automated>npx tsc --noEmit 2>&1 | grep -i "editable-cell" ; echo "exit: $?"</automated>
</verify>
<acceptance_criteria>
- `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`
</acceptance_criteria>
<done>
`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.
</done>
</task>
<task type="auto" tdd="true">
<name>Task 2: Build TagMultiSelect component with deterministic color hashing</name>
<files>src/components/ui/tag-multi-select.tsx</files>
<read_first>
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)
</read_first>
<behavior>
- 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`
</behavior>
<action>
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
</action>
<verify>
<automated>npx tsc --noEmit 2>&1 | grep -i "tag-multi-select" ; echo "exit: $?"</automated>
</verify>
<acceptance_criteria>
- `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`
</acceptance_criteria>
<done>
`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.
</done>
</task>
</tasks>
<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>
<verification>
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)
</verification>
<success_criteria>
- `EditableCell` supports text/number/textarea/toggle with click-to-edit, Enter/blur save, Escape cancel, required validation, disabled state (D-11, D-14)
- `TagMultiSelect` renders 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>
<output>
After completion, create `.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-03-SUMMARY.md`
</output>