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:
@@ -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),
|
||||
}));
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user