558 lines
17 KiB
Markdown
558 lines
17 KiB
Markdown
# 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 1–4):
|
||
```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 8–32):
|
||
```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 20–24):
|
||
```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 26–36):
|
||
```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 138–141):
|
||
```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 10–80):
|
||
```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 46–64):
|
||
```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 22–54):
|
||
```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 192–211):
|
||
```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 7–29):
|
||
```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 159–172):
|
||
```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 166–168):
|
||
```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 20–24, 138–141
|
||
|
||
**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 10–114
|
||
|
||
**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 46–64
|
||
|
||
**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 20–39
|
||
|
||
**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 36–45
|
||
|
||
**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 |