- 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>
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 generateto auto-generate from schema.ts - Migration must be idempotent (can run multiple times safely)
- Create
tagstable 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:
- EditableCell pattern (extract to separate component, see below): Each cell becomes clickable, transitions to
<input>on click, saves on blur/Enter, cancels on Esc - No "Actions" column — all edits happen inline
- Active/Inactive toggle — becomes an inline cell (checkbox/toggle, same EditableCell pattern)
- Description column — full text with ellipsis, click to expand in textarea inline (same EditableCell pattern)
- Tags column — uses new TagMultiSelect component (see below)
- Quick-add row — last row, always visible, placeholder "+ Aggiungi servizio", becomes normal row after save
- Inactive services — grouped at bottom below a divider, visually attenuated (opacity-50)
- 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-primaryon 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.tsstill JOINquote_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 inservicesare not referenced by quote_items) - Recommend researcher verify in Phase 12 when rewiring
/admin/offerstoservices
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