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:
2026-09-03 22:51:40 -03:00
parent abe056c8b6
commit 19e67dbe63
14 changed files with 2732 additions and 16 deletions

View File

@@ -0,0 +1,613 @@
import { useCallback, useEffect, useState } from "react";
import type { FormEvent } from "react";
import { useParams } 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 {
getClientRegistration,
updateClientRegistration,
REGISTRATION_STATUSES,
} from "../../lib/api/client-registrations.js";
import type { ClientRegistration, RegistrationStatus, UpdateClientRegistrationInput } from "../../lib/api/client-registrations.js";
import { formatDocument } from "../../lib/format.js";
import { Breadcrumb } from "../../components/Breadcrumb.js";
import { PartnersSection } from "./PartnersSection.js";
/**
* Layout reference: DreamsERP theme `.tmp/theme-reference/salesOrderDetails.tsx`
* (breadcrumb + title/status header, grouped info blocks) and `customers.tsx`
* (status badge) — rebuilt with `@eden/ui` Card sections, one per group of
* fields, same pattern as QuoteDetailPage/CompanyFormPage. Unlike those two
* (pure display or pure form), this screen mixes both: everything below the
* header is one editable form (single PATCH on submit, matching how the
* API accepts a fully partial `UpdateClientRegistrationDto`), plus a
* separate Sócios sub-section with its own independent list/add/edit calls.
*/
const STATUS_LABEL: Record<RegistrationStatus, string> = {
rascunho: "Rascunho",
pendente_validacao: "Pendente de validação",
ativo: "Ativo",
bloqueado: "Bloqueado",
inativo: "Inativo",
};
const STATUS_VARIANT: Record<RegistrationStatus, BadgeVariant> = {
rascunho: "neutral",
pendente_validacao: "warning",
ativo: "success",
bloqueado: "danger",
inativo: "neutral",
};
const STATUS_OPTIONS = REGISTRATION_STATUSES.map((status) => ({ value: status, label: STATUS_LABEL[status] }));
interface FormState {
registrationStatus: RegistrationStatus;
internalNotes: string;
ixcClientId: string;
ixcContractNumber: string;
addressZip: string;
addressStreet: string;
addressNumber: string;
addressComplement: string;
addressNeighborhood: string;
addressCity: string;
addressState: string;
addressCountry: string;
contactPrincipalName: string;
contactPrincipalEmail: string;
contactPrincipalPhone: string;
contactFinancialName: string;
contactFinancialEmail: string;
contactFinancialPhone: string;
contactTechnicalName: string;
contactTechnicalEmail: string;
contactTechnicalPhone: string;
pfFullName: string;
pfBirthDate: string;
pfEmail: string;
pfMobilePhone: string;
pfAltPhone: string;
pfSocialName: string;
pfIdDocument: string;
pfIdIssuer: string;
pjCompanyName: string;
pjTradeName: string;
pjStateRegistration: string;
pjMunicipalRegistration: string;
pjOpeningDate: string;
pjLegalNature: string;
pjCnpjSituation: string;
pjMainActivity: string;
pjRepFullName: string;
pjRepRole: string;
pjRepEmail: string;
pjRepMobilePhone: string;
pjRepAuthorizationAck: boolean;
/** One number per line — parsed into `portabilityNumbers: string[]` on submit. */
portabilityNumbersText: string;
}
function toDateInputValue(value: string | null): string {
return value ? value.slice(0, 10) : "";
}
function toFormState(registration: ClientRegistration): FormState {
return {
registrationStatus: registration.registration_status,
internalNotes: registration.internal_notes ?? "",
ixcClientId: registration.ixc_client_id ?? "",
ixcContractNumber: registration.ixc_contract_number ?? "",
addressZip: registration.address_zip ?? "",
addressStreet: registration.address_street ?? "",
addressNumber: registration.address_number ?? "",
addressComplement: registration.address_complement ?? "",
addressNeighborhood: registration.address_neighborhood ?? "",
addressCity: registration.address_city ?? "",
addressState: registration.address_state ?? "",
addressCountry: registration.address_country ?? "",
contactPrincipalName: registration.contact_principal_name ?? "",
contactPrincipalEmail: registration.contact_principal_email ?? "",
contactPrincipalPhone: registration.contact_principal_phone ?? "",
contactFinancialName: registration.contact_financial_name ?? "",
contactFinancialEmail: registration.contact_financial_email ?? "",
contactFinancialPhone: registration.contact_financial_phone ?? "",
contactTechnicalName: registration.contact_technical_name ?? "",
contactTechnicalEmail: registration.contact_technical_email ?? "",
contactTechnicalPhone: registration.contact_technical_phone ?? "",
pfFullName: registration.pf_full_name ?? "",
pfBirthDate: toDateInputValue(registration.pf_birth_date),
pfEmail: registration.pf_email ?? "",
pfMobilePhone: registration.pf_mobile_phone ?? "",
pfAltPhone: registration.pf_alt_phone ?? "",
pfSocialName: registration.pf_social_name ?? "",
pfIdDocument: registration.pf_id_document ?? "",
pfIdIssuer: registration.pf_id_issuer ?? "",
pjCompanyName: registration.pj_company_name ?? "",
pjTradeName: registration.pj_trade_name ?? "",
pjStateRegistration: registration.pj_state_registration ?? "",
pjMunicipalRegistration: registration.pj_municipal_registration ?? "",
pjOpeningDate: toDateInputValue(registration.pj_opening_date),
pjLegalNature: registration.pj_legal_nature ?? "",
pjCnpjSituation: registration.pj_cnpj_situation ?? "",
pjMainActivity: registration.pj_main_activity ?? "",
pjRepFullName: registration.pj_rep_full_name ?? "",
pjRepRole: registration.pj_rep_role ?? "",
pjRepEmail: registration.pj_rep_email ?? "",
pjRepMobilePhone: registration.pj_rep_mobile_phone ?? "",
pjRepAuthorizationAck: registration.pj_rep_authorization_ack,
portabilityNumbersText: registration.portability_numbers.join("\n"),
};
}
function toOptional(value: string): string | undefined {
const trimmed = value.trim();
return trimmed === "" ? undefined : trimmed;
}
function registrationName(registration: ClientRegistration): string {
const name = registration.person_type === "pf" ? registration.pf_full_name : registration.pj_company_name;
return name ?? `Cadastro #${registration.client_code}`;
}
export function ClientRegistrationDetailPage() {
const { id } = useParams<{ id: string }>();
const { accessToken } = useAuth();
const [registration, setRegistration] = useState<ClientRegistration | null>(null);
const [form, setForm] = useState<FormState | null>(null);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [saveError, setSaveError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
const [submitting, setSubmitting] = useState(false);
const load = useCallback(() => {
if (!id || !accessToken) return;
setLoading(true);
setLoadError(null);
getClientRegistration(id, accessToken)
.then((data) => {
setRegistration(data);
setForm(toFormState(data));
})
.catch((err) =>
setLoadError(err instanceof ApiError ? err.message : "Não foi possível carregar o cadastro de cliente."),
)
.finally(() => setLoading(false));
}, [id, accessToken]);
useEffect(() => {
load();
}, [load]);
function updateField<K extends keyof FormState>(field: K, value: FormState[K]) {
setForm((prev) => (prev ? { ...prev, [field]: value } : prev));
}
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!accessToken || !id || !form || !registration) return;
setSaveError(null);
setSaveSuccess(false);
setSubmitting(true);
try {
const payload: UpdateClientRegistrationInput = {
registrationStatus: form.registrationStatus !== registration.registration_status ? form.registrationStatus : undefined,
internalNotes: toOptional(form.internalNotes),
ixcClientId: toOptional(form.ixcClientId),
ixcContractNumber: toOptional(form.ixcContractNumber),
addressZip: toOptional(form.addressZip),
addressStreet: toOptional(form.addressStreet),
addressNumber: toOptional(form.addressNumber),
addressComplement: toOptional(form.addressComplement),
addressNeighborhood: toOptional(form.addressNeighborhood),
addressCity: toOptional(form.addressCity),
addressState: toOptional(form.addressState),
addressCountry: toOptional(form.addressCountry),
contactPrincipalName: toOptional(form.contactPrincipalName),
contactPrincipalEmail: toOptional(form.contactPrincipalEmail),
contactPrincipalPhone: toOptional(form.contactPrincipalPhone),
contactFinancialName: toOptional(form.contactFinancialName),
contactFinancialEmail: toOptional(form.contactFinancialEmail),
contactFinancialPhone: toOptional(form.contactFinancialPhone),
contactTechnicalName: toOptional(form.contactTechnicalName),
contactTechnicalEmail: toOptional(form.contactTechnicalEmail),
contactTechnicalPhone: toOptional(form.contactTechnicalPhone),
};
if (registration.person_type === "pf") {
payload.pfFullName = toOptional(form.pfFullName);
payload.pfBirthDate = toOptional(form.pfBirthDate);
payload.pfEmail = toOptional(form.pfEmail);
payload.pfMobilePhone = toOptional(form.pfMobilePhone);
payload.pfAltPhone = toOptional(form.pfAltPhone);
payload.pfSocialName = toOptional(form.pfSocialName);
payload.pfIdDocument = toOptional(form.pfIdDocument);
payload.pfIdIssuer = toOptional(form.pfIdIssuer);
} else {
payload.pjCompanyName = toOptional(form.pjCompanyName);
payload.pjTradeName = toOptional(form.pjTradeName);
payload.pjStateRegistration = toOptional(form.pjStateRegistration);
payload.pjMunicipalRegistration = toOptional(form.pjMunicipalRegistration);
payload.pjOpeningDate = toOptional(form.pjOpeningDate);
payload.pjLegalNature = toOptional(form.pjLegalNature);
payload.pjCnpjSituation = toOptional(form.pjCnpjSituation);
payload.pjMainActivity = toOptional(form.pjMainActivity);
payload.pjRepFullName = toOptional(form.pjRepFullName);
payload.pjRepRole = toOptional(form.pjRepRole);
payload.pjRepEmail = toOptional(form.pjRepEmail);
payload.pjRepMobilePhone = toOptional(form.pjRepMobilePhone);
payload.pjRepAuthorizationAck = form.pjRepAuthorizationAck;
}
if (registration.is_portability) {
payload.portabilityNumbers = form.portabilityNumbersText
.split("\n")
.map((line) => line.trim())
.filter((line) => line !== "");
}
const updated = await updateClientRegistration(id, payload, accessToken);
setRegistration(updated);
setForm(toFormState(updated));
setSaveSuccess(true);
} catch (err) {
setSaveError(err instanceof ApiError ? err.message : "Não foi possível salvar o cadastro de cliente.");
} finally {
setSubmitting(false);
}
}
if (loading) {
return (
<div>
<PageHeader title="Cadastro de cliente" />
<p className="text-sm text-default">Carregando cadastro…</p>
</div>
);
}
if (loadError || !registration || !form) {
return (
<div>
<PageHeader title="Cadastro de cliente" />
<Alert variant="error">{loadError ?? "Cadastro não encontrado."}</Alert>
</div>
);
}
return (
<div>
<Breadcrumb
items={[{ label: "Clientes", href: "/client-registrations" }, { label: registrationName(registration) }]}
/>
<PageHeader
title={registrationName(registration)}
description={`#${registration.client_code} · ${registration.person_type === "pf" ? "Pessoa Física" : "Pessoa Jurídica"}`}
actions={
<Badge variant={STATUS_VARIANT[registration.registration_status]}>
{STATUS_LABEL[registration.registration_status]}
</Badge>
}
/>
{saveError ? (
<Alert variant="error" className="mb-4">
{saveError}
</Alert>
) : null}
{saveSuccess ? (
<Alert variant="success" className="mb-4">
Cadastro salvo com sucesso.
</Alert>
) : null}
<form className="space-y-4" onSubmit={handleSubmit}>
<Card title="Status">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Select
label="Status do cadastro"
options={STATUS_OPTIONS}
value={form.registrationStatus}
onChange={(e) => updateField("registrationStatus", e.target.value as RegistrationStatus)}
/>
<Input
label="ID do cliente no IXC"
value={form.ixcClientId}
onChange={(e) => updateField("ixcClientId", e.target.value)}
/>
<Input
label="Número do contrato no IXC"
value={form.ixcContractNumber}
onChange={(e) => updateField("ixcContractNumber", e.target.value)}
/>
<div className="sm:col-span-2 flex flex-col gap-1">
<label className="text-sm font-semibold text-gray-900">Notas internas</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={3}
value={form.internalNotes}
onChange={(e) => updateField("internalNotes", e.target.value)}
/>
</div>
</div>
</Card>
<Card title="Endereço">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Input label="CEP" value={form.addressZip} onChange={(e) => updateField("addressZip", e.target.value)} />
<div className="sm:col-span-2">
<Input
label="Logradouro"
value={form.addressStreet}
onChange={(e) => updateField("addressStreet", e.target.value)}
/>
</div>
<Input
label="Número"
value={form.addressNumber}
onChange={(e) => updateField("addressNumber", e.target.value)}
/>
<Input
label="Complemento"
value={form.addressComplement}
onChange={(e) => updateField("addressComplement", e.target.value)}
/>
<Input
label="Bairro"
value={form.addressNeighborhood}
onChange={(e) => updateField("addressNeighborhood", e.target.value)}
/>
<Input
label="Cidade"
value={form.addressCity}
onChange={(e) => updateField("addressCity", e.target.value)}
/>
<Input
label="UF"
maxLength={2}
value={form.addressState}
onChange={(e) => updateField("addressState", e.target.value.toUpperCase())}
/>
<Input
label="País"
value={form.addressCountry}
onChange={(e) => updateField("addressCountry", e.target.value)}
placeholder="Brasil"
/>
</div>
</Card>
<Card title="Contatos">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
<div className="space-y-3">
<p className="text-xs font-semibold uppercase text-default">Principal</p>
<Input
label="Nome"
value={form.contactPrincipalName}
onChange={(e) => updateField("contactPrincipalName", e.target.value)}
/>
<Input
label="E-mail"
type="email"
value={form.contactPrincipalEmail}
onChange={(e) => updateField("contactPrincipalEmail", e.target.value)}
/>
<Input
label="Telefone"
value={form.contactPrincipalPhone}
onChange={(e) => updateField("contactPrincipalPhone", e.target.value)}
/>
</div>
<div className="space-y-3">
<p className="text-xs font-semibold uppercase text-default">Financeiro</p>
<Input
label="Nome"
value={form.contactFinancialName}
onChange={(e) => updateField("contactFinancialName", e.target.value)}
/>
<Input
label="E-mail"
type="email"
value={form.contactFinancialEmail}
onChange={(e) => updateField("contactFinancialEmail", e.target.value)}
/>
<Input
label="Telefone"
value={form.contactFinancialPhone}
onChange={(e) => updateField("contactFinancialPhone", e.target.value)}
/>
</div>
<div className="space-y-3">
<p className="text-xs font-semibold uppercase text-default">Técnico</p>
<Input
label="Nome"
value={form.contactTechnicalName}
onChange={(e) => updateField("contactTechnicalName", e.target.value)}
/>
<Input
label="E-mail"
type="email"
value={form.contactTechnicalEmail}
onChange={(e) => updateField("contactTechnicalEmail", e.target.value)}
/>
<Input
label="Telefone"
value={form.contactTechnicalPhone}
onChange={(e) => updateField("contactTechnicalPhone", e.target.value)}
/>
</div>
</div>
</Card>
{registration.person_type === "pf" ? (
<Card title="Dados PF">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Input label="CPF" value={formatDocument(registration.pf_cpf)} readOnly disabled />
<Input
label="Nome completo"
value={form.pfFullName}
onChange={(e) => updateField("pfFullName", e.target.value)}
/>
<Input
label="Nome social"
value={form.pfSocialName}
onChange={(e) => updateField("pfSocialName", e.target.value)}
/>
<Input
label="Data de nascimento"
type="date"
value={form.pfBirthDate}
onChange={(e) => updateField("pfBirthDate", e.target.value)}
/>
<Input
label="E-mail"
type="email"
value={form.pfEmail}
onChange={(e) => updateField("pfEmail", e.target.value)}
/>
<Input
label="Celular"
value={form.pfMobilePhone}
onChange={(e) => updateField("pfMobilePhone", e.target.value)}
/>
<Input
label="Telefone alternativo"
value={form.pfAltPhone}
onChange={(e) => updateField("pfAltPhone", e.target.value)}
/>
<Input
label="Documento de identidade"
value={form.pfIdDocument}
onChange={(e) => updateField("pfIdDocument", e.target.value)}
/>
<Input
label="Órgão emissor"
value={form.pfIdIssuer}
onChange={(e) => updateField("pfIdIssuer", e.target.value)}
/>
</div>
</Card>
) : (
<Card title="Dados PJ">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<Input label="CNPJ" value={formatDocument(registration.pj_cnpj)} readOnly disabled />
<Input
label="Razão social"
value={form.pjCompanyName}
onChange={(e) => updateField("pjCompanyName", e.target.value)}
/>
<Input
label="Nome fantasia"
value={form.pjTradeName}
onChange={(e) => updateField("pjTradeName", e.target.value)}
/>
<Input
label="Inscrição estadual"
value={form.pjStateRegistration}
onChange={(e) => updateField("pjStateRegistration", e.target.value)}
/>
<Input
label="Inscrição municipal"
value={form.pjMunicipalRegistration}
onChange={(e) => updateField("pjMunicipalRegistration", e.target.value)}
/>
<Input
label="Data de abertura"
type="date"
value={form.pjOpeningDate}
onChange={(e) => updateField("pjOpeningDate", e.target.value)}
/>
<Input
label="Natureza jurídica"
value={form.pjLegalNature}
onChange={(e) => updateField("pjLegalNature", e.target.value)}
/>
<Input
label="Situação do CNPJ"
value={form.pjCnpjSituation}
onChange={(e) => updateField("pjCnpjSituation", e.target.value)}
/>
<Input
label="Atividade principal"
value={form.pjMainActivity}
onChange={(e) => updateField("pjMainActivity", e.target.value)}
/>
<Input label="CPF do representante" value={formatDocument(registration.pj_rep_cpf)} readOnly disabled />
<Input
label="Nome do representante"
value={form.pjRepFullName}
onChange={(e) => updateField("pjRepFullName", e.target.value)}
/>
<Input
label="Cargo do representante"
value={form.pjRepRole}
onChange={(e) => updateField("pjRepRole", e.target.value)}
/>
<Input
label="E-mail do representante"
type="email"
value={form.pjRepEmail}
onChange={(e) => updateField("pjRepEmail", e.target.value)}
/>
<Input
label="Celular do representante"
value={form.pjRepMobilePhone}
onChange={(e) => updateField("pjRepMobilePhone", e.target.value)}
/>
<label className="flex items-center gap-2 py-2.5 text-sm text-title">
<input
type="checkbox"
className="shrink-0 rounded-sm border-border-color text-primary focus:ring-primary"
checked={form.pjRepAuthorizationAck}
onChange={(e) => updateField("pjRepAuthorizationAck", e.target.checked)}
/>
Representante confirmou autorização
</label>
</div>
</Card>
)}
{registration.is_portability ? (
<Card title="Portabilidade" bodyClassName="p-5">
<p className="mb-2 text-xs text-default">Um número de telefone por linha.</p>
<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={form.portabilityNumbersText}
onChange={(e) => updateField("portabilityNumbersText", e.target.value)}
placeholder={"(11) 91234-5678\n(11) 98765-4321"}
/>
</Card>
) : null}
<div className="flex items-center gap-2">
<Button type="submit" loading={submitting}>
Salvar alterações
</Button>
</div>
</form>
{registration.person_type === "pj" ? (
<div className="mt-4">
<PartnersSection registrationId={registration.id} accessToken={accessToken} />
</div>
) : null}
</div>
);
}

View File

@@ -0,0 +1,130 @@
import { useEffect, useState } from "react";
import type { FormEvent } from "react";
import { useNavigate } from "react-router-dom";
import { Alert, Button, Card, PageHeader, Select } from "@eden/ui";
import { createDirectClientRegistration } from "../../lib/api/client-registrations.js";
import type { PersonType } from "../../lib/api/client-registrations.js";
import { listResellers } from "../../lib/api/resellers.js";
import type { Reseller } from "../../lib/api/resellers.js";
import { ApiError } from "../../lib/api/http.js";
import { useAuth } from "../../lib/auth/AuthContext.js";
import { Breadcrumb } from "../../components/Breadcrumb.js";
const PERSON_TYPE_OPTIONS = [
{ value: "pf", label: "Pessoa Física" },
{ value: "pj", label: "Pessoa Jurídica" },
];
const PORTABILITY_OPTIONS = [
{ value: "false", label: "Não" },
{ value: "true", label: "Sim" },
];
/**
* Only creation path in this MVP is "cadastro direto" (`POST
* /client-registrations/direct`) — 2-3 fields, then straight to the rich
* detail page to fill everything else in. `resellerId` is always shown;
* the backend only honors it for an actor with `all` edit scope and
* silently forces the actor's own reseller otherwise (see
* client-registrations.ts doc comment).
*/
export function ClientRegistrationFormPage() {
const { accessToken } = useAuth();
const navigate = useNavigate();
const [personType, setPersonType] = useState<PersonType>("pf");
const [isPortability, setIsPortability] = useState(false);
const [resellerId, setResellerId] = useState("");
const [resellers, setResellers] = useState<Reseller[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!accessToken) return;
let cancelled = false;
listResellers(accessToken)
.then((data) => {
if (!cancelled) setResellers(data);
})
.catch(() => {
if (!cancelled) setResellers([]);
});
return () => {
cancelled = true;
};
}, [accessToken]);
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!accessToken) return;
setError(null);
setSubmitting(true);
try {
const registration = await createDirectClientRegistration(
{ personType, isPortability, resellerId: resellerId || undefined },
accessToken,
);
navigate(`/client-registrations/${registration.id}`, { replace: true });
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível criar o cadastro de cliente.");
} finally {
setSubmitting(false);
}
}
const resellerOptions = (resellers ?? []).map((reseller) => ({ value: reseller.id, label: reseller.name }));
return (
<div>
<Breadcrumb items={[{ label: "Clientes", href: "/client-registrations" }, { label: "Novo cadastro" }]} />
<PageHeader
title="Novo cadastro de cliente"
description="Depois de criar, o cadastro é aberto para você completar endereço, contatos e documentos."
/>
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
<Card>
<form className="grid grid-cols-1 gap-4 sm:grid-cols-2" onSubmit={handleSubmit}>
<Select
label="Tipo de pessoa"
options={PERSON_TYPE_OPTIONS}
value={personType}
onChange={(e) => setPersonType(e.target.value as PersonType)}
required
/>
<Select
label="Portabilidade"
options={PORTABILITY_OPTIONS}
value={String(isPortability)}
onChange={(e) => setIsPortability(e.target.value === "true")}
hint="Cliente deseja portar números de outra operadora"
/>
<div className="sm:col-span-2">
<Select
label="Revenda"
options={resellerOptions}
value={resellerId}
onChange={(e) => setResellerId(e.target.value)}
placeholder={resellers === null ? "Carregando revendas…" : "Usar a revenda do usuário logado"}
hint="Só é aplicado se você tiver permissão para cadastrar em qualquer revenda; caso contrário, a revenda do seu usuário é usada automaticamente."
/>
</div>
<div className="sm:col-span-2 flex items-center gap-2 pt-2">
<Button type="submit" loading={submitting}>
Criar cadastro
</Button>
<Button type="button" variant="secondary" onClick={() => navigate("/client-registrations")}>
Cancelar
</Button>
</div>
</form>
</Card>
</div>
);
}

View File

@@ -0,0 +1,149 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Alert, Badge, Button, Card, PageHeader, Table } from "@eden/ui";
import type { BadgeVariant, TableColumn } from "@eden/ui";
import { listClientRegistrations } from "../../lib/api/client-registrations.js";
import type {
ClientRegistration,
PersonType,
RegistrationStatus,
} from "../../lib/api/client-registrations.js";
import { ApiError } from "../../lib/api/http.js";
import { formatDateBR } from "../../lib/format.js";
import { useAuth } from "../../lib/auth/AuthContext.js";
/**
* Layout reference: DreamsERP theme `.tmp/theme-reference/customers.tsx`
* (code column + name + status badge list) — rebuilt with `@eden/ui`'s
* `Card`/`Table`/`Badge` instead of the theme's antd Datatable, matching
* the pattern already used by CompaniesListPage/QuotesListPage.
*/
const PERSON_TYPE_LABEL: Record<PersonType, string> = {
pf: "Pessoa Física",
pj: "Pessoa Jurídica",
};
const STATUS_LABEL: Record<RegistrationStatus, string> = {
rascunho: "Rascunho",
pendente_validacao: "Pendente de validação",
ativo: "Ativo",
bloqueado: "Bloqueado",
inativo: "Inativo",
};
const STATUS_VARIANT: Record<RegistrationStatus, BadgeVariant> = {
rascunho: "neutral",
pendente_validacao: "warning",
ativo: "success",
bloqueado: "danger",
inativo: "neutral",
};
/** PF uses `pf_full_name`, PJ uses `pj_company_name` — falls back to a placeholder while the cadastro is still a `rascunho` with no name entered yet. */
function registrationName(registration: ClientRegistration): string {
const name = registration.person_type === "pf" ? registration.pf_full_name : registration.pj_company_name;
return name ?? "Sem nome preenchido";
}
export function ClientRegistrationsListPage() {
const { accessToken } = useAuth();
const navigate = useNavigate();
const [registrations, setRegistrations] = useState<ClientRegistration[] | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!accessToken) return;
let cancelled = false;
setError(null);
listClientRegistrations(accessToken)
.then((data) => {
if (!cancelled) setRegistrations(data);
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof ApiError ? err.message : "Não foi possível carregar os cadastros de cliente.");
});
return () => {
cancelled = true;
};
}, [accessToken]);
const columns: Array<TableColumn<ClientRegistration>> = [
{
key: "code",
header: "Código",
render: (registration) => <span className="font-semibold text-title">#{registration.client_code}</span>,
},
{
key: "name",
header: "Nome",
render: (registration) => (
<div>
<p className="font-semibold text-title">{registrationName(registration)}</p>
{registration.person_type === "pj" && registration.pj_trade_name ? (
<p className="text-xs text-default">{registration.pj_trade_name}</p>
) : null}
</div>
),
},
{
key: "personType",
header: "Tipo",
render: (registration) => <Badge variant="info">{PERSON_TYPE_LABEL[registration.person_type]}</Badge>,
},
{
key: "status",
header: "Status",
render: (registration) => (
<Badge variant={STATUS_VARIANT[registration.registration_status]}>
{STATUS_LABEL[registration.registration_status]}
</Badge>
),
},
{
key: "createdAt",
header: "Criado em",
render: (registration) => formatDateBR(registration.created_at),
},
{
key: "actions",
header: "",
className: "text-right",
render: (registration) => (
<Button variant="secondary" size="sm" onClick={() => navigate(`/client-registrations/${registration.id}`)}>
Ver
</Button>
),
},
];
return (
<div>
<PageHeader
title="Clientes"
description="Cadastros de cliente (Customer 360) usados para fechar negócios e integrar com o provisionamento."
actions={<Button onClick={() => navigate("/client-registrations/new")}>Novo cadastro</Button>}
/>
{error ? (
<Alert variant="error" className="mb-4">
{error}
</Alert>
) : null}
<Card>
{registrations === null && !error ? (
<p className="text-sm text-default">Carregando…</p>
) : (
<Table
columns={columns}
data={registrations ?? []}
rowKey={(registration) => registration.id}
emptyMessage="Nenhum cadastro de cliente encontrado."
/>
)}
</Card>
</div>
);
}

View File

@@ -0,0 +1,278 @@
import { useCallback, useEffect, useState } from "react";
import type { FormEvent } from "react";
import { Alert, Badge, Button, Card, Input, Table } from "@eden/ui";
import type { TableColumn } from "@eden/ui";
import { ApiError } from "../../lib/api/http.js";
import {
createClientRegistrationPartner,
listClientRegistrationPartners,
updateClientRegistrationPartner,
} from "../../lib/api/client-registrations.js";
import type { ClientRegistrationPartner, CreatePartnerInput } from "../../lib/api/client-registrations.js";
/**
* Sócios/QSA sub-section, PJ cadastros only (eden.md §1.2, §4). Structural
* reference: DreamsERP theme `.tmp/theme-reference/addSalesOrder.tsx` /
* `salesOrders.tsx` (a "detail + editable sub-list" pattern — order header
* plus a line-items table with inline add) — rebuilt with `@eden/ui`
* `Card`/`Table`/`Input`/`Button`, own list/add/edit state independent
* from the parent registration form (each partner is its own PATCH/POST).
*/
const EMPTY_NEW_PARTNER: CreatePartnerInput = {
name: "",
document: "",
qualification: "",
cpf: "",
willSign: false,
signatureEmail: "",
};
function toOptional(value: string | undefined): string | undefined {
const trimmed = (value ?? "").trim();
return trimmed === "" ? undefined : trimmed;
}
interface EditState {
cpf: string;
willSign: boolean;
signatureEmail: string;
}
export function PartnersSection({
registrationId,
accessToken,
}: {
registrationId: string;
accessToken: string | null;
}) {
const [partners, setPartners] = useState<ClientRegistrationPartner[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [newPartner, setNewPartner] = useState<CreatePartnerInput>(EMPTY_NEW_PARTNER);
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [editState, setEditState] = useState<EditState>({ cpf: "", willSign: false, signatureEmail: "" });
const [savingEdit, setSavingEdit] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
const load = useCallback(() => {
if (!accessToken) return;
setError(null);
listClientRegistrationPartners(registrationId, accessToken)
.then(setPartners)
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar os sócios."));
}, [registrationId, accessToken]);
useEffect(() => {
load();
}, [load]);
async function handleCreate(event: FormEvent) {
event.preventDefault();
if (!accessToken) return;
setCreateError(null);
setCreating(true);
try {
await createClientRegistrationPartner(
registrationId,
{
name: newPartner.name.trim(),
document: toOptional(newPartner.document),
qualification: toOptional(newPartner.qualification),
cpf: toOptional(newPartner.cpf),
willSign: newPartner.willSign,
signatureEmail: toOptional(newPartner.signatureEmail),
},
accessToken,
);
setNewPartner(EMPTY_NEW_PARTNER);
load();
} catch (err) {
setCreateError(err instanceof ApiError ? err.message : "Não foi possível adicionar o sócio.");
} finally {
setCreating(false);
}
}
function startEdit(partner: ClientRegistrationPartner) {
setEditingId(partner.id);
setEditError(null);
setEditState({
cpf: partner.cpf ?? "",
willSign: partner.will_sign,
signatureEmail: partner.signature_email ?? "",
});
}
async function handleSaveEdit(partnerId: string) {
if (!accessToken) return;
setEditError(null);
setSavingEdit(true);
try {
await updateClientRegistrationPartner(
registrationId,
partnerId,
{
cpf: toOptional(editState.cpf),
willSign: editState.willSign,
signatureEmail: toOptional(editState.signatureEmail),
},
accessToken,
);
setEditingId(null);
load();
} catch (err) {
setEditError(err instanceof ApiError ? err.message : "Não foi possível salvar o sócio.");
} finally {
setSavingEdit(false);
}
}
const columns: Array<TableColumn<ClientRegistrationPartner>> = [
{ key: "name", header: "Nome", render: (p) => <span className="font-semibold text-title">{p.name}</span> },
{ key: "document", header: "Documento", render: (p) => p.document ?? "—" },
{ key: "qualification", header: "Qualificação", render: (p) => p.qualification ?? "—" },
{
key: "cpf",
header: "CPF",
render: (p) =>
editingId === p.id ? (
<Input value={editState.cpf} onChange={(e) => setEditState((s) => ({ ...s, cpf: e.target.value }))} />
) : (
p.cpf ?? "—"
),
},
{
key: "willSign",
header: "Vai assinar",
render: (p) =>
editingId === p.id ? (
<label className="flex items-center gap-2 text-sm text-title">
<input
type="checkbox"
className="shrink-0 rounded-sm border-border-color text-primary focus:ring-primary"
checked={editState.willSign}
onChange={(e) => setEditState((s) => ({ ...s, willSign: e.target.checked }))}
/>
Sim
</label>
) : (
<Badge variant={p.will_sign ? "success" : "neutral"}>{p.will_sign ? "Sim" : "Não"}</Badge>
),
},
{
key: "signatureEmail",
header: "E-mail p/ assinatura",
render: (p) =>
editingId === p.id ? (
<Input
type="email"
value={editState.signatureEmail}
onChange={(e) => setEditState((s) => ({ ...s, signatureEmail: e.target.value }))}
/>
) : (
p.signature_email ?? "—"
),
},
{
key: "actions",
header: "",
className: "text-right",
render: (p) =>
editingId === p.id ? (
<div className="flex justify-end gap-2">
<Button type="button" size="sm" loading={savingEdit} onClick={() => handleSaveEdit(p.id)}>
Salvar
</Button>
<Button type="button" size="sm" variant="secondary" onClick={() => setEditingId(null)}>
Cancelar
</Button>
</div>
) : (
<Button type="button" size="sm" variant="secondary" onClick={() => startEdit(p)}>
Editar
</Button>
),
},
];
return (
<Card title="Sócios">
{error ? (
<Alert variant="error" className="mb-3">
{error}
</Alert>
) : null}
{editError ? (
<Alert variant="error" className="mb-3">
{editError}
</Alert>
) : null}
{partners === null && !error ? (
<p className="text-sm text-default">Carregando sócios…</p>
) : (
<Table
columns={columns}
data={partners ?? []}
rowKey={(p) => p.id}
emptyMessage="Nenhum sócio cadastrado."
/>
)}
<div className="mt-4 border-t border-border-color pt-4">
<p className="mb-3 text-sm font-semibold text-title">Adicionar sócio</p>
{createError ? (
<Alert variant="error" className="mb-3">
{createError}
</Alert>
) : null}
<form className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3" onSubmit={handleCreate}>
<Input
label="Nome"
value={newPartner.name}
onChange={(e) => setNewPartner((s) => ({ ...s, name: e.target.value }))}
required
/>
<Input
label="Documento"
value={newPartner.document}
onChange={(e) => setNewPartner((s) => ({ ...s, document: e.target.value }))}
/>
<Input
label="Qualificação"
value={newPartner.qualification}
onChange={(e) => setNewPartner((s) => ({ ...s, qualification: e.target.value }))}
/>
<Input
label="CPF"
value={newPartner.cpf}
onChange={(e) => setNewPartner((s) => ({ ...s, cpf: e.target.value }))}
/>
<Input
label="E-mail p/ assinatura"
type="email"
value={newPartner.signatureEmail}
onChange={(e) => setNewPartner((s) => ({ ...s, signatureEmail: e.target.value }))}
/>
<label className="flex items-center gap-2 self-end py-2.5 text-sm text-title">
<input
type="checkbox"
className="shrink-0 rounded-sm border-border-color text-primary focus:ring-primary"
checked={newPartner.willSign ?? false}
onChange={(e) => setNewPartner((s) => ({ ...s, willSign: e.target.checked }))}
/>
Vai assinar
</label>
<div className="sm:col-span-2 lg:col-span-3">
<Button type="submit" loading={creating}>
Adicionar sócio
</Button>
</div>
</form>
</div>
</Card>
);
}