feat(resellers): UI de CNPJ/sócios/endereço/contatos, na ordem certa

Mesma UI construída pro Cadastro de Cliente, aplicada a Revenda, já
direto na ordem pedida (Dados -> Sócios -> Endereço -> Contatos), sem
precisar de uma segunda leva de reordenação. Criar uma revenda agora
redireciona pra edição dela (não pra lista) já que CNPJ-lookup/Sócios
só fazem sentido com um id existente. PartnersSection generalizada
para reuso entre Cliente e Revenda.

Corrige um bug real encontrado na verificação ao vivo: o rótulo do
botão de busca estava amarrado ao que estava sendo digitado, não ao
que já foi salvo — digitar um CNPJ válido trocava pra "Buscar
novamente" antes de qualquer busca real acontecer.

Suíte: 106/106 (inalterada). pnpm turbo run typecheck build: 28/28.
Testado ao vivo com CNPJ real (Petrobras).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 15:18:34 -03:00
parent 2ae61dba22
commit ec13dccee6
5 changed files with 943 additions and 46 deletions

View File

@@ -0,0 +1,310 @@
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";
/**
* Sócios/QSA sub-section, shared shape between `client_registration_partners`
* and `reseller_partners` — same columns (name/document/qualification/cpf/
* will_sign/signature_email), same "add inline + edit cpf/willSign/email"
* behavior, only the backing endpoints differ. Generalized out of
* client-registrations' original `PartnersSection` so the reseller screen
* doesn't duplicate the whole component — callers pass their own
* list/create/update functions (already the exact shape of
* `client-registrations.ts`'s and `resellers.ts`'s partner functions, no
* adapter needed at the call site).
*/
export interface PartnerLike {
id: string;
name: string;
document: string | null;
qualification: string | null;
cpf: string | null;
will_sign: boolean;
signature_email: string | null;
}
export interface PartnerCreateInput {
name: string;
document?: string;
qualification?: string;
cpf?: string;
willSign?: boolean;
signatureEmail?: string;
}
export interface PartnerUpdateInput {
cpf?: string;
willSign?: boolean;
signatureEmail?: string;
}
const EMPTY_NEW_PARTNER: PartnerCreateInput = {
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 interface PartnersSectionProps<P extends PartnerLike> {
/** Owning entity id (client registration or reseller) — `:id` in the partner endpoints. */
entityId: string;
accessToken: string | null;
/** Bump (e.g. a counter) to force a reload — used after a successful CNPJ lookup adds/updates partners. */
refreshKey?: unknown;
listPartners: (entityId: string, accessToken: string) => Promise<P[]>;
createPartner: (entityId: string, input: PartnerCreateInput, accessToken: string) => Promise<P>;
updatePartner: (entityId: string, partnerId: string, input: PartnerUpdateInput, accessToken: string) => Promise<P>;
title?: string;
}
export function PartnersSection<P extends PartnerLike>({
entityId,
accessToken,
refreshKey,
listPartners,
createPartner,
updatePartner,
title = "Sócios",
}: PartnersSectionProps<P>) {
const [partners, setPartners] = useState<P[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [newPartner, setNewPartner] = useState<PartnerCreateInput>(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);
listPartners(entityId, accessToken)
.then(setPartners)
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar os sócios."));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [entityId, accessToken, refreshKey]);
useEffect(() => {
load();
}, [load]);
async function handleCreate(event: FormEvent) {
event.preventDefault();
if (!accessToken) return;
setCreateError(null);
setCreating(true);
try {
await createPartner(
entityId,
{
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: P) {
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 updatePartner(
entityId,
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<P>> = [
{ 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={title}>
{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>
);
}

View File

@@ -1,7 +1,7 @@
/**
* Client for `apps/api` `/v1/resellers` (see apps/api/src/resellers/resellers.controller.ts,
* resellers.service.ts and packages/database/src/repositories/resellers.ts for the source of
* truth — shapes below are mirrored, not invented).
* resellers.service.ts and packages/database/src/repositories/resellers.ts +
* reseller-partners.ts for the source of truth — shapes below are mirrored, not invented).
*/
import { apiRequest } from "./http.js";
@@ -15,6 +15,30 @@ export interface Reseller {
document: string | null;
contact_email: string | null;
active: boolean;
trade_name: string | null;
state_registration: string | null;
municipal_registration: string | null;
opening_date: string | null;
legal_nature: string | null;
cnpj_situation: string | null;
main_activity: string | null;
address_zip: string | null;
address_street: string | null;
address_number: string | null;
address_complement: string | null;
address_neighborhood: string | null;
address_city: string | null;
address_state: string | null;
address_country: string;
contact_principal_name: string | null;
contact_principal_email: string | null;
contact_principal_phone: string | null;
contact_financial_name: string | null;
contact_financial_email: string | null;
contact_financial_phone: string | null;
contact_technical_name: string | null;
contact_technical_email: string | null;
contact_technical_phone: string | null;
}
/** Request body — camelCase, matches `CreateResellerDto` exactly. */
@@ -23,6 +47,33 @@ export interface CreateResellerInput {
document?: string;
contactEmail?: string;
active?: boolean;
tradeName?: string;
stateRegistration?: string;
municipalRegistration?: string;
openingDate?: string;
legalNature?: string;
cnpjSituation?: string;
mainActivity?: 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;
}
/** Matches `UpdateResellerDto`: everything optional. */
@@ -76,3 +127,88 @@ export function unassignUserFromReseller(
{ accessToken },
);
}
/** `reseller_partners` row — see packages/database/src/repositories/reseller-partners.ts. */
export interface ResellerPartner {
id: string;
reseller_id: string;
name: string;
document: string | null;
qualification: string | null;
source: string;
cpf: string | null;
will_sign: boolean;
signature_email: string | null;
created_at: string;
}
/** Body of `POST /v1/resellers/:id/partners` — matches `CreateResellerPartnerDto`. */
export interface CreateResellerPartnerInput {
name: string;
document?: string;
qualification?: string;
cpf?: string;
willSign?: boolean;
signatureEmail?: string;
}
/** Body of `PATCH /v1/resellers/:id/partners/:partnerId` — matches `UpdateResellerPartnerDto`. */
export interface UpdateResellerPartnerInput {
cpf?: string;
willSign?: boolean;
signatureEmail?: string;
}
export function listResellerPartners(id: string, accessToken: string): Promise<ResellerPartner[]> {
return apiRequest<ResellerPartner[]>(`/resellers/${id}/partners`, { method: "GET" }, { accessToken });
}
export function createResellerPartner(
id: string,
input: CreateResellerPartnerInput,
accessToken: string,
): Promise<ResellerPartner> {
return apiRequest<ResellerPartner>(
`/resellers/${id}/partners`,
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
export function updateResellerPartner(
id: string,
partnerId: string,
input: UpdateResellerPartnerInput,
accessToken: string,
): Promise<ResellerPartner> {
return apiRequest<ResellerPartner>(
`/resellers/${id}/partners/${partnerId}`,
{ method: "PATCH", body: JSON.stringify(input) },
{ accessToken },
);
}
/** Result of `POST /v1/resellers/:id/cnpj-lookup` on a match (201). */
export interface ResellerCnpjLookupResult {
reseller: Reseller;
partners: ResellerPartner[];
}
/**
* Looks up a CNPJ against the external source and applies the result to the
* reseller (company data, address, partners) server-side. 404 means the CNPJ
* wasn't found — caller should surface the API's message and leave the
* reseller untouched (caller catches `ApiError` and inspects
* `.status`/`.message`, same pattern as client-registrations).
*/
export function applyResellerCnpjLookup(
id: string,
cnpj: string,
accessToken: string,
): Promise<ResellerCnpjLookupResult> {
return apiRequest<ResellerCnpjLookupResult>(
`/resellers/${id}/cnpj-lookup`,
{ method: "POST", body: JSON.stringify({ cnpj }) },
{ accessToken },
);
}

View File

@@ -1,63 +1,187 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import type { FormEvent } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Alert, Button, Card, Input, PageHeader } from "@eden/ui";
import { createReseller, getReseller, updateReseller } from "../../lib/api/resellers.js";
import type { CreateResellerInput } from "../../lib/api/resellers.js";
import {
applyResellerCnpjLookup,
createReseller,
getReseller,
updateReseller,
} from "../../lib/api/resellers.js";
import type { CreateResellerInput, Reseller, UpdateResellerInput } from "../../lib/api/resellers.js";
import { ApiError } from "../../lib/api/http.js";
import { useAuth } from "../../lib/auth/AuthContext.js";
import { ResellerPartnersSection } from "./ResellerPartnersSection.js";
interface ResellerFormState {
/**
* Reused for both `/resellers/new` and `/resellers/:id/edit`, but the two
* modes diverge: creating a reseller has nowhere to persist CNPJ-lookup
* results or Sócios yet (both endpoints need an existing `:id`), so `/new`
* only shows the minimal fields, same as before. On successful creation the
* user is redirected into `/resellers/:id/edit` (not back to the list) so
* they can immediately use CNPJ-lookup/Sócios — see `handleSubmit`.
*
* Section order when editing (per product direction, same order as
* `ClientRegistrationDetailPage`): Dados da revenda (incl. CNPJ lookup) ->
* Sócios -> Endereço -> Contatos -> Status.
*/
interface FormState {
name: string;
document: string;
contactEmail: string;
active: boolean;
tradeName: string;
stateRegistration: string;
municipalRegistration: string;
openingDate: string;
legalNature: string;
cnpjSituation: string;
mainActivity: 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;
}
const EMPTY_FORM: ResellerFormState = { name: "", document: "", contactEmail: "", active: true };
const EMPTY_FORM: FormState = {
name: "",
document: "",
contactEmail: "",
active: true,
tradeName: "",
stateRegistration: "",
municipalRegistration: "",
openingDate: "",
legalNature: "",
cnpjSituation: "",
mainActivity: "",
addressZip: "",
addressStreet: "",
addressNumber: "",
addressComplement: "",
addressNeighborhood: "",
addressCity: "",
addressState: "",
addressCountry: "",
contactPrincipalName: "",
contactPrincipalEmail: "",
contactPrincipalPhone: "",
contactFinancialName: "",
contactFinancialEmail: "",
contactFinancialPhone: "",
contactTechnicalName: "",
contactTechnicalEmail: "",
contactTechnicalPhone: "",
};
function toDateInputValue(value: string | null): string {
return value ? value.slice(0, 10) : "";
}
function toFormState(reseller: Reseller): FormState {
return {
name: reseller.name,
document: reseller.document ?? "",
contactEmail: reseller.contact_email ?? "",
active: reseller.active,
tradeName: reseller.trade_name ?? "",
stateRegistration: reseller.state_registration ?? "",
municipalRegistration: reseller.municipal_registration ?? "",
openingDate: toDateInputValue(reseller.opening_date),
legalNature: reseller.legal_nature ?? "",
cnpjSituation: reseller.cnpj_situation ?? "",
mainActivity: reseller.main_activity ?? "",
addressZip: reseller.address_zip ?? "",
addressStreet: reseller.address_street ?? "",
addressNumber: reseller.address_number ?? "",
addressComplement: reseller.address_complement ?? "",
addressNeighborhood: reseller.address_neighborhood ?? "",
addressCity: reseller.address_city ?? "",
addressState: reseller.address_state ?? "",
addressCountry: reseller.address_country ?? "",
contactPrincipalName: reseller.contact_principal_name ?? "",
contactPrincipalEmail: reseller.contact_principal_email ?? "",
contactPrincipalPhone: reseller.contact_principal_phone ?? "",
contactFinancialName: reseller.contact_financial_name ?? "",
contactFinancialEmail: reseller.contact_financial_email ?? "",
contactFinancialPhone: reseller.contact_financial_phone ?? "",
contactTechnicalName: reseller.contact_technical_name ?? "",
contactTechnicalEmail: reseller.contact_technical_email ?? "",
contactTechnicalPhone: reseller.contact_technical_phone ?? "",
};
}
function toOptional(value: string): string | undefined {
const trimmed = value.trim();
return trimmed === "" ? undefined : trimmed;
}
/** Reused for both `/resellers/new` and `/resellers/:id/edit`. */
export function ResellerFormPage() {
const { id } = useParams<{ id: string }>();
const isEditing = Boolean(id);
const { accessToken } = useAuth();
const navigate = useNavigate();
const [form, setForm] = useState<ResellerFormState>(EMPTY_FORM);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [loading, setLoading] = useState(isEditing);
const [loadError, setLoadError] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [saveSuccess, setSaveSuccess] = useState(false);
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
const [cnpjLookupLoading, setCnpjLookupLoading] = useState(false);
const [cnpjLookupError, setCnpjLookupError] = useState<string | null>(null);
const [cnpjLookupNotFound, setCnpjLookupNotFound] = useState<string | null>(null);
const [cnpjLookupSuccess, setCnpjLookupSuccess] = useState<string | null>(null);
const [partnersRefreshKey, setPartnersRefreshKey] = useState(0);
// Persisted value (from the server), never the live `form.document` being
// typed — the "Buscar CNPJ" vs. "Buscar novamente" label reflects whether
// this reseller already HAS a saved document, not whether the field
// currently holds 14 digits (typing a CNPJ before ever searching/saving
// must not flip the label prematurely).
const [savedDocument, setSavedDocument] = useState<string | null>(null);
const load = useCallback(() => {
if (!id || !accessToken) return;
let cancelled = false;
setLoading(true);
setLoadError(null);
getReseller(id, accessToken)
.then((reseller) => {
if (cancelled) return;
setForm({
name: reseller.name,
document: reseller.document ?? "",
contactEmail: reseller.contact_email ?? "",
active: reseller.active,
});
setForm(toFormState(reseller));
setSavedDocument(reseller.document);
})
.catch((err) => {
if (!cancelled) setError(err instanceof ApiError ? err.message : "Não foi possível carregar a revenda.");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
.catch((err) => setLoadError(err instanceof ApiError ? err.message : "Não foi possível carregar a revenda."))
.finally(() => setLoading(false));
}, [id, accessToken]);
function updateField<K extends keyof ResellerFormState>(field: K, value: ResellerFormState[K]) {
useEffect(() => {
load();
}, [load]);
function updateField<K extends keyof FormState>(field: K, value: FormState[K]) {
setForm((prev) => ({ ...prev, [field]: value }));
}
@@ -65,21 +189,52 @@ export function ResellerFormPage() {
event.preventDefault();
if (!accessToken) return;
setError(null);
setSaveSuccess(false);
setSubmitting(true);
try {
const payload: CreateResellerInput = {
const payload: CreateResellerInput | UpdateResellerInput = {
name: form.name.trim(),
document: toOptional(form.document),
contactEmail: toOptional(form.contactEmail),
active: form.active,
tradeName: toOptional(form.tradeName),
stateRegistration: toOptional(form.stateRegistration),
municipalRegistration: toOptional(form.municipalRegistration),
openingDate: toOptional(form.openingDate),
legalNature: toOptional(form.legalNature),
cnpjSituation: toOptional(form.cnpjSituation),
mainActivity: toOptional(form.mainActivity),
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 (isEditing && id) {
await updateReseller(id, payload, accessToken);
const updated = await updateReseller(id, payload, accessToken);
setForm(toFormState(updated));
setSavedDocument(updated.document);
setSaveSuccess(true);
} else {
await createReseller(payload, accessToken);
const created = await createReseller(payload as CreateResellerInput, accessToken);
navigate(`/resellers/${created.id}/edit`, { replace: true });
}
navigate("/resellers", { replace: true });
} catch (err) {
setError(err instanceof ApiError ? err.message : "Não foi possível salvar a revenda. Tente novamente.");
} finally {
@@ -87,6 +242,40 @@ export function ResellerFormPage() {
}
}
async function runCnpjLookup(cnpj: string) {
if (!accessToken || !id) return;
setCnpjLookupError(null);
setCnpjLookupNotFound(null);
setCnpjLookupSuccess(null);
setCnpjLookupLoading(true);
try {
const result = await applyResellerCnpjLookup(id, cnpj, accessToken);
setForm(toFormState(result.reseller));
setSavedDocument(result.reseller.document);
setCnpjLookupSuccess(
`Dados encontrados e preenchidos automaticamente. ${result.partners.length} sócio(s) no cadastro.`,
);
setPartnersRefreshKey((k) => k + 1);
} catch (err) {
if (err instanceof ApiError && err.status === 404) {
setCnpjLookupNotFound(err.message);
} else {
setCnpjLookupError(err instanceof ApiError ? err.message : "Não foi possível buscar o CNPJ.");
}
} finally {
setCnpjLookupLoading(false);
}
}
if (loadError) {
return (
<div>
<PageHeader title="Editar revenda" />
<Alert variant="error">{loadError}</Alert>
</div>
);
}
return (
<div>
<PageHeader
@@ -113,25 +302,243 @@ export function ResellerFormPage() {
{error}
</Alert>
) : null}
{saveSuccess ? (
<Alert variant="success" className="mb-4">
Revenda salva com sucesso.
</Alert>
) : null}
{loading ? (
<Card>
<p className="text-sm text-default">Carregando…</p>
</Card>
) : (
<form id="reseller-form" className="space-y-4" onSubmit={handleSubmit}>
<Card title="Dados da revenda">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<Input label="Nome" value={form.name} onChange={(e) => updateField("name", e.target.value)} required />
<>
<form id="reseller-form" className="space-y-4" onSubmit={handleSubmit}>
<Card title="Dados da revenda">
{isEditing ? (
<>
{cnpjLookupError ? (
<Alert variant="error" className="mb-4">
{cnpjLookupError}
</Alert>
) : null}
{cnpjLookupNotFound ? (
<Alert variant="warning" className="mb-4">
{cnpjLookupNotFound}
</Alert>
) : null}
{cnpjLookupSuccess ? (
<Alert variant="success" className="mb-4">
{cnpjLookupSuccess}
</Alert>
) : null}
</>
) : null}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<div className="sm:col-span-2 lg:col-span-3">
<Input label="Nome" value={form.name} onChange={(e) => updateField("name", e.target.value)} required />
</div>
{isEditing ? (
<div className="flex items-end gap-2">
<div className="flex-1">
<Input
label="Documento (CPF/CNPJ)"
value={form.document}
onChange={(e) => updateField("document", e.target.value)}
placeholder="00.000.000/0000-00"
/>
</div>
<Button
type="button"
variant="secondary"
size="sm"
loading={cnpjLookupLoading}
disabled={form.document.replace(/\D/g, "").length < 14}
onClick={() => runCnpjLookup(form.document)}
>
{savedDocument ? "Buscar novamente" : "Buscar CNPJ"}
</Button>
</div>
) : (
<Input
label="Documento (CPF/CNPJ)"
value={form.document}
onChange={(e) => updateField("document", e.target.value)}
/>
)}
<Input
label="E-mail de contato"
type="email"
value={form.contactEmail}
onChange={(e) => updateField("contactEmail", e.target.value)}
/>
{isEditing ? (
<>
<Input
label="Nome fantasia"
value={form.tradeName}
onChange={(e) => updateField("tradeName", e.target.value)}
/>
<Input
label="Inscrição estadual"
value={form.stateRegistration}
onChange={(e) => updateField("stateRegistration", e.target.value)}
/>
<Input
label="Inscrição municipal"
value={form.municipalRegistration}
onChange={(e) => updateField("municipalRegistration", e.target.value)}
/>
<Input
label="Data de abertura"
type="date"
value={form.openingDate}
onChange={(e) => updateField("openingDate", e.target.value)}
/>
<Input
label="Natureza jurídica"
value={form.legalNature}
onChange={(e) => updateField("legalNature", e.target.value)}
/>
<Input
label="Situação do CNPJ"
value={form.cnpjSituation}
onChange={(e) => updateField("cnpjSituation", e.target.value)}
/>
<Input
label="Atividade principal"
value={form.mainActivity}
onChange={(e) => updateField("mainActivity", e.target.value)}
/>
</>
) : null}
</div>
<Input label="Documento (CPF/CNPJ)" value={form.document} onChange={(e) => updateField("document", e.target.value)} />
<Input
label="E-mail de contato"
type="email"
value={form.contactEmail}
onChange={(e) => updateField("contactEmail", e.target.value)}
/>
</Card>
</form>
{isEditing && id ? (
<div className="mt-4">
<ResellerPartnersSection resellerId={id} accessToken={accessToken} refreshKey={partnersRefreshKey} />
</div>
) : null}
<form className={isEditing ? "mt-4 space-y-4" : "space-y-4"} onSubmit={handleSubmit}>
{isEditing ? (
<>
<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>
</>
) : null}
<Card title="Status">
<div className="flex flex-col gap-1">
<span className="text-sm font-semibold text-gray-900">Status</span>
<label className="flex items-center gap-2 py-2.5 text-sm text-title">
@@ -144,9 +551,15 @@ export function ResellerFormPage() {
Revenda ativa
</label>
</div>
</Card>
<div className="flex items-center gap-2">
<Button type="submit" loading={submitting}>
Salvar
</Button>
</div>
</Card>
</form>
</form>
</>
)}
</div>
);

View File

@@ -0,0 +1,25 @@
import { PartnersSection } from "../../components/PartnersSection.js";
import { createResellerPartner, listResellerPartners, updateResellerPartner } from "../../lib/api/resellers.js";
import type { ResellerPartner } from "../../lib/api/resellers.js";
/** Thin wrapper around the shared `PartnersSection`, bound to `/resellers/:id/partners`. */
export function ResellerPartnersSection({
resellerId,
accessToken,
refreshKey,
}: {
resellerId: string;
accessToken: string | null;
refreshKey?: unknown;
}) {
return (
<PartnersSection<ResellerPartner>
entityId={resellerId}
accessToken={accessToken}
refreshKey={refreshKey}
listPartners={listResellerPartners}
createPartner={createResellerPartner}
updatePartner={updateResellerPartner}
/>
);
}

View File

@@ -762,3 +762,16 @@ Endpoints novos em `resellers.controller.ts`: `GET/POST /v1/resellers/:id/partne
### Pendências
UI ainda não construída — só o backend nesta leva (frontend é o próximo passo natural, mesmo padrão de Cliente: Dados PJ → Sócios → Endereço → Contatos, já direto na ordem certa desta vez, sem precisar de uma segunda leva de reordenação).
---
## UI de Cadastro de Revenda — CNPJ, sócios, endereço, contatos
Data: 2026-09-18. Frontend da leva anterior (agente `eden-frontend`), construído direto na ordem pedida: Dados da revenda (com busca de CNPJ) → Sócios → Endereço → Contatos → Status.
`ResellerFormPage.tsx` reescrita: ao criar uma revenda nova (sem `id` ainda), continua só com os campos mínimos (Nome/Documento/E-mail/Ativo) — CNPJ-lookup e Sócios precisam de um `id` existente. Diferença de UX deliberada em relação a Cliente: depois de criar, o usuário é redirecionado direto pra `/resellers/:id/edit` (antes voltava pra lista) — senão não teria como usar as novidades sem um clique extra. `PartnersSection.tsx` foi generalizado (`apps/core-web/src/components/PartnersSection.tsx`, funções de API via prop) em vez de duplicado — a versão de Cliente ficou como estava, pronta pra migrar depois se fizer sentido, sem necessidade agora.
**Bug real encontrado e corrigido na verificação ao vivo (por mim, não pelo agente)**: o rótulo do botão ("Buscar CNPJ" vs. "Buscar novamente") estava amarrado à contagem de dígitos do campo `document` sendo DIGITADO (`form.document`, ainda não salvo) em vez de ao valor já persistido no servidor — resultado: digitar um CNPJ válido já trocava o botão para "Buscar novamente" antes de qualquer busca real ter acontecido, uma mensagem enganosa (funcionalmente a busca ainda funcionava, mas o texto mentia sobre o estado). Corrigido com um novo estado `savedDocument` (preenchido só a partir do que o servidor confirma — ao carregar, ao salvar manualmente, ao concluir uma busca), o rótulo agora reflete a persistência real, não a digitação.
### Verificação
`pnpm turbo run typecheck build` 28/28 (antes e depois da correção). Suíte 106/106 (inalterada, mudança de frontend). Testado ao vivo em navegador real via Playwright, incluindo o cenário exato do bug (digitar 14 dígitos antes de qualquer busca — botão permanece "Buscar CNPJ" corretamente agora) e o ciclo completo com CNPJ real (Petrobras, nome fantasia e sócio confirmados). Revendas de teste desativadas ao final.