diff --git a/apps/core-web/src/routes/quotes/QuoteFormPage.tsx b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx index 75afdb3..f081d18 100644 --- a/apps/core-web/src/routes/quotes/QuoteFormPage.tsx +++ b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx @@ -15,20 +15,24 @@ import { Breadcrumb } from "../../components/Breadcrumb.js"; import { PRIMARY_LINK_BUTTON_CLASS } from "../../components/linkButtonClasses.js"; /** - * Layout reference: DreamsERP theme `.tmp/theme-reference/sales/addSalesQuotes.tsx` - * (two-column "details" header, editable item table with add/remove rows, - * right-hand totals panel) — rebuilt with `@eden/ui` + plain Tailwind - * instead of the theme's react-select/CommonDatePicker. Every price/total + * Item-picking layout reference: the legacy OrçaFácil "Nova oferta" screen + * (two-column catalog + live cart — no longer available to inspect + * directly, rebuilt from a precise description) — NOT the DreamsERP theme, + * whose `sales/addSalesQuotes.tsx` is a generic invoice-row table with no + * browsable-catalog/live-cart pattern and was used only as loose finish + * inspiration (rounded cards, clear hierarchy, a highlighted total block), + * in the EDEN light theme, not the theme's dark one. Built with `@eden/ui` + * + plain Tailwind, no react-select/CommonDatePicker. Every price/total * shown here is illustrative only; the server (apps/api/src/quotes/pricing.ts) * is the single source of truth and recomputes everything on save. */ -interface ItemRow { +interface CartLine { productId: string; - quantity: string; + quantity: number; } -const EMPTY_ROW: ItemRow = { productId: "", quantity: "1" }; +type ProductSort = "code" | "name"; function getAvailablePaymentConditions(implTotal: number): readonly ImplPaymentCondition[] { if (implTotal <= 1500) return ["À vista"]; @@ -36,6 +40,23 @@ function getAvailablePaymentConditions(implTotal: number): readonly ImplPaymentC return IMPL_PAYMENT_CONDITIONS; } +function contractPeriodLabel(period: number): string { + return period === 0 ? "Sem fidelidade (0 meses)" : `${period} meses`; +} + +function priceForPeriod(product: Product, period: number): number | null { + const key = `price_${period}` as keyof Pick< + Product, + "price_0" | "price_12" | "price_24" | "price_36" | "price_48" + >; + const raw = product[key]; + return raw != null ? Number(raw) : null; +} + +function round2(value: number): number { + return Math.round((value + Number.EPSILON) * 100) / 100; +} + export function QuoteFormPage() { const { accessToken } = useAuth(); const navigate = useNavigate(); @@ -51,12 +72,16 @@ export function QuoteFormPage() { const [clientPhone, setClientPhone] = useState(""); const [contractPeriod, setContractPeriod] = useState("12"); const [fidelityPeriod, setFidelityPeriod] = useState(""); - const [items, setItems] = useState([{ ...EMPTY_ROW }]); + const [salesRepName, setSalesRepName] = useState(""); + + const [cart, setCart] = useState([]); + const [productSearch, setProductSearch] = useState(""); + const [productSort, setProductSort] = useState("code"); + const [implGeral, setImplGeral] = useState(""); const [proposedMonthlyTotal, setProposedMonthlyTotal] = useState(""); const [implPaymentCondition, setImplPaymentCondition] = useState(""); const [notes, setNotes] = useState(""); - const [salesRepName, setSalesRepName] = useState(""); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -75,34 +100,81 @@ export function QuoteFormPage() { ); }, [accessToken]); - const productOptions = useMemo( - () => (products ?? []).map((p) => ({ value: p.id, label: `${p.name} (cód. ${p.product_code})` })), + const contractPeriodNum = Number(contractPeriod) || 0; + + const productsById = useMemo(() => { + const map = new Map(); + for (const product of products ?? []) map.set(product.id, product); + return map; + }, [products]); + + const browsableProducts = useMemo( + () => (products ?? []).filter((p) => p.active && !p.discontinued), [products], ); - const estimatedImplTotal = useMemo(() => { - if (!products) return 0; - const fromItems = items.reduce((sum, row) => { - const product = products.find((p) => p.id === row.productId); - const qty = Number(row.quantity) || 0; - if (!product) return sum; - return sum + Number(product.impl_unit_price ?? 0) * qty; - }, 0); - return fromItems + (Number(implGeral) || 0); - }, [items, products, implGeral]); + const filteredProducts = useMemo(() => { + const query = productSearch.trim().toLowerCase(); + const filtered = query + ? browsableProducts.filter( + (p) => p.name.toLowerCase().includes(query) || String(p.product_code).includes(query), + ) + : browsableProducts; + return [...filtered].sort((a, b) => + productSort === "code" ? a.product_code - b.product_code : a.name.localeCompare(b.name, "pt-BR"), + ); + }, [browsableProducts, productSearch, productSort]); - const availablePaymentConditions = getAvailablePaymentConditions(estimatedImplTotal); + const cartLines = useMemo( + () => + cart + .map((line) => { + const product = productsById.get(line.productId); + if (!product) return null; + const unitPrice = priceForPeriod(product, contractPeriodNum) ?? 0; + const implUnitPrice = Number(product.impl_unit_price ?? 0); + return { + product, + quantity: line.quantity, + unitPrice, + subtotal: round2(unitPrice * line.quantity), + implSubtotal: round2(implUnitPrice * line.quantity), + }; + }) + .filter((line): line is NonNullable => line !== null), + [cart, productsById, contractPeriodNum], + ); - function updateItem(index: number, patch: Partial) { - setItems((prev) => prev.map((row, i) => (i === index ? { ...row, ...patch } : row))); + const monthlyTotal = round2(cartLines.reduce((sum, line) => sum + line.subtotal, 0)); + const implFromProducts = round2(cartLines.reduce((sum, line) => sum + line.implSubtotal, 0)); + const implGeralNum = Number(implGeral) || 0; + const totalImpl = round2(implFromProducts + implGeralNum); + const proposedMonthlyNum = Number(proposedMonthlyTotal) || 0; + const hasProposedMonthly = proposedMonthlyTotal !== "" && proposedMonthlyNum > 0; + const effectiveMonthly = hasProposedMonthly ? proposedMonthlyNum : monthlyTotal; + const contractTotal = round2(effectiveMonthly * (contractPeriodNum || 1) + totalImpl); + + const availablePaymentConditions = getAvailablePaymentConditions(totalImpl); + + function addToCart(productId: string) { + setCart((prev) => { + const existing = prev.find((line) => line.productId === productId); + if (existing) { + return prev.map((line) => + line.productId === productId ? { ...line, quantity: line.quantity + 1 } : line, + ); + } + return [...prev, { productId, quantity: 1 }]; + }); } - function addItem() { - setItems((prev) => [...prev, { ...EMPTY_ROW }]); + function setQuantity(productId: string, quantity: number) { + if (quantity < 1) return; + setCart((prev) => prev.map((line) => (line.productId === productId ? { ...line, quantity } : line))); } - function removeItem(index: number) { - setItems((prev) => (prev.length > 1 ? prev.filter((_, i) => i !== index) : prev)); + function removeFromCart(productId: string) { + setCart((prev) => prev.filter((line) => line.productId !== productId)); } async function handleSubmit(event: FormEvent) { @@ -110,12 +182,8 @@ export function QuoteFormPage() { if (!accessToken) return; setError(null); - const validItems = items - .filter((row) => row.productId && Number(row.quantity) > 0) - .map((row) => ({ productId: row.productId, quantity: Number(row.quantity) })); - - if (validItems.length === 0) { - setError("Adicione ao menos um item com produto e quantidade válidos."); + if (cart.length === 0) { + setError("Adicione ao menos um produto à oferta."); return; } @@ -130,7 +198,7 @@ export function QuoteFormPage() { clientPhone, contractPeriod: Number(contractPeriod) as ContractPeriod, fidelityPeriod: fidelityPeriod ? Number(fidelityPeriod) : undefined, - items: validItems, + items: cart.map((line) => ({ productId: line.productId, quantity: line.quantity })), implGeral: implGeral ? Number(implGeral) : undefined, proposedMonthlyTotal: proposedMonthlyTotal ? Number(proposedMonthlyTotal) : undefined, implPaymentCondition: implPaymentCondition || undefined, @@ -194,178 +262,252 @@ export function QuoteFormPage() { ) : null} - - {!resellers || !products ? ( + {!resellers || !products ? ( +

Carregando revendas e produtos…

- ) : ( -
-
-

Dados do cliente

-
- setClientName(e.target.value)} required /> - setClientCompany(e.target.value)} /> - setClientEmail(e.target.value)} /> - setClientPhone(e.target.value)} required /> -
-
- -
-

Contrato

-
- setFidelityPeriod(e.target.value)} - hint="Só preencha se for menor que o período do contrato — entra em aprovação" - /> -
-
- -
-
-

Itens

- -
-
- - - - - - - - - {items.map((row, index) => ( - - - - - - ))} - -
ProdutoQuantidade -
- - updateItem(index, { quantity: e.target.value })} - aria-label="Quantidade" - required - /> - - {items.length > 1 ? ( - - ) : null} -
-
-
- -
-

Implantação e valores

-
- setImplGeral(e.target.value)} - hint="Custo de implantação além dos produtos" - /> - setProposedMonthlyTotal(e.target.value)} - hint="Preencha só se for oferecer desconto — a oferta precisará de aprovação" - /> -
+ + ) : ( + + +
+
+

Dados do cliente

+
setClientName(e.target.value)} + required + /> + setClientCompany(e.target.value)} /> + setClientEmail(e.target.value)} + /> + setClientPhone(e.target.value)} + required + /> +
+
+ +
+

Contrato

+
+ setFidelityPeriod(e.target.value)} + hint="Só preencha se for menor que o período do contrato — entra em aprovação" + /> + setSalesRepName(e.target.value)} + hint='Impresso no PDF — cai para "Responsável Comercial" quando ausente.' /> -

- Faixas sugeridas para {formatCurrencyBRL(estimatedImplTotal)} de implantação estimada:{" "} - {availablePaymentConditions.map((c) => ( - - {c} - - ))} - {" "}— o servidor valida a faixa real ao salvar. -

+
- setSalesRepName(e.target.value)} - hint='Impresso no PDF de oferta — cai para "Responsável Comercial" quando ausente.' - /> +
+
+ +
+
+ setProductSearch(e.target.value)} + aria-label="Buscar produto" + /> +
+
+ Ordenar por +