From 19e67dbe634ed7b13b91d321d49b97f989f4d9bb Mon Sep 17 00:00:00 2001 From: "Matheus (Handix)" Date: Thu, 3 Sep 2026 22:51:40 -0300 Subject: [PATCH] feat(web): telas de Cadastro de Cliente e Contratos no EDEN Core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Continuação direta da leva anterior, sem parar para autorização (a pedido do usuário). 2 subagentes eden-frontend em paralelo. - Cadastro de Cliente: lista, criação direta (PF/PJ + portabilidade), detalhe rico editável por seção (status, endereço, contatos, dados PF/PJ com CPF/CNPJ sempre somente-leitura, portabilidade condicional) + sub-seção de Sócios para PJ. Fecha a pendência da leva anterior: QuoteDetailPage não pede mais UUID colado à mão para fechar negócio, agora é um select real com atalho para criar cadastro novo. - Contratos: lista, criação (itens produto+quantidade, partes contratada/contratante), detalhe com resumo financeiro, aviso de fidelidade sem efeito legal quando não aprovada, histórico de status, e botões de transição limitados aos alvos válidos a partir do status atual (servidor continua sendo a única fonte de verdade real). QuoteDetailPage ganha link "Criar contrato a partir desta oferta" quando a oferta está fechada. Reconciliação manual de App.tsx/AuthenticatedLayout.tsx/QuoteDetailPage.tsx entre os 2 agentes (mais o de Empresas/CRM/Ofertas da leva anterior), um de cada vez, confirmando typecheck/build limpo a cada mesclagem. Verificado: pnpm turbo run typecheck build (28/28); containers eden-api/eden-core reconstruídos; fluxo completo testado ao vivo ATRAVÉS DO PROXY same-origin (porta 3001, não direto na API) — login, criação de cadastro, criação de contrato, transição de status inválida corretamente rejeitada com 409. Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx Co-Authored-By: Claude Sonnet 5 --- apps/core-web/src/App.tsx | 14 + .../src/lib/api/client-registrations.ts | 266 ++++++++ apps/core-web/src/lib/api/contracts.ts | 198 ++++++ .../src/routes/AuthenticatedLayout.tsx | 2 + .../ClientRegistrationDetailPage.tsx | 613 ++++++++++++++++++ .../ClientRegistrationFormPage.tsx | 130 ++++ .../ClientRegistrationsListPage.tsx | 149 +++++ .../client-registrations/PartnersSection.tsx | 278 ++++++++ .../routes/contracts/ContractDetailPage.tsx | 346 ++++++++++ .../src/routes/contracts/ContractFormPage.tsx | 492 ++++++++++++++ .../routes/contracts/ContractsListPage.tsx | 103 +++ .../src/routes/contracts/contractStatus.ts | 25 + .../src/routes/quotes/QuoteDetailPage.tsx | 107 ++- docs/progress.md | 25 + 14 files changed, 2732 insertions(+), 16 deletions(-) create mode 100644 apps/core-web/src/lib/api/client-registrations.ts create mode 100644 apps/core-web/src/lib/api/contracts.ts create mode 100644 apps/core-web/src/routes/client-registrations/ClientRegistrationDetailPage.tsx create mode 100644 apps/core-web/src/routes/client-registrations/ClientRegistrationFormPage.tsx create mode 100644 apps/core-web/src/routes/client-registrations/ClientRegistrationsListPage.tsx create mode 100644 apps/core-web/src/routes/client-registrations/PartnersSection.tsx create mode 100644 apps/core-web/src/routes/contracts/ContractDetailPage.tsx create mode 100644 apps/core-web/src/routes/contracts/ContractFormPage.tsx create mode 100644 apps/core-web/src/routes/contracts/ContractsListPage.tsx create mode 100644 apps/core-web/src/routes/contracts/contractStatus.ts diff --git a/apps/core-web/src/App.tsx b/apps/core-web/src/App.tsx index cb2c722..57c56a2 100644 --- a/apps/core-web/src/App.tsx +++ b/apps/core-web/src/App.tsx @@ -18,6 +18,12 @@ import { OpportunityFormPage } from "./routes/crm/OpportunityFormPage.js"; import { QuotesListPage } from "./routes/quotes/QuotesListPage.js"; import { QuoteFormPage } from "./routes/quotes/QuoteFormPage.js"; import { QuoteDetailPage } from "./routes/quotes/QuoteDetailPage.js"; +import { ClientRegistrationsListPage } from "./routes/client-registrations/ClientRegistrationsListPage.js"; +import { ClientRegistrationFormPage } from "./routes/client-registrations/ClientRegistrationFormPage.js"; +import { ClientRegistrationDetailPage } from "./routes/client-registrations/ClientRegistrationDetailPage.js"; +import { ContractsListPage } from "./routes/contracts/ContractsListPage.js"; +import { ContractFormPage } from "./routes/contracts/ContractFormPage.js"; +import { ContractDetailPage } from "./routes/contracts/ContractDetailPage.js"; export function App() { return ( @@ -53,6 +59,14 @@ export function App() { } /> } /> } /> + + } /> + } /> + } /> + + } /> + } /> + } /> diff --git a/apps/core-web/src/lib/api/client-registrations.ts b/apps/core-web/src/lib/api/client-registrations.ts new file mode 100644 index 0000000..882ed3f --- /dev/null +++ b/apps/core-web/src/lib/api/client-registrations.ts @@ -0,0 +1,266 @@ +/** + * Client for `apps/api` `/v1/client-registrations` (see + * apps/api/src/client-registrations/client-registrations.controller.ts, + * client-registrations.service.ts, dto/*, and + * packages/database/src/repositories/client-registrations.ts + + * client-registration-partners.ts for the source of truth — shapes below + * are mirrored, not invented). + * + * Like Companies/Resellers, the response is the DB row passed straight + * through — snake_case, no repository-level mapper. Every route here + * (including reads) requires authentication + the `client-registrations` + * resource permission, since this data carries customer PII. + */ +import { apiRequest } from "./http.js"; + +export const PERSON_TYPES = ["pf", "pj"] as const; +export type PersonType = (typeof PERSON_TYPES)[number]; + +export const REGISTRATION_STATUSES = [ + "rascunho", + "pendente_validacao", + "ativo", + "bloqueado", + "inativo", +] as const; +export type RegistrationStatus = (typeof REGISTRATION_STATUSES)[number]; + +export interface PortabilityRange { + first: string; + last: string; +} + +/** Response shape — snake_case, exactly as returned by the `client_registrations` table. */ +export interface ClientRegistration { + id: string; + created_at: string; + updated_at: string; + created_by: string | null; + quote_id: string | null; + reseller_id: string | null; + token: string; + token_created_at: string; + /** Sequential human-readable code, e.g. display as `#123`. */ + client_code: number; + is_portability: boolean; + person_type: PersonType; + registration_status: RegistrationStatus; + submitted_at: string | null; + activated_at: string | null; + internal_notes: 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; + pf_full_name: string | null; + pf_cpf: string | null; + pf_birth_date: string | null; + pf_email: string | null; + pf_mobile_phone: string | null; + pf_alt_phone: string | null; + pf_social_name: string | null; + pf_id_document: string | null; + pf_id_issuer: string | null; + pj_cnpj: string | null; + pj_company_name: string | null; + pj_trade_name: string | null; + pj_state_registration: string | null; + pj_municipal_registration: string | null; + pj_opening_date: string | null; + pj_legal_nature: string | null; + pj_cnpj_situation: string | null; + pj_main_activity: string | null; + pj_rep_full_name: string | null; + pj_rep_cpf: string | null; + pj_rep_role: string | null; + pj_rep_email: string | null; + pj_rep_mobile_phone: string | null; + pj_rep_authorization_ack: boolean; + cpf_format_valid: boolean | null; + cnpj_format_valid: boolean | null; + portability_numbers: string[]; + portability_ranges: PortabilityRange[]; + ixc_client_id: string | null; + ixc_contract_number: string | null; +} + +/** Request body of `POST /v1/client-registrations/direct` — matches `CreateDirectClientRegistrationDto`. */ +export interface CreateDirectClientRegistrationInput { + personType: PersonType; + isPortability: boolean; + /** + * Only actually honored server-side when the actor holds `all` edit + * scope for client-registrations; for a narrower (e.g. reseller) actor + * the backend always forces their own reseller — safe to always show + * the field and let the backend decide/ignore it. + */ + resellerId?: string; +} + +/** + * Body of `PATCH /v1/client-registrations/:id` — matches + * `UpdateClientRegistrationDto` exactly. `pfCpf`/`pjCnpj`/`pjRepCpf` are + * deliberately NOT here — not editable via this endpoint. + */ +export interface UpdateClientRegistrationInput { + registrationStatus?: RegistrationStatus; + internalNotes?: string; + ixcClientId?: string; + ixcContractNumber?: string; + + addressZip?: string; + addressStreet?: string; + addressNumber?: string; + addressComplement?: string; + addressNeighborhood?: string; + addressCity?: string; + addressState?: string; + addressCountry?: string; + + contactPrincipalName?: string; + contactPrincipalEmail?: string; + contactPrincipalPhone?: string; + contactFinancialName?: string; + contactFinancialEmail?: string; + contactFinancialPhone?: string; + contactTechnicalName?: string; + contactTechnicalEmail?: string; + contactTechnicalPhone?: string; + + pfFullName?: string; + pfBirthDate?: string; + pfEmail?: string; + pfMobilePhone?: string; + pfAltPhone?: string; + pfSocialName?: string; + pfIdDocument?: string; + pfIdIssuer?: string; + + pjCompanyName?: string; + pjTradeName?: string; + pjStateRegistration?: string; + pjMunicipalRegistration?: string; + pjOpeningDate?: string; + pjLegalNature?: string; + pjCnpjSituation?: string; + pjMainActivity?: string; + pjRepFullName?: string; + pjRepRole?: string; + pjRepEmail?: string; + pjRepMobilePhone?: string; + pjRepAuthorizationAck?: boolean; + + portabilityNumbers?: string[]; + portabilityRanges?: PortabilityRange[]; +} + +/** `client_registration_partners` row — see packages/database/src/repositories/client-registration-partners.ts. */ +export interface ClientRegistrationPartner { + id: string; + client_registration_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/client-registrations/:id/partners` — matches `CreatePartnerDto`. */ +export interface CreatePartnerInput { + name: string; + document?: string; + qualification?: string; + cpf?: string; + willSign?: boolean; + signatureEmail?: string; +} + +/** Body of `PATCH /v1/client-registrations/:id/partners/:partnerId` — matches `UpdatePartnerDto`, the only 3 admin-editable fields. */ +export interface UpdatePartnerInput { + cpf?: string; + willSign?: boolean; + signatureEmail?: string; +} + +export function listClientRegistrations(accessToken: string): Promise { + return apiRequest("/client-registrations", { method: "GET" }, { accessToken }); +} + +export function getClientRegistration(id: string, accessToken: string): Promise { + return apiRequest(`/client-registrations/${id}`, { method: "GET" }, { accessToken }); +} + +export function createDirectClientRegistration( + input: CreateDirectClientRegistrationInput, + accessToken: string, +): Promise { + return apiRequest( + "/client-registrations/direct", + { method: "POST", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +export function updateClientRegistration( + id: string, + input: UpdateClientRegistrationInput, + accessToken: string, +): Promise { + return apiRequest( + `/client-registrations/${id}`, + { method: "PATCH", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +export function listClientRegistrationPartners( + id: string, + accessToken: string, +): Promise { + return apiRequest( + `/client-registrations/${id}/partners`, + { method: "GET" }, + { accessToken }, + ); +} + +export function createClientRegistrationPartner( + id: string, + input: CreatePartnerInput, + accessToken: string, +): Promise { + return apiRequest( + `/client-registrations/${id}/partners`, + { method: "POST", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +export function updateClientRegistrationPartner( + id: string, + partnerId: string, + input: UpdatePartnerInput, + accessToken: string, +): Promise { + return apiRequest( + `/client-registrations/${id}/partners/${partnerId}`, + { method: "PATCH", body: JSON.stringify(input) }, + { accessToken }, + ); +} diff --git a/apps/core-web/src/lib/api/contracts.ts b/apps/core-web/src/lib/api/contracts.ts new file mode 100644 index 0000000..d48562c --- /dev/null +++ b/apps/core-web/src/lib/api/contracts.ts @@ -0,0 +1,198 @@ +/** + * Client for `apps/api` `/v1/contracts` (see apps/api/src/contracts/contracts.controller.ts, + * contracts.service.ts, dto/*, and packages/database/src/repositories/contracts.ts + * for the source of truth — shapes below are mirrored, not invented). + * + * Like Quotes, the top-level response is snake_case straight off the + * `contracts` table. Money fields (`monthly_value`, `contract_total`, + * `implementation_fee`) and item `quantity`/`unit_price`/`total` come back + * as NUMERIC-typed strings from Postgres — always format via + * `formatCurrencyBRL` (or `Number(...)` for `quantity`), never coerce to + * `number` for storage. + */ +import { apiRequest } from "./http.js"; + +export const CONTRACT_PERIODS = [0, 12, 24, 36, 48] as const; +export type ContractPeriod = (typeof CONTRACT_PERIODS)[number]; + +export type ContractStatus = + | "draft" + | "pending_signature" + | "active" + | "suspended" + | "cancelled" + | "terminated" + | "expired" + | "renewed"; + +export type PartyRole = "contratada" | "contratante"; +export type PersonType = "pf" | "pj"; + +/** + * Valid `toStatus` targets from the current status — terminal statuses map + * to `[]`. Mirrors packages/database/src/repositories/contracts.ts's + * `CONTRACT_STATUS_TRANSITIONS` exactly — the server is the real + * enforcement (409 on an invalid transition), this only drives which + * buttons the UI offers. + */ +export const CONTRACT_STATUS_TRANSITIONS: Record = { + draft: ["pending_signature", "cancelled"], + pending_signature: ["active", "cancelled"], + active: ["suspended", "cancelled", "terminated", "expired"], + suspended: ["active", "cancelled", "terminated"], + cancelled: [], + terminated: [], + expired: [], + renewed: [], +}; + +export interface ContractItem { + id: string; + product_id: string | null; + product_name: string; + quantity: string; + unit_price: string; + total: string; + sort_order: number; +} + +export interface ContractParty { + id: string; + party_role: PartyRole; + person_type: PersonType | null; + full_name: string; + document: string | null; + email: string | null; + phone: string | null; + role_title: string | null; +} + +export interface ContractStatusHistoryEntry { + id: string; + from_status: ContractStatus | null; + to_status: ContractStatus; + reason: string | null; + changed_by: string | null; + changed_at: string; +} + +/** Response shape for `GET /contracts` (list) and the status-transition endpoint — no detail arrays. */ +export interface Contract { + id: string; + created_at: string; + updated_at: string; + created_by: string | null; + contract_number: string; + quote_id: string | null; + client_registration_id: string | null; + reseller_id: string | null; + company_id: string | null; + status: ContractStatus; + contract_period: number; + fidelity_period: number | null; + fidelity_approved: boolean; + monthly_value: string | null; + contract_total: string | null; + implementation_fee: string; + signed_at: string | null; + activated_at: string | null; + notes: string | null; +} + +/** Response shape for `GET /contracts/:id` and `POST /contracts` — adds detail arrays. */ +export interface ContractWithDetails extends Contract { + items: ContractItem[]; + parties: ContractParty[]; + statusHistory: ContractStatusHistoryEntry[]; +} + +export interface ContractItemInput { + productId: string; + quantity: number; +} + +export interface ContractPartyInput { + partyRole: PartyRole; + personType?: PersonType; + fullName: string; + document?: string; + email?: string; + phone?: string; + roleTitle?: string; +} + +export interface CreateContractInput { + resellerId?: string; + companyId?: string; + quoteId?: string; + clientRegistrationId?: string; + contractPeriod: ContractPeriod; + fidelityPeriod?: number; + items: ContractItemInput[]; + parties: ContractPartyInput[]; + implementationFee?: number; + notes?: string; +} + +export interface UpdateContractStatusInput { + toStatus: ContractStatus; + reason?: string; +} + +export function listContracts(accessToken: string): Promise { + return apiRequest("/contracts", { method: "GET" }, { accessToken }); +} + +export function getContract(id: string, accessToken: string): Promise { + return apiRequest(`/contracts/${id}`, { method: "GET" }, { accessToken }); +} + +export function createContract( + input: CreateContractInput, + accessToken: string, +): Promise { + return apiRequest( + "/contracts", + { method: "POST", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +/** Returns only the bare `Contract` row (no items/parties/statusHistory) — reload via `getContract` to refresh those. */ +export function updateContractStatus( + id: string, + input: UpdateContractStatusInput, + accessToken: string, +): Promise { + return apiRequest( + `/contracts/${id}/status`, + { method: "PATCH", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +/** Returns only the newly created `ContractItem` row — reload via `getContract` to refresh totals. */ +export function addContractItem( + id: string, + input: ContractItemInput, + accessToken: string, +): Promise { + return apiRequest( + `/contracts/${id}/items`, + { method: "POST", body: JSON.stringify(input) }, + { accessToken }, + ); +} + +/** Returns only the newly created `ContractParty` row — reload via `getContract` to refresh the parties list. */ +export function addContractParty( + id: string, + input: ContractPartyInput, + accessToken: string, +): Promise { + return apiRequest( + `/contracts/${id}/parties`, + { method: "POST", body: JSON.stringify(input) }, + { accessToken }, + ); +} diff --git a/apps/core-web/src/routes/AuthenticatedLayout.tsx b/apps/core-web/src/routes/AuthenticatedLayout.tsx index 3efa306..5aaaddc 100644 --- a/apps/core-web/src/routes/AuthenticatedLayout.tsx +++ b/apps/core-web/src/routes/AuthenticatedLayout.tsx @@ -22,6 +22,8 @@ export function AuthenticatedLayout() { { key: "resellers", label: "Revendas", href: "/resellers" }, { key: "leads", label: "CRM", href: "/leads" }, { key: "quotes", label: "Ofertas", href: "/quotes" }, + { key: "client-registrations", label: "Clientes", href: "/client-registrations" }, + { key: "contracts", label: "Contratos", href: "/contracts" }, ].map((item) => ({ ...item, active: isNavItemActive(item.href, location.pathname) })); async function handleLogout() { diff --git a/apps/core-web/src/routes/client-registrations/ClientRegistrationDetailPage.tsx b/apps/core-web/src/routes/client-registrations/ClientRegistrationDetailPage.tsx new file mode 100644 index 0000000..89e23c5 --- /dev/null +++ b/apps/core-web/src/routes/client-registrations/ClientRegistrationDetailPage.tsx @@ -0,0 +1,613 @@ +import { useCallback, useEffect, useState } from "react"; +import type { FormEvent } from "react"; +import { useParams } from "react-router-dom"; +import { Alert, Badge, Button, Card, Input, PageHeader, Select } from "@eden/ui"; +import type { BadgeVariant } from "@eden/ui"; +import { useAuth } from "../../lib/auth/AuthContext.js"; +import { ApiError } from "../../lib/api/http.js"; +import { + getClientRegistration, + updateClientRegistration, + REGISTRATION_STATUSES, +} from "../../lib/api/client-registrations.js"; +import type { ClientRegistration, RegistrationStatus, UpdateClientRegistrationInput } from "../../lib/api/client-registrations.js"; +import { formatDocument } from "../../lib/format.js"; +import { Breadcrumb } from "../../components/Breadcrumb.js"; +import { PartnersSection } from "./PartnersSection.js"; + +/** + * Layout reference: DreamsERP theme `.tmp/theme-reference/salesOrderDetails.tsx` + * (breadcrumb + title/status header, grouped info blocks) and `customers.tsx` + * (status badge) — rebuilt with `@eden/ui` Card sections, one per group of + * fields, same pattern as QuoteDetailPage/CompanyFormPage. Unlike those two + * (pure display or pure form), this screen mixes both: everything below the + * header is one editable form (single PATCH on submit, matching how the + * API accepts a fully partial `UpdateClientRegistrationDto`), plus a + * separate Sócios sub-section with its own independent list/add/edit calls. + */ +const STATUS_LABEL: Record = { + rascunho: "Rascunho", + pendente_validacao: "Pendente de validação", + ativo: "Ativo", + bloqueado: "Bloqueado", + inativo: "Inativo", +}; + +const STATUS_VARIANT: Record = { + rascunho: "neutral", + pendente_validacao: "warning", + ativo: "success", + bloqueado: "danger", + inativo: "neutral", +}; + +const STATUS_OPTIONS = REGISTRATION_STATUSES.map((status) => ({ value: status, label: STATUS_LABEL[status] })); + +interface FormState { + registrationStatus: RegistrationStatus; + internalNotes: string; + ixcClientId: string; + ixcContractNumber: string; + + addressZip: string; + addressStreet: string; + addressNumber: string; + addressComplement: string; + addressNeighborhood: string; + addressCity: string; + addressState: string; + addressCountry: string; + + contactPrincipalName: string; + contactPrincipalEmail: string; + contactPrincipalPhone: string; + contactFinancialName: string; + contactFinancialEmail: string; + contactFinancialPhone: string; + contactTechnicalName: string; + contactTechnicalEmail: string; + contactTechnicalPhone: string; + + pfFullName: string; + pfBirthDate: string; + pfEmail: string; + pfMobilePhone: string; + pfAltPhone: string; + pfSocialName: string; + pfIdDocument: string; + pfIdIssuer: string; + + pjCompanyName: string; + pjTradeName: string; + pjStateRegistration: string; + pjMunicipalRegistration: string; + pjOpeningDate: string; + pjLegalNature: string; + pjCnpjSituation: string; + pjMainActivity: string; + pjRepFullName: string; + pjRepRole: string; + pjRepEmail: string; + pjRepMobilePhone: string; + pjRepAuthorizationAck: boolean; + + /** One number per line — parsed into `portabilityNumbers: string[]` on submit. */ + portabilityNumbersText: string; +} + +function toDateInputValue(value: string | null): string { + return value ? value.slice(0, 10) : ""; +} + +function toFormState(registration: ClientRegistration): FormState { + return { + registrationStatus: registration.registration_status, + internalNotes: registration.internal_notes ?? "", + ixcClientId: registration.ixc_client_id ?? "", + ixcContractNumber: registration.ixc_contract_number ?? "", + + addressZip: registration.address_zip ?? "", + addressStreet: registration.address_street ?? "", + addressNumber: registration.address_number ?? "", + addressComplement: registration.address_complement ?? "", + addressNeighborhood: registration.address_neighborhood ?? "", + addressCity: registration.address_city ?? "", + addressState: registration.address_state ?? "", + addressCountry: registration.address_country ?? "", + + contactPrincipalName: registration.contact_principal_name ?? "", + contactPrincipalEmail: registration.contact_principal_email ?? "", + contactPrincipalPhone: registration.contact_principal_phone ?? "", + contactFinancialName: registration.contact_financial_name ?? "", + contactFinancialEmail: registration.contact_financial_email ?? "", + contactFinancialPhone: registration.contact_financial_phone ?? "", + contactTechnicalName: registration.contact_technical_name ?? "", + contactTechnicalEmail: registration.contact_technical_email ?? "", + contactTechnicalPhone: registration.contact_technical_phone ?? "", + + pfFullName: registration.pf_full_name ?? "", + pfBirthDate: toDateInputValue(registration.pf_birth_date), + pfEmail: registration.pf_email ?? "", + pfMobilePhone: registration.pf_mobile_phone ?? "", + pfAltPhone: registration.pf_alt_phone ?? "", + pfSocialName: registration.pf_social_name ?? "", + pfIdDocument: registration.pf_id_document ?? "", + pfIdIssuer: registration.pf_id_issuer ?? "", + + pjCompanyName: registration.pj_company_name ?? "", + pjTradeName: registration.pj_trade_name ?? "", + pjStateRegistration: registration.pj_state_registration ?? "", + pjMunicipalRegistration: registration.pj_municipal_registration ?? "", + pjOpeningDate: toDateInputValue(registration.pj_opening_date), + pjLegalNature: registration.pj_legal_nature ?? "", + pjCnpjSituation: registration.pj_cnpj_situation ?? "", + pjMainActivity: registration.pj_main_activity ?? "", + pjRepFullName: registration.pj_rep_full_name ?? "", + pjRepRole: registration.pj_rep_role ?? "", + pjRepEmail: registration.pj_rep_email ?? "", + pjRepMobilePhone: registration.pj_rep_mobile_phone ?? "", + pjRepAuthorizationAck: registration.pj_rep_authorization_ack, + + portabilityNumbersText: registration.portability_numbers.join("\n"), + }; +} + +function toOptional(value: string): string | undefined { + const trimmed = value.trim(); + return trimmed === "" ? undefined : trimmed; +} + +function registrationName(registration: ClientRegistration): string { + const name = registration.person_type === "pf" ? registration.pf_full_name : registration.pj_company_name; + return name ?? `Cadastro #${registration.client_code}`; +} + +export function ClientRegistrationDetailPage() { + const { id } = useParams<{ id: string }>(); + const { accessToken } = useAuth(); + + const [registration, setRegistration] = useState(null); + const [form, setForm] = useState(null); + const [loading, setLoading] = useState(true); + const [loadError, setLoadError] = useState(null); + const [saveError, setSaveError] = useState(null); + const [saveSuccess, setSaveSuccess] = useState(false); + const [submitting, setSubmitting] = useState(false); + + const load = useCallback(() => { + if (!id || !accessToken) return; + setLoading(true); + setLoadError(null); + getClientRegistration(id, accessToken) + .then((data) => { + setRegistration(data); + setForm(toFormState(data)); + }) + .catch((err) => + setLoadError(err instanceof ApiError ? err.message : "Não foi possível carregar o cadastro de cliente."), + ) + .finally(() => setLoading(false)); + }, [id, accessToken]); + + useEffect(() => { + load(); + }, [load]); + + function updateField(field: K, value: FormState[K]) { + setForm((prev) => (prev ? { ...prev, [field]: value } : prev)); + } + + async function handleSubmit(event: FormEvent) { + event.preventDefault(); + if (!accessToken || !id || !form || !registration) return; + setSaveError(null); + setSaveSuccess(false); + setSubmitting(true); + try { + const payload: UpdateClientRegistrationInput = { + registrationStatus: form.registrationStatus !== registration.registration_status ? form.registrationStatus : undefined, + internalNotes: toOptional(form.internalNotes), + ixcClientId: toOptional(form.ixcClientId), + ixcContractNumber: toOptional(form.ixcContractNumber), + + addressZip: toOptional(form.addressZip), + addressStreet: toOptional(form.addressStreet), + addressNumber: toOptional(form.addressNumber), + addressComplement: toOptional(form.addressComplement), + addressNeighborhood: toOptional(form.addressNeighborhood), + addressCity: toOptional(form.addressCity), + addressState: toOptional(form.addressState), + addressCountry: toOptional(form.addressCountry), + + contactPrincipalName: toOptional(form.contactPrincipalName), + contactPrincipalEmail: toOptional(form.contactPrincipalEmail), + contactPrincipalPhone: toOptional(form.contactPrincipalPhone), + contactFinancialName: toOptional(form.contactFinancialName), + contactFinancialEmail: toOptional(form.contactFinancialEmail), + contactFinancialPhone: toOptional(form.contactFinancialPhone), + contactTechnicalName: toOptional(form.contactTechnicalName), + contactTechnicalEmail: toOptional(form.contactTechnicalEmail), + contactTechnicalPhone: toOptional(form.contactTechnicalPhone), + }; + + if (registration.person_type === "pf") { + payload.pfFullName = toOptional(form.pfFullName); + payload.pfBirthDate = toOptional(form.pfBirthDate); + payload.pfEmail = toOptional(form.pfEmail); + payload.pfMobilePhone = toOptional(form.pfMobilePhone); + payload.pfAltPhone = toOptional(form.pfAltPhone); + payload.pfSocialName = toOptional(form.pfSocialName); + payload.pfIdDocument = toOptional(form.pfIdDocument); + payload.pfIdIssuer = toOptional(form.pfIdIssuer); + } else { + payload.pjCompanyName = toOptional(form.pjCompanyName); + payload.pjTradeName = toOptional(form.pjTradeName); + payload.pjStateRegistration = toOptional(form.pjStateRegistration); + payload.pjMunicipalRegistration = toOptional(form.pjMunicipalRegistration); + payload.pjOpeningDate = toOptional(form.pjOpeningDate); + payload.pjLegalNature = toOptional(form.pjLegalNature); + payload.pjCnpjSituation = toOptional(form.pjCnpjSituation); + payload.pjMainActivity = toOptional(form.pjMainActivity); + payload.pjRepFullName = toOptional(form.pjRepFullName); + payload.pjRepRole = toOptional(form.pjRepRole); + payload.pjRepEmail = toOptional(form.pjRepEmail); + payload.pjRepMobilePhone = toOptional(form.pjRepMobilePhone); + payload.pjRepAuthorizationAck = form.pjRepAuthorizationAck; + } + + if (registration.is_portability) { + payload.portabilityNumbers = form.portabilityNumbersText + .split("\n") + .map((line) => line.trim()) + .filter((line) => line !== ""); + } + + const updated = await updateClientRegistration(id, payload, accessToken); + setRegistration(updated); + setForm(toFormState(updated)); + setSaveSuccess(true); + } catch (err) { + setSaveError(err instanceof ApiError ? err.message : "Não foi possível salvar o cadastro de cliente."); + } finally { + setSubmitting(false); + } + } + + if (loading) { + return ( +
+ +

Carregando cadastro…

+
+ ); + } + + if (loadError || !registration || !form) { + return ( +
+ + {loadError ?? "Cadastro não encontrado."} +
+ ); + } + + return ( +
+ + + {STATUS_LABEL[registration.registration_status]} + + } + /> + + {saveError ? ( + + {saveError} + + ) : null} + {saveSuccess ? ( + + Cadastro salvo com sucesso. + + ) : null} + +
+ +
+ updateField("ixcClientId", e.target.value)} + /> + updateField("ixcContractNumber", e.target.value)} + /> +
+ +