diff --git a/src/app/admin/quotes/new/actions.ts b/src/app/admin/quotes/new/actions.ts
new file mode 100644
index 0000000..0530a9c
--- /dev/null
+++ b/src/app/admin/quotes/new/actions.ts
@@ -0,0 +1,3 @@
+"use server";
+
+export { createQuote } from "@/lib/quote-actions";
diff --git a/src/app/admin/quotes/new/page.tsx b/src/app/admin/quotes/new/page.tsx
new file mode 100644
index 0000000..3ec49cf
--- /dev/null
+++ b/src/app/admin/quotes/new/page.tsx
@@ -0,0 +1,28 @@
+import { Card } from "@/components/ui/card";
+import { QuoteBuilderForm } from "@/components/admin/quotes/QuoteBuilderForm";
+import { getAllClientsWithPayments } from "@/lib/admin-queries";
+import { getAllOfferMacrosWithMicros } from "@/lib/admin-queries";
+
+export const revalidate = 0;
+
+export default async function QuoteBuilderPage() {
+ const [clientsData, offersData] = await Promise.all([
+ getAllClientsWithPayments(),
+ getAllOfferMacrosWithMicros(),
+ ]);
+
+ return (
+
+
+
Crea Preventivo
+
+ Componi un preventivo per il cliente selezionando l'offerta e il prezzo
+
+
+
+
+
+
+
+ );
+}
diff --git a/src/components/admin/quotes/OfferSelector.tsx b/src/components/admin/quotes/OfferSelector.tsx
new file mode 100644
index 0000000..0893eab
--- /dev/null
+++ b/src/components/admin/quotes/OfferSelector.tsx
@@ -0,0 +1,46 @@
+"use client";
+
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectGroup,
+ SelectLabel,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { OfferMacro, OfferMicro } from "@/db/schema";
+
+interface OfferSelectorProps {
+ macros: (OfferMacro & { micros: OfferMicro[] })[];
+ value: string;
+ onValueChange: (value: string) => void;
+}
+
+export function OfferSelector({ macros, value, onValueChange }: OfferSelectorProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/src/components/admin/quotes/PriceOverrideInput.tsx b/src/components/admin/quotes/PriceOverrideInput.tsx
new file mode 100644
index 0000000..c0484ab
--- /dev/null
+++ b/src/components/admin/quotes/PriceOverrideInput.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import { Check, X } from "lucide-react";
+import { useState } from "react";
+
+interface PriceOverrideInputProps {
+ value: string;
+ onChange: (value: string) => void;
+ calculatedTotal?: number;
+}
+
+export function PriceOverrideInput({ value, onChange, calculatedTotal }: PriceOverrideInputProps) {
+ const [isTouched, setIsTouched] = useState(false);
+
+ const numValue = parseFloat(value) || 0;
+ const matches = calculatedTotal !== undefined && Math.abs(numValue - calculatedTotal) < 0.01;
+
+ return (
+
+
+
+
onChange(e.target.value)}
+ onBlur={() => setIsTouched(true)}
+ className="pr-10"
+ required
+ />
+ {isTouched && value && (
+
+ {matches ? (
+
+ ) : (
+
+ )}
+
+ )}
+
+
+ {calculatedTotal !== undefined && (
+
+ Prezzo calcolato: €{calculatedTotal.toLocaleString("it-IT", {
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ })}
+
+ )}
+
+ {isTouched && value && !matches && (
+
+ Attenzione: il valore inserito non corrisponde al totale calcolato (server verificherà)
+
+ )}
+
+ );
+}
diff --git a/src/components/admin/quotes/QuoteBuilderForm.tsx b/src/components/admin/quotes/QuoteBuilderForm.tsx
new file mode 100644
index 0000000..69af168
--- /dev/null
+++ b/src/components/admin/quotes/QuoteBuilderForm.tsx
@@ -0,0 +1,210 @@
+"use client";
+
+import { useState, useCallback, useTransition } from "react";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Label } from "@/components/ui/label";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import { Card } from "@/components/ui/card";
+import { QuotePreview } from "./QuotePreview";
+import { OfferSelector } from "./OfferSelector";
+import { PriceOverrideInput } from "./PriceOverrideInput";
+import { createQuote, getOfferWithPhases } from "@/lib/quote-actions";
+import { OfferMacro, OfferMicro } from "@/db/schema";
+import { Check, Copy } from "lucide-react";
+
+interface ClientOption {
+ id: string;
+ name: string;
+}
+
+interface QuoteBuilderFormProps {
+ clients: ClientOption[];
+ offerMacros: (OfferMacro & { micros: OfferMicro[] })[];
+}
+
+export function QuoteBuilderForm({ clients, offerMacros }: QuoteBuilderFormProps) {
+ const [isPending, startTransition] = useTransition();
+ const [selectedClient, setSelectedClient] = useState("");
+ const [selectedOffer, setSelectedOffer] = useState("");
+ const [selectedTotal, setSelectedTotal] = useState("");
+ const [currentOffer, setCurrentOffer] = useState(null);
+ const [successLink, setSuccessLink] = useState(null);
+ const [error, setError] = useState(null);
+ const [copied, setCopied] = useState(false);
+
+ // Fetch offer details when selection changes
+ const handleOfferChange = useCallback((offerId: string) => {
+ setSelectedOffer(offerId);
+ setError(null);
+ startTransition(async () => {
+ const offer = await getOfferWithPhases(offerId);
+ setCurrentOffer(offer);
+ });
+ }, []);
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ setError(null);
+ setSuccessLink(null);
+
+ startTransition(async () => {
+ const result = await createQuote({
+ client_id: selectedClient,
+ offer_micro_id: selectedOffer,
+ accepted_total: selectedTotal,
+ });
+
+ if (result.success) {
+ setSuccessLink(result.publicLink || "");
+ // Reset form
+ setSelectedClient("");
+ setSelectedOffer("");
+ setSelectedTotal("");
+ setCurrentOffer(null);
+ } else {
+ setError(result.error || "Errore sconosciuto");
+ }
+ });
+ };
+
+ const handleCopyLink = async () => {
+ if (successLink) {
+ const fullUrl = `${window.location.origin}${successLink}`;
+ await navigator.clipboard.writeText(fullUrl);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ }
+ };
+
+ if (successLink) {
+ return (
+
+
+
+
+
+
Preventivo creato
+
+ Il preventivo è stato salvato e il link è pronto per essere condiviso
+
+
+
+
+
Link pubblico
+
+
+ {window.location.origin}
+ {successLink}
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+
+ {/* LEFT COLUMN: FORM */}
+
+
+ {/* RIGHT COLUMN: PREVIEW */}
+
+
+
+
+ );
+}
diff --git a/src/components/admin/quotes/QuotePreview.tsx b/src/components/admin/quotes/QuotePreview.tsx
new file mode 100644
index 0000000..0c8c76c
--- /dev/null
+++ b/src/components/admin/quotes/QuotePreview.tsx
@@ -0,0 +1,61 @@
+"use client";
+
+import { Card } from "@/components/ui/card";
+import { OfferMicro, OfferPhase } from "@/db/schema";
+
+interface QuotePreviewProps {
+ offer: (OfferMicro & { phases: OfferPhase[] }) | null;
+ selectedTotal: number;
+}
+
+export function QuotePreview({ offer, selectedTotal }: QuotePreviewProps) {
+ if (!offer) {
+ return (
+
+
Seleziona un'offerta per visualizzare l'anteprima
+
+ );
+ }
+
+ return (
+
+
+
{offer.public_name}
+ {offer.transformation_promise && (
+
{offer.transformation_promise}
+ )}
+
+ Durata: {offer.duration_months} mese{offer.duration_months !== 1 ? "i" : ""}
+
+
+
+
+
Fasi incluse
+ {offer.phases && offer.phases.length > 0 ? (
+ offer.phases.map((phase) => (
+
+
{phase.title}
+ {phase.description && (
+
{phase.description}
+ )}
+
+ ))
+ ) : (
+
Nessuna fase configurata
+ )}
+
+
+
+
+ Totale preventivo
+
+ €{selectedTotal.toLocaleString("it-IT", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
+
+
+
+ Questo preventivo sarà visibile al cliente tramite link riservato
+
+
+
+ );
+}
diff --git a/src/lib/admin-queries.ts b/src/lib/admin-queries.ts
index 9dcea76..034c294 100644
--- a/src/lib/admin-queries.ts
+++ b/src/lib/admin-queries.ts
@@ -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 {
.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),
+ }));
}
\ No newline at end of file
diff --git a/src/lib/quote-actions.ts b/src/lib/quote-actions.ts
new file mode 100644
index 0000000..a7e1036
--- /dev/null
+++ b/src/lib/quote-actions.ts
@@ -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,
+ };
+ }
+}