From 48f81e711098246bb683c86c3c245e1a2f57d86d Mon Sep 17 00:00:00 2001 From: Simone Cavalli Date: Sun, 17 May 2026 11:44:57 +0200 Subject: [PATCH] feat(03-03): QuoteTab component + Preventivo tab in client detail page - Create QuoteTab.tsx: catalog dropdown + freeform toggle + items table + accepted total editor - Wire QuoteTab as 5th tab (Preventivo) in /admin/clients/[id]/page.tsx - Destructure quoteItems + activeServices from getClientFullDetail result - TypeScript clean, build passes --- src/app/admin/clients/[id]/page.tsx | 12 +- src/components/admin/tabs/QuoteTab.tsx | 333 +++++++++++++++++++++++++ 2 files changed, 344 insertions(+), 1 deletion(-) create mode 100644 src/components/admin/tabs/QuoteTab.tsx diff --git a/src/app/admin/clients/[id]/page.tsx b/src/app/admin/clients/[id]/page.tsx index cc0d23e..32447ac 100644 --- a/src/app/admin/clients/[id]/page.tsx +++ b/src/app/admin/clients/[id]/page.tsx @@ -5,6 +5,7 @@ import { PhasesTab } from "@/components/admin/tabs/PhasesTab"; import { PaymentsTab } from "@/components/admin/tabs/PaymentsTab"; import { DocumentsTab } from "@/components/admin/tabs/DocumentsTab"; import { CommentsTab } from "@/components/admin/tabs/CommentsTab"; +import { QuoteTab } from "@/components/admin/tabs/QuoteTab"; import { PhasesViewToggle } from "@/components/admin/kanban/PhasesViewToggle"; import { ClientActions } from "@/components/admin/ClientActions"; import Link from "next/link"; @@ -20,7 +21,7 @@ export default async function ClientDetailPage({ const detail = await getClientFullDetail(id); if (!detail) notFound(); - const { client, phases, payments, documents, comments } = detail; + const { client, phases, payments, documents, comments, quoteItems, activeServices } = detail; return (
@@ -59,6 +60,7 @@ export default async function ClientDetailPage({ Pagamenti Documenti Commenti + Preventivo @@ -81,6 +83,14 @@ export default async function ClientDetailPage({ + + +
); diff --git a/src/components/admin/tabs/QuoteTab.tsx b/src/components/admin/tabs/QuoteTab.tsx new file mode 100644 index 0000000..2674e1a --- /dev/null +++ b/src/components/admin/tabs/QuoteTab.tsx @@ -0,0 +1,333 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + addQuoteItem, + removeQuoteItem, + updateAcceptedTotal, +} from "@/app/admin/clients/[id]/quote-actions"; +import type { QuoteItemWithLabel } from "@/lib/admin-queries"; +import type { ServiceCatalog } from "@/db/schema"; + +type Props = { + clientId: string; + items: QuoteItemWithLabel[]; + activeServices: ServiceCatalog[]; + acceptedTotal: string; +}; + +export function QuoteTab({ clientId, items, activeServices, acceptedTotal }: Props) { + const [showCustom, setShowCustom] = useState(false); + const [addError, setAddError] = useState(null); + const [totalError, setTotalError] = useState(null); + // For catalog mode: pre-fill unit_price when service is selected + const [selectedServicePrice, setSelectedServicePrice] = useState(""); + const [, startTransition] = useTransition(); + const router = useRouter(); + + const calculatedTotal = items.reduce( + (sum, item) => sum + parseFloat(item.subtotal), + 0 + ); + + function handleAddItem(fd: FormData) { + setAddError(null); + startTransition(async () => { + try { + await addQuoteItem(clientId, fd); + router.refresh(); + } catch (e) { + setAddError(e instanceof Error ? e.message : "Errore nell'aggiunta"); + } + }); + } + + function handleRemove(quoteItemId: string) { + startTransition(async () => { + await removeQuoteItem(quoteItemId, clientId); + router.refresh(); + }); + } + + function handleSaveTotal(fd: FormData) { + setTotalError(null); + startTransition(async () => { + try { + await updateAcceptedTotal(clientId, fd); + router.refresh(); + } catch (e) { + setTotalError( + e instanceof Error ? e.message : "Errore nel salvataggio" + ); + } + }); + } + + return ( +
+ + {/* Section 1: Add items */} +
+

+ Aggiungi voci +

+ + {!showCustom ? ( + /* Catalog mode */ +
+ +
+
+ + +
+
+ + setSelectedServicePrice(e.target.value)} + placeholder="0.00" + required + /> +
+
+ + +
+ +
+ + {addError &&

{addError}

} +
+ ) : ( + /* Freeform mode */ +
+ +
+ + +
+
+
+ + +
+
+ + +
+
+ {addError &&

{addError}

} +
+ + +
+
+ )} +
+ + {/* Section 2: Quote items table + calculated total */} +
+

+ Voci preventivo +

+ {items.length === 0 ? ( +

+ Nessuna voce aggiunta. Seleziona dal catalogo per iniziare. +

+ ) : ( + <> +
+ + + + + + + + + + + + {items.map((item) => ( + + + + + + + + ))} + +
+ Voce + + Qty + + Prezzo unit. + + Subtotale +
{item.label} + {parseFloat(item.quantity).toLocaleString("it-IT", { + minimumFractionDigits: 2, + })} + + € + {parseFloat(item.unit_price).toLocaleString("it-IT", { + minimumFractionDigits: 2, + })} + + € + {parseFloat(item.subtotal).toLocaleString("it-IT", { + minimumFractionDigits: 2, + })} + + +
+
+
+

+ Totale calcolato: € + {calculatedTotal.toLocaleString("it-IT", { + minimumFractionDigits: 2, + })} +

+
+ + )} +
+ + {/* Section 3: Accepted total */} +
+

+ Totale accettato dal cliente +

+
+
+ + +
+ +
+ {totalError &&

{totalError}

} +

+ Il cliente vede solo questo importo, non le singole voci del preventivo. +

+
+ +
+ ); +}