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:
310
apps/core-web/src/components/PartnersSection.tsx
Normal file
310
apps/core-web/src/components/PartnersSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Client for `apps/api` `/v1/resellers` (see apps/api/src/resellers/resellers.controller.ts,
|
* 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
|
* resellers.service.ts and packages/database/src/repositories/resellers.ts +
|
||||||
* truth — shapes below are mirrored, not invented).
|
* reseller-partners.ts for the source of truth — shapes below are mirrored, not invented).
|
||||||
*/
|
*/
|
||||||
import { apiRequest } from "./http.js";
|
import { apiRequest } from "./http.js";
|
||||||
|
|
||||||
@@ -15,6 +15,30 @@ export interface Reseller {
|
|||||||
document: string | null;
|
document: string | null;
|
||||||
contact_email: string | null;
|
contact_email: string | null;
|
||||||
active: boolean;
|
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. */
|
/** Request body — camelCase, matches `CreateResellerDto` exactly. */
|
||||||
@@ -23,6 +47,33 @@ export interface CreateResellerInput {
|
|||||||
document?: string;
|
document?: string;
|
||||||
contactEmail?: string;
|
contactEmail?: string;
|
||||||
active?: boolean;
|
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. */
|
/** Matches `UpdateResellerDto`: everything optional. */
|
||||||
@@ -76,3 +127,88 @@ export function unassignUserFromReseller(
|
|||||||
{ accessToken },
|
{ 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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,63 +1,187 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import type { FormEvent } from "react";
|
import type { FormEvent } from "react";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
import { Alert, Button, Card, Input, PageHeader } from "@eden/ui";
|
import { Alert, Button, Card, Input, PageHeader } from "@eden/ui";
|
||||||
import { createReseller, getReseller, updateReseller } from "../../lib/api/resellers.js";
|
import {
|
||||||
import type { CreateResellerInput } from "../../lib/api/resellers.js";
|
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 { ApiError } from "../../lib/api/http.js";
|
||||||
import { useAuth } from "../../lib/auth/AuthContext.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;
|
name: string;
|
||||||
document: string;
|
document: string;
|
||||||
contactEmail: string;
|
contactEmail: string;
|
||||||
active: boolean;
|
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 {
|
function toOptional(value: string): string | undefined {
|
||||||
const trimmed = value.trim();
|
const trimmed = value.trim();
|
||||||
return trimmed === "" ? undefined : trimmed;
|
return trimmed === "" ? undefined : trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Reused for both `/resellers/new` and `/resellers/:id/edit`. */
|
|
||||||
export function ResellerFormPage() {
|
export function ResellerFormPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const isEditing = Boolean(id);
|
const isEditing = Boolean(id);
|
||||||
const { accessToken } = useAuth();
|
const { accessToken } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
const [form, setForm] = useState<ResellerFormState>(EMPTY_FORM);
|
const [form, setForm] = useState<FormState>(EMPTY_FORM);
|
||||||
const [loading, setLoading] = useState(isEditing);
|
const [loading, setLoading] = useState(isEditing);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||||
const [submitting, setSubmitting] = 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;
|
if (!id || !accessToken) return;
|
||||||
let cancelled = false;
|
setLoading(true);
|
||||||
|
setLoadError(null);
|
||||||
getReseller(id, accessToken)
|
getReseller(id, accessToken)
|
||||||
.then((reseller) => {
|
.then((reseller) => {
|
||||||
if (cancelled) return;
|
setForm(toFormState(reseller));
|
||||||
setForm({
|
setSavedDocument(reseller.document);
|
||||||
name: reseller.name,
|
|
||||||
document: reseller.document ?? "",
|
|
||||||
contactEmail: reseller.contact_email ?? "",
|
|
||||||
active: reseller.active,
|
|
||||||
});
|
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => setLoadError(err instanceof ApiError ? err.message : "Não foi possível carregar a revenda."))
|
||||||
if (!cancelled) setError(err instanceof ApiError ? err.message : "Não foi possível carregar a revenda.");
|
.finally(() => setLoading(false));
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (!cancelled) setLoading(false);
|
|
||||||
});
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [id, accessToken]);
|
}, [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 }));
|
setForm((prev) => ({ ...prev, [field]: value }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,21 +189,52 @@ export function ResellerFormPage() {
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!accessToken) return;
|
if (!accessToken) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setSaveSuccess(false);
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const payload: CreateResellerInput = {
|
const payload: CreateResellerInput | UpdateResellerInput = {
|
||||||
name: form.name.trim(),
|
name: form.name.trim(),
|
||||||
document: toOptional(form.document),
|
document: toOptional(form.document),
|
||||||
contactEmail: toOptional(form.contactEmail),
|
contactEmail: toOptional(form.contactEmail),
|
||||||
active: form.active,
|
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) {
|
if (isEditing && id) {
|
||||||
await updateReseller(id, payload, accessToken);
|
const updated = await updateReseller(id, payload, accessToken);
|
||||||
|
setForm(toFormState(updated));
|
||||||
|
setSavedDocument(updated.document);
|
||||||
|
setSaveSuccess(true);
|
||||||
} else {
|
} 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) {
|
} catch (err) {
|
||||||
setError(err instanceof ApiError ? err.message : "Não foi possível salvar a revenda. Tente novamente.");
|
setError(err instanceof ApiError ? err.message : "Não foi possível salvar a revenda. Tente novamente.");
|
||||||
} finally {
|
} 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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -113,25 +302,243 @@ export function ResellerFormPage() {
|
|||||||
{error}
|
{error}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
|
{saveSuccess ? (
|
||||||
|
<Alert variant="success" className="mb-4">
|
||||||
|
Revenda salva com sucesso.
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Card>
|
<Card>
|
||||||
<p className="text-sm text-default">Carregando…</p>
|
<p className="text-sm text-default">Carregando…</p>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<form id="reseller-form" className="space-y-4" onSubmit={handleSubmit}>
|
<form id="reseller-form" className="space-y-4" onSubmit={handleSubmit}>
|
||||||
<Card title="Dados da revenda">
|
<Card title="Dados da revenda">
|
||||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
{isEditing ? (
|
||||||
<div className="sm:col-span-2">
|
<>
|
||||||
|
{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 />
|
<Input label="Nome" value={form.name} onChange={(e) => updateField("name", e.target.value)} required />
|
||||||
</div>
|
</div>
|
||||||
<Input label="Documento (CPF/CNPJ)" value={form.document} onChange={(e) => updateField("document", e.target.value)} />
|
|
||||||
|
{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
|
<Input
|
||||||
label="E-mail de contato"
|
label="E-mail de contato"
|
||||||
type="email"
|
type="email"
|
||||||
value={form.contactEmail}
|
value={form.contactEmail}
|
||||||
onChange={(e) => updateField("contactEmail", e.target.value)}
|
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>
|
||||||
|
</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">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-sm font-semibold text-gray-900">Status</span>
|
<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">
|
<label className="flex items-center gap-2 py-2.5 text-sm text-title">
|
||||||
@@ -144,9 +551,15 @@ export function ResellerFormPage() {
|
|||||||
Revenda ativa
|
Revenda ativa
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="submit" loading={submitting}>
|
||||||
|
Salvar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -762,3 +762,16 @@ Endpoints novos em `resellers.controller.ts`: `GET/POST /v1/resellers/:id/partne
|
|||||||
|
|
||||||
### Pendências
|
### 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 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.
|
||||||
|
|||||||
Reference in New Issue
Block a user