---
phase: 09
plan: 02
type: execute
wave: 1
depends_on:
- 09-01
files_modified:
- src/app/admin/quotes/new/page.tsx
- src/app/admin/quotes/new/actions.ts
- src/components/admin/quotes/QuoteBuilderForm.tsx
- src/components/admin/quotes/OfferSelector.tsx
- src/components/admin/quotes/PriceOverrideInput.tsx
- src/components/admin/quotes/QuotePreview.tsx
- src/lib/quote-actions.ts
autonomous: true
requirements:
- QUOTE-01
user_setup: []
must_haves:
truths:
- "Admin can select a client and offer on /admin/quotes/new"
- "Two-column form shows inputs on left and live preview on right"
- "Form submit calls createQuote server action with validated data"
- "Server action recalculates total and rejects if mismatch"
- "On success, public link /quote/[token] is displayed for copy"
artifacts:
- path: src/lib/quote-actions.ts
provides: "createQuote server action with price validation"
exports: ["createQuote", "getOfferWithPhases"]
- path: src/components/admin/quotes/QuoteBuilderForm.tsx
provides: "Form state management and submission"
min_lines: 80
- path: src/app/admin/quotes/new/page.tsx
provides: "Admin quote builder page at /admin/quotes/new"
contains: "QuoteBuilderForm"
key_links:
- from: "src/app/admin/quotes/new/page.tsx"
to: "src/components/admin/quotes/QuoteBuilderForm.tsx"
via: "import and render"
pattern: "import.*QuoteBuilderForm"
- from: "src/components/admin/quotes/QuoteBuilderForm.tsx"
to: "src/lib/quote-actions.ts"
via: "server action call"
pattern: "await createQuote"
- from: "src/lib/quote-actions.ts"
to: "src/db/schema.ts"
via: "quote insert and validation"
pattern: "db.insert.*quotes"
---
Implement the admin Quote Builder UI (`/admin/quotes/new`) with two-column form (left: inputs, right: live preview) and server actions for creating quotes. Admin selects client, offer, and overrides pricing; saves generates nanoid token and creates public link.
Purpose: Enable admins to compose quotes in 2-3 clicks, validate pricing server-side, and generate shareable public links for clients.
Output:
- `/admin/quotes/new` page with form and live preview
- Server action `createQuote()` validates and saves quote + quote_items to DB
- Generated public link `/quote/[token]` shareable via email (Phase 12)
- Quote saved as draft; state can be updated to "sent" when link shared
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
@.planning/phases/09-quote-builder-client-acceptance/09-RESEARCH.md
@.planning/phases/09-quote-builder-client-acceptance/09-01-SUMMARY.md
### Key Interfaces (from Phase 8 schema)
Quote Header:
```typescript
type Quote = {
id: string;
client_id: string | null;
offer_micro_id: string;
token: string;
state: "draft" | "sent" | "viewed" | "accepted" | "rejected";
accepted_total: string;
accepted_at: Date | null;
client_email: string | null;
client_notes: string | null;
created_at: Date;
updated_at: Date;
};
```
Quote Item (line):
```typescript
type QuoteItem = {
id: string;
quote_id: string;
offer_phase_id: string;
service_id: string | null;
quantity: string;
unit_price: string;
subtotal: string;
custom_label: string | null;
};
```
OfferMicro (from Phase 5):
```typescript
type OfferMicro = {
id: string;
macro_id: string;
internal_name: string;
public_name: string;
transformation_promise: string | null;
duration_months: number;
};
```
OfferPhase (from Phase 8):
```typescript
type OfferPhase = {
id: string;
micro_id: string;
title: string;
description: string | null;
sort_order: number;
created_at: Date;
};
```
Task 1: Create server action for quote creation (createQuote)
src/lib/quote-actions.ts
Create a new server action file with the core quote creation logic. Key responsibilities:
- Validate input via Zod schema (client, offer, items array)
- Verify client and offer exist in database
- **Recalculate total server-side** by summing all quote_items subtotals (quantity × unit_price)
- Reject if client-submitted total doesn't match calculated total (0.01 tolerance for rounding)
- Generate nanoid(21) token — 21 characters, ~122-bit entropy
- Create atomic transaction: insert quote header, then insert quote_items
- Return success/error + public link `/quote/[token]`
Security per CLAUDE.md:
- Never trust client-side pricing calculation
- Server-side recalculation is THE security boundary
- If attacker modifies total before submit, server action rejects it
- Tokens are collision-free and unguessable via nanoid
Error handling: Localize error messages to Italian (client not found, offer not found, total mismatch).
Use existing patterns from quote-validators.ts where applicable; reference createQuoteSchema and quoteItemSchema for Zod integration.
npm run build 2>&1 | grep -c "error TS" | xargs test 0 -eq && echo "✓ Actions compile"
createQuote action handles validation, server-side total recalculation, and atomic quote+items creation. Error messages localized to Italian. Returns token and public link.
Task 2: Create QuoteBuilderForm component (two-column layout)
src/components/admin/quotes/QuoteBuilderForm.tsx, src/components/admin/quotes/OfferSelector.tsx, src/components/admin/quotes/PriceOverrideInput.tsx, src/components/admin/quotes/QuotePreview.tsx
Create four new component files that make up the two-column form UI:
**src/components/admin/quotes/QuoteBuilderForm.tsx** — Parent component managing form state:
- Uses React Hook Form + Zod for validation
- Manages three sections: client select, offer select, price overrides
- Left column: form inputs (client dropdown, offer dropdown, price fields)
- Right column: live preview (selected offer summary + calculated total)
- On form submit: call createQuote server action
- On success: display public link in a copiable text box + "Copy Link" button
- Handles form errors (display via Form/FormMessage from shadcn/ui)
**src/components/admin/quotes/OfferSelector.tsx** — Dropdown to select offer_micro:
- Fetches all offer_macros with their offer_micros (via server component query)
- Displays as grouped dropdown: "Entry Offer" > [Entry A, Entry B, Entry C]
- On selection change: trigger preview update in parent
**src/components/admin/quotes/PriceOverrideInput.tsx** — Input for final accepted_total:
- Single numeric input field
- On blur: parent calculates preview total and compares to this input
- Visual feedback: green checkmark if matches, red X if mismatch (but allow save — server validates)
**src/components/admin/quotes/QuotePreview.tsx** — Right column preview:
- Displays selected offer name, public name, transformation promise
- Lists phases (Discovery, Strategy, Execution)
- For each phase, lists the services that will be included
- Shows live calculated total as admin enters price overrides
- Displays estimated revenue and duration
All components use shadcn/ui (Form, Input, Select, Button, Card) for consistency with existing admin UI.
Use tailwind grid-cols-2 for two-column layout in QuoteBuilderForm.
npm run build 2>&1 | grep -c "error TS" | xargs test 0 -eq && echo "✓ Components compile"
Four component files created. Two-column form renders with client/offer selection, price input, and live preview. Form submission calls createQuote action.
Task 3: Create /admin/quotes/new page and wire form
src/app/admin/quotes/new/page.tsx, src/app/admin/quotes/new/actions.ts
Create the page file and optional actions helper:
**src/app/admin/quotes/new/page.tsx** — Page component:
- Server component that imports QuoteBuilderForm
- Renders page title "Crea Preventivo"
- Renders QuoteBuilderForm centered in a Card
- Wraps in AdminLayout (existing pattern from /admin/clients, /admin/projects)
- Page has Auth.js guard via middleware (existing /admin/* protection)
**src/app/admin/quotes/new/actions.ts** — Optional re-export of quote actions:
- Can be empty or re-export createQuote from lib/quote-actions.ts
- Keeps page-level action imports clean if needed by form
The form will be a client component ("use client") managing form state with React Hook Form.
curl -s http://localhost:3000/admin/quotes/new 2>&1 | grep -q "Crea Preventivo" && echo "✓ Page loads"
Page and form wired. Admin can navigate to /admin/quotes/new and see the two-column builder form.
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| Admin → Form Input | Form data can be manipulated before submit |
| Client Browser → Server | Pricing total can be modified in network inspector before sending |
| Server → Database | Quote must be immutable once accepted |
## STRIDE Threat Register
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|-----------|----------|-----------|-------------|-----------------|
| T-09-01 | Tampering | Price total modified before submit | mitigate | Server-side recalculation in createQuote action; reject if mismatch between item sum and submitted total |
| T-09-02 | Tampering | Quote item quantity/unit_price tampered | mitigate | Zod schema validates numeric fields; server recalculates before saving |
| T-09-03 | Elevation | Non-admin access to /admin/quotes/new | mitigate | Auth.js middleware guards /admin/* routes (existing infrastructure) |
| T-09-04 | Information Disclosure | offer_micro details over-exposed | accept | Offer details are internal-facing; not visible to public (per Phase 9 Task 5) |
After Phase 9 Plan 2 execution:
1. Server action compiles: `npm run build` passes
2. Components compile: All four components render without errors
3. Page loads: `/admin/quotes/new` accessible to authenticated admin
4. Form submits: Clicking "Salva Preventivo" calls createQuote
5. Link generated: On success, public link displayed in copyable box
6. Server validation works: Manually modifying total in browser before submit should be rejected
7. Database: Quote + quote_items inserted to DB with correct structure
- `/admin/quotes/new` page accessible and renders form
- Two-column layout (left: inputs, right: preview) visually distinct
- Form validates client and offer selection (required fields)
- Form submit calls createQuote server action
- Server action validates data and recalculates total server-side
- On success: public `/quote/[token]` link displayed and copyable
- Quote saved to DB with state="draft", token unique, accepted_total immutable
- On error: user-friendly error message displayed (Italian localization)