feat(web): telas de negócio reais no EDEN Core (Empresas, Produtos, Revendas, CRM, Ofertas)

Prioriza a parte web a pedido do usuário. 2 subagentes eden-frontend em
paralelo sobre o shell autenticado já pronto:

- Empresas/Produtos/Revendas: CRUD completo (lista + criar/editar),
  layout modelado nas páginas reais do tema DreamsERP comprado
  (purchase/vendors.tsx, inventory/products.tsx) — máscara de moeda para
  as 5 faixas de preço + implantação. Novo componente Table<T> genérico
  em packages/ui.
- CRM: Leads (lista, criar/editar, converter em oportunidade) e
  Oportunidades, modelados em crm/leads.tsx/dealsDetails.tsx.
- Ofertas/Quotes: o módulo mais rico — criação com lista dinâmica de
  itens produto/quantidade, fidelidade reduzida opcional, desconto
  opcional, totais calculados pelo servidor; detalhe com aprovar/rejeitar
  e fechar negócio. Modelado em sales/salesQuotes*.tsx. Novo componente
  Select em packages/ui.

Correção de instrução no meio da leva: usuário pediu para seguir de
perto o layout real do tema comprado, não só os tokens visuais já
extraídos — as páginas React relevantes do tema foram extraídas para
.tmp/theme-reference/ (nunca versionado) em cada worktree e os agentes
já em execução foram redirecionados via mensagem (não relançados).

Bug real do próprio agente, corrigindo meu brief: Leads/Opportunities
respondem em camelCase (mapLead/mapOpportunity), não snake_case como o
resto da API — verificado contra o código-fonte real antes de aceitar.

Integração manual: um dos 2 agentes não conseguiu git merge no próprio
sandbox e restaurou o shell existente por leitura+escrita — cada arquivo
restaurado foi comparado byte a byte contra main (diff -rq) antes de
aceitar. App.tsx/AuthenticatedLayout.tsx/packages/ui/index.ts
reconciliados manualmente juntando as rotas/exports dos dois agentes.

Verificado: pnpm turbo run typecheck build (28/28); containers
eden-api/eden-core reconstruídos; bundle de produção contém todas as
novas telas; login simulando Origin de browser real; listagens
retornando dados reais; criação de lead confirmando o formato camelCase.

Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 18:31:07 -03:00
parent 4d67fe5f1a
commit f96c465ee5
31 changed files with 3493 additions and 5 deletions

View File

@@ -0,0 +1,327 @@
import { useCallback, useEffect, useState } from "react";
import type { FormEvent } from "react";
import { useParams } from "react-router-dom";
import { Alert, Badge, Button, Card, Input, PageHeader } from "@eden/ui";
import type { BadgeVariant } from "@eden/ui";
import { useAuth } from "../../lib/auth/AuthContext.js";
import { ApiError } from "../../lib/api/http.js";
import { closeQuoteDeal, decideQuoteApproval, getQuote } from "../../lib/api/quotes.js";
import type { ApprovalStatus, DealStatus, Quote } from "../../lib/api/quotes.js";
import { listResellers } from "../../lib/api/resellers.js";
import type { Reseller } from "../../lib/api/resellers.js";
import { formatCurrencyBRL, formatDateBR } from "../../lib/format.js";
import { Breadcrumb } from "../../components/Breadcrumb.js";
/**
* Layout reference: DreamsERP theme `.tmp/theme-reference/sales/salesQuotesDetails.tsx`
* (breadcrumb + title/status header, bill-to summary block, item table,
* right-aligned financial totals) plus `.tmp/theme-reference/crm/dealsDetails.tsx`
* (summary sidebar's `flex justify-between` key/value rows for the numbers
* that don't fit an invoice-style table). Rebuilt with `@eden/ui` + plain
* Tailwind — no antd/Preline dependency.
*/
const APPROVAL_LABEL: Record<ApprovalStatus, string> = {
none: "Sem aprovação",
pending: "Pendente",
approved: "Aprovada",
rejected: "Rejeitada",
};
const APPROVAL_VARIANT: Record<ApprovalStatus, BadgeVariant> = {
none: "neutral",
pending: "warning",
approved: "success",
rejected: "danger",
};
const DEAL_LABEL: Record<DealStatus, string> = {
orcamento: "Orçamento",
fechado: "Fechado",
perdido: "Perdido",
};
const DEAL_VARIANT: Record<DealStatus, BadgeVariant> = {
orcamento: "info",
fechado: "success",
perdido: "danger",
};
export function QuoteDetailPage() {
const { id } = useParams<{ id: string }>();
const { accessToken } = useAuth();
const [quote, setQuote] = useState<Quote | null>(null);
const [reseller, setReseller] = useState<Reseller | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const load = useCallback(() => {
if (!id || !accessToken) return;
setLoading(true);
setError(null);
Promise.all([getQuote(id, accessToken), listResellers(accessToken)])
.then(([quoteResult, resellers]) => {
setQuote(quoteResult);
setReseller(resellers.find((r) => r.id === quoteResult.reseller_id) ?? null);
})
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar a oferta."))
.finally(() => setLoading(false));
}, [id, accessToken]);
useEffect(() => {
load();
}, [load]);
if (loading) {
return (
<div>
<PageHeader title="Oferta" />
<p className="text-sm text-default">Carregando oferta…</p>
</div>
);
}
if (error || !quote) {
return (
<div>
<PageHeader title="Oferta" />
<Alert variant="error">{error ?? "Oferta não encontrada."}</Alert>
</div>
);
}
return (
<div>
<Breadcrumb items={[{ label: "Ofertas", href: "/quotes" }, { label: quote.quote_number }]} />
<PageHeader
title={`Oferta ${quote.quote_number}`}
actions={
<div className="flex items-center gap-2">
<Badge variant={APPROVAL_VARIANT[quote.approval_status]}>{APPROVAL_LABEL[quote.approval_status]}</Badge>
<Badge variant={DEAL_VARIANT[quote.deal_status]}>{DEAL_LABEL[quote.deal_status]}</Badge>
</div>
}
/>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className="lg:col-span-1">
<Card title="Cliente">
<dl className="space-y-3 text-sm">
<Row label="Nome" value={quote.client_name} />
<Row label="Empresa" value={quote.client_company ?? "—"} />
<Row label="E-mail" value={quote.client_email ?? "—"} />
<Row label="Telefone" value={quote.client_phone} />
<Row label="Revenda" value={reseller?.name ?? quote.reseller_id} />
<Row
label="Período de contrato"
value={quote.contract_period === 0 ? "Sem fidelidade" : `${quote.contract_period} meses`}
/>
<Row
label="Fidelidade reduzida"
value={quote.fidelity_period != null ? `${quote.fidelity_period} meses` : "—"}
/>
<Row label="Condição de pagamento" value={quote.impl_payment_condition} />
<Row label="Criada em" value={formatDateBR(quote.created_at)} />
</dl>
</Card>
</div>
<div className="space-y-4 lg:col-span-2">
<Card title="Itens">
<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-3 font-semibold text-title">Produto</th>
<th className="py-2 pe-3 text-right font-semibold text-title">Qtd.</th>
<th className="py-2 pe-3 text-right font-semibold text-title">Preço unit.</th>
<th className="py-2 pe-3 text-right font-semibold text-title">Total</th>
<th className="py-2 pe-3 text-right font-semibold text-title">Impl. unit.</th>
<th className="py-2 text-right font-semibold text-title">Impl. total</th>
</tr>
</thead>
<tbody>
{quote.items.map((item, index) => (
<tr key={`${item.productId}-${index}`} className="border-b border-border-color last:border-0">
<td className="py-2 pe-3 font-semibold text-title">{item.productName}</td>
<td className="py-2 pe-3 text-right text-default">{item.quantity}</td>
<td className="py-2 pe-3 text-right text-default">{formatCurrencyBRL(item.unitPrice)}</td>
<td className="py-2 pe-3 text-right text-default">{formatCurrencyBRL(item.total)}</td>
<td className="py-2 pe-3 text-right text-default">{formatCurrencyBRL(item.implUnitPrice)}</td>
<td className="py-2 text-right text-default">{formatCurrencyBRL(item.implTotal)}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
<Card title="Resumo financeiro">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<SummaryRow label="Mensalidade calculada" value={formatCurrencyBRL(quote.monthly_total)} />
{quote.proposed_monthly_total != null ? (
<SummaryRow
label="Mensalidade proposta"
value={formatCurrencyBRL(quote.proposed_monthly_total)}
highlight
/>
) : null}
<SummaryRow label="Implantação (produtos)" value={formatCurrencyBRL(quote.implementation_fee)} />
<SummaryRow label="Implantação geral" value={formatCurrencyBRL(quote.impl_geral)} />
<SummaryRow label="Total do contrato" value={formatCurrencyBRL(quote.contract_total)} highlight />
</div>
{quote.notes ? (
<div className="mt-4 border-t border-border-color pt-4">
<p className="text-xs font-semibold text-default">Notas</p>
<p className="mt-1 text-sm text-title">{quote.notes}</p>
</div>
) : null}
</Card>
{quote.approval_status === "pending" ? (
<ApprovalCard quoteId={quote.id} accessToken={accessToken} onDecided={load} />
) : quote.approval_status !== "none" && quote.approval_notes ? (
<Card title="Aprovação">
<p className="text-sm text-default">{quote.approval_notes}</p>
</Card>
) : null}
{quote.deal_status === "orcamento" ? (
<CloseDealCard quoteId={quote.id} accessToken={accessToken} onClosed={load} />
) : quote.client_registration_id ? (
<Card title="Negócio">
<Row label="Cadastro de cliente" value={quote.client_registration_id} />
</Card>
) : null}
</div>
</div>
</div>
);
}
function Row({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-start justify-between gap-3">
<dt className="text-default">{label}</dt>
<dd className="text-right font-semibold text-title">{value}</dd>
</div>
);
}
function SummaryRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
return (
<div className="flex items-center justify-between rounded-md border border-border-color p-3">
<span className="text-sm text-default">{label}</span>
<span className={highlight ? "text-base font-bold text-primary" : "text-sm font-semibold text-title"}>
{value}
</span>
</div>
);
}
function ApprovalCard({
quoteId,
accessToken,
onDecided,
}: {
quoteId: string;
accessToken: string | null;
onDecided: () => void;
}) {
const [notes, setNotes] = useState("");
const [submitting, setSubmitting] = useState<"approved" | "rejected" | null>(null);
const [error, setError] = useState<string | null>(null);
async function decide(decision: "approved" | "rejected") {
if (!accessToken) return;
setError(null);
setSubmitting(decision);
try {
await decideQuoteApproval(quoteId, { decision, notes: notes || undefined }, accessToken);
onDecided();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível registrar a decisão.");
} finally {
setSubmitting(null);
}
}
return (
<Card title="Aprovação pendente">
{error ? (
<Alert variant="error" className="mb-3">
{error}
</Alert>
) : null}
<p className="mb-3 text-sm text-default">
Esta oferta tem desconto e/ou fidelidade reduzida e precisa de aprovação antes de seguir.
</p>
<Input
label="Observação (opcional)"
value={notes}
onChange={(e) => setNotes(e.target.value)}
className="mb-3"
/>
<div className="flex items-center gap-2">
<Button type="button" onClick={() => decide("approved")} loading={submitting === "approved"}>
Aprovar
</Button>
<Button type="button" variant="danger" onClick={() => decide("rejected")} loading={submitting === "rejected"}>
Rejeitar
</Button>
</div>
</Card>
);
}
function CloseDealCard({
quoteId,
accessToken,
onClosed,
}: {
quoteId: string;
accessToken: string | null;
onClosed: () => void;
}) {
const [clientRegistrationId, setClientRegistrationId] = useState("");
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!accessToken) return;
setError(null);
setSubmitting(true);
try {
await closeQuoteDeal(quoteId, { clientRegistrationId }, accessToken);
onClosed();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível fechar o negócio.");
} finally {
setSubmitting(false);
}
}
return (
<Card title="Fechar negócio">
{error ? (
<Alert variant="error" className="mb-3">
{error}
</Alert>
) : null}
<form className="flex flex-wrap items-end gap-3" onSubmit={handleSubmit}>
<Input
label="UUID do cadastro de cliente"
value={clientRegistrationId}
onChange={(e) => setClientRegistrationId(e.target.value)}
hint="Cole o UUID de um cadastro de cliente já existente"
className="min-w-72 flex-1"
required
/>
<Button type="submit" loading={submitting}>
Fechar negócio
</Button>
</form>
</Card>
);
}