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,
|
||||
* 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 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user