feat(09-02): implement admin quote builder UI with server actions

- src/lib/quote-actions.ts: createQuote server action with nanoid tokens and atomic DB transaction
- src/components/admin/quotes/QuoteBuilderForm.tsx: Two-column form (left: client/offer/price inputs, right: live preview)
- src/components/admin/quotes/OfferSelector.tsx: Grouped dropdown for macro/micro offer selection
- src/components/admin/quotes/PriceOverrideInput.tsx: Price input with server validation feedback
- src/components/admin/quotes/QuotePreview.tsx: Live preview card showing offer details and calculated total
- src/app/admin/quotes/new/page.tsx: Quote builder page rendering form with data from DB
- src/app/admin/quotes/new/actions.ts: Re-export of createQuote for page-level action imports
- src/lib/admin-queries.ts: Added getAllOfferMacrosWithMicros() query for form population

Features:
- Admin selects client and offer (required fields)
- Form validates on blur and displays success/error states
- Server action validates data and rejects if total mismatch
- On success, displays public /quote/[token] link with copy button
- Quote saved as draft state with immutable accepted_total

Completes Phase 9 Plan 2 Wave 1: Admin UI Layer

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 07:34:16 +02:00
parent 5fb34c57a3
commit 614cf0114f
8 changed files with 551 additions and 0 deletions
+26
View File
@@ -15,6 +15,7 @@ import {
services,
settings,
offer_micros,
offer_macros,
project_offers,
offer_phases,
offer_phase_services,
@@ -35,6 +36,7 @@ import type {
ServiceCatalog,
Service,
OfferMicro,
OfferMacro,
ProjectOffer,
OfferPhase,
OfferPhaseService,
@@ -768,4 +770,28 @@ export async function getQuotesByClient(clientId: string): Promise<Quote[]> {
.from(quotes)
.where(eq(quotes.client_id, clientId))
.orderBy(sql`${quotes.created_at} DESC`);
}
/**
* Get all offer macros with their micros (ordered by sort_order)
* Used by: /admin/quotes/new form (OfferSelector dropdown)
*/
export async function getAllOfferMacrosWithMicros(): Promise<
(OfferMacro & { micros: OfferMicro[] })[]
> {
const macros = await db
.select()
.from(offer_macros)
.orderBy(asc(offer_macros.sort_order));
if (macros.length === 0) return [];
const allMicros = await db.select().from(offer_micros);
return macros.map((macro) => ({
...macro,
micros: allMicros
.filter((micro) => micro.macro_id === macro.id)
.sort((a, b) => a.sort_order - b.sort_order),
}));
}