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),
}));
}
+114
View File
@@ -0,0 +1,114 @@
"use server";
import { db } from "@/db";
import { quotes, quote_items, clients, offer_micros, offer_phases } from "@/db/schema";
import { createQuoteSchema } from "@/lib/quote-validators";
import { eq } from "drizzle-orm";
import { nanoid } from "nanoid";
// Fetch offer with all phases and services for preview
export async function getOfferWithPhases(offerMicroId: string) {
const [micro] = await db
.select()
.from(offer_micros)
.where(eq(offer_micros.id, offerMicroId))
.limit(1);
if (!micro) return null;
const phases = await db
.select()
.from(offer_phases)
.where(eq(offer_phases.micro_id, offerMicroId));
return {
...micro,
phases,
};
}
// Server action: create quote with validation
export async function createQuote(input: unknown) {
try {
// Validate input
const validated = createQuoteSchema.parse(input);
// Verify client exists
const [client] = await db
.select()
.from(clients)
.where(eq(clients.id, validated.client_id))
.limit(1);
if (!client) {
return {
success: false,
error: "Cliente non trovato",
};
}
// Verify offer exists
const [offer] = await db
.select()
.from(offer_micros)
.where(eq(offer_micros.id, validated.offer_micro_id))
.limit(1);
if (!offer) {
return {
success: false,
error: "Offerta non trovata",
};
}
// Generate unique token (nanoid 21 chars = ~122 bits entropy)
const token = nanoid(21);
// Convert accepted_total to numeric for DB storage
const totalAmount = parseFloat(validated.accepted_total);
// Create quote (atomic transaction)
const [insertedQuote] = await db
.insert(quotes)
.values({
client_id: validated.client_id,
offer_micro_id: validated.offer_micro_id,
token,
state: "draft",
accepted_total: totalAmount.toString(),
})
.returning();
if (!insertedQuote) {
return {
success: false,
error: "Errore nel salvataggio del preventivo",
};
}
// Return success with public link
const publicLink = `/quote/${token}`;
return {
success: true as const,
quote: insertedQuote,
token: token as string,
publicLink: publicLink as string,
};
} catch (error) {
const message = error instanceof Error ? error.message : "Errore sconosciuto";
// Check if it's a Zod validation error
if (message.includes("validation")) {
return {
success: false,
error: "Dati non validi. Controlla i campi obbligatori.",
};
}
return {
success: false,
error: message,
};
}
}