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 */} +
+
+

Crea Preventivo

+ + {/* Client Select */} +
+ + +
+ + {/* Offer Select */} + {selectedClient && ( + + )} + + {/* Price Input */} + {selectedOffer && ( + + )} + + {/* Error Message */} + {error && ( +
+

{error}

+
+ )} + + {/* Submit Button */} + + +
+ + {/* 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, + }; + } +}