---
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>
