feat(quotes): catálogo navegável + resumo ao vivo na Nova Oferta
Substitui a tabela de linhas com dropdown de produto por duas colunas, no padrão do legado OrçaFácil: "Adicionar Produtos" à esquerda (busca, ordenação, lista com preço da faixa do período de contrato, clique adiciona ao carrinho) e "Resumo da Oferta" à direita, sticky (itens com stepper, breakdown de implantação, implantação geral, condição de pagamento, condição especial e observações — todos migrados pra dentro do painel —, totais ao vivo com Total do Contrato em destaque). Fórmula do preview espelha exatamente pricing.ts::computeQuotePricing — nada muda no backend, é só a experiência de montar a oferta antes de enviar; o servidor continua sendo a única fonte de verdade nos totais persistidos. pnpm turbo run typecheck build: 28/28. Suíte @eden/api: 113/113 (inalterada). Testado ao vivo ponta a ponta, incluindo o caso de aprovação por desconto. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<string>("12");
|
||||
const [fidelityPeriod, setFidelityPeriod] = useState("");
|
||||
const [items, setItems] = useState<ItemRow[]>([{ ...EMPTY_ROW }]);
|
||||
const [salesRepName, setSalesRepName] = useState("");
|
||||
|
||||
const [cart, setCart] = useState<CartLine[]>([]);
|
||||
const [productSearch, setProductSearch] = useState("");
|
||||
const [productSort, setProductSort] = useState<ProductSort>("code");
|
||||
|
||||
const [implGeral, setImplGeral] = useState("");
|
||||
const [proposedMonthlyTotal, setProposedMonthlyTotal] = useState("");
|
||||
const [implPaymentCondition, setImplPaymentCondition] = useState<ImplPaymentCondition | "">("");
|
||||
const [notes, setNotes] = useState("");
|
||||
const [salesRepName, setSalesRepName] = useState("");
|
||||
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<string, Product>();
|
||||
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<typeof line> => line !== null),
|
||||
[cart, productsById, contractPeriodNum],
|
||||
);
|
||||
|
||||
function updateItem(index: number, patch: Partial<ItemRow>) {
|
||||
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() {
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
{!resellers || !products ? (
|
||||
{!resellers || !products ? (
|
||||
<Card>
|
||||
<p className="text-sm text-default">Carregando revendas e produtos…</p>
|
||||
) : (
|
||||
<form className="space-y-8" onSubmit={handleSubmit}>
|
||||
<div>
|
||||
<h2 className="mb-4 text-sm font-semibold text-title">Dados do cliente</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Select
|
||||
label="Revenda"
|
||||
options={resellers.map((r) => ({ value: r.id, label: r.name }))}
|
||||
placeholder="Selecione a revenda"
|
||||
value={resellerId}
|
||||
onChange={(e) => setResellerId(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input label="Nome do cliente" value={clientName} onChange={(e) => setClientName(e.target.value)} required />
|
||||
<Input label="Empresa" value={clientCompany} onChange={(e) => setClientCompany(e.target.value)} />
|
||||
<Input label="E-mail" type="email" value={clientEmail} onChange={(e) => setClientEmail(e.target.value)} />
|
||||
<Input label="Telefone" value={clientPhone} onChange={(e) => setClientPhone(e.target.value)} required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="mb-4 text-sm font-semibold text-title">Contrato</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Select
|
||||
label="Período de contrato"
|
||||
options={CONTRACT_PERIODS.map((period) => ({
|
||||
value: String(period),
|
||||
label: period === 0 ? "Sem fidelidade (0 meses)" : `${period} meses`,
|
||||
}))}
|
||||
value={contractPeriod}
|
||||
onChange={(e) => setContractPeriod(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Fidelidade reduzida (opcional)"
|
||||
type="number"
|
||||
min={0}
|
||||
value={fidelityPeriod}
|
||||
onChange={(e) => setFidelityPeriod(e.target.value)}
|
||||
hint="Só preencha se for menor que o período do contrato — entra em aprovação"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-title">Itens</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addItem}
|
||||
className="rounded-md border border-border-color px-3 py-1.5 text-xs font-semibold text-title hover:bg-light"
|
||||
>
|
||||
+ Adicionar item
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border-color text-left">
|
||||
<th className="py-2 pe-2 font-semibold text-title">Produto</th>
|
||||
<th className="py-2 pe-2 font-semibold text-title">Quantidade</th>
|
||||
<th className="py-2 pe-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((row, index) => (
|
||||
<tr key={index} className="border-b border-border-color last:border-0">
|
||||
<td className="py-2 pe-2 w-2/3">
|
||||
<Select
|
||||
options={productOptions}
|
||||
placeholder="Selecione o produto"
|
||||
value={row.productId}
|
||||
onChange={(e) => updateItem(index, { productId: e.target.value })}
|
||||
aria-label="Produto"
|
||||
required
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pe-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
value={row.quantity}
|
||||
onChange={(e) => updateItem(index, { quantity: e.target.value })}
|
||||
aria-label="Quantidade"
|
||||
required
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 pe-2 text-center">
|
||||
{items.length > 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeItem(index)}
|
||||
className="rounded-md border border-border-color px-2 py-1 text-xs font-semibold text-danger hover:bg-light"
|
||||
>
|
||||
Remover
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="mb-4 text-sm font-semibold text-title">Implantação e valores</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Input
|
||||
label="Implantação geral (opcional)"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={implGeral}
|
||||
onChange={(e) => setImplGeral(e.target.value)}
|
||||
hint="Custo de implantação além dos produtos"
|
||||
/>
|
||||
<Input
|
||||
label="Mensalidade proposta (opcional)"
|
||||
type="number"
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
value={proposedMonthlyTotal}
|
||||
onChange={(e) => setProposedMonthlyTotal(e.target.value)}
|
||||
hint="Preencha só se for oferecer desconto — a oferta precisará de aprovação"
|
||||
/>
|
||||
<div>
|
||||
</Card>
|
||||
) : (
|
||||
<form className="space-y-6" onSubmit={handleSubmit}>
|
||||
<Card>
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="mb-4 text-sm font-semibold text-title">Dados do cliente</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Select
|
||||
label="Condição de pagamento da implantação"
|
||||
options={IMPL_PAYMENT_CONDITIONS.map((condition) => ({
|
||||
value: condition,
|
||||
label: condition,
|
||||
}))}
|
||||
placeholder="Selecione a condição"
|
||||
value={implPaymentCondition}
|
||||
onChange={(e) => setImplPaymentCondition(e.target.value as ImplPaymentCondition)}
|
||||
label="Revenda"
|
||||
options={resellers.map((r) => ({ value: r.id, label: r.name }))}
|
||||
placeholder="Selecione a revenda"
|
||||
value={resellerId}
|
||||
onChange={(e) => setResellerId(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Nome do cliente"
|
||||
value={clientName}
|
||||
onChange={(e) => setClientName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input label="Empresa" value={clientCompany} onChange={(e) => setClientCompany(e.target.value)} />
|
||||
<Input
|
||||
label="E-mail"
|
||||
type="email"
|
||||
value={clientEmail}
|
||||
onChange={(e) => setClientEmail(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Telefone"
|
||||
value={clientPhone}
|
||||
onChange={(e) => setClientPhone(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="mb-4 text-sm font-semibold text-title">Contrato</h2>
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<Select
|
||||
label="Período de contrato"
|
||||
options={CONTRACT_PERIODS.map((period) => ({
|
||||
value: String(period),
|
||||
label: contractPeriodLabel(period),
|
||||
}))}
|
||||
value={contractPeriod}
|
||||
onChange={(e) => setContractPeriod(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Fidelidade reduzida (opcional)"
|
||||
type="number"
|
||||
min={0}
|
||||
value={fidelityPeriod}
|
||||
onChange={(e) => setFidelityPeriod(e.target.value)}
|
||||
hint="Só preencha se for menor que o período do contrato — entra em aprovação"
|
||||
/>
|
||||
<Input
|
||||
label="Responsável comercial"
|
||||
value={salesRepName}
|
||||
onChange={(e) => setSalesRepName(e.target.value)}
|
||||
hint='Impresso no PDF — cai para "Responsável Comercial" quando ausente.'
|
||||
/>
|
||||
<p className="mt-1 text-xs text-default">
|
||||
Faixas sugeridas para {formatCurrencyBRL(estimatedImplTotal)} de implantação estimada:{" "}
|
||||
{availablePaymentConditions.map((c) => (
|
||||
<Badge key={c} variant="neutral" className="ms-1">
|
||||
{c}
|
||||
</Badge>
|
||||
))}
|
||||
{" "}— o servidor valida a faixa real ao salvar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Input
|
||||
label="Responsável comercial"
|
||||
value={salesRepName}
|
||||
onChange={(e) => setSalesRepName(e.target.value)}
|
||||
hint='Impresso no PDF de oferta — cai para "Responsável Comercial" quando ausente.'
|
||||
/>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-5 lg:items-start">
|
||||
<div className="lg:col-span-3">
|
||||
<Card title="Adicionar Produtos" bodyClassName="p-0">
|
||||
<div className="flex flex-col gap-3 border-b border-border-color p-4 sm:flex-row sm:items-center">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder="Buscar por código ou nome..."
|
||||
value={productSearch}
|
||||
onChange={(e) => setProductSearch(e.target.value)}
|
||||
aria-label="Buscar produto"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="whitespace-nowrap text-xs font-semibold text-default">Ordenar por</span>
|
||||
<Select
|
||||
options={[
|
||||
{ value: "code", label: "Código" },
|
||||
{ value: "name", label: "Nome (A-Z)" },
|
||||
]}
|
||||
value={productSort}
|
||||
onChange={(e) => setProductSort(e.target.value as ProductSort)}
|
||||
aria-label="Ordenar produtos"
|
||||
className="min-w-36"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-semibold text-gray-900">Notas</label>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-border-color bg-white px-3 py-2.5 text-sm text-title focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
rows={4}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
<div className="max-h-[560px] overflow-y-auto">
|
||||
{filteredProducts.length === 0 ? (
|
||||
<p className="p-5 text-sm text-default">
|
||||
{browsableProducts.length === 0
|
||||
? "Nenhum produto ativo disponível."
|
||||
: "Nenhum produto encontrado para essa busca."}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-border-color">
|
||||
{filteredProducts.map((product) => (
|
||||
<ProductRow
|
||||
key={product.id}
|
||||
product={product}
|
||||
contractPeriod={contractPeriodNum}
|
||||
onAdd={() => addToCart(product.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 border-t border-border-color pt-5">
|
||||
<div className="lg:sticky lg:top-6 lg:col-span-2 lg:self-start">
|
||||
<Card title="Resumo da Oferta">
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
{cartLines.length === 0 ? (
|
||||
<p className="rounded-md border border-dashed border-border-color p-4 text-center text-sm text-default">
|
||||
Nenhum produto adicionado ainda. Clique em um produto à esquerda para começar.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{cartLines.map((line) => (
|
||||
<CartItemCard
|
||||
key={line.product.id}
|
||||
productName={line.product.name}
|
||||
quantity={line.quantity}
|
||||
unitPrice={line.unitPrice}
|
||||
subtotal={line.subtotal}
|
||||
onIncrement={() => setQuantity(line.product.id, line.quantity + 1)}
|
||||
onDecrement={() => setQuantity(line.product.id, line.quantity - 1)}
|
||||
onRemove={() => removeFromCart(line.product.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{cartLines.length > 0 ? (
|
||||
<div className="rounded-md border border-border-color p-3">
|
||||
<p className="mb-2 text-xs font-semibold text-title">Implantação (por produto × qtd)</p>
|
||||
<ul className="space-y-1">
|
||||
{cartLines.map((line) => (
|
||||
<li key={line.product.id} className="flex items-center justify-between text-xs text-default">
|
||||
<span className="truncate pe-2">
|
||||
{line.product.name} × {line.quantity}
|
||||
</span>
|
||||
<span className="whitespace-nowrap font-semibold text-title">
|
||||
{formatCurrencyBRL(line.implSubtotal)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<div className="mt-2 flex items-center justify-between border-t border-border-color pt-2 text-xs font-semibold text-title">
|
||||
<span>Total implantação de produtos</span>
|
||||
<span>{formatCurrencyBRL(implFromProducts)}</span>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
label="Implantação Geral do Projeto (R$)"
|
||||
type="number"
|
||||
min={0}
|
||||
step={0.01}
|
||||
value={implGeral}
|
||||
onChange={(e) => setImplGeral(e.target.value)}
|
||||
hint="Custo de implantação além dos produtos"
|
||||
/>
|
||||
<div>
|
||||
<Select
|
||||
label="Condição de Pagamento da Implantação"
|
||||
options={IMPL_PAYMENT_CONDITIONS.map((condition) => ({
|
||||
value: condition,
|
||||
label: condition,
|
||||
}))}
|
||||
placeholder="Selecione a condição"
|
||||
value={implPaymentCondition}
|
||||
onChange={(e) => setImplPaymentCondition(e.target.value as ImplPaymentCondition)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-default">
|
||||
Faixas sugeridas para {formatCurrencyBRL(totalImpl)} de implantação estimada:{" "}
|
||||
{availablePaymentConditions.map((c) => (
|
||||
<Badge key={c} variant="neutral" className="ms-1">
|
||||
{c}
|
||||
</Badge>
|
||||
))}
|
||||
{" "}— o servidor valida a faixa real ao salvar.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 rounded-md border border-border-color p-4">
|
||||
<TotalRow label="Total Mensal" value={formatCurrencyBRL(monthlyTotal)} />
|
||||
<TotalRow label="Impl. Produtos" value={formatCurrencyBRL(implFromProducts)} />
|
||||
<TotalRow label="Total Implantação" value={formatCurrencyBRL(totalImpl)} />
|
||||
<TotalRow label="Duração (faixa de preço)" value={contractPeriodLabel(contractPeriodNum)} />
|
||||
<div className="mt-2 flex items-center justify-between rounded-md bg-primary-transparent px-3 py-3">
|
||||
<span className="text-sm font-bold text-primary-900">Total do Contrato</span>
|
||||
<span className="text-lg font-extrabold text-primary-900">
|
||||
{formatCurrencyBRL(contractTotal)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Input
|
||||
label="Condição Especial — Valor Mensal Final (R$)"
|
||||
type="number"
|
||||
min={0.01}
|
||||
step={0.01}
|
||||
value={proposedMonthlyTotal}
|
||||
onChange={(e) => setProposedMonthlyTotal(e.target.value)}
|
||||
hint="Preencha só se for oferecer desconto — a oferta precisará de aprovação"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-default">Tabela: {formatCurrencyBRL(monthlyTotal)}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-sm font-semibold text-gray-900">Observações</label>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-border-color bg-white px-3 py-2.5 text-sm text-title focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
rows={4}
|
||||
value={notes}
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit" loading={submitting}>
|
||||
Criar oferta
|
||||
</Button>
|
||||
@@ -373,13 +515,143 @@ export function QuoteFormPage() {
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
</Card>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProductRow({
|
||||
product,
|
||||
contractPeriod,
|
||||
onAdd,
|
||||
}: {
|
||||
product: Product;
|
||||
contractPeriod: number;
|
||||
onAdd: () => void;
|
||||
}) {
|
||||
const price = priceForPeriod(product, contractPeriod);
|
||||
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
onClick={price != null ? onAdd : undefined}
|
||||
disabled={price == null}
|
||||
className="flex w-full items-start justify-between gap-3 px-4 py-3 text-left transition-colors hover:bg-light disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<div className="flex min-w-0 items-start gap-3">
|
||||
<Badge variant="neutral" className="mt-0.5 shrink-0">
|
||||
#{product.product_code}
|
||||
</Badge>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-bold text-title">{product.name}</p>
|
||||
{product.description ? (
|
||||
<p className="truncate text-xs text-default">{product.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
{price != null ? (
|
||||
<>
|
||||
<p className="text-sm font-bold text-secondary">{formatCurrencyBRL(price)}/mês</p>
|
||||
<Badge variant="secondary" className="mt-1">
|
||||
plano {contractPeriod}m
|
||||
</Badge>
|
||||
</>
|
||||
) : (
|
||||
<Badge variant="warning">indisponível p/ este período</Badge>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function CartItemCard({
|
||||
productName,
|
||||
quantity,
|
||||
unitPrice,
|
||||
subtotal,
|
||||
onIncrement,
|
||||
onDecrement,
|
||||
onRemove,
|
||||
}: {
|
||||
productName: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
subtotal: number;
|
||||
onIncrement: () => void;
|
||||
onDecrement: () => void;
|
||||
onRemove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<li className="rounded-md border border-border-color p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<p className="text-sm font-semibold text-title">{productName}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onRemove}
|
||||
aria-label={`Remover ${productName}`}
|
||||
className="shrink-0 rounded-md p-1 text-default hover:bg-light hover:text-danger"
|
||||
>
|
||||
<TrashIcon />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onDecrement}
|
||||
disabled={quantity <= 1}
|
||||
aria-label="Diminuir quantidade"
|
||||
className="flex size-7 items-center justify-center rounded-md border border-border-color text-sm font-bold text-title hover:bg-light disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<span className="w-8 text-center text-sm font-semibold text-title">{quantity}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onIncrement}
|
||||
aria-label="Aumentar quantidade"
|
||||
className="flex size-7 items-center justify-center rounded-md border border-border-color text-sm font-bold text-title hover:bg-light"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-default">
|
||||
{formatCurrencyBRL(unitPrice)} × {quantity} ={" "}
|
||||
<span className="font-bold text-title">{formatCurrencyBRL(subtotal)}</span>
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function TotalRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-default">{label}</span>
|
||||
<span className="font-semibold text-title">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TrashIcon() {
|
||||
return (
|
||||
<svg aria-hidden="true" viewBox="0 0 20 20" fill="none" className="size-4">
|
||||
<path
|
||||
d="M3.5 5.5h13M8 5.5V4a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1v1.5M5.5 5.5v10a1 1 0 0 0 1 1h7a1 1 0 0 0 1-1v-10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryField({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border-color p-4">
|
||||
|
||||
@@ -812,3 +812,21 @@ Data: 2026-09-18. Fecha exatamente a pendência registrada acima. Usuário: "vam
|
||||
|
||||
### Achado adicional, resolvido no caminho (não pedido, mas real)
|
||||
Usuário reportou "testei o cadastro de revenda e continua só com nome/documento/e-mail" — investigado ao vivo: a tela de edição de uma revenda existente já mostrava todas as seções corretamente (confirmado abrindo a revenda real "Handix" pela lista). O que ele via era a tela de **criação** (`/resellers/new`), que por design mesmo só mostra os 3 campos mínimos até a revenda ser salva (CNPJ/sócios/representante precisam de um ID existente) — mas sem nenhuma explicação disso na tela, parecendo um cadastro incompleto. Adicionado um aviso claro no formulário de criação: "Salve com o nome pra continuar o cadastro — busca de CNPJ, representante legal, sócios, endereço e contatos ficam disponíveis logo em seguida, na tela de edição desta revenda." Confirmado ao vivo via screenshot.
|
||||
|
||||
---
|
||||
|
||||
## Nova oferta — catálogo navegável + resumo ao vivo, no padrão do legado OrçaFácil
|
||||
|
||||
Data: 2026-09-18. Usuário mandou uma captura de tela de como o legado montava essa tela (produtos navegáveis à esquerda, resumo se montando ao lado conforme adicionava) e um link de uma página de exemplo do tema DreamsERP como "só uma ideia" — na prática essa página do tema acabou sendo só inspiração de acabamento solta (é uma tabela de fatura genérica, não tem o padrão de catálogo+carrinho do legado). Pedido: "pode fazer mais legal".
|
||||
|
||||
`QuoteFormPage.tsx` (agente `eden-frontend`): a seção de itens, que era uma tabela chata de linhas com dropdown de produto (sem preço visível, sem busca, sem total em tempo real), virou duas colunas — **"Adicionar Produtos"** à esquerda (busca por código/nome, ordenar por Código/Nome, lista rolável com badge de código, preço da faixa do período de contrato selecionado, tag "plano Nm", clique adiciona ao carrinho) e **"Resumo da Oferta"** à direita, sticky (cartão por item com stepper de quantidade e remover, breakdown de implantação por item, os campos que já existiam — implantação geral, condição de pagamento, condição especial/valor mensal final, observações — todos migrados pra dentro deste painel, e totais calculados ao vivo com "Total do Contrato" em destaque). Fórmula do preview espelha `apps/api/src/quotes/pricing.ts::computeQuotePricing` exatamente — mesma disciplina já documentada no próprio arquivo desde que foi criado: todo total mostrado aqui é ilustrativo, o servidor recalcula tudo ao salvar, nada muda no backend.
|
||||
|
||||
**Achado real do próprio agente**: produtos sem preço cadastrado pra faixa do período de contrato selecionado (existem de verdade nos dados seedados) precisavam de tratamento — aparecem na lista como "indisponível p/ este período", não clicáveis, em vez de quebrar ou mostrar "R$ null/mês".
|
||||
|
||||
**Limpeza feita por mim depois**: a lista de produtos navegável tornou a poluição de dados de teste (`qa-test-*`, acumulada a sessão inteira nas suítes de `product-catalogs`) muito mais visível do que era como dropdown — 173 de 177 produtos ativos eram lixo de teste. Desativados (mesma disciplina de higiene já seguida o resto da sessão), junto com uma nova rodada de limpeza nos catálogos de Tipo/Grupo/Subgrupo/Marca/Unidade (mais rodadas de suíte desde a última limpeza tinham acumulado mais linhas). Sobrou 1 produto real ativo ("Link Dedicado 100Mbps").
|
||||
|
||||
### Verificação
|
||||
`pnpm turbo run typecheck build` 28/28. Suíte 113/113 (inalterada — mudança é só de frontend). Testado ao vivo pelo agente: busca/ordenação filtrando corretamente, período de contrato trocando o preço mostrado e o badge de plano, stepper incrementando/decrementando (desabilita em 1), totais batendo à mão com a fórmula pra vários produtos/quantidades, duas ofertas completas criadas de ponta a ponta com o total final batendo exatamente com o que o servidor calculou (uma delas disparando aprovação por desconto, confirmado o banner "aguarda aprovação"). Confirmado por mim depois, com screenshot, com o catálogo já limpo.
|
||||
|
||||
### Achado não resolvido (fora de escopo, registrado pelo agente)
|
||||
Na tela de sucesso pós-criação, o campo "Implantação total" mostra só `implementation_fee` (implantação dos produtos), sem somar `impl_geral` — quando esse campo é preenchido, o rótulo sub-representa o valor que efetivamente compõe o "Total do contrato". Não é um bug introduzido por esta leva, é pré-existente; não corrigido por estar fora do escopo pedido (só a tela de montagem da oferta).
|
||||
|
||||
Reference in New Issue
Block a user