Files
clienthub/.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-03-PLAN.md
T
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

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
11-02
src/components/ui/editable-cell.tsx
src/components/ui/tag-multi-select.tsx
true
OFFER-07
OFFER-08
truths artifacts key_links
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)
path provides contains
src/components/ui/editable-cell.tsx EditableCell component — click-to-edit text/number/textarea/toggle cell export function EditableCell
path provides contains
src/components/ui/tag-multi-select.tsx TagMultiSelect component — tag badges + add/remove dropdown with deterministic color hashing export function TagMultiSelect
from to via pattern
src/components/ui/tag-multi-select.tsx src/app/admin/catalog/actions.ts addTagToService / removeTagFromService server action calls addTagToService|removeTagFromService
from to via pattern
src/components/ui/editable-cell.tsx src/components/ui/input.tsx, src/components/ui/textarea.tsx renders Input/Textarea in edit mode with ring-1 ring-primary ring-1 ring-primary
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.tsxEditableCell component + EditableCellProps type
  • src/components/ui/tag-multi-select.tsxTagMultiSelect component + 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;
Task 1: Build EditableCell component (text/number/textarea/toggle) src/components/ui/editable-cell.tsx 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") - 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, `` 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 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
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>

  • 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>
After completion, create `.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-03-SUMMARY.md`