feat(web): telas de Cadastro de Cliente e Contratos no EDEN Core
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>
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
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 { 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";
|
||||
@@ -9,7 +10,10 @@ import { closeQuoteDeal, decideQuoteApproval, getQuote } from "../../lib/api/quo
|
||||
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";
|
||||
|
||||
/**
|
||||
@@ -99,6 +103,18 @@ export function QuoteDetailPage() {
|
||||
<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>
|
||||
}
|
||||
/>
|
||||
@@ -190,7 +206,17 @@ export function QuoteDetailPage() {
|
||||
<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} />
|
||||
<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>
|
||||
@@ -274,6 +300,15 @@ function ApprovalCard({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
@@ -283,13 +318,29 @@ function CloseDealCard({
|
||||
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) return;
|
||||
if (!accessToken || !clientRegistrationId) return;
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
@@ -302,6 +353,13 @@ function CloseDealCard({
|
||||
}
|
||||
}
|
||||
|
||||
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 ? (
|
||||
@@ -309,19 +367,36 @@ function CloseDealCard({
|
||||
{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>
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user