docs(03): plan Phase 3 — Service Catalog & Quote Builder (4 plans, 2 waves)
Wave 1: schema push (service_id nullable + custom_label). Wave 2 (parallel): catalog CRUD page + quote builder tab. Wave 3: E2E human verification checkpoint. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,725 @@
|
||||
---
|
||||
phase: "03"
|
||||
plan: "03"
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "03-01"
|
||||
files_modified:
|
||||
- src/app/admin/clients/[id]/quote-actions.ts
|
||||
- src/components/admin/tabs/QuoteTab.tsx
|
||||
- src/app/admin/clients/[id]/page.tsx
|
||||
- src/lib/admin-queries.ts
|
||||
autonomous: true
|
||||
requirements:
|
||||
- CAT-02
|
||||
- ADMIN-03
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Admin can see a 'Preventivo' tab in /admin/clients/[id] — the 5th tab after Commenti"
|
||||
- "Admin can select an active catalog service from a dropdown and add it (with qty) to the quote — the item appears in the table with snapshotted unit_price"
|
||||
- "Admin can toggle to 'Voce libera' mode and add a custom label + price + qty item (service_id = null in DB)"
|
||||
- "Admin can click 'Rimuovi' to delete a quote item — it disappears from the table"
|
||||
- "The table footer shows 'Totale calcolato' as the sum of all subtotals"
|
||||
- "Admin can set a separate 'Totale accettato dal cliente' via an editable input + Salva button — this writes to clients.accepted_total"
|
||||
- "quote_items are NEVER returned by any client-facing route — only clients.accepted_total is visible to clients"
|
||||
artifacts:
|
||||
- path: "src/app/admin/clients/[id]/quote-actions.ts"
|
||||
provides: "Server Actions: addQuoteItem, removeQuoteItem, updateAcceptedTotal"
|
||||
exports: ["addQuoteItem", "removeQuoteItem", "updateAcceptedTotal"]
|
||||
- path: "src/components/admin/tabs/QuoteTab.tsx"
|
||||
provides: "Quote builder UI — add items (catalog + freeform), items table, accepted total editor"
|
||||
contains: "QuoteTab"
|
||||
- path: "src/app/admin/clients/[id]/page.tsx"
|
||||
provides: "Client detail page with 5th Preventivo tab wired to QuoteTab"
|
||||
contains: "Preventivo"
|
||||
- path: "src/lib/admin-queries.ts"
|
||||
provides: "getClientFullDetail extended to include quoteItems and activeServices"
|
||||
contains: "quoteItems"
|
||||
key_links:
|
||||
- from: "src/components/admin/tabs/QuoteTab.tsx add-item form"
|
||||
to: "src/app/admin/clients/[id]/quote-actions.ts addQuoteItem"
|
||||
via: "form action (Server Action)"
|
||||
pattern: "addQuoteItem"
|
||||
- from: "src/components/admin/tabs/QuoteTab.tsx remove button"
|
||||
to: "src/app/admin/clients/[id]/quote-actions.ts removeQuoteItem"
|
||||
via: "form action"
|
||||
pattern: "removeQuoteItem"
|
||||
- from: "src/components/admin/tabs/QuoteTab.tsx accepted total form"
|
||||
to: "src/app/admin/clients/[id]/quote-actions.ts updateAcceptedTotal"
|
||||
via: "form action"
|
||||
pattern: "updateAcceptedTotal"
|
||||
- from: "src/app/admin/clients/[id]/page.tsx"
|
||||
to: "src/lib/admin-queries.ts getClientFullDetail"
|
||||
via: "await getClientFullDetail(id)"
|
||||
pattern: "getClientFullDetail"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Deliver the "Preventivo" tab in the admin client detail page. This is the quote builder vertical slice: Server Actions for quote item CRUD + accepted_total write, the QuoteTab component (catalog dropdown + freeform toggle + items table + accepted total editor), and the wiring of both into the existing client detail page.
|
||||
|
||||
Purpose: Fulfills CAT-02 (catalog as quote generation base) and ADMIN-03 (full quote detail visible to admin only). The client sees only `clients.accepted_total` — this constraint is enforced at the query layer.
|
||||
Output: 4 new/modified files — a fully operational quote builder tab.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@/Users/simonecavalli/IAMCAVALLI/.planning/ROADMAP.md
|
||||
@/Users/simonecavalli/IAMCAVALLI/.planning/phases/03-service-catalog-quote-builder/03-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- Existing client detail page tabs structure — add Preventivo as 5th tab -->
|
||||
```typescript
|
||||
// src/app/admin/clients/[id]/page.tsx (current)
|
||||
<Tabs defaultValue="phases" className="w-full">
|
||||
<TabsList className="mb-6">
|
||||
<TabsTrigger value="phases">Fasi & Task</TabsTrigger>
|
||||
<TabsTrigger value="payments">Pagamenti</TabsTrigger>
|
||||
<TabsTrigger value="documents">Documenti</TabsTrigger>
|
||||
<TabsTrigger value="comments">Commenti</TabsTrigger>
|
||||
{/* ADD: <TabsTrigger value="quote">Preventivo</TabsTrigger> */}
|
||||
</TabsList>
|
||||
{/* ADD: <TabsContent value="quote"><QuoteTab ... /></TabsContent> */}
|
||||
</Tabs>
|
||||
```
|
||||
|
||||
<!-- getClientFullDetail current return type — must be extended with quoteItems and activeServices -->
|
||||
```typescript
|
||||
export type ClientFullDetail = {
|
||||
client: Client;
|
||||
phases: Array<Phase & { tasks: Array<Task & { deliverables: Deliverable[] }> }>;
|
||||
payments: Payment[];
|
||||
documents: Document[];
|
||||
notes: Note[];
|
||||
comments: Comment[];
|
||||
// ADD:
|
||||
// quoteItems: QuoteItemWithLabel[];
|
||||
// activeServices: ServiceCatalog[];
|
||||
};
|
||||
```
|
||||
|
||||
<!-- PaymentsTab — analog structure for QuoteTab (server component with inline Server Action calls) -->
|
||||
```typescript
|
||||
// src/components/admin/tabs/PaymentsTab.tsx pattern
|
||||
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">...</h3>
|
||||
<form action={async (fd) => { "use server"; await updateAcceptedTotal(clientId, fd); }}>
|
||||
...
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
<!-- SECURITY: getClientFullDetail must NOT expose quote_items via client-facing API.
|
||||
The quote data is added only to the admin query result — not to any client route.
|
||||
Comment to add at the top of quote-actions.ts: -->
|
||||
// quote_items NEVER exposed — security constraint from Phase 1 (CLAUDE.md)
|
||||
// Only clients.accepted_total is visible to client-facing routes
|
||||
|
||||
<!-- Drizzle leftJoin + COALESCE pattern for quote items with service name -->
|
||||
```typescript
|
||||
import { sql, eq, asc } from "drizzle-orm";
|
||||
import { quote_items, service_catalog, clients } from "@/db/schema";
|
||||
|
||||
// Get quote items for a client — service name from catalog OR custom_label
|
||||
const items = await db
|
||||
.select({
|
||||
id: quote_items.id,
|
||||
label: sql<string>`COALESCE(${service_catalog.name}, ${quote_items.custom_label})`,
|
||||
custom_label: quote_items.custom_label,
|
||||
service_id: quote_items.service_id,
|
||||
quantity: quote_items.quantity,
|
||||
unit_price: quote_items.unit_price, // snapshotted — NEVER use service_catalog.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))
|
||||
.orderBy(asc(quote_items.id));
|
||||
```
|
||||
|
||||
<!-- addQuoteItem — numeric precision and subtotal calculation -->
|
||||
```typescript
|
||||
const qty = parseFloat(formData.get("quantity") as string);
|
||||
const price = parseFloat(formData.get("unit_price") as string);
|
||||
const subtotal = (qty * price).toFixed(2);
|
||||
// Insert: unit_price stored as string with 2dp (matches numeric(10,2) column)
|
||||
await db.insert(quote_items).values({
|
||||
client_id: clientId,
|
||||
service_id: serviceId ?? null, // null for freeform items
|
||||
custom_label: customLabel ?? null,
|
||||
quantity: qty.toFixed(2),
|
||||
unit_price: price.toFixed(2),
|
||||
subtotal,
|
||||
});
|
||||
```
|
||||
|
||||
<!-- ServiceCatalog type for dropdown -->
|
||||
```typescript
|
||||
export type ServiceCatalog = typeof service_catalog.$inferSelect;
|
||||
// Fields: id: string, name: string, unit_price: string, active: boolean, description: string | null
|
||||
```
|
||||
|
||||
<!-- QuoteItem type (updated after 03-01) -->
|
||||
```typescript
|
||||
export type QuoteItem = typeof quote_items.$inferSelect;
|
||||
// Fields: id, client_id, service_id: string | null, custom_label: string | null,
|
||||
// quantity, unit_price, subtotal (all numeric as string)
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: quote-actions.ts Server Actions + extend getClientFullDetail</name>
|
||||
<read_first>
|
||||
- /Users/simonecavalli/IAMCAVALLI/src/app/admin/clients/[id]/actions.ts (pattern: Zod, requireAdmin, revalidatePath)
|
||||
- /Users/simonecavalli/IAMCAVALLI/src/lib/admin-queries.ts (current getClientFullDetail to extend — add quoteItems and activeServices)
|
||||
- /Users/simonecavalli/IAMCAVALLI/src/db/schema.ts (confirm custom_label and nullable service_id from 03-01)
|
||||
- /Users/simonecavalli/IAMCAVALLI/src/lib/client-view.ts (VERIFY this file does NOT query quote_items — if it does, remove that query)
|
||||
</read_first>
|
||||
<files>
|
||||
src/app/admin/clients/[id]/quote-actions.ts
|
||||
src/lib/admin-queries.ts
|
||||
</files>
|
||||
<action>
|
||||
**Create `src/app/admin/clients/[id]/quote-actions.ts`** — three Server Actions:
|
||||
|
||||
```typescript
|
||||
"use server";
|
||||
|
||||
// quote_items NEVER exposed — security constraint from Phase 1 (CLAUDE.md)
|
||||
// Only clients.accepted_total is visible to client-facing routes
|
||||
|
||||
import { db } from "@/db";
|
||||
import { quote_items, clients, service_catalog } from "@/db/schema";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { z } from "zod";
|
||||
import { getServerSession } from "next-auth";
|
||||
import { authOptions } from "@/lib/auth";
|
||||
|
||||
async function requireAdmin() {
|
||||
const session = await getServerSession(authOptions);
|
||||
if (!session) throw new Error("Non autorizzato");
|
||||
}
|
||||
|
||||
const quoteItemSchema = z.object({
|
||||
service_id: z.string().nullable(),
|
||||
custom_label: z.string().nullable(),
|
||||
quantity: z.coerce.number().min(0.01, "Quantità deve essere > 0"),
|
||||
unit_price: z.coerce.number().min(0.01, "Prezzo deve essere > 0"),
|
||||
});
|
||||
|
||||
export async function addQuoteItem(clientId: string, formData: FormData) {
|
||||
await requireAdmin();
|
||||
|
||||
const rawServiceId = formData.get("service_id") as string | null;
|
||||
const rawCustomLabel = formData.get("custom_label") as string | null;
|
||||
|
||||
const parsed = quoteItemSchema.safeParse({
|
||||
service_id: rawServiceId && rawServiceId !== "" ? rawServiceId : null,
|
||||
custom_label: rawCustomLabel && rawCustomLabel !== "" ? rawCustomLabel : null,
|
||||
quantity: formData.get("quantity"),
|
||||
unit_price: formData.get("unit_price"),
|
||||
});
|
||||
|
||||
if (!parsed.success) throw new Error(parsed.error.issues[0].message);
|
||||
|
||||
// Validate: either service_id or custom_label must be present
|
||||
if (!parsed.data.service_id && !parsed.data.custom_label) {
|
||||
throw new Error("Seleziona un servizio dal catalogo o inserisci il nome di una voce libera");
|
||||
}
|
||||
|
||||
const { service_id, custom_label, quantity, unit_price } = parsed.data;
|
||||
const subtotal = (quantity * unit_price).toFixed(2);
|
||||
|
||||
await db.insert(quote_items).values({
|
||||
client_id: clientId,
|
||||
service_id: service_id ?? null,
|
||||
custom_label: custom_label ?? null,
|
||||
quantity: quantity.toFixed(2),
|
||||
unit_price: unit_price.toFixed(2),
|
||||
subtotal,
|
||||
});
|
||||
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
}
|
||||
|
||||
export async function removeQuoteItem(quoteItemId: string, clientId: string) {
|
||||
await requireAdmin();
|
||||
await db.delete(quote_items).where(eq(quote_items.id, quoteItemId));
|
||||
revalidatePath(`/admin/clients/${clientId}`);
|
||||
}
|
||||
|
||||
export async function updateAcceptedTotal(clientId: string, formData: FormData) {
|
||||
await requireAdmin();
|
||||
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}`);
|
||||
}
|
||||
```
|
||||
|
||||
**Extend `src/lib/admin-queries.ts`** — add `QuoteItemWithLabel` type and extend `ClientFullDetail` + `getClientFullDetail`:
|
||||
|
||||
1. Add imports at top: `quote_items`, `service_catalog` from `@/db/schema`; `sql` from `drizzle-orm`; `ServiceCatalog` from `@/db/schema`.
|
||||
|
||||
2. Add new type before `ClientFullDetail`:
|
||||
```typescript
|
||||
export type QuoteItemWithLabel = {
|
||||
id: string;
|
||||
label: string; // COALESCE(service_catalog.name, quote_items.custom_label)
|
||||
custom_label: string | null;
|
||||
service_id: string | null;
|
||||
quantity: string;
|
||||
unit_price: string; // snapshotted — never joined back to service_catalog.unit_price
|
||||
subtotal: string;
|
||||
};
|
||||
```
|
||||
|
||||
3. Add `quoteItems: QuoteItemWithLabel[]` and `activeServices: ServiceCatalog[]` to the `ClientFullDetail` type.
|
||||
|
||||
4. Add two queries inside `getClientFullDetail()` before the `return` statement:
|
||||
|
||||
```typescript
|
||||
// quote_items NEVER exposed via client API — admin workspace query only
|
||||
const quoteItemRows: QuoteItemWithLabel[] = await db
|
||||
.select({
|
||||
id: quote_items.id,
|
||||
label: sql<string>`COALESCE(${service_catalog.name}, ${quote_items.custom_label})`,
|
||||
custom_label: quote_items.custom_label,
|
||||
service_id: quote_items.service_id,
|
||||
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, id))
|
||||
.orderBy(asc(quote_items.id));
|
||||
|
||||
const activeServiceRows = await db
|
||||
.select()
|
||||
.from(service_catalog)
|
||||
.where(eq(service_catalog.active, true))
|
||||
.orderBy(asc(service_catalog.name));
|
||||
```
|
||||
|
||||
5. Add `quoteItems: quoteItemRows` and `activeServices: activeServiceRows` to the return object.
|
||||
|
||||
IMPORTANT: Also read `src/lib/client-view.ts` to verify it does NOT query `quote_items`. If it does, remove that query entirely — `accepted_total` is the only field the client sees.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export async function addQuoteItem' src/app/admin/clients/\[id\]/quote-actions.ts</automated>
|
||||
Expected: 1
|
||||
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export async function removeQuoteItem' src/app/admin/clients/\[id\]/quote-actions.ts</automated>
|
||||
Expected: 1
|
||||
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export async function updateAcceptedTotal' src/app/admin/clients/\[id\]/quote-actions.ts</automated>
|
||||
Expected: 1
|
||||
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -c 'quoteItems' src/lib/admin-queries.ts</automated>
|
||||
Expected: 3 or more (type definition, query, return)
|
||||
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -c 'quote_items' src/lib/client-view.ts 2>/dev/null || echo 0</automated>
|
||||
Expected: 0 (quote_items must NOT appear in client-view.ts)
|
||||
<automated>cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20</automated>
|
||||
Expected: no output (zero errors)
|
||||
</verify>
|
||||
<done>
|
||||
Three Server Actions exported with `requireAdmin()` guard and Zod validation. `getClientFullDetail` returns `quoteItems` and `activeServices`. `client-view.ts` contains zero references to `quote_items`. TypeScript compiles clean.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: QuoteTab component + wire into client detail page</name>
|
||||
<read_first>
|
||||
- /Users/simonecavalli/IAMCAVALLI/src/components/admin/tabs/PaymentsTab.tsx (exact analog structure to follow)
|
||||
- /Users/simonecavalli/IAMCAVALLI/src/app/admin/clients/[id]/page.tsx (current tab structure to extend)
|
||||
- /Users/simonecavalli/IAMCAVALLI/src/app/admin/clients/[id]/quote-actions.ts (actions from Task 1)
|
||||
- /Users/simonecavalli/IAMCAVALLI/src/lib/admin-queries.ts (updated ClientFullDetail type from Task 1)
|
||||
</read_first>
|
||||
<files>
|
||||
src/components/admin/tabs/QuoteTab.tsx
|
||||
src/app/admin/clients/[id]/page.tsx
|
||||
</files>
|
||||
<action>
|
||||
**Create `src/components/admin/tabs/QuoteTab.tsx`** — "use client" component with three form sections:
|
||||
|
||||
```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 { Label } from "@/components/ui/label";
|
||||
import { addQuoteItem, removeQuoteItem, updateAcceptedTotal } from "@/app/admin/clients/[id]/quote-actions";
|
||||
import type { QuoteItemWithLabel } from "@/lib/admin-queries";
|
||||
import type { ServiceCatalog } from "@/db/schema";
|
||||
|
||||
type Props = {
|
||||
clientId: string;
|
||||
items: QuoteItemWithLabel[];
|
||||
activeServices: ServiceCatalog[];
|
||||
acceptedTotal: string;
|
||||
};
|
||||
|
||||
export function QuoteTab({ clientId, items, activeServices, acceptedTotal }: Props) {
|
||||
const [showCustom, setShowCustom] = useState(false);
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
const [totalError, setTotalError] = useState<string | null>(null);
|
||||
// For catalog mode: pre-fill unit_price when service is selected
|
||||
const [selectedServicePrice, setSelectedServicePrice] = useState<string>("");
|
||||
const [, startTransition] = useTransition();
|
||||
const router = useRouter();
|
||||
|
||||
const calculatedTotal = items.reduce((sum, item) => sum + parseFloat(item.subtotal), 0);
|
||||
|
||||
function handleAddItem(fd: FormData) {
|
||||
setAddError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await addQuoteItem(clientId, fd);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setAddError(e instanceof Error ? e.message : "Errore nell'aggiunta");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleRemove(quoteItemId: string) {
|
||||
startTransition(async () => {
|
||||
await removeQuoteItem(quoteItemId, clientId);
|
||||
router.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
function handleSaveTotal(fd: FormData) {
|
||||
setTotalError(null);
|
||||
startTransition(async () => {
|
||||
try {
|
||||
await updateAcceptedTotal(clientId, fd);
|
||||
router.refresh();
|
||||
} catch (e) {
|
||||
setTotalError(e instanceof Error ? e.message : "Errore nel salvataggio");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-2xl">
|
||||
|
||||
{/* Section 1: Add items */}
|
||||
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4 space-y-4">
|
||||
<h3 className="text-xs font-bold text-[#71717a] uppercase tracking-wider">Aggiungi voci</h3>
|
||||
|
||||
{!showCustom ? (
|
||||
/* Catalog mode */
|
||||
<form action={handleAddItem} className="space-y-3">
|
||||
<input type="hidden" name="custom_label" value="" />
|
||||
<div className="flex items-end gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-[180px] space-y-1">
|
||||
<Label htmlFor="service_id">Seleziona dal catalogo</Label>
|
||||
<select
|
||||
name="service_id"
|
||||
id="service_id"
|
||||
className="w-full border border-[#e5e7eb] rounded-md px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-[#1A463C]/30"
|
||||
onChange={(e) => {
|
||||
const svc = activeServices.find((s) => s.id === e.target.value);
|
||||
setSelectedServicePrice(svc ? parseFloat(svc.unit_price).toFixed(2) : "");
|
||||
}}
|
||||
required
|
||||
>
|
||||
<option value="">— Scegli servizio —</option>
|
||||
{activeServices.map((s) => (
|
||||
<option key={s.id} value={s.id}>
|
||||
{s.name} (€{parseFloat(s.unit_price).toLocaleString("it-IT", { minimumFractionDigits: 2 })})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="w-28 space-y-1">
|
||||
<Label htmlFor="unit_price_catalog">Prezzo unit.</Label>
|
||||
<Input
|
||||
id="unit_price_catalog"
|
||||
name="unit_price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
value={selectedServicePrice}
|
||||
onChange={(e) => setSelectedServicePrice(e.target.value)}
|
||||
placeholder="0.00"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="w-20 space-y-1">
|
||||
<Label htmlFor="quantity_catalog">Qty</Label>
|
||||
<Input
|
||||
id="quantity_catalog"
|
||||
name="quantity"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
defaultValue="1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" size="sm" className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90">
|
||||
Aggiungi
|
||||
</Button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setShowCustom(true); setAddError(null); }}
|
||||
className="text-xs text-[#71717a] hover:text-[#1a1a1a] underline"
|
||||
>
|
||||
Oppure aggiungi voce libera →
|
||||
</button>
|
||||
{addError && <p className="text-xs text-red-600">{addError}</p>}
|
||||
</form>
|
||||
) : (
|
||||
/* Freeform mode */
|
||||
<form action={handleAddItem} className="space-y-3">
|
||||
<input type="hidden" name="service_id" value="" />
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="custom_label">Nome voce</Label>
|
||||
<Input
|
||||
id="custom_label"
|
||||
name="custom_label"
|
||||
placeholder="es. Consulenza extra, Spese viaggi"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-3 flex-wrap">
|
||||
<div className="flex-1 min-w-[120px] space-y-1">
|
||||
<Label htmlFor="unit_price_custom">Prezzo unitario (€)</Label>
|
||||
<Input
|
||||
id="unit_price_custom"
|
||||
name="unit_price"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
placeholder="0.00"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="w-20 space-y-1">
|
||||
<Label htmlFor="quantity_custom">Qty</Label>
|
||||
<Input
|
||||
id="quantity_custom"
|
||||
name="quantity"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0.01"
|
||||
defaultValue="1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{addError && <p className="text-xs text-red-600">{addError}</p>}
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" size="sm" className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90">
|
||||
Aggiungi voce libera
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => { setShowCustom(false); setAddError(null); }}
|
||||
>
|
||||
Torna al catalogo
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section 2: Quote items table + calculated total */}
|
||||
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4">
|
||||
<h3 className="text-xs font-bold text-[#71717a] uppercase tracking-wider mb-3">Voci preventivo</h3>
|
||||
{items.length === 0 ? (
|
||||
<p className="text-sm text-[#71717a] py-4 text-center">
|
||||
Nessuna voce aggiunta. Seleziona dal catalogo per iniziare.
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-[#e5e7eb]">
|
||||
<tr>
|
||||
<th className="text-left py-2 px-2 font-medium text-[#71717a]">Voce</th>
|
||||
<th className="text-right py-2 px-2 font-medium text-[#71717a]">Qty</th>
|
||||
<th className="text-right py-2 px-2 font-medium text-[#71717a]">Prezzo unit.</th>
|
||||
<th className="text-right py-2 px-2 font-medium text-[#71717a]">Subtotale</th>
|
||||
<th className="py-2 px-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => (
|
||||
<tr key={item.id} className="border-b border-[#f4f4f5] hover:bg-[#f9f9f9]">
|
||||
<td className="py-2 px-2 text-[#1a1a1a]">{item.label}</td>
|
||||
<td className="py-2 px-2 text-right tabular-nums">{parseFloat(item.quantity).toLocaleString("it-IT", { minimumFractionDigits: 2 })}</td>
|
||||
<td className="py-2 px-2 text-right tabular-nums font-mono">
|
||||
€{parseFloat(item.unit_price).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className="py-2 px-2 text-right tabular-nums font-mono font-medium">
|
||||
€{parseFloat(item.subtotal).toLocaleString("it-IT", { minimumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className="py-2 px-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemove(item.id)}
|
||||
className="text-xs text-[#71717a] hover:text-red-600 transition-colors"
|
||||
>
|
||||
Rimuovi
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t border-[#e5e7eb] flex justify-end">
|
||||
<p className="font-bold text-[#1a1a1a] tabular-nums">
|
||||
Totale calcolato: €{calculatedTotal.toLocaleString("it-IT", { minimumFractionDigits: 2 })}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Section 3: Accepted total */}
|
||||
<div className="bg-white border border-[#e5e7eb] rounded-xl p-4 space-y-3">
|
||||
<h3 className="text-xs font-bold text-[#71717a] uppercase tracking-wider">Totale accettato dal cliente</h3>
|
||||
<form action={handleSaveTotal} className="flex items-end gap-3">
|
||||
<div className="flex-1 max-w-[200px] space-y-1">
|
||||
<Label htmlFor="accepted_total">Importo (€)</Label>
|
||||
<Input
|
||||
id="accepted_total"
|
||||
name="accepted_total"
|
||||
type="number"
|
||||
step="0.01"
|
||||
min="0"
|
||||
defaultValue={parseFloat(acceptedTotal).toFixed(2)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" size="sm" className="bg-[#1A463C] text-white hover:bg-[#1A463C]/90">
|
||||
Salva
|
||||
</Button>
|
||||
</form>
|
||||
{totalError && <p className="text-xs text-red-600">{totalError}</p>}
|
||||
<p className="text-xs text-[#71717a]">
|
||||
Il cliente vede solo questo importo, non le singole voci del preventivo.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Modify `src/app/admin/clients/[id]/page.tsx`** — add QuoteTab as 5th tab:
|
||||
|
||||
1. Add import at top:
|
||||
```typescript
|
||||
import { QuoteTab } from "@/components/admin/tabs/QuoteTab";
|
||||
```
|
||||
|
||||
2. Update destructure from `getClientFullDetail`:
|
||||
```typescript
|
||||
const { client, phases, payments, documents, comments, quoteItems, activeServices } = detail;
|
||||
```
|
||||
|
||||
3. Add 5th TabsTrigger after "Commenti":
|
||||
```typescript
|
||||
<TabsTrigger value="quote">Preventivo</TabsTrigger>
|
||||
```
|
||||
|
||||
4. Add 5th TabsContent after the comments TabsContent:
|
||||
```typescript
|
||||
<TabsContent value="quote">
|
||||
<QuoteTab
|
||||
clientId={client.id}
|
||||
items={quoteItems}
|
||||
activeServices={activeServices}
|
||||
acceptedTotal={client.accepted_total ?? "0"}
|
||||
/>
|
||||
</TabsContent>
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -c 'export function QuoteTab' src/components/admin/tabs/QuoteTab.tsx</automated>
|
||||
Expected: 1
|
||||
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -c 'Preventivo' src/app/admin/clients/\[id\]/page.tsx</automated>
|
||||
Expected: 2 (TabsTrigger text + TabsContent value)
|
||||
<automated>cd /Users/simonecavalli/IAMCAVALLI && grep -c 'quoteItems' src/app/admin/clients/\[id\]/page.tsx</automated>
|
||||
Expected: 1 (destructured from detail)
|
||||
<automated>cd /Users/simonecavalli/IAMCAVALLI && npx tsc --noEmit 2>&1 | head -20</automated>
|
||||
Expected: no output (zero errors)
|
||||
<automated>cd /Users/simonecavalli/IAMCAVALLI && npm run build 2>&1 | tail -10</automated>
|
||||
Expected: build succeeds with no errors
|
||||
</verify>
|
||||
<done>
|
||||
QuoteTab component renders with three sections. "Preventivo" tab appears in client detail page. TypeScript and build both pass clean.
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<threat_model>
|
||||
## Trust Boundaries
|
||||
|
||||
| Boundary | Description |
|
||||
|----------|-------------|
|
||||
| Admin browser → quote-actions.ts Server Actions | FormData (clientId, service_id, unit_price, quantity) crosses to server — must be validated before DB write |
|
||||
| getClientFullDetail → /admin/clients/[id]/page.tsx | quoteItems and activeServices returned ONLY to admin page — never to client-facing routes |
|
||||
| client-view.ts / client API routes | Must NOT include quote_items in any query result — enforced at query layer |
|
||||
|
||||
## STRIDE Threat Register
|
||||
|
||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||
|-----------|----------|-----------|-------------|-----------------|
|
||||
| T-03-03-01 | Spoofing | addQuoteItem / removeQuoteItem / updateAcceptedTotal | mitigate | `requireAdmin()` calls `getServerSession(authOptions)` at top of every Server Action — rejects unauthenticated requests |
|
||||
| T-03-03-02 | Tampering | addQuoteItem formData (unit_price, quantity) | mitigate | Zod `quoteItemSchema` validates both as `z.coerce.number().min(0.01)` — prevents zero/negative values or non-numeric injection |
|
||||
| T-03-03-03 | Information Disclosure | quote_items exposed via client-facing route | mitigate | `getClientFullDetail` query adds quoteItems ONLY to admin return type; `client-view.ts` and all `/api/client/*` routes must never query `quote_items`; verified via grep gate in Task 1 verify |
|
||||
| T-03-03-04 | Tampering | IDOR — removeQuoteItem with foreign clientId | mitigate | removeQuoteItem deletes by `quoteItemId` only — the admin must be authenticated (requireAdmin). Phase scope has single admin; if multi-admin added in future, add `AND client_id = clientId` to delete WHERE clause |
|
||||
| T-03-03-05 | Tampering | XSS in custom_label field | accept | React JSX auto-escapes; custom_label rendered via `{item.label}` — no dangerouslySetInnerHTML; UI-SPEC prohibits it |
|
||||
| T-03-03-06 | Tampering | Confusing calculated_total vs accepted_total | accept | Visual design enforces separation: calculated total is read-only bold text; accepted_total is distinct editable input with Save button and helper text "Il cliente vede solo questo importo" |
|
||||
</threat_model>
|
||||
|
||||
<verification>
|
||||
After both tasks complete:
|
||||
1. `grep -c 'quote_items' src/lib/client-view.ts` returns 0
|
||||
2. `npx tsc --noEmit` exits clean
|
||||
3. `npm run build` succeeds
|
||||
4. Client detail page at `/admin/clients/[id]` shows "Preventivo" as 5th tab
|
||||
5. Adding a catalog item: item appears in table with snapshotted unit_price (not pulled from service_catalog)
|
||||
6. Adding a freeform item: row appears with custom_label, service_id is null in DB
|
||||
7. Clicking "Salva" on accepted_total updates `clients.accepted_total` — visible in PaymentsTab "Totale preventivo" field
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `src/app/admin/clients/[id]/quote-actions.ts` exports three Server Actions with requireAdmin + Zod guards
|
||||
- `getClientFullDetail` returns `quoteItems: QuoteItemWithLabel[]` and `activeServices: ServiceCatalog[]`
|
||||
- QuoteTab renders all three sections: add items (catalog + freeform toggle), items table with calculated total, accepted total editor
|
||||
- `client-view.ts` contains zero references to `quote_items`
|
||||
- TypeScript and build both pass clean
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/03-service-catalog-quote-builder/03-03-SUMMARY.md`
|
||||
</output>
|
||||
Reference in New Issue
Block a user