# 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 { 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 { 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(); 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 = {}; 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 (

Catalogo Servizi

{services.length === 0 ? (

Nessun servizio...

) : ( )}
); } ``` **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 `` 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 ( handleFieldChange("name", val)} required /> handleFieldChange("description", val)} /> handleFieldChange("category", val)} /> handleFieldChange("unit_price", val)} /> { router.refresh(); onTagsChanged?.(); }} /> handleFieldChange("active", val === "attivo" ? !service.active : service.active)} /> ); } ``` **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) { if (e.key !== "Enter" || !newName.trim()) return; e.preventDefault(); startTransition(async () => { try { await quickAddService(newName); setNewName(""); router.refresh(); } catch (e) { // error handling } }); } return (
{/* Active services */} {activeServices.map((s) => ( ))} {/* Quick-add row */} {/* Inactive services (if any) */} {inactiveServices.length > 0 && ( <> {inactiveServices.map((s) => ( ))} )}
Nome Descrizione Categoria Prezzo Tag Stato
setNewName(e.target.value)} onKeyDown={handleQuickAdd} className="border-0 bg-transparent placeholder:text-[#71717a]" />
Servizi disattivati
); } ``` --- ### `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(null); useEffect(() => { if (isEditing && inputRef.current) { inputRef.current.focus(); inputRef.current.select?.(); } }, [isEditing]); if (!isEditing) { return (
!disabled && setIsEditing(true)} className={cn( "px-2 py-1 rounded cursor-pointer hover:bg-[#f0f0f0]", disabled && "cursor-not-allowed opacity-50" )} > {type === "toggle" ? ( {tempValue === "true" ? "✓ Attivo" : "✗ Disattivato"} ) : type === "textarea" ? (
{tempValue || "—"}
) : (
{tempValue || "—"}
)}
); } 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) { if (e.key === "Enter" && type !== "textarea") { handleSave(); } else if (e.key === "Escape") { handleCancel(); } } return (
{type === "textarea" ? (