Files
clienthub/.planning/phases/11-catalog-database-view-ux-legacy-consolidation/11-PATTERNS.md
T
simone 03898f2a59 docs(planning): v2.1 milestone setup + Phase 11 context/patterns
- Archive v2.0 phases (07-10) under .planning/milestones/
- v2.1 REQUIREMENTS/ROADMAP (Phases 11-17: Offer Studio + Proposal AI)
- DESIGN-SYSTEM.md (database-view UI contract for Phases 11-14)
- Phase 11 CONTEXT, DISCUSSION-LOG, PATTERNS

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 15:07:06 +02:00

34 KiB

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

// 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):

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

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

// 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):

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

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:

// 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):

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

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:

// 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):

"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):

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

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:

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

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

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:

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

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

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

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

revalidatePath("/admin/catalog");

Zod Validation

Source: src/app/admin/catalog/actions.ts (lines 11-16) Apply to: All form input validation

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

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