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:
2026-09-18 17:24:26 -03:00
parent ef4591b8bc
commit 8ab18ababc
2 changed files with 490 additions and 200 deletions

View File

@@ -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,11 +262,14 @@ export function QuoteFormPage() {
</Alert>
) : null}
<Card>
{!resellers || !products ? (
<Card>
<p className="text-sm text-default">Carregando revendas e produtos…</p>
</Card>
) : (
<form className="space-y-8" onSubmit={handleSubmit}>
<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">
@@ -210,21 +281,36 @@ export function QuoteFormPage() {
onChange={(e) => setResellerId(e.target.value)}
required
/>
<Input label="Nome do cliente" value={clientName} onChange={(e) => setClientName(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 />
<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">
<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: period === 0 ? "Sem fidelidade (0 meses)" : `${period} meses`,
label: contractPeriodLabel(period),
}))}
value={contractPeriod}
onChange={(e) => setContractPeriod(e.target.value)}
@@ -238,76 +324,118 @@ export function QuoteFormPage() {
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.'
/>
</div>
</div>
</div>
</Card>
<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 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="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">
<div className="flex items-center gap-2">
<span className="whitespace-nowrap text-xs font-semibold text-default">Ordenar por</span>
<Select
options={productOptions}
placeholder="Selecione o produto"
value={row.productId}
onChange={(e) => updateItem(index, { productId: e.target.value })}
aria-label="Produto"
required
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"
/>
</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 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="lg:sticky lg:top-6 lg:col-span-2 lg:self-start">
<Card title="Resumo da Oferta">
<div className="space-y-6">
<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">
{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 (opcional)"
label="Implantação Geral do Projeto (R$)"
type="number"
min={0}
step={0.01}
@@ -315,18 +443,9 @@ export function QuoteFormPage() {
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>
<Select
label="Condição de pagamento da implantação"
label="Condição de Pagamento da Implantação"
options={IMPL_PAYMENT_CONDITIONS.map((condition) => ({
value: condition,
label: condition,
@@ -336,7 +455,7 @@ export function QuoteFormPage() {
onChange={(e) => setImplPaymentCondition(e.target.value as ImplPaymentCondition)}
/>
<p className="mt-1 text-xs text-default">
Faixas sugeridas para {formatCurrencyBRL(estimatedImplTotal)} de implantação estimada:{" "}
Faixas sugeridas para {formatCurrencyBRL(totalImpl)} de implantação estimada:{" "}
{availablePaymentConditions.map((c) => (
<Badge key={c} variant="neutral" className="ms-1">
{c}
@@ -346,17 +465,35 @@ export function QuoteFormPage() {
</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="Responsável comercial"
value={salesRepName}
onChange={(e) => setSalesRepName(e.target.value)}
hint='Impresso no PDF de oferta — cai para "Responsável Comercial" quando ausente.'
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">Notas</label>
<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}
@@ -364,8 +501,13 @@ export function QuoteFormPage() {
onChange={(e) => setNotes(e.target.value)}
/>
</div>
</div>
</Card>
</div>
</div>
<div className="flex items-center gap-2 border-t border-border-color pt-5">
<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>
</Card>
</form>
)}
</Card>
</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">

View File

@@ -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).