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:
266
apps/core-web/src/lib/api/client-registrations.ts
Normal file
266
apps/core-web/src/lib/api/client-registrations.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
198
apps/core-web/src/lib/api/contracts.ts
Normal file
198
apps/core-web/src/lib/api/contracts.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user