Continuação direta da leva anterior, sem parar para autorização (a pedido do usuário). 2 subagentes eden-frontend em paralelo. - Cadastro de Cliente: lista, criação direta (PF/PJ + portabilidade), detalhe rico editável por seção (status, endereço, contatos, dados PF/PJ com CPF/CNPJ sempre somente-leitura, portabilidade condicional) + sub-seção de Sócios para PJ. Fecha a pendência da leva anterior: QuoteDetailPage não pede mais UUID colado à mão para fechar negócio, agora é um select real com atalho para criar cadastro novo. - Contratos: lista, criação (itens produto+quantidade, partes contratada/contratante), detalhe com resumo financeiro, aviso de fidelidade sem efeito legal quando não aprovada, histórico de status, e botões de transição limitados aos alvos válidos a partir do status atual (servidor continua sendo a única fonte de verdade real). QuoteDetailPage ganha link "Criar contrato a partir desta oferta" quando a oferta está fechada. Reconciliação manual de App.tsx/AuthenticatedLayout.tsx/QuoteDetailPage.tsx entre os 2 agentes (mais o de Empresas/CRM/Ofertas da leva anterior), um de cada vez, confirmando typecheck/build limpo a cada mesclagem. Verificado: pnpm turbo run typecheck build (28/28); containers eden-api/eden-core reconstruídos; fluxo completo testado ao vivo ATRAVÉS DO PROXY same-origin (porta 3001, não direto na API) — login, criação de cadastro, criação de contrato, transição de status inválida corretamente rejeitada com 409. Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
403 lines
15 KiB
TypeScript
403 lines
15 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import type { FormEvent } from "react";
|
|
import { useParams } from "react-router-dom";
|
|
import { Link } from "react-router-dom";
|
|
import { Alert, Badge, Button, Card, Input, PageHeader, Select } 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 { listClientRegistrations } from "../../lib/api/client-registrations.js";
|
|
import type { ClientRegistration } from "../../lib/api/client-registrations.js";
|
|
import { formatCurrencyBRL, formatDateBR } from "../../lib/format.js";
|
|
import { PRIMARY_LINK_BUTTON_CLASS } from "../../components/linkButtonClasses.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>
|
|
{quote.deal_status === "fechado" ? (
|
|
<Link
|
|
to={`/contracts/new?quoteId=${encodeURIComponent(quote.id)}${
|
|
quote.client_registration_id
|
|
? `&clientRegistrationId=${encodeURIComponent(quote.client_registration_id)}`
|
|
: ""
|
|
}`}
|
|
className={PRIMARY_LINK_BUTTON_CLASS}
|
|
>
|
|
Criar contrato a partir desta oferta
|
|
</Link>
|
|
) : null}
|
|
</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">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<dt className="text-sm text-default">Cadastro de cliente</dt>
|
|
<dd>
|
|
<Link
|
|
to={`/client-registrations/${quote.client_registration_id}`}
|
|
className="text-sm font-semibold text-primary hover:underline"
|
|
>
|
|
Ver cadastro →
|
|
</Link>
|
|
</dd>
|
|
</div>
|
|
</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>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Was a free-text UUID field the user had to paste manually — replaced with
|
|
* a real `<select>` populated from `GET /v1/client-registrations` (name +
|
|
* code), matching every other reference field in the app (e.g. Reseller in
|
|
* the summary card above). When there's nothing to pick yet, a button
|
|
* navigates to `/client-registrations/new` in a new tab so the in-progress
|
|
* quote page isn't lost — the user comes back to this tab and reloads the
|
|
* list once the cadastro exists.
|
|
*/
|
|
function CloseDealCard({
|
|
quoteId,
|
|
accessToken,
|
|
onClosed,
|
|
}: {
|
|
quoteId: string;
|
|
accessToken: string | null;
|
|
onClosed: () => void;
|
|
}) {
|
|
const [registrations, setRegistrations] = useState<ClientRegistration[] | null>(null);
|
|
const [loadError, setLoadError] = useState<string | null>(null);
|
|
const [clientRegistrationId, setClientRegistrationId] = useState("");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
const loadRegistrations = useCallback(() => {
|
|
if (!accessToken) return;
|
|
setLoadError(null);
|
|
listClientRegistrations(accessToken)
|
|
.then(setRegistrations)
|
|
.catch((err) =>
|
|
setLoadError(err instanceof ApiError ? err.message : "Não foi possível carregar os cadastros de cliente."),
|
|
);
|
|
}, [accessToken]);
|
|
|
|
useEffect(() => {
|
|
loadRegistrations();
|
|
}, [loadRegistrations]);
|
|
|
|
async function handleSubmit(event: FormEvent) {
|
|
event.preventDefault();
|
|
if (!accessToken || !clientRegistrationId) 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);
|
|
}
|
|
}
|
|
|
|
const registrationOptions = (registrations ?? []).map((registration) => {
|
|
const name =
|
|
(registration.person_type === "pf" ? registration.pf_full_name : registration.pj_company_name) ??
|
|
"Sem nome preenchido";
|
|
return { value: registration.id, label: `#${registration.client_code} — ${name}` };
|
|
});
|
|
|
|
return (
|
|
<Card title="Fechar negócio">
|
|
{error ? (
|
|
<Alert variant="error" className="mb-3">
|
|
{error}
|
|
</Alert>
|
|
) : null}
|
|
{loadError ? (
|
|
<Alert variant="error" className="mb-3">
|
|
{loadError}
|
|
</Alert>
|
|
) : null}
|
|
|
|
{registrations !== null && registrations.length === 0 ? (
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<p className="text-sm text-default">Nenhum cadastro de cliente encontrado ainda.</p>
|
|
<Button type="button" variant="secondary" onClick={() => window.open("/client-registrations/new", "_blank")}>
|
|
Criar novo cadastro de cliente
|
|
</Button>
|
|
</div>
|
|
) : (
|
|
<form className="flex flex-wrap items-end gap-3" onSubmit={handleSubmit}>
|
|
<Select
|
|
label="Cadastro de cliente"
|
|
options={registrationOptions}
|
|
value={clientRegistrationId}
|
|
onChange={(e) => setClientRegistrationId(e.target.value)}
|
|
placeholder={registrations === null ? "Carregando cadastros…" : "Selecione um cadastro"}
|
|
hint="Cadastro de cliente que receberá este negócio"
|
|
className="min-w-72 flex-1"
|
|
required
|
|
/>
|
|
<Button type="submit" loading={submitting}>
|
|
Fechar negócio
|
|
</Button>
|
|
</form>
|
|
)}
|
|
</Card>
|
|
);
|
|
}
|