03898f2a59
- 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>
1086 lines
34 KiB
Markdown
1086 lines
34 KiB
Markdown
# 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
|