docs(phase-03): complete phase execution — service catalog + quote builder verified

This commit is contained in:
Simone Cavalli
2026-05-19 23:12:59 +02:00
parent fe0a65ebeb
commit 67e4483b48
7 changed files with 1796 additions and 14 deletions
@@ -0,0 +1,558 @@
# Phase 3: Service Catalog & Quote Builder — Pattern Map
**Mapped:** 2026-05-17
**Files analyzed:** 7 new/modified files
**Analogs found:** 7/7 with exact or role-match
## File Classification
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|---|---|---|---|---|
| `src/app/admin/catalog/page.tsx` | page | request-response | `src/app/admin/page.tsx` | exact |
| `src/app/admin/catalog/actions.ts` | server-actions | CRUD | `src/app/admin/clients/[id]/actions.ts` | exact |
| `src/components/admin/catalog/ServiceTable.tsx` | component | CRUD (display + inline edit) | `src/components/admin/DocumentRow.tsx` | exact |
| `src/components/admin/tabs/QuoteTab.tsx` | component (client) | CRUD | `src/components/admin/tabs/PaymentsTab.tsx` | exact |
| `src/app/admin/clients/[id]/quote-actions.ts` | server-actions | CRUD | `src/app/admin/clients/[id]/actions.ts` | exact |
| `src/components/admin/NavBar.tsx` | component | request-response (MODIFIED) | `src/components/admin/NavBar.tsx` | exact |
| `src/db/schema.ts` | config (MODIFIED) | schema | `src/db/schema.ts` | exact |
---
## Pattern Assignments
### `src/app/admin/catalog/page.tsx` (page, request-response)
**Analog:** `src/app/admin/page.tsx`
**Pattern:** Server Component with header, table, and action buttons. Fetches data, renders read-only structure with empty state.
**Imports pattern** (lines 14):
```typescript
import Link from "next/link";
import { getAllClientsWithPayments } from "@/lib/admin-queries";
import { ClientRow } from "@/components/admin/ClientRow";
import { Button } from "@/components/ui/button";
```
**Page structure** (lines 832):
```typescript
export default async function AdminDashboard({
searchParams,
}: {
searchParams: Promise<{ archived?: string }>;
}) {
const { archived } = await searchParams;
const showArchived = archived === "1";
const clients = await getAllClientsWithPayments(showArchived);
return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-[#1a1a1a]">Clienti</h1>
<Button asChild>
<Link href="/admin/clients/new">+ Nuovo cliente</Link>
</Button>
</div>
{/* ... table rendering ... */}
</div>
);
}
```
**For Catalog Page:** Replace query with `getAllServices()`, render ServiceTable component, add "+ Aggiungi servizio" button.
---
### `src/app/admin/catalog/actions.ts` (server-actions, CRUD)
**Analog:** `src/app/admin/clients/[id]/actions.ts`
**Pattern:** Server action exports with Zod schema validation, FormData parsing, DB operations, and revalidatePath.
**Zod validation pattern** (lines 2024):
```typescript
const clientSchema = z.object({
name: z.string().min(1, "Nome richiesto"),
brand_name: z.string().min(1, "Brand name richiesto"),
brief: z.string(),
});
```
**Server action with validation** (lines 2636):
```typescript
export async function updateClient(clientId: string, formData: FormData) {
const parsed = clientSchema.safeParse({
name: formData.get("name"),
brand_name: formData.get("brand_name"),
brief: formData.get("brief") ?? "",
});
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
await db.update(clients).set(parsed.data).where(eq(clients.id, clientId));
revalidatePath(`/admin/clients/${clientId}`);
revalidatePath("/admin");
}
```
**Document validation pattern** (lines 138141):
```typescript
const docSchema = z.object({
label: z.string().min(1, "Etichetta richiesta"),
url: z.string().url("URL non valido"),
});
```
**For Catalog Actions:** Create `serviceSchema` with name, description, unit_price. Implement `createService`, `updateService`, `toggleServiceActive`. Path revalidation: `/admin/catalog`.
---
### `src/components/admin/catalog/ServiceTable.tsx` (component, CRUD)
**Analog:** `src/components/admin/DocumentRow.tsx`
**Pattern:** Client component with local `editing` state, inline edit toggle, form submission via Server Action, error handling via useTransition.
**DocumentRow structure** (lines 1080):
```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 { updateDocument, deleteDocument } from "@/app/admin/clients/[id]/actions";
import type { Document } from "@/db/schema";
export function DocumentRow({
doc,
clientId,
}: {
doc: Document;
clientId: string;
}) {
const [editing, setEditing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [, startTransition] = useTransition();
const router = useRouter();
function handleSave(fd: FormData) {
setError(null);
startTransition(async () => {
try {
await updateDocument(doc.id, clientId, fd);
setEditing(false);
router.refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "Errore nel salvataggio");
}
});
}
if (editing) {
return (
<form action={handleSave} className="bg-white border-2 border-[#1A463C]/30 rounded-lg px-4 py-3 space-y-2">
<Input name="label" defaultValue={doc.label} required />
<Input name="url" defaultValue={doc.url} type="url" required />
{error && <p className="text-xs text-red-600">{error}</p>}
<div className="flex gap-2 pt-1">
<Button type="submit" size="sm">Salva</Button>
<Button type="button" variant="ghost" size="sm" onClick={() => setEditing(false)}>
Annulla
</Button>
</div>
</form>
);
}
return (
<div className="flex items-center justify-between bg-white border border-[#e5e7eb] rounded-lg px-4 py-3 group">
<a href={doc.url} className="text-sm text-[#1A463C] hover:underline font-medium">
{doc.label}
</a>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => setEditing(true)}>
Modifica
</Button>
<Button variant="ghost" size="sm" onClick={handleDelete}>
Rimuovi
</Button>
</div>
</div>
);
}
```
**For ServiceTable:** Render as table (not row), include service name, description, price, active status. Toggle row → editable inputs (name, description, price). Delete = soft toggle (`active = false`). Hover reveal "Disattiva"/"Riattiva" button.
**Table styling** (from admin/page.tsx lines 4664):
```typescript
<div className="bg-white rounded-lg border border-[#e5e7eb] overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
<tr>
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Column</th>
</tr>
</thead>
<tbody>
{/* rows */}
</tbody>
</table>
</div>
```
---
### `src/components/admin/tabs/QuoteTab.tsx` (component client, CRUD)
**Analog:** `src/components/admin/tabs/PaymentsTab.tsx`
**Pattern:** Async server component (not client) that receives props (items, services, acceptedTotal, clientId), renders multiple form sections, each with its own Server Action call.
**PaymentsTab structure** (lines 2254):
```typescript
export async function PaymentsTab({ payments, acceptedTotal, clientId }: Props) {
return (
<div className="space-y-6 max-w-md">
<div className="bg-white border border-gray-200 rounded-lg p-4">
<h3 className="font-medium text-gray-900 mb-3">Totale preventivo</h3>
<form
action={async (fd: FormData) => {
"use server";
await updateAcceptedTotal(clientId, fd);
}}
className="flex items-end gap-3"
>
<div className="space-y-1 flex-1">
<Label htmlFor="accepted_total">Importo ()</Label>
<Input
id="accepted_total"
name="accepted_total"
type="number"
step="0.01"
min="0"
defaultValue={acceptedTotal}
/>
</div>
<Button type="submit" size="sm">Salva</Button>
</form>
</div>
{payments.map((p) => (
<div key={p.id} className="bg-white border border-gray-200 rounded-lg p-4">
{/* ... */}
</div>
))}
</div>
);
}
```
**For QuoteTab:** Structure as three sections:
1. Add items (dropdown catalog + qty OR toggle to custom label/price/qty)
2. Quote items table (Voce | Qty | Unit Price | Subtotal | Delete)
3. Accepted total (editable input + Save button)
Each section is its own form with inline Server Action call. Use same card styling (`bg-white border border-[#e5e7eb] rounded-lg p-4`).
---
### `src/app/admin/clients/[id]/quote-actions.ts` (server-actions, CRUD)
**Analog:** `src/app/admin/clients/[id]/actions.ts`
**Pattern:** Identical to catalog actions — Zod validation, FormData parsing, numeric precision handling.
**Numeric precision pattern** (lines 192211):
```typescript
export async function updateAcceptedTotal(clientId: string, formData: FormData) {
const raw = (formData.get("accepted_total") as string)?.trim();
const val = parseFloat(raw);
if (isNaN(val) || val < 0) throw new Error("Importo non valido");
await db
.update(clients)
.set({ accepted_total: val.toFixed(2) })
.where(eq(clients.id, clientId));
revalidatePath(`/admin/clients/${clientId}`);
}
```
**For Quote Actions:** Implement:
- `addQuoteItem(clientId, formData)` — parse service_id (nullable), custom_label (nullable), quantity, unit_price. Calculate subtotal. Insert into quote_items.
- `removeQuoteItem(quoteItemId, clientId)` — delete from quote_items.
- `updateAcceptedTotal(clientId, formData)` — identical to existing pattern in actions.ts.
All paths: `revalidatePath(/admin/clients/${clientId})`.
---
### `src/components/admin/NavBar.tsx` (component, request-response — MODIFIED)
**Analog:** `src/components/admin/NavBar.tsx`
**Current structure** (lines 729):
```typescript
export function NavBar() {
return (
<nav className="bg-[#1A463C] px-6 py-3 flex items-center justify-between">
<div className="flex items-center gap-6">
<span className="font-bold text-white tracking-tight">iamcavalli</span>
<Link href="/admin" className="text-sm text-white/70 hover:text-white transition-colors">
Clienti
</Link>
<Link href="/admin/analytics" className="text-sm text-white/70 hover:text-white transition-colors">
Statistiche
</Link>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => signOut({ callbackUrl: "/admin/login" })}
className="text-sm text-white/70 hover:text-white hover:bg-white/10"
>
Esci
</Button>
</nav>
);
}
```
**Modification:** Add new Link after "Statistiche":
```typescript
<Link href="/admin/catalog" className="text-sm text-white/70 hover:text-white transition-colors">
Catalogo
</Link>
```
---
### `src/db/schema.ts` (config — MODIFIED)
**Analog:** `src/db/schema.ts`
**Current quote_items definition** (lines 159172):
```typescript
export const quote_items = pgTable("quote_items", {
id: text("id")
.primaryKey()
.$defaultFn(() => nanoid()),
client_id: text("client_id")
.notNull()
.references(() => clients.id, { onDelete: "cascade" }),
service_id: text("service_id")
.notNull()
.references(() => service_catalog.id, { onDelete: "restrict" }),
quantity: numeric("quantity", { precision: 10, scale: 2 }).notNull(),
unit_price: numeric("unit_price", { precision: 10, scale: 2 }).notNull(),
subtotal: numeric("subtotal", { precision: 10, scale: 2 }).notNull(),
});
```
**Required changes:**
1. **Make service_id nullable** (line 166168):
```typescript
service_id: text("service_id")
.references(() => service_catalog.id, { onDelete: "restrict" }),
// removed .notNull()
```
2. **Add custom_label field** (after subtotal):
```typescript
custom_label: text("custom_label"),
```
**After schema changes:**
- Run `npx drizzle-kit push` to apply migrations to database
- Verify no TypeScript errors in types (QuoteItem type will auto-update)
---
## Shared Patterns
### Form Validation (All CRUD Actions)
**Source:** `src/app/admin/clients/[id]/actions.ts` lines 2024, 138141
**Pattern:** Use Zod schema with `.safeParse()`, throw first error message.
**Apply to:** All catalog and quote actions
```typescript
import { z } from "zod";
const serviceSchema = z.object({
name: z.string().min(1, "Nome richiesto"),
description: z.string().optional(),
unit_price: z.coerce.number().positive("Prezzo deve essere positivo"),
});
export async function createService(formData: FormData) {
const parsed = serviceSchema.safeParse({
name: formData.get("name"),
description: formData.get("description") ?? "",
unit_price: formData.get("unit_price"),
});
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
await db.insert(service_catalog).values(parsed.data);
revalidatePath("/admin/catalog");
}
```
### Inline Edit Component Pattern (ServiceTable, ServiceRow)
**Source:** `src/components/admin/DocumentRow.tsx` lines 10114
**Pattern:**
- "use client" directive
- useState for `editing`, `error`
- useTransition for async form submission
- useRouter for refresh
- Toggle render: editing mode (form inputs) vs read mode (display + hover buttons)
- Server Action called inline in form action
**Apply to:** ServiceTable with per-row inline edit.
### Currency Formatting
**Source:** `src/components/admin/ClientRow.tsx` line 33
**Pattern:**
```typescript
€{parseFloat(amount).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
```
**Apply to:** All price displays in ServiceTable and QuoteTab.
### Table Styling
**Source:** `src/app/admin/page.tsx` lines 4664
**Pattern:**
```typescript
<div className="bg-white rounded-lg border border-[#e5e7eb] overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[#f9f9f9] border-b border-[#e5e7eb]">
<tr>
<th className="text-left py-3 px-4 font-medium text-[#71717a]">Colonna</th>
</tr>
</thead>
<tbody>
{items.map(item => (
<tr key={item.id} className="border-b border-[#f4f4f5] hover:bg-[#f9f9f9]">
<td className="py-3 px-4">…</td>
</tr>
))}
</tbody>
</table>
</div>
```
**Apply to:** ServiceTable layout in catalog/page.tsx
### Card Styling (Forms, Sections)
**Source:** `src/components/admin/tabs/DocumentsTab.tsx` line 18
**Pattern:**
```typescript
<div className="bg-white border border-[#e5e7eb] rounded-lg p-4 space-y-3">
<h3 className="font-medium text-[#1a1a1a]">Titolo</h3>
{/* content */}
</div>
```
**Apply to:** All form sections in QuoteTab and ServiceTable.
### Label + Input Grid
**Source:** `src/components/admin/tabs/DocumentsTab.tsx` lines 2039
**Pattern:**
```typescript
<div className="space-y-1">
<Label htmlFor="field-id">Label testo</Label>
<Input
id="field-id"
name="field-name"
type="text"
placeholder="placeholder"
required
/>
</div>
```
**Apply to:** All form inputs in catalog and quote builders.
### Numeric Input Pattern
**Source:** `src/components/admin/tabs/PaymentsTab.tsx` lines 3645
**Pattern:**
```typescript
<Input
id="price"
name="unit_price"
type="number"
step="0.01"
min="0"
defaultValue={price}
/>
```
**Apply to:** All price/quantity inputs; use `step="0.01"` for EUR precision.
---
## No Analog Found
No files require external patterns. All code patterns (Server Actions, inline edit, table layout, form validation) exist in the codebase.
---
## Query Pattern (for page data fetching)
**Not extracted as code** — will be implemented in quote-actions.ts and documented in planning phase.
Example from RESEARCH.md:
```typescript
// Get all active services for dropdown
const activeServices = await db
.select()
.from(service_catalog)
.where(eq(service_catalog.active, true))
.orderBy(asc(service_catalog.name));
// Get quote items with service names
const items = await db
.select({
id: quote_items.id,
label: sql`COALESCE(${service_catalog.name}, ${quote_items.custom_label})`,
quantity: quote_items.quantity,
unit_price: quote_items.unit_price,
subtotal: quote_items.subtotal,
})
.from(quote_items)
.leftJoin(service_catalog, eq(quote_items.service_id, service_catalog.id))
.where(eq(quote_items.client_id, clientId));
```
---
## Metadata
**Analog search scope:** `/src/app/admin/`, `/src/components/admin/`, `/src/app/admin/clients/[id]/`
**Files scanned:** 13 analog files
**Pattern extraction date:** 2026-05-17
**Coverage summary:**
- Exact match (same role + data flow): 7/7
- Role-match (same role, similar flow): 0
- No analog: 0
**Key insights:**
- Phase 2 established Server Actions + Zod pattern — directly reusable for Phase 3 CRUD
- Inline edit pattern from DocumentRow is the gold standard for catalog service editing
- PaymentsTab structure fits QuoteTab exactly (multiple form sections, each with own Server Action)
- Table styling is consistent across admin interface — use directly
- No new dependencies or libraries needed — all patterns are vanilla React + Next.js built-ins