diff --git a/apps/core-web/src/components/PartnersSection.tsx b/apps/core-web/src/components/PartnersSection.tsx new file mode 100644 index 0000000..0f9ce94 --- /dev/null +++ b/apps/core-web/src/components/PartnersSection.tsx @@ -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

{ + /** 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; + createPartner: (entityId: string, input: PartnerCreateInput, accessToken: string) => Promise

; + updatePartner: (entityId: string, partnerId: string, input: PartnerUpdateInput, accessToken: string) => Promise

; + title?: string; +} + +export function PartnersSection

({ + entityId, + accessToken, + refreshKey, + listPartners, + createPartner, + updatePartner, + title = "Sócios", +}: PartnersSectionProps

) { + const [partners, setPartners] = useState(null); + const [error, setError] = useState(null); + + const [newPartner, setNewPartner] = useState(EMPTY_NEW_PARTNER); + const [creating, setCreating] = useState(false); + const [createError, setCreateError] = useState(null); + + const [editingId, setEditingId] = useState(null); + const [editState, setEditState] = useState({ cpf: "", willSign: false, signatureEmail: "" }); + const [savingEdit, setSavingEdit] = useState(false); + const [editError, setEditError] = useState(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> = [ + { key: "name", header: "Nome", render: (p) => {p.name} }, + { 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 ? ( + setEditState((s) => ({ ...s, cpf: e.target.value }))} /> + ) : ( + p.cpf ?? "—" + ), + }, + { + key: "willSign", + header: "Vai assinar", + render: (p) => + editingId === p.id ? ( + + ) : ( + {p.will_sign ? "Sim" : "Não"} + ), + }, + { + key: "signatureEmail", + header: "E-mail p/ assinatura", + render: (p) => + editingId === p.id ? ( + setEditState((s) => ({ ...s, signatureEmail: e.target.value }))} + /> + ) : ( + p.signature_email ?? "—" + ), + }, + { + key: "actions", + header: "", + className: "text-right", + render: (p) => + editingId === p.id ? ( +

+ + +
+ ) : ( + + ), + }, + ]; + + return ( + + {error ? ( + + {error} + + ) : null} + {editError ? ( + + {editError} + + ) : null} + + {partners === null && !error ? ( +

Carregando sócios…

+ ) : ( + p.id} emptyMessage="Nenhum sócio cadastrado." /> + )} + +
+

Adicionar sócio

+ {createError ? ( + + {createError} + + ) : null} +
+ setNewPartner((s) => ({ ...s, name: e.target.value }))} + required + /> + setNewPartner((s) => ({ ...s, document: e.target.value }))} + /> + setNewPartner((s) => ({ ...s, qualification: e.target.value }))} + /> + setNewPartner((s) => ({ ...s, cpf: e.target.value }))} + /> + setNewPartner((s) => ({ ...s, signatureEmail: e.target.value }))} + /> + +
+ +
+ +
+ + ); +} diff --git a/apps/core-web/src/lib/api/resellers.ts b/apps/core-web/src/lib/api/resellers.ts index e2aa409..691fadc 100644 --- a/apps/core-web/src/lib/api/resellers.ts +++ b/apps/core-web/src/lib/api/resellers.ts @@ -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 { + return apiRequest(`/resellers/${id}/partners`, { method: "GET" }, { accessToken }); +} + +export function createResellerPartner( + id: string, + input: CreateResellerPartnerInput, + accessToken: string, +): Promise { + return apiRequest( + `/resellers/${id}/partners`, + { method: "POST", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +export function updateResellerPartner( + id: string, + partnerId: string, + input: UpdateResellerPartnerInput, + accessToken: string, +): Promise { + return apiRequest( + `/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 { + return apiRequest( + `/resellers/${id}/cnpj-lookup`, + { method: "POST", body: JSON.stringify({ cnpj }) }, + { accessToken }, + ); +} diff --git a/apps/core-web/src/routes/resellers/ResellerFormPage.tsx b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx index be7c921..d8bc752 100644 --- a/apps/core-web/src/routes/resellers/ResellerFormPage.tsx +++ b/apps/core-web/src/routes/resellers/ResellerFormPage.tsx @@ -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(EMPTY_FORM); + const [form, setForm] = useState(EMPTY_FORM); const [loading, setLoading] = useState(isEditing); + const [loadError, setLoadError] = useState(null); const [error, setError] = useState(null); + const [saveSuccess, setSaveSuccess] = useState(false); const [submitting, setSubmitting] = useState(false); - useEffect(() => { + const [cnpjLookupLoading, setCnpjLookupLoading] = useState(false); + const [cnpjLookupError, setCnpjLookupError] = useState(null); + const [cnpjLookupNotFound, setCnpjLookupNotFound] = useState(null); + const [cnpjLookupSuccess, setCnpjLookupSuccess] = useState(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(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(field: K, value: ResellerFormState[K]) { + useEffect(() => { + load(); + }, [load]); + + function updateField(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 ( +
+ + {loadError} +
+ ); + } + return (
) : null} + {saveSuccess ? ( + + Revenda salva com sucesso. + + ) : null} {loading ? (

Carregando…

) : ( -
- -
-
- updateField("name", e.target.value)} required /> + <> + + + {isEditing ? ( + <> + {cnpjLookupError ? ( + + {cnpjLookupError} + + ) : null} + {cnpjLookupNotFound ? ( + + {cnpjLookupNotFound} + + ) : null} + {cnpjLookupSuccess ? ( + + {cnpjLookupSuccess} + + ) : null} + + ) : null} +
+
+ updateField("name", e.target.value)} required /> +
+ + {isEditing ? ( +
+
+ updateField("document", e.target.value)} + placeholder="00.000.000/0000-00" + /> +
+ +
+ ) : ( + updateField("document", e.target.value)} + /> + )} + + updateField("contactEmail", e.target.value)} + /> + + {isEditing ? ( + <> + updateField("tradeName", e.target.value)} + /> + updateField("stateRegistration", e.target.value)} + /> + updateField("municipalRegistration", e.target.value)} + /> + updateField("openingDate", e.target.value)} + /> + updateField("legalNature", e.target.value)} + /> + updateField("cnpjSituation", e.target.value)} + /> + updateField("mainActivity", e.target.value)} + /> + + ) : null}
- updateField("document", e.target.value)} /> - updateField("contactEmail", e.target.value)} - /> +
+ + + {isEditing && id ? ( +
+ +
+ ) : null} + +
+ {isEditing ? ( + <> + +
+ updateField("addressZip", e.target.value)} /> +
+ updateField("addressStreet", e.target.value)} + /> +
+ updateField("addressNumber", e.target.value)} + /> + updateField("addressComplement", e.target.value)} + /> + updateField("addressNeighborhood", e.target.value)} + /> + updateField("addressCity", e.target.value)} + /> + updateField("addressState", e.target.value.toUpperCase())} + /> + updateField("addressCountry", e.target.value)} + placeholder="Brasil" + /> +
+
+ + +
+
+

Principal

+ updateField("contactPrincipalName", e.target.value)} + /> + updateField("contactPrincipalEmail", e.target.value)} + /> + updateField("contactPrincipalPhone", e.target.value)} + /> +
+
+

Financeiro

+ updateField("contactFinancialName", e.target.value)} + /> + updateField("contactFinancialEmail", e.target.value)} + /> + updateField("contactFinancialPhone", e.target.value)} + /> +
+
+

Técnico

+ updateField("contactTechnicalName", e.target.value)} + /> + updateField("contactTechnicalEmail", e.target.value)} + /> + updateField("contactTechnicalPhone", e.target.value)} + /> +
+
+
+ + ) : null} + +
Status
+
+ +
+
- - + + )}
); diff --git a/apps/core-web/src/routes/resellers/ResellerPartnersSection.tsx b/apps/core-web/src/routes/resellers/ResellerPartnersSection.tsx new file mode 100644 index 0000000..f7b72b2 --- /dev/null +++ b/apps/core-web/src/routes/resellers/ResellerPartnersSection.tsx @@ -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 ( + + entityId={resellerId} + accessToken={accessToken} + refreshKey={refreshKey} + listPartners={listResellerPartners} + createPartner={createResellerPartner} + updatePartner={updateResellerPartner} + /> + ); +} diff --git a/docs/progress.md b/docs/progress.md index b18ed7b..7ba8f8e 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -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.