feat(web): telas de Cadastro de Cliente e Contratos no EDEN Core

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 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 22:51:40 -03:00
parent abe056c8b6
commit 19e67dbe63
14 changed files with 2732 additions and 16 deletions

View File

@@ -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<ClientRegistration[]> {
return apiRequest<ClientRegistration[]>("/client-registrations", { method: "GET" }, { accessToken });
}
export function getClientRegistration(id: string, accessToken: string): Promise<ClientRegistration> {
return apiRequest<ClientRegistration>(`/client-registrations/${id}`, { method: "GET" }, { accessToken });
}
export function createDirectClientRegistration(
input: CreateDirectClientRegistrationInput,
accessToken: string,
): Promise<ClientRegistration> {
return apiRequest<ClientRegistration>(
"/client-registrations/direct",
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
export function updateClientRegistration(
id: string,
input: UpdateClientRegistrationInput,
accessToken: string,
): Promise<ClientRegistration> {
return apiRequest<ClientRegistration>(
`/client-registrations/${id}`,
{ method: "PATCH", body: JSON.stringify(input) },
{ accessToken },
);
}
export function listClientRegistrationPartners(
id: string,
accessToken: string,
): Promise<ClientRegistrationPartner[]> {
return apiRequest<ClientRegistrationPartner[]>(
`/client-registrations/${id}/partners`,
{ method: "GET" },
{ accessToken },
);
}
export function createClientRegistrationPartner(
id: string,
input: CreatePartnerInput,
accessToken: string,
): Promise<ClientRegistrationPartner> {
return apiRequest<ClientRegistrationPartner>(
`/client-registrations/${id}/partners`,
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
export function updateClientRegistrationPartner(
id: string,
partnerId: string,
input: UpdatePartnerInput,
accessToken: string,
): Promise<ClientRegistrationPartner> {
return apiRequest<ClientRegistrationPartner>(
`/client-registrations/${id}/partners/${partnerId}`,
{ method: "PATCH", body: JSON.stringify(input) },
{ accessToken },
);
}

View File

@@ -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<ContractStatus, ContractStatus[]> = {
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<Contract[]> {
return apiRequest<Contract[]>("/contracts", { method: "GET" }, { accessToken });
}
export function getContract(id: string, accessToken: string): Promise<ContractWithDetails> {
return apiRequest<ContractWithDetails>(`/contracts/${id}`, { method: "GET" }, { accessToken });
}
export function createContract(
input: CreateContractInput,
accessToken: string,
): Promise<ContractWithDetails> {
return apiRequest<ContractWithDetails>(
"/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<Contract> {
return apiRequest<Contract>(
`/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<ContractItem> {
return apiRequest<ContractItem>(
`/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<ContractParty> {
return apiRequest<ContractParty>(
`/contracts/${id}/parties`,
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}