Files
clienthub/src/lib/quote-actions.ts
T
simone 614cf0114f 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>
2026-06-11 07:34:16 +02:00

115 lines
2.7 KiB
TypeScript

"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,
};
}
}