# Phase 11: Catalog Database-View UX & Legacy Consolidation - Pattern Map

**Mapped:** 2026-06-13
**Files analyzed:** 11 new/modified files
**Analogs found:** 9 / 11 (2 entirely new components without analogs)

## File Classification

| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|-------------------|------|-----------|----------------|---------------|
| `src/db/schema.ts` (tags table + junction) | model | CRUD | `src/db/schema.ts` (comments table, lines 100-111) | exact |
| `src/db/migrations/` (tags schema SQL) | migration | schema | `src/db/migrations/0001_add_services_table.sql` | exact |
| `src/lib/admin-queries.ts` (getAllServices + tags join) | service | CRUD | `src/lib/admin-queries.ts` (getAllServices, lines 359-364) | exact |
| `src/app/admin/catalog/actions.ts` (inline edit + tag actions) | controller | request-response | `src/app/admin/catalog/actions.ts` (createService/updateService/toggleServiceActive, lines 1-67) | exact |
| `src/app/admin/catalog/page.tsx` | controller | request-response | `src/app/admin/catalog/page.tsx` (lines 1-29) | exact |
| `src/components/admin/catalog/ServiceTable.tsx` (rewrite for inline edit) | component | CRUD | `src/components/admin/catalog/ServiceTable.tsx` (lines 1-174) | exact |
| `src/components/admin/catalog/ServiceForm.tsx` (quick-add row) | component | request-response | `src/components/admin/catalog/ServiceForm.tsx` (lines 1-102) | exact |
| `src/components/ui/editable-cell.tsx` (NEW) | component | request-response | `src/components/admin/catalog/ServiceTable.tsx` (ServiceRow inline edit, lines 37-108) | role-match |
| `src/components/ui/tag-multi-select.tsx` (NEW) | component | CRUD | `src/components/ui/select.tsx` + `src/components/ui/badge.tsx` | role-match |
| `scripts/migrate-tags.ts` (additive tag migration + "Offerta" assignment) | utility | batch | `scripts/migrate-services.ts` (lines 1-86) | exact |
| `scripts/validate-tags-migration.ts` (row count + FK checks) | utility | batch | `scripts/validate-services-migration.ts` (lines 1-116) | exact |

## Pattern Assignments

---

### `src/db/schema.ts` — Tags Table + Polymorphic Junction (model, CRUD)

**Analog:** `src/db/schema.ts` (comments table, lines 100-111)

**Polymorphic Junction Pattern** (lines 100-111):
```typescript
// EXISTING PATTERN TO REPLICATE:
export const comments = pgTable("comments", {
  id: text("id")
    .primaryKey()
    .$defaultFn(() => nanoid()),
  entity_type: text("entity_type").notNull(), // task | deliverable
  entity_id: text("entity_id").notNull(),
  author: text("author").notNull(), // client | admin
  body: text("body").notNull(),
  created_at: timestamp("created_at", { withTimezone: true })
    .notNull()
    .defaultNow(),
});

// NEW PATTERN FOR TAGS (copy structure, adapt for polymorphic scope):
// - entity_type: "services" | "leads" (Phase 14 reuses)
// - entity_id: references the service.id or lead.id
// - name: the tag label (e.g., "Offerta", "Urgente")
// - NO color column (D-07: derived deterministically from name hash)
```

**Imports pattern** (lines 1-12):
```typescript
import {
  pgTable,
  text,
  integer,
  numeric,
  timestamp,
  boolean,
  primaryKey,
} from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm";
import { nanoid } from "nanoid";
```

**Schema structure** (follow existing conventions):
- Use `text("id").primaryKey().$defaultFn(() => nanoid())` for PK
- Use `timestamp(..., { withTimezone: true }).notNull().defaultNow()` for audit
- Unique constraint on `(entity_type, entity_id, name)` to prevent duplicate tags per entity

---

### `src/db/migrations/` — Tags Schema Migration (migration, schema)

**Analog:** `src/db/migrations/0001_add_services_table.sql` and Phase 7 pattern

**Drizzle migration structure:**
- Use `drizzle-kit generate` to auto-generate from schema.ts
- Migration must be idempotent (can run multiple times safely)
- Create `tags` table with columns: `id`, `entity_type`, `entity_id`, `name`, `created_at`
- Create unique index: `UNIQUE(entity_type, entity_id, name)`
- Applied to production BEFORE pushing schema-dependent code (per CLAUDE.md Data Safety rule)

---

### `src/lib/admin-queries.ts` — getAllServices + Tags Join (service, CRUD)

**Analog:** `src/lib/admin-queries.ts` (lines 359-364)

**Current getAllServices (lines 359-364):**
```typescript
export async function getAllServices(): Promise<Service[]> {
  return db
    .select()
    .from(services)
    .orderBy(asc(services.name));
}
```

**New pattern to extend getAllServices** (join tags, group by service):
```typescript
// Return type: Service + tags array
export type ServiceWithTags = Service & { tags: string[] };

export async function getAllServices(): Promise<ServiceWithTags[]> {
  const rows = await db
    .select({
      id: services.id,
      name: services.name,
      description: services.description,
      unit_price: services.unit_price,
      category: services.category,
      active: services.active,
      migrated_from: services.migrated_from,
      migrated_id: services.migrated_id,
      created_at: services.created_at,
      tag_name: tags.name, // nullable for services with no tags
    })
    .from(services)
    .leftJoin(
      tags,
      and(eq(tags.entity_type, "services"), eq(tags.entity_id, services.id))
    )
    .orderBy(asc(services.name), asc(tags.name)); // consistent sort

  // Group by service.id, collect tags into array
  const serviceMap = new Map<string, ServiceWithTags>();
  for (const row of rows) {
    if (!serviceMap.has(row.id)) {
      serviceMap.set(row.id, {
        ...row,
        tags: [],
      });
    }
    if (row.tag_name) {
      serviceMap.get(row.id)?.tags.push(row.tag_name);
    }
  }
  return Array.from(serviceMap.values());
}
```

**Import pattern** (add to existing imports):
```typescript
import { tags } from "@/db/schema"; // NEW
import { and } from "drizzle-orm"; // extend existing eq import
```

---

### `src/app/admin/catalog/actions.ts` — Inline Edit + Tag Actions (controller, request-response)

**Analog:** `src/app/admin/catalog/actions.ts` (lines 1-67)

**Existing CRUD pattern** (lines 18-67):
```typescript
async function requireAdmin() {
  const session = await getServerSession(authOptions);
  if (!session) throw new Error("Non autorizzato");
}

export async function updateService(serviceId: string, formData: FormData) {
  await requireAdmin();
  const parsed = serviceSchema.safeParse({
    name: formData.get("name"),
    description: formData.get("description") ?? "",
    unit_price: formData.get("unit_price"),
    category: formData.get("category") ?? "",
  });
  if (!parsed.success) throw new Error(parsed.error.issues[0].message);
  await db.update(services).set({...}).where(eq(services.id, serviceId));
  revalidatePath("/admin/catalog");
}
```

**New inline-edit server actions to add:**
```typescript
// Single-field inline edit (reusable for any column)
// Call: updateServiceField(serviceId, "unit_price", "150.00")
export async function updateServiceField(
  serviceId: string,
  fieldName: "name" | "description" | "category" | "unit_price" | "active",
  value: string | boolean
) {
  await requireAdmin();
  
  // Validate based on field
  if (fieldName === "unit_price") {
    const num = parseFloat(value as string);
    if (isNaN(num) || num < 0.01) throw new Error("Prezzo invalido");
  }
  if (fieldName === "name") {
    const s = value as string;
    if (!s || s.trim().length === 0) throw new Error("Nome richiesto");
  }
  
  const updates: Record<string, any> = {};
  updates[fieldName] = fieldName === "unit_price" ? (value as string).toFixed(2) : value;
  
  await db
    .update(services)
    .set(updates)
    .where(eq(services.id, serviceId));
  
  revalidatePath("/admin/catalog");
}

// Tag operations
export async function addTagToService(serviceId: string, tagName: string) {
  await requireAdmin();
  if (!tagName || tagName.trim().length === 0) throw new Error("Tag name required");
  
  await db.insert(tags).values({
    entity_type: "services",
    entity_id: serviceId,
    name: tagName.trim(),
  }).onConflictDoNothing(); // Idempotent: ignore if already exists
  
  revalidatePath("/admin/catalog");
}

export async function removeTagFromService(serviceId: string, tagName: string) {
  await requireAdmin();
  await db
    .delete(tags)
    .where(
      and(
        eq(tags.entity_type, "services"),
        eq(tags.entity_id, serviceId),
        eq(tags.name, tagName)
      )
    );
  
  revalidatePath("/admin/catalog");
}

// Quick-add: create service with unit_price = 0
export async function quickAddService(name: string) {
  await requireAdmin();
  if (!name || name.trim().length === 0) throw new Error("Nome richiesto");
  
  await db.insert(services).values({
    name: name.trim(),
    unit_price: "0.00",
    active: true,
  });
  
  revalidatePath("/admin/catalog");
}
```

**Imports pattern** (extend existing):
```typescript
import { db } from "@/db";
import { services, tags } from "@/db/schema"; // add tags
import { revalidatePath } from "next/cache";
import { eq, and } from "drizzle-orm"; // extend eq with and
import { z } from "zod";
import { getServerSession } from "next-auth";
import { authOptions } from "@/lib/auth";
```

---

### `src/app/admin/catalog/page.tsx` — Page Structure (controller, request-response)

**Analog:** `src/app/admin/catalog/page.tsx` (lines 1-29)

**Current structure** (lines 1-29):
```typescript
import { getAllServices } from "@/lib/admin-queries";
import { ServiceTable } from "@/components/admin/catalog/ServiceTable";
import { ServiceForm } from "@/components/admin/catalog/ServiceForm";

export const revalidate = 0;

export default async function CatalogPage() {
  const services = await getAllServices();
  return (
    <div>
      <div className="flex items-center justify-between mb-6">
        <h1 className="text-2xl font-bold text-[#1a1a1a]">Catalogo Servizi</h1>
      </div>
      <div className="mb-6">
        <ServiceForm />
      </div>
      {services.length === 0 ? (
        <p className="text-sm text-[#71717a]">Nessun servizio...</p>
      ) : (
        <ServiceTable services={services} />
      )}
    </div>
  );
}
```

**New pattern for Phase 11** (minimal changes; main work in ServiceTable):
- Add search/filter input above the table (client-side filtering per DESIGN-SYSTEM.md)
- Replace ServiceForm modal with quick-add row inside ServiceTable
- Pass services (now with tags) to ServiceTable for rendering

**Structure:**
```typescript
// Add search state management:
// "use client" wrapper around filter input + ServiceTable
// Quick-add row should be INSIDE ServiceTable (see ServiceTable pattern below)
// Sticky header (per DESIGN-SYSTEM.md)
```

---

### `src/components/admin/catalog/ServiceTable.tsx` — Database-View Rewrite (component, CRUD)

**Analog:** `src/components/admin/catalog/ServiceTable.tsx` (lines 1-174)

**Current structure** (ServiceRow + ServiceTable):
- Lines 11-149: ServiceRow handles edit state locally, form submission to action
- Lines 152-174: ServiceTable maps services to rows

**New pattern for Phase 11 — Inline Edit + Quick-Add Row:**

**Key changes:**
1. **EditableCell pattern** (extract to separate component, see below): Each cell becomes clickable, transitions to `<input>` on click, saves on blur/Enter, cancels on Esc
2. **No "Actions" column** — all edits happen inline
3. **Active/Inactive toggle** — becomes an inline cell (checkbox/toggle, same EditableCell pattern)
4. **Description column** — full text with ellipsis, click to expand in textarea inline (same EditableCell pattern)
5. **Tags column** — uses new TagMultiSelect component (see below)
6. **Quick-add row** — last row, always visible, placeholder "+ Aggiungi servizio", becomes normal row after save
7. **Inactive services** — grouped at bottom below a divider, visually attenuated (opacity-50)
8. **Row height** — ~40px per DESIGN-SYSTEM.md

**Imports pattern** (extend existing):
```typescript
"use client";
import { useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Badge } from "@/components/ui/badge";
import {
  updateServiceField,
  addTagToService,
  removeTagFromService,
  quickAddService,
} from "@/app/admin/catalog/actions";
import { EditableCell } from "@/components/ui/editable-cell";
import { TagMultiSelect } from "@/components/ui/tag-multi-select";
import type { ServiceWithTags } from "@/lib/admin-queries"; // extends Service with tags array
```

**ServiceRow component** (inline editable cells):
```typescript
function ServiceRow({ service, onTagsChanged }: { service: ServiceWithTags; onTagsChanged?: () => void }) {
  const [isPending, startTransition] = useTransition();
  const router = useRouter();

  // EditableCell handling (example for name — same pattern for other fields)
  function handleFieldChange(field: string, value: string | boolean) {
    startTransition(async () => {
      try {
        await updateServiceField(service.id, field as any, value);
        router.refresh();
      } catch (e) {
        // error state in EditableCell
      }
    });
  }

  return (
    <tr className={`border-b border-[#e5e7eb] ${!service.active ? 'opacity-50' : ''}`}>
      <td className="py-3 px-4">
        <EditableCell
          value={service.name}
          type="text"
          onSave={(val) => handleFieldChange("name", val)}
          required
        />
      </td>
      <td className="py-3 px-4">
        <EditableCell
          value={service.description || ""}
          type="textarea"
          onSave={(val) => handleFieldChange("description", val)}
        />
      </td>
      <td className="py-3 px-4">
        <EditableCell
          value={service.category || ""}
          type="text"
          onSave={(val) => handleFieldChange("category", val)}
        />
      </td>
      <td className="py-3 px-4">
        <EditableCell
          value={parseFloat(service.unit_price).toFixed(2)}
          type="number"
          onSave={(val) => handleFieldChange("unit_price", val)}
        />
      </td>
      <td className="py-3 px-4">
        <TagMultiSelect
          tags={service.tags}
          serviceId={service.id}
          onTagsChanged={() => {
            router.refresh();
            onTagsChanged?.();
          }}
        />
      </td>
      <td className="py-3 px-4">
        <EditableCell
          value={service.active ? "attivo" : "disattivato"}
          type="toggle"
          onSave={(val) => handleFieldChange("active", val === "attivo" ? !service.active : service.active)}
        />
      </td>
    </tr>
  );
}
```

**ServiceTable component** (with quick-add row):
```typescript
export function ServiceTable({ services }: { services: ServiceWithTags[] }) {
  const [newName, setNewName] = useState("");
  const [, startTransition] = useTransition();
  const router = useRouter();

  const activeServices = services.filter((s) => s.active);
  const inactiveServices = services.filter((s) => !s.active);

  function handleQuickAdd(e: React.KeyboardEvent<HTMLInputElement>) {
    if (e.key !== "Enter" || !newName.trim()) return;
    e.preventDefault();
    startTransition(async () => {
      try {
        await quickAddService(newName);
        setNewName("");
        router.refresh();
      } catch (e) {
        // error handling
      }
    });
  }

  return (
    <div className="bg-white rounded-xl border border-[#e5e7eb] overflow-hidden">
      <div className="overflow-x-auto">
        <table className="w-full text-sm">
          <thead className="bg-[#f9f9f9] border-b border-[#e5e7eb] sticky top-0">
            <tr>
              <th className="text-left py-3 px-4 font-medium text-[#71717a]">Nome</th>
              <th className="text-left py-3 px-4 font-medium text-[#71717a]">Descrizione</th>
              <th className="text-left py-3 px-4 font-medium text-[#71717a]">Categoria</th>
              <th className="text-left py-3 px-4 font-medium text-[#71717a]">Prezzo</th>
              <th className="text-left py-3 px-4 font-medium text-[#71717a]">Tag</th>
              <th className="text-left py-3 px-4 font-medium text-[#71717a]">Stato</th>
            </tr>
          </thead>
          <tbody>
            {/* Active services */}
            {activeServices.map((s) => (
              <ServiceRow key={s.id} service={s} />
            ))}

            {/* Quick-add row */}
            <tr className="border-b border-[#e5e7eb] bg-[#f9f9f9]">
              <td className="py-3 px-4">
                <Input
                  placeholder="+ Aggiungi servizio"
                  value={newName}
                  onChange={(e) => setNewName(e.target.value)}
                  onKeyDown={handleQuickAdd}
                  className="border-0 bg-transparent placeholder:text-[#71717a]"
                />
              </td>
              <td colSpan={5}></td>
            </tr>

            {/* Inactive services (if any) */}
            {inactiveServices.length > 0 && (
              <>
                <tr className="border-b border-[#e5e7eb]">
                  <td colSpan={6} className="py-2 px-4 text-xs text-[#71717a]">
                    Servizi disattivati
                  </td>
                </tr>
                {inactiveServices.map((s) => (
                  <ServiceRow key={s.id} service={s} />
                ))}
              </>
            )}
          </tbody>
        </table>
      </div>
    </div>
  );
}
```

---

### `src/components/admin/catalog/ServiceForm.tsx` — Update for Quick-Add (component, request-response)

**Analog:** `src/components/admin/catalog/ServiceForm.tsx` (lines 1-102)

**Current pattern** (modal form, button toggles open/close):
- Lines 31-40: Closed state shows "+ Aggiungi servizio" button
- Lines 42-102: Open state shows form

**New pattern for Phase 11:**
- ServiceForm can be **removed or simplified** — quick-add row is now in ServiceTable
- OR keep it as a secondary "full form" option for users who want to pre-fill description/category/price (optional)
- Decision: Per CONTEXT.md "Claude's Discretion", leave implementation details to planner

**If kept**, structure remains the same (no breaking changes needed). If removed, update page.tsx to not import it.

---

### `src/components/ui/editable-cell.tsx` (NEW) — Inline Edit Component (component, request-response)

**Analog:** `src/components/admin/catalog/ServiceTable.tsx` (ServiceRow inline edit, lines 37-108)

**Pattern extracted from ServiceRow:**

```typescript
"use client";

import { useState, useRef, useEffect } from "react";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";

export interface EditableCellProps {
  value: string | number | boolean;
  type?: "text" | "number" | "textarea" | "toggle" | "select";
  onSave: (value: string) => void;
  onCancel?: () => void;
  required?: boolean;
  options?: { value: string; label: string }[]; // for type="select"
  error?: string;
  disabled?: boolean;
}

export function EditableCell({
  value,
  type = "text",
  onSave,
  onCancel,
  required,
  options,
  error,
  disabled,
}: EditableCellProps) {
  const [isEditing, setIsEditing] = useState(false);
  const [tempValue, setTempValue] = useState(String(value));
  const inputRef = useRef<HTMLInputElement | HTMLTextAreaElement>(null);

  useEffect(() => {
    if (isEditing && inputRef.current) {
      inputRef.current.focus();
      inputRef.current.select?.();
    }
  }, [isEditing]);

  if (!isEditing) {
    return (
      <div
        onClick={() => !disabled && setIsEditing(true)}
        className={cn(
          "px-2 py-1 rounded cursor-pointer hover:bg-[#f0f0f0]",
          disabled && "cursor-not-allowed opacity-50"
        )}
      >
        {type === "toggle" ? (
          <span className="text-xs font-medium">
            {tempValue === "true" ? "✓ Attivo" : "✗ Disattivato"}
          </span>
        ) : type === "textarea" ? (
          <div className="text-sm line-clamp-2">{tempValue || "—"}</div>
        ) : (
          <div className="text-sm">{tempValue || "—"}</div>
        )}
      </div>
    );
  }

  function handleSave() {
    if (required && !tempValue.trim()) {
      // error state
      return;
    }
    onSave(tempValue);
    setIsEditing(false);
  }

  function handleCancel() {
    setTempValue(String(value));
    setIsEditing(false);
    onCancel?.();
  }

  function handleKeyDown(e: React.KeyboardEvent<any>) {
    if (e.key === "Enter" && type !== "textarea") {
      handleSave();
    } else if (e.key === "Escape") {
      handleCancel();
    }
  }

  return (
    <div className="flex flex-col gap-1">
      {type === "textarea" ? (
        <textarea
          ref={inputRef as any}
          value={tempValue}
          onChange={(e) => setTempValue(e.target.value)}
          onBlur={handleSave}
          onKeyDown={handleKeyDown}
          className={cn(
            "px-2 py-1 border rounded ring-1 ring-primary resize-none",
            error && "ring-red-500"
          )}
          rows={3}
        />
      ) : type === "toggle" ? (
        <input
          ref={inputRef}
          type="checkbox"
          checked={tempValue === "true"}
          onChange={(e) => setTempValue(e.target.checked ? "true" : "false")}
          onBlur={handleSave}
          className="h-4 w-4 cursor-pointer"
        />
      ) : (
        <Input
          ref={inputRef}
          type={type}
          value={tempValue}
          onChange={(e) => setTempValue(e.target.value)}
          onBlur={handleSave}
          onKeyDown={handleKeyDown}
          className="ring-1 ring-primary"
          step={type === "number" ? "0.01" : undefined}
          min={type === "number" ? "0" : undefined}
        />
      )}
      {error && <p className="text-xs text-red-600">{error}</p>}
    </div>
  );
}
```

**Styling notes** (per DESIGN-SYSTEM.md):
- No cell in edit mode: borderless input with `ring-1 ring-primary` on focus
- On blur: save immediately
- On Esc: cancel (revert to previous value)
- On Enter: save (except textarea, which allows Enter for newline)
- Hover on display mode: `bg-[#f0f0f0]` (subtle highlight, not full row)
- Transition: 150ms `transition-colors`

---

### `src/components/ui/tag-multi-select.tsx` (NEW) — Tag Multi-Select Component (component, CRUD)

**Analog:** `src/components/ui/select.tsx` (lines 1-60) + `src/components/ui/badge.tsx` (lines 1-36)

**Pattern to build:**

```typescript
"use client";

import { useState, useRef, useEffect } 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";
import { useTransition } from "react";

// Hash function to deterministically map tag name to color index (D-07)
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; // Convert to 32bit integer
  }
  // 6-8 color palette (from DESIGN-SYSTEM.md)
  const 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-yellow-100 text-yellow-900",
    "bg-amber-100 text-amber-900",
  ];
  return Math.abs(hash) % colors.length;
}

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-yellow-100 text-yellow-900",
  "bg-amber-100 text-amber-900",
];

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 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);
  }, []);

  function handleAddTag() {
    if (!newTag.trim()) return;
    startTransition(async () => {
      try {
        await addTagToService(serviceId, newTag);
        setNewTag("");
        setIsOpen(false);
        onTagsChanged?.();
      } catch (e) {
        // error handling
      }
    });
  }

  function handleRemoveTag(tagName: string) {
    startTransition(async () => {
      try {
        await removeTagFromService(serviceId, tagName);
        onTagsChanged?.();
      } catch (e) {
        // error handling
      }
    });
  }

  return (
    <div ref={containerRef} className="relative w-full">
      <div
        onClick={() => setIsOpen(!isOpen)}
        className="flex flex-wrap gap-1 items-center px-2 py-1 border rounded cursor-pointer hover:bg-[#f0f0f0]"
      >
        {tags.length === 0 ? (
          <span className="text-xs text-[#71717a]">—</span>
        ) : (
          tags.map((tag) => {
            const colorIdx = getTagColorIndex(tag);
            return (
              <Badge
                key={tag}
                className={cn(
                  TAG_COLORS[colorIdx],
                  "text-xs px-2 py-0.5 flex items-center gap-1"
                )}
              >
                {tag}
                <button
                  onClick={(e) => {
                    e.stopPropagation();
                    handleRemoveTag(tag);
                  }}
                  className="hover:opacity-70"
                  disabled={isPending}
                >
                  <X className="h-3 w-3" />
                </button>
              </Badge>
            );
          })
        )}
        {isOpen && (
          <Plus className="h-4 w-4 text-[#71717a]" />
        )}
      </div>

      {isOpen && (
        <div className="absolute top-full left-0 right-0 mt-1 bg-white border rounded shadow-lg p-2 z-10">
          <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") handleAddTag();
                if (e.key === "Escape") setIsOpen(false);
              }}
              className="text-sm"
              disabled={isPending}
              autoFocus
            />
            <Button
              size="sm"
              onClick={handleAddTag}
              disabled={isPending || !newTag.trim()}
              className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90"
            >
              +
            </Button>
          </div>
        </div>
      )}
    </div>
  );
}
```

**Design notes** (per DESIGN-SYSTEM.md):
- Badge colors rotate through 6-8 pastels, determined by name hash (no storage)
- Click on cell/badges to open dropdown with "+ aggiungi tag" input
- Enter in input: create tag (case-insensitive, trim whitespace)
- Click X on badge: delete tag immediately (server action)
- On blur/Esc: close dropdown
- Transition: 150ms for dropdown open/close

---

### `scripts/migrate-tags.ts` — Assign "Offerta" Tag to Migrated Services (utility, batch)

**Analog:** `scripts/migrate-services.ts` (lines 1-86)

**Pattern:**

```typescript
import { db } from "@/db";
import { services, tags } from "@/db/schema";
import { eq } from "drizzle-orm";

async function migrate() {
  console.log("Starting tags migration: assigning 'Offerta' tag to offer_services...\n");

  // Query all services migrated from offer_services
  const offerServices = await db
    .select({ id: services.id })
    .from(services)
    .where(eq(services.migrated_from, "offer_services"));

  let taggedCount = 0;
  let skippedCount = 0;

  for (const service of offerServices) {
    // Check if tag already exists
    const existing = await db
      .select()
      .from(tags)
      .where(
        and(
          eq(tags.entity_type, "services"),
          eq(tags.entity_id, service.id),
          eq(tags.name, "Offerta")
        )
      )
      .limit(1);

    if (existing.length > 0) {
      skippedCount++;
      continue;
    }

    await db.insert(tags).values({
      entity_type: "services",
      entity_id: service.id,
      name: "Offerta",
    });
    taggedCount++;
  }

  console.log(`Assigned 'Offerta' tag: ${taggedCount} services, ${skippedCount} already tagged`);
  console.log("\nMigration complete. Run scripts/validate-tags-migration.ts next.");
  process.exit(0);
}

migrate().catch((err) => {
  console.error("Migration failed:", err);
  process.exit(1);
});
```

**Key differences from Phase 7:**
- This is **post-schema-migration** — assumes tags table already exists (created by Drizzle migration first)
- Runs AFTER users have tested catalog to assign the semantic "Offerta" tag
- Much simpler: no field mapping, just tag assignment based on migrated_from source

---

### `scripts/validate-tags-migration.ts` — Validation Script (utility, batch)

**Analog:** `scripts/validate-services-migration.ts` (lines 1-116)

**Pattern:**

```typescript
import { db } from "@/db";
import { services, tags } from "@/db/schema";
import { eq, sql } from "drizzle-orm";

async function validate() {
  let failures = 0;

  // Check 1: "Offerta" tag count matches offer_services migrated count
  const [offerServicesCount] = await db
    .select({ n: sql<number>`count(*)::int` })
    .from(services)
    .where(eq(services.migrated_from, "offer_services"));

  const [offerTagCount] = await db
    .select({ n: sql<number>`count(*)::int` })
    .from(tags)
    .where(
      and(
        eq(tags.entity_type, "services"),
        eq(tags.name, "Offerta")
      )
    );

  console.log(`offer_services migrated: ${offerServicesCount.n} | Offerta tags: ${offerTagCount.n}`);

  if (offerServicesCount.n !== offerTagCount.n) {
    console.log("FAIL: Offerta tag count does not match offer_services migrated count");
    failures++;
  } else {
    console.log("PASS: all offer_services have Offerta tag");
  }

  // Check 2: no orphaned tags (entity_id references non-existent service)
  const orphanedTags = await db.execute(sql`
    SELECT COUNT(*)::int AS n FROM tags t
    WHERE t.entity_type = 'services'
    AND NOT EXISTS (SELECT 1 FROM services s WHERE s.id = t.entity_id)
  `);
  const orphanedTagsCount = (orphanedTags as unknown as Array<{ n: number }>)[0]?.n ?? 0;
  if (orphanedTagsCount > 0) {
    console.log(`FAIL: ${orphanedTagsCount} tags reference non-existent services`);
    failures++;
  } else {
    console.log("PASS: no orphaned tag references");
  }

  // Check 3: informational — total tags per entity_type
  const tagCounts = await db.execute(sql`
    SELECT entity_type, COUNT(*)::int AS n FROM tags GROUP BY entity_type
  `);
  const counts = tagCounts as unknown as Array<{ entity_type: string; n: number }>;
  for (const row of counts) {
    console.log(`INFO: ${row.n} tags for entity_type=${row.entity_type}`);
  }

  console.log(`\n${failures === 0 ? "ALL CHECKS PASSED" : `${failures} CHECK(S) FAILED`}`);
  process.exit(failures === 0 ? 0 : 1);
}

validate().catch((err) => {
  console.error("Validation failed:", err);
  process.exit(1);
});
```

---

## Shared Patterns

### Authentication & Authorization
**Source:** `src/app/admin/catalog/actions.ts` (lines 18-21)
**Apply to:** All new/modified server actions
```typescript
async function requireAdmin() {
  const session = await getServerSession(authOptions);
  if (!session) throw new Error("Non autorizzato");
}
// Every server action must call requireAdmin() first
```

### Error Handling
**Source:** `src/app/admin/catalog/actions.ts` (lines 23-31, 44-50)
**Apply to:** All server actions and client components
```typescript
// In actions:
const parsed = schema.safeParse(data);
if (!parsed.success) throw new Error(parsed.error.issues[0].message);

// In components:
try {
  await serverAction(...);
} catch (e) {
  setError(e instanceof Error ? e.message : "Errore generico");
}
```

### Data Types & Formatting
**Source:** `src/components/admin/catalog/ServiceTable.tsx` (lines 124-125)
**Apply to:** All price/currency display
```typescript
// Prices: always use tabular-nums, toLocaleString("it-IT", { minimumFractionDigits: 2 })
€{parseFloat(service.unit_price).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
```

### Revalidation
**Source:** `src/app/admin/catalog/actions.ts` (lines 39, 60, 66)
**Apply to:** All mutating server actions
```typescript
revalidatePath("/admin/catalog");
```

### Zod Validation
**Source:** `src/app/admin/catalog/actions.ts` (lines 11-16)
**Apply to:** All form input validation
```typescript
const serviceSchema = z.object({
  name: z.string().min(1, "Nome richiesto"),
  description: z.string().optional(),
  unit_price: z.coerce.number().min(0.01, "Prezzo deve essere maggiore di 0"),
  category: z.string().optional(),
});
```

### TailwindCSS Color Tokens
**Source:** `src/components/admin/catalog/ServiceForm.tsx` (lines 35, 43, 89-90)
**Apply to:** All UI components
```typescript
// Primary: #1A463C (dark green)
className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90"

// Muted bg: #f9f9f9
className="bg-[#f9f9f9]"

// Border: #e5e7eb
className="border-[#e5e7eb]"

// Text muted: #71717a
className="text-[#71717a]"
```

---

## No Analog Found

| File | Role | Data Flow | Reason |
|------|------|-----------|--------|
| None | — | — | All files have clear analogs; EditableCell and TagMultiSelect extracted from existing ServiceRow pattern |

---

## Migration & Data Safety Notes

**Per CLAUDE.md Data Safety (LOCKED):**
- Phase 11 schema migration (tags table + junction) must be applied to production BEFORE pushing schema-dependent code
- Additive only: `CREATE TABLE tags`, no drops/truncates
- Migration script (`migrate-tags.ts`) is idempotent: safe to re-run

**Legacy JOIN Fix (Claude's Discretion):**
- Lines 331-345 and 520-545 of `src/lib/admin-queries.ts` still JOIN `quote_items.service_id` → `service_catalog.id`
- Per CONTEXT.md: this is acceptable for Phase 11 (quote_items stay scoped to legacy `service_catalog`; migrated rows in `services` are not referenced by quote_items)
- Recommend researcher verify in Phase 12 when rewiring `/admin/offers` to `services`

---

## Metadata

**Analog search scope:** `src/db/schema.ts`, `src/app/admin/catalog/*`, `src/components/admin/catalog/*`, `src/components/ui/*`, `src/lib/admin-queries.ts`, `scripts/migrate-*.ts`
**Files scanned:** 11 source files
**Pattern extraction date:** 2026-06-13
