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:
@@ -18,6 +18,12 @@ import { OpportunityFormPage } from "./routes/crm/OpportunityFormPage.js";
|
|||||||
import { QuotesListPage } from "./routes/quotes/QuotesListPage.js";
|
import { QuotesListPage } from "./routes/quotes/QuotesListPage.js";
|
||||||
import { QuoteFormPage } from "./routes/quotes/QuoteFormPage.js";
|
import { QuoteFormPage } from "./routes/quotes/QuoteFormPage.js";
|
||||||
import { QuoteDetailPage } from "./routes/quotes/QuoteDetailPage.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() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
@@ -53,6 +59,14 @@ export function App() {
|
|||||||
<Route path="/quotes" element={<QuotesListPage />} />
|
<Route path="/quotes" element={<QuotesListPage />} />
|
||||||
<Route path="/quotes/new" element={<QuoteFormPage />} />
|
<Route path="/quotes/new" element={<QuoteFormPage />} />
|
||||||
<Route path="/quotes/:id" element={<QuoteDetailPage />} />
|
<Route path="/quotes/:id" element={<QuoteDetailPage />} />
|
||||||
|
|
||||||
|
<Route path="/client-registrations" element={<ClientRegistrationsListPage />} />
|
||||||
|
<Route path="/client-registrations/new" element={<ClientRegistrationFormPage />} />
|
||||||
|
<Route path="/client-registrations/:id" element={<ClientRegistrationDetailPage />} />
|
||||||
|
|
||||||
|
<Route path="/contracts" element={<ContractsListPage />} />
|
||||||
|
<Route path="/contracts/new" element={<ContractFormPage />} />
|
||||||
|
<Route path="/contracts/:id" element={<ContractDetailPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
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 },
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,8 @@ export function AuthenticatedLayout() {
|
|||||||
{ key: "resellers", label: "Revendas", href: "/resellers" },
|
{ key: "resellers", label: "Revendas", href: "/resellers" },
|
||||||
{ key: "leads", label: "CRM", href: "/leads" },
|
{ key: "leads", label: "CRM", href: "/leads" },
|
||||||
{ key: "quotes", label: "Ofertas", href: "/quotes" },
|
{ 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) }));
|
].map((item) => ({ ...item, active: isNavItemActive(item.href, location.pathname) }));
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
|
|||||||
@@ -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<RegistrationStatus, string> = {
|
||||||
|
rascunho: "Rascunho",
|
||||||
|
pendente_validacao: "Pendente de validação",
|
||||||
|
ativo: "Ativo",
|
||||||
|
bloqueado: "Bloqueado",
|
||||||
|
inativo: "Inativo",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_VARIANT: Record<RegistrationStatus, BadgeVariant> = {
|
||||||
|
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<ClientRegistration | null>(null);
|
||||||
|
const [form, setForm] = useState<FormState | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
const [saveError, setSaveError] = useState<string | null>(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<K extends keyof FormState>(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 (
|
||||||
|
<div>
|
||||||
|
<PageHeader title="Cadastro de cliente" />
|
||||||
|
<p className="text-sm text-default">Carregando cadastro…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loadError || !registration || !form) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader title="Cadastro de cliente" />
|
||||||
|
<Alert variant="error">{loadError ?? "Cadastro não encontrado."}</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Breadcrumb
|
||||||
|
items={[{ label: "Clientes", href: "/client-registrations" }, { label: registrationName(registration) }]}
|
||||||
|
/>
|
||||||
|
<PageHeader
|
||||||
|
title={registrationName(registration)}
|
||||||
|
description={`#${registration.client_code} · ${registration.person_type === "pf" ? "Pessoa Física" : "Pessoa Jurídica"}`}
|
||||||
|
actions={
|
||||||
|
<Badge variant={STATUS_VARIANT[registration.registration_status]}>
|
||||||
|
{STATUS_LABEL[registration.registration_status]}
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{saveError ? (
|
||||||
|
<Alert variant="error" className="mb-4">
|
||||||
|
{saveError}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
{saveSuccess ? (
|
||||||
|
<Alert variant="success" className="mb-4">
|
||||||
|
Cadastro salvo com sucesso.
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||||
|
<Card title="Status">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Select
|
||||||
|
label="Status do cadastro"
|
||||||
|
options={STATUS_OPTIONS}
|
||||||
|
value={form.registrationStatus}
|
||||||
|
onChange={(e) => updateField("registrationStatus", e.target.value as RegistrationStatus)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="ID do cliente no IXC"
|
||||||
|
value={form.ixcClientId}
|
||||||
|
onChange={(e) => updateField("ixcClientId", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Número do contrato no IXC"
|
||||||
|
value={form.ixcContractNumber}
|
||||||
|
onChange={(e) => updateField("ixcContractNumber", e.target.value)}
|
||||||
|
/>
|
||||||
|
<div className="sm:col-span-2 flex flex-col gap-1">
|
||||||
|
<label className="text-sm font-semibold text-gray-900">Notas internas</label>
|
||||||
|
<textarea
|
||||||
|
className="w-full rounded-md border border-border-color bg-white px-3 py-2.5 text-sm text-title focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||||
|
rows={3}
|
||||||
|
value={form.internalNotes}
|
||||||
|
onChange={(e) => updateField("internalNotes", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Endereço">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Input label="CEP" value={form.addressZip} onChange={(e) => updateField("addressZip", e.target.value)} />
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Input
|
||||||
|
label="Logradouro"
|
||||||
|
value={form.addressStreet}
|
||||||
|
onChange={(e) => updateField("addressStreet", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Input
|
||||||
|
label="Número"
|
||||||
|
value={form.addressNumber}
|
||||||
|
onChange={(e) => updateField("addressNumber", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Complemento"
|
||||||
|
value={form.addressComplement}
|
||||||
|
onChange={(e) => updateField("addressComplement", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Bairro"
|
||||||
|
value={form.addressNeighborhood}
|
||||||
|
onChange={(e) => updateField("addressNeighborhood", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Cidade"
|
||||||
|
value={form.addressCity}
|
||||||
|
onChange={(e) => updateField("addressCity", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="UF"
|
||||||
|
maxLength={2}
|
||||||
|
value={form.addressState}
|
||||||
|
onChange={(e) => updateField("addressState", e.target.value.toUpperCase())}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="País"
|
||||||
|
value={form.addressCountry}
|
||||||
|
onChange={(e) => updateField("addressCountry", e.target.value)}
|
||||||
|
placeholder="Brasil"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Contatos">
|
||||||
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs font-semibold uppercase text-default">Principal</p>
|
||||||
|
<Input
|
||||||
|
label="Nome"
|
||||||
|
value={form.contactPrincipalName}
|
||||||
|
onChange={(e) => updateField("contactPrincipalName", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="E-mail"
|
||||||
|
type="email"
|
||||||
|
value={form.contactPrincipalEmail}
|
||||||
|
onChange={(e) => updateField("contactPrincipalEmail", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Telefone"
|
||||||
|
value={form.contactPrincipalPhone}
|
||||||
|
onChange={(e) => updateField("contactPrincipalPhone", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs font-semibold uppercase text-default">Financeiro</p>
|
||||||
|
<Input
|
||||||
|
label="Nome"
|
||||||
|
value={form.contactFinancialName}
|
||||||
|
onChange={(e) => updateField("contactFinancialName", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="E-mail"
|
||||||
|
type="email"
|
||||||
|
value={form.contactFinancialEmail}
|
||||||
|
onChange={(e) => updateField("contactFinancialEmail", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Telefone"
|
||||||
|
value={form.contactFinancialPhone}
|
||||||
|
onChange={(e) => updateField("contactFinancialPhone", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs font-semibold uppercase text-default">Técnico</p>
|
||||||
|
<Input
|
||||||
|
label="Nome"
|
||||||
|
value={form.contactTechnicalName}
|
||||||
|
onChange={(e) => updateField("contactTechnicalName", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="E-mail"
|
||||||
|
type="email"
|
||||||
|
value={form.contactTechnicalEmail}
|
||||||
|
onChange={(e) => updateField("contactTechnicalEmail", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Telefone"
|
||||||
|
value={form.contactTechnicalPhone}
|
||||||
|
onChange={(e) => updateField("contactTechnicalPhone", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{registration.person_type === "pf" ? (
|
||||||
|
<Card title="Dados PF">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Input label="CPF" value={formatDocument(registration.pf_cpf)} readOnly disabled />
|
||||||
|
<Input
|
||||||
|
label="Nome completo"
|
||||||
|
value={form.pfFullName}
|
||||||
|
onChange={(e) => updateField("pfFullName", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Nome social"
|
||||||
|
value={form.pfSocialName}
|
||||||
|
onChange={(e) => updateField("pfSocialName", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Data de nascimento"
|
||||||
|
type="date"
|
||||||
|
value={form.pfBirthDate}
|
||||||
|
onChange={(e) => updateField("pfBirthDate", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="E-mail"
|
||||||
|
type="email"
|
||||||
|
value={form.pfEmail}
|
||||||
|
onChange={(e) => updateField("pfEmail", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Celular"
|
||||||
|
value={form.pfMobilePhone}
|
||||||
|
onChange={(e) => updateField("pfMobilePhone", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Telefone alternativo"
|
||||||
|
value={form.pfAltPhone}
|
||||||
|
onChange={(e) => updateField("pfAltPhone", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Documento de identidade"
|
||||||
|
value={form.pfIdDocument}
|
||||||
|
onChange={(e) => updateField("pfIdDocument", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Órgão emissor"
|
||||||
|
value={form.pfIdIssuer}
|
||||||
|
onChange={(e) => updateField("pfIdIssuer", e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card title="Dados PJ">
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Input label="CNPJ" value={formatDocument(registration.pj_cnpj)} readOnly disabled />
|
||||||
|
<Input
|
||||||
|
label="Razão social"
|
||||||
|
value={form.pjCompanyName}
|
||||||
|
onChange={(e) => updateField("pjCompanyName", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Nome fantasia"
|
||||||
|
value={form.pjTradeName}
|
||||||
|
onChange={(e) => updateField("pjTradeName", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Inscrição estadual"
|
||||||
|
value={form.pjStateRegistration}
|
||||||
|
onChange={(e) => updateField("pjStateRegistration", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Inscrição municipal"
|
||||||
|
value={form.pjMunicipalRegistration}
|
||||||
|
onChange={(e) => updateField("pjMunicipalRegistration", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Data de abertura"
|
||||||
|
type="date"
|
||||||
|
value={form.pjOpeningDate}
|
||||||
|
onChange={(e) => updateField("pjOpeningDate", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Natureza jurídica"
|
||||||
|
value={form.pjLegalNature}
|
||||||
|
onChange={(e) => updateField("pjLegalNature", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Situação do CNPJ"
|
||||||
|
value={form.pjCnpjSituation}
|
||||||
|
onChange={(e) => updateField("pjCnpjSituation", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Atividade principal"
|
||||||
|
value={form.pjMainActivity}
|
||||||
|
onChange={(e) => updateField("pjMainActivity", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input label="CPF do representante" value={formatDocument(registration.pj_rep_cpf)} readOnly disabled />
|
||||||
|
<Input
|
||||||
|
label="Nome do representante"
|
||||||
|
value={form.pjRepFullName}
|
||||||
|
onChange={(e) => updateField("pjRepFullName", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Cargo do representante"
|
||||||
|
value={form.pjRepRole}
|
||||||
|
onChange={(e) => updateField("pjRepRole", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="E-mail do representante"
|
||||||
|
type="email"
|
||||||
|
value={form.pjRepEmail}
|
||||||
|
onChange={(e) => updateField("pjRepEmail", e.target.value)}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Celular do representante"
|
||||||
|
value={form.pjRepMobilePhone}
|
||||||
|
onChange={(e) => updateField("pjRepMobilePhone", e.target.value)}
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-2 py-2.5 text-sm text-title">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="shrink-0 rounded-sm border-border-color text-primary focus:ring-primary"
|
||||||
|
checked={form.pjRepAuthorizationAck}
|
||||||
|
onChange={(e) => updateField("pjRepAuthorizationAck", e.target.checked)}
|
||||||
|
/>
|
||||||
|
Representante confirmou autorização
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{registration.is_portability ? (
|
||||||
|
<Card title="Portabilidade" bodyClassName="p-5">
|
||||||
|
<p className="mb-2 text-xs text-default">Um número de telefone por linha.</p>
|
||||||
|
<textarea
|
||||||
|
className="w-full rounded-md border border-border-color bg-white px-3 py-2.5 text-sm text-title focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||||
|
rows={4}
|
||||||
|
value={form.portabilityNumbersText}
|
||||||
|
onChange={(e) => updateField("portabilityNumbersText", e.target.value)}
|
||||||
|
placeholder={"(11) 91234-5678\n(11) 98765-4321"}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button type="submit" loading={submitting}>
|
||||||
|
Salvar alterações
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{registration.person_type === "pj" ? (
|
||||||
|
<div className="mt-4">
|
||||||
|
<PartnersSection registrationId={registration.id} accessToken={accessToken} />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,130 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import type { FormEvent } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Alert, Button, Card, PageHeader, Select } from "@eden/ui";
|
||||||
|
import { createDirectClientRegistration } from "../../lib/api/client-registrations.js";
|
||||||
|
import type { PersonType } from "../../lib/api/client-registrations.js";
|
||||||
|
import { listResellers } from "../../lib/api/resellers.js";
|
||||||
|
import type { Reseller } from "../../lib/api/resellers.js";
|
||||||
|
import { ApiError } from "../../lib/api/http.js";
|
||||||
|
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||||
|
import { Breadcrumb } from "../../components/Breadcrumb.js";
|
||||||
|
|
||||||
|
const PERSON_TYPE_OPTIONS = [
|
||||||
|
{ value: "pf", label: "Pessoa Física" },
|
||||||
|
{ value: "pj", label: "Pessoa Jurídica" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PORTABILITY_OPTIONS = [
|
||||||
|
{ value: "false", label: "Não" },
|
||||||
|
{ value: "true", label: "Sim" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only creation path in this MVP is "cadastro direto" (`POST
|
||||||
|
* /client-registrations/direct`) — 2-3 fields, then straight to the rich
|
||||||
|
* detail page to fill everything else in. `resellerId` is always shown;
|
||||||
|
* the backend only honors it for an actor with `all` edit scope and
|
||||||
|
* silently forces the actor's own reseller otherwise (see
|
||||||
|
* client-registrations.ts doc comment).
|
||||||
|
*/
|
||||||
|
export function ClientRegistrationFormPage() {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [personType, setPersonType] = useState<PersonType>("pf");
|
||||||
|
const [isPortability, setIsPortability] = useState(false);
|
||||||
|
const [resellerId, setResellerId] = useState("");
|
||||||
|
|
||||||
|
const [resellers, setResellers] = useState<Reseller[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
let cancelled = false;
|
||||||
|
listResellers(accessToken)
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setResellers(data);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (!cancelled) setResellers([]);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!accessToken) return;
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const registration = await createDirectClientRegistration(
|
||||||
|
{ personType, isPortability, resellerId: resellerId || undefined },
|
||||||
|
accessToken,
|
||||||
|
);
|
||||||
|
navigate(`/client-registrations/${registration.id}`, { replace: true });
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "Não foi possível criar o cadastro de cliente.");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resellerOptions = (resellers ?? []).map((reseller) => ({ value: reseller.id, label: reseller.name }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Breadcrumb items={[{ label: "Clientes", href: "/client-registrations" }, { label: "Novo cadastro" }]} />
|
||||||
|
<PageHeader
|
||||||
|
title="Novo cadastro de cliente"
|
||||||
|
description="Depois de criar, o cadastro é aberto para você completar endereço, contatos e documentos."
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Alert variant="error" className="mb-4">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<form className="grid grid-cols-1 gap-4 sm:grid-cols-2" onSubmit={handleSubmit}>
|
||||||
|
<Select
|
||||||
|
label="Tipo de pessoa"
|
||||||
|
options={PERSON_TYPE_OPTIONS}
|
||||||
|
value={personType}
|
||||||
|
onChange={(e) => setPersonType(e.target.value as PersonType)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Portabilidade"
|
||||||
|
options={PORTABILITY_OPTIONS}
|
||||||
|
value={String(isPortability)}
|
||||||
|
onChange={(e) => setIsPortability(e.target.value === "true")}
|
||||||
|
hint="Cliente deseja portar números de outra operadora"
|
||||||
|
/>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<Select
|
||||||
|
label="Revenda"
|
||||||
|
options={resellerOptions}
|
||||||
|
value={resellerId}
|
||||||
|
onChange={(e) => setResellerId(e.target.value)}
|
||||||
|
placeholder={resellers === null ? "Carregando revendas…" : "Usar a revenda do usuário logado"}
|
||||||
|
hint="Só é aplicado se você tiver permissão para cadastrar em qualquer revenda; caso contrário, a revenda do seu usuário é usada automaticamente."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2 flex items-center gap-2 pt-2">
|
||||||
|
<Button type="submit" loading={submitting}>
|
||||||
|
Criar cadastro
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => navigate("/client-registrations")}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { Alert, Badge, Button, Card, PageHeader, Table } from "@eden/ui";
|
||||||
|
import type { BadgeVariant, TableColumn } from "@eden/ui";
|
||||||
|
import { listClientRegistrations } from "../../lib/api/client-registrations.js";
|
||||||
|
import type {
|
||||||
|
ClientRegistration,
|
||||||
|
PersonType,
|
||||||
|
RegistrationStatus,
|
||||||
|
} from "../../lib/api/client-registrations.js";
|
||||||
|
import { ApiError } from "../../lib/api/http.js";
|
||||||
|
import { formatDateBR } from "../../lib/format.js";
|
||||||
|
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout reference: DreamsERP theme `.tmp/theme-reference/customers.tsx`
|
||||||
|
* (code column + name + status badge list) — rebuilt with `@eden/ui`'s
|
||||||
|
* `Card`/`Table`/`Badge` instead of the theme's antd Datatable, matching
|
||||||
|
* the pattern already used by CompaniesListPage/QuotesListPage.
|
||||||
|
*/
|
||||||
|
const PERSON_TYPE_LABEL: Record<PersonType, string> = {
|
||||||
|
pf: "Pessoa Física",
|
||||||
|
pj: "Pessoa Jurídica",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_LABEL: Record<RegistrationStatus, string> = {
|
||||||
|
rascunho: "Rascunho",
|
||||||
|
pendente_validacao: "Pendente de validação",
|
||||||
|
ativo: "Ativo",
|
||||||
|
bloqueado: "Bloqueado",
|
||||||
|
inativo: "Inativo",
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_VARIANT: Record<RegistrationStatus, BadgeVariant> = {
|
||||||
|
rascunho: "neutral",
|
||||||
|
pendente_validacao: "warning",
|
||||||
|
ativo: "success",
|
||||||
|
bloqueado: "danger",
|
||||||
|
inativo: "neutral",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** PF uses `pf_full_name`, PJ uses `pj_company_name` — falls back to a placeholder while the cadastro is still a `rascunho` with no name entered yet. */
|
||||||
|
function registrationName(registration: ClientRegistration): string {
|
||||||
|
const name = registration.person_type === "pf" ? registration.pf_full_name : registration.pj_company_name;
|
||||||
|
return name ?? "Sem nome preenchido";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ClientRegistrationsListPage() {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const [registrations, setRegistrations] = useState<ClientRegistration[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setError(null);
|
||||||
|
listClientRegistrations(accessToken)
|
||||||
|
.then((data) => {
|
||||||
|
if (!cancelled) setRegistrations(data);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setError(err instanceof ApiError ? err.message : "Não foi possível carregar os cadastros de cliente.");
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
const columns: Array<TableColumn<ClientRegistration>> = [
|
||||||
|
{
|
||||||
|
key: "code",
|
||||||
|
header: "Código",
|
||||||
|
render: (registration) => <span className="font-semibold text-title">#{registration.client_code}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "name",
|
||||||
|
header: "Nome",
|
||||||
|
render: (registration) => (
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-title">{registrationName(registration)}</p>
|
||||||
|
{registration.person_type === "pj" && registration.pj_trade_name ? (
|
||||||
|
<p className="text-xs text-default">{registration.pj_trade_name}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "personType",
|
||||||
|
header: "Tipo",
|
||||||
|
render: (registration) => <Badge variant="info">{PERSON_TYPE_LABEL[registration.person_type]}</Badge>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "status",
|
||||||
|
header: "Status",
|
||||||
|
render: (registration) => (
|
||||||
|
<Badge variant={STATUS_VARIANT[registration.registration_status]}>
|
||||||
|
{STATUS_LABEL[registration.registration_status]}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "createdAt",
|
||||||
|
header: "Criado em",
|
||||||
|
render: (registration) => formatDateBR(registration.created_at),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
header: "",
|
||||||
|
className: "text-right",
|
||||||
|
render: (registration) => (
|
||||||
|
<Button variant="secondary" size="sm" onClick={() => navigate(`/client-registrations/${registration.id}`)}>
|
||||||
|
Ver
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Clientes"
|
||||||
|
description="Cadastros de cliente (Customer 360) usados para fechar negócios e integrar com o provisionamento."
|
||||||
|
actions={<Button onClick={() => navigate("/client-registrations/new")}>Novo cadastro</Button>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Alert variant="error" className="mb-4">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
{registrations === null && !error ? (
|
||||||
|
<p className="text-sm text-default">Carregando…</p>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={registrations ?? []}
|
||||||
|
rowKey={(registration) => registration.id}
|
||||||
|
emptyMessage="Nenhum cadastro de cliente encontrado."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,278 @@
|
|||||||
|
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";
|
||||||
|
import {
|
||||||
|
createClientRegistrationPartner,
|
||||||
|
listClientRegistrationPartners,
|
||||||
|
updateClientRegistrationPartner,
|
||||||
|
} from "../../lib/api/client-registrations.js";
|
||||||
|
import type { ClientRegistrationPartner, CreatePartnerInput } from "../../lib/api/client-registrations.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sócios/QSA sub-section, PJ cadastros only (eden.md §1.2, §4). Structural
|
||||||
|
* reference: DreamsERP theme `.tmp/theme-reference/addSalesOrder.tsx` /
|
||||||
|
* `salesOrders.tsx` (a "detail + editable sub-list" pattern — order header
|
||||||
|
* plus a line-items table with inline add) — rebuilt with `@eden/ui`
|
||||||
|
* `Card`/`Table`/`Input`/`Button`, own list/add/edit state independent
|
||||||
|
* from the parent registration form (each partner is its own PATCH/POST).
|
||||||
|
*/
|
||||||
|
const EMPTY_NEW_PARTNER: CreatePartnerInput = {
|
||||||
|
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 function PartnersSection({
|
||||||
|
registrationId,
|
||||||
|
accessToken,
|
||||||
|
}: {
|
||||||
|
registrationId: string;
|
||||||
|
accessToken: string | null;
|
||||||
|
}) {
|
||||||
|
const [partners, setPartners] = useState<ClientRegistrationPartner[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [newPartner, setNewPartner] = useState<CreatePartnerInput>(EMPTY_NEW_PARTNER);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [createError, setCreateError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
|
const [editState, setEditState] = useState<EditState>({ cpf: "", willSign: false, signatureEmail: "" });
|
||||||
|
const [savingEdit, setSavingEdit] = useState(false);
|
||||||
|
const [editError, setEditError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
setError(null);
|
||||||
|
listClientRegistrationPartners(registrationId, accessToken)
|
||||||
|
.then(setPartners)
|
||||||
|
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar os sócios."));
|
||||||
|
}, [registrationId, accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
async function handleCreate(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!accessToken) return;
|
||||||
|
setCreateError(null);
|
||||||
|
setCreating(true);
|
||||||
|
try {
|
||||||
|
await createClientRegistrationPartner(
|
||||||
|
registrationId,
|
||||||
|
{
|
||||||
|
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: ClientRegistrationPartner) {
|
||||||
|
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 updateClientRegistrationPartner(
|
||||||
|
registrationId,
|
||||||
|
partnerId,
|
||||||
|
{
|
||||||
|
cpf: toOptional(editState.cpf),
|
||||||
|
willSign: editState.willSign,
|
||||||
|
signatureEmail: toOptional(editState.signatureEmail),
|
||||||
|
},
|
||||||
|
accessToken,
|
||||||
|
);
|
||||||
|
setEditingId(null);
|
||||||
|
load();
|
||||||
|
} catch (err) {
|
||||||
|
setEditError(err instanceof ApiError ? err.message : "Não foi possível salvar o sócio.");
|
||||||
|
} finally {
|
||||||
|
setSavingEdit(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const columns: Array<TableColumn<ClientRegistrationPartner>> = [
|
||||||
|
{ key: "name", header: "Nome", render: (p) => <span className="font-semibold text-title">{p.name}</span> },
|
||||||
|
{ key: "document", header: "Documento", render: (p) => p.document ?? "—" },
|
||||||
|
{ key: "qualification", header: "Qualificação", render: (p) => p.qualification ?? "—" },
|
||||||
|
{
|
||||||
|
key: "cpf",
|
||||||
|
header: "CPF",
|
||||||
|
render: (p) =>
|
||||||
|
editingId === p.id ? (
|
||||||
|
<Input value={editState.cpf} onChange={(e) => setEditState((s) => ({ ...s, cpf: e.target.value }))} />
|
||||||
|
) : (
|
||||||
|
p.cpf ?? "—"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "willSign",
|
||||||
|
header: "Vai assinar",
|
||||||
|
render: (p) =>
|
||||||
|
editingId === p.id ? (
|
||||||
|
<label className="flex items-center gap-2 text-sm text-title">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="shrink-0 rounded-sm border-border-color text-primary focus:ring-primary"
|
||||||
|
checked={editState.willSign}
|
||||||
|
onChange={(e) => setEditState((s) => ({ ...s, willSign: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
Sim
|
||||||
|
</label>
|
||||||
|
) : (
|
||||||
|
<Badge variant={p.will_sign ? "success" : "neutral"}>{p.will_sign ? "Sim" : "Não"}</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "signatureEmail",
|
||||||
|
header: "E-mail p/ assinatura",
|
||||||
|
render: (p) =>
|
||||||
|
editingId === p.id ? (
|
||||||
|
<Input
|
||||||
|
type="email"
|
||||||
|
value={editState.signatureEmail}
|
||||||
|
onChange={(e) => setEditState((s) => ({ ...s, signatureEmail: e.target.value }))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
p.signature_email ?? "—"
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "actions",
|
||||||
|
header: "",
|
||||||
|
className: "text-right",
|
||||||
|
render: (p) =>
|
||||||
|
editingId === p.id ? (
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button type="button" size="sm" loading={savingEdit} onClick={() => handleSaveEdit(p.id)}>
|
||||||
|
Salvar
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="sm" variant="secondary" onClick={() => setEditingId(null)}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Button type="button" size="sm" variant="secondary" onClick={() => startEdit(p)}>
|
||||||
|
Editar
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="Sócios">
|
||||||
|
{error ? (
|
||||||
|
<Alert variant="error" className="mb-3">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
{editError ? (
|
||||||
|
<Alert variant="error" className="mb-3">
|
||||||
|
{editError}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{partners === null && !error ? (
|
||||||
|
<p className="text-sm text-default">Carregando sócios…</p>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={partners ?? []}
|
||||||
|
rowKey={(p) => p.id}
|
||||||
|
emptyMessage="Nenhum sócio cadastrado."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 border-t border-border-color pt-4">
|
||||||
|
<p className="mb-3 text-sm font-semibold text-title">Adicionar sócio</p>
|
||||||
|
{createError ? (
|
||||||
|
<Alert variant="error" className="mb-3">
|
||||||
|
{createError}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
<form className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3" onSubmit={handleCreate}>
|
||||||
|
<Input
|
||||||
|
label="Nome"
|
||||||
|
value={newPartner.name}
|
||||||
|
onChange={(e) => setNewPartner((s) => ({ ...s, name: e.target.value }))}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Documento"
|
||||||
|
value={newPartner.document}
|
||||||
|
onChange={(e) => setNewPartner((s) => ({ ...s, document: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Qualificação"
|
||||||
|
value={newPartner.qualification}
|
||||||
|
onChange={(e) => setNewPartner((s) => ({ ...s, qualification: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="CPF"
|
||||||
|
value={newPartner.cpf}
|
||||||
|
onChange={(e) => setNewPartner((s) => ({ ...s, cpf: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="E-mail p/ assinatura"
|
||||||
|
type="email"
|
||||||
|
value={newPartner.signatureEmail}
|
||||||
|
onChange={(e) => setNewPartner((s) => ({ ...s, signatureEmail: e.target.value }))}
|
||||||
|
/>
|
||||||
|
<label className="flex items-center gap-2 self-end py-2.5 text-sm text-title">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="shrink-0 rounded-sm border-border-color text-primary focus:ring-primary"
|
||||||
|
checked={newPartner.willSign ?? false}
|
||||||
|
onChange={(e) => setNewPartner((s) => ({ ...s, willSign: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
Vai assinar
|
||||||
|
</label>
|
||||||
|
<div className="sm:col-span-2 lg:col-span-3">
|
||||||
|
<Button type="submit" loading={creating}>
|
||||||
|
Adicionar sócio
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
346
apps/core-web/src/routes/contracts/ContractDetailPage.tsx
Normal file
346
apps/core-web/src/routes/contracts/ContractDetailPage.tsx
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
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 { useAuth } from "../../lib/auth/AuthContext.js";
|
||||||
|
import { ApiError } from "../../lib/api/http.js";
|
||||||
|
import { CONTRACT_STATUS_TRANSITIONS, getContract, updateContractStatus } from "../../lib/api/contracts.js";
|
||||||
|
import type { ContractStatus, ContractWithDetails } from "../../lib/api/contracts.js";
|
||||||
|
import { listResellers } from "../../lib/api/resellers.js";
|
||||||
|
import type { Reseller } from "../../lib/api/resellers.js";
|
||||||
|
import { listCompanies } from "../../lib/api/companies.js";
|
||||||
|
import type { Company } from "../../lib/api/companies.js";
|
||||||
|
import { formatCurrencyBRL, formatDateBR } from "../../lib/format.js";
|
||||||
|
import { Breadcrumb } from "../../components/Breadcrumb.js";
|
||||||
|
import { CONTRACT_STATUS_LABEL, CONTRACT_STATUS_VARIANT } from "./contractStatus.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout reference: DreamsERP theme `.tmp/theme-reference/salesOrderDetails.tsx`
|
||||||
|
* (breadcrumb + title/status badge header, "Bill To"-style summary block,
|
||||||
|
* item table, right-aligned totals) plus `.tmp/theme-reference/contacts.tsx`
|
||||||
|
* for the parties list. Rebuilt with `@eden/ui` + plain Tailwind — no theme
|
||||||
|
* JSX copied. This is the richest of the three Contracts screens: financial
|
||||||
|
* summary, fidelity-approval warning, items, parties, status history
|
||||||
|
* timeline, and the status-transition action, all driven by the real
|
||||||
|
* `ContractWithDetails` response.
|
||||||
|
*/
|
||||||
|
const PARTY_ROLE_LABEL: Record<string, string> = {
|
||||||
|
contratada: "Contratada",
|
||||||
|
contratante: "Contratante",
|
||||||
|
};
|
||||||
|
|
||||||
|
const PERSON_TYPE_LABEL: Record<string, string> = {
|
||||||
|
pf: "Pessoa física",
|
||||||
|
pj: "Pessoa jurídica",
|
||||||
|
};
|
||||||
|
|
||||||
|
function periodLabel(period: number): string {
|
||||||
|
return period === 0 ? "Sem fidelidade" : `${period} meses`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContractDetailPage() {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
|
||||||
|
const [contract, setContract] = useState<ContractWithDetails | null>(null);
|
||||||
|
const [reseller, setReseller] = useState<Reseller | null>(null);
|
||||||
|
const [company, setCompany] = useState<Company | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = useCallback(() => {
|
||||||
|
if (!id || !accessToken) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
Promise.all([getContract(id, accessToken), listResellers(accessToken), listCompanies(accessToken)])
|
||||||
|
.then(([contractResult, resellers, companies]) => {
|
||||||
|
setContract(contractResult);
|
||||||
|
setReseller(resellers.find((r) => r.id === contractResult.reseller_id) ?? null);
|
||||||
|
setCompany(companies.find((c) => c.id === contractResult.company_id) ?? null);
|
||||||
|
})
|
||||||
|
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar o contrato."))
|
||||||
|
.finally(() => setLoading(false));
|
||||||
|
}, [id, accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader title="Contrato" />
|
||||||
|
<p className="text-sm text-default">Carregando contrato…</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !contract) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader title="Contrato" />
|
||||||
|
<Alert variant="error">{error ?? "Contrato não encontrado."}</Alert>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const showFidelityWarning = contract.fidelity_period != null && !contract.fidelity_approved;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Breadcrumb items={[{ label: "Contratos", href: "/contracts" }, { label: contract.contract_number }]} />
|
||||||
|
<PageHeader
|
||||||
|
title={`Contrato ${contract.contract_number}`}
|
||||||
|
actions={<Badge variant={CONTRACT_STATUS_VARIANT[contract.status]}>{CONTRACT_STATUS_LABEL[contract.status]}</Badge>}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showFidelityWarning ? (
|
||||||
|
<Alert variant="warning" className="mb-4" title="Fidelidade ainda não aprovada">
|
||||||
|
Este contrato tem fidelidade reduzida de {contract.fidelity_period} meses, mas ela ainda não foi aprovada —
|
||||||
|
sem efeito legal até a aprovação.
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
|
||||||
|
<div className="space-y-4 lg:col-span-1">
|
||||||
|
<Card title="Dados do contrato">
|
||||||
|
<dl className="space-y-3 text-sm">
|
||||||
|
<Row label="Revenda" value={reseller?.name ?? contract.reseller_id ?? "—"} />
|
||||||
|
<Row label="Empresa" value={company?.company_name ?? contract.company_id ?? "—"} />
|
||||||
|
<Row label="Período de contrato" value={periodLabel(contract.contract_period)} />
|
||||||
|
<Row
|
||||||
|
label="Fidelidade reduzida"
|
||||||
|
value={contract.fidelity_period != null ? `${contract.fidelity_period} meses` : "—"}
|
||||||
|
/>
|
||||||
|
<Row label="Fidelidade aprovada" value={contract.fidelity_approved ? "Sim" : "Não"} />
|
||||||
|
<Row label="Oferta de origem" value={contract.quote_id ?? "—"} />
|
||||||
|
<Row label="Cadastro de cliente" value={contract.client_registration_id ?? "—"} />
|
||||||
|
<Row label="Criado em" value={formatDateBR(contract.created_at)} />
|
||||||
|
<Row label="Assinado em" value={formatDateBR(contract.signed_at)} />
|
||||||
|
<Row label="Ativado em" value={formatDateBR(contract.activated_at)} />
|
||||||
|
</dl>
|
||||||
|
{contract.notes ? (
|
||||||
|
<div className="mt-4 border-t border-border-color pt-4">
|
||||||
|
<p className="text-xs font-semibold text-default">Notas</p>
|
||||||
|
<p className="mt-1 text-sm text-title">{contract.notes}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<StatusTransitionCard contract={contract} accessToken={accessToken} onChanged={load} />
|
||||||
|
|
||||||
|
<StatusHistoryCard contract={contract} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4 lg:col-span-2">
|
||||||
|
<Card title="Resumo financeiro">
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
|
<SummaryRow label="Mensalidade" value={formatCurrencyBRL(contract.monthly_value)} />
|
||||||
|
<SummaryRow label="Implantação" value={formatCurrencyBRL(contract.implementation_fee)} />
|
||||||
|
<SummaryRow label="Total do contrato" value={formatCurrencyBRL(contract.contract_total)} highlight />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Itens">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border-color text-left">
|
||||||
|
<th className="py-2 pe-3 font-semibold text-title">Produto</th>
|
||||||
|
<th className="py-2 pe-3 text-right font-semibold text-title">Qtd.</th>
|
||||||
|
<th className="py-2 pe-3 text-right font-semibold text-title">Preço unit.</th>
|
||||||
|
<th className="py-2 text-right font-semibold text-title">Total</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{contract.items.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={4} className="py-4 text-center text-sm text-default">
|
||||||
|
Nenhum item cadastrado.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
contract.items
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => a.sort_order - b.sort_order)
|
||||||
|
.map((item) => (
|
||||||
|
<tr key={item.id} className="border-b border-border-color last:border-0">
|
||||||
|
<td className="py-2 pe-3 font-semibold text-title">{item.product_name}</td>
|
||||||
|
<td className="py-2 pe-3 text-right text-default">{Number(item.quantity)}</td>
|
||||||
|
<td className="py-2 pe-3 text-right text-default">{formatCurrencyBRL(item.unit_price)}</td>
|
||||||
|
<td className="py-2 text-right text-default">{formatCurrencyBRL(item.total)}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Partes">
|
||||||
|
{contract.parties.length === 0 ? (
|
||||||
|
<p className="text-sm text-default">Nenhuma parte cadastrada.</p>
|
||||||
|
) : (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border-color text-left">
|
||||||
|
<th className="py-2 pe-3 font-semibold text-title">Papel</th>
|
||||||
|
<th className="py-2 pe-3 font-semibold text-title">Nome</th>
|
||||||
|
<th className="py-2 pe-3 font-semibold text-title">Tipo</th>
|
||||||
|
<th className="py-2 pe-3 font-semibold text-title">Documento</th>
|
||||||
|
<th className="py-2 pe-3 font-semibold text-title">Contato</th>
|
||||||
|
<th className="py-2 font-semibold text-title">Cargo</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{contract.parties.map((party) => (
|
||||||
|
<tr key={party.id} className="border-b border-border-color last:border-0">
|
||||||
|
<td className="py-2 pe-3">
|
||||||
|
<Badge variant={party.party_role === "contratada" ? "primary" : "info"}>
|
||||||
|
{PARTY_ROLE_LABEL[party.party_role] ?? party.party_role}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pe-3 font-semibold text-title">{party.full_name}</td>
|
||||||
|
<td className="py-2 pe-3 text-default">
|
||||||
|
{party.person_type ? PERSON_TYPE_LABEL[party.person_type] : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pe-3 text-default">{party.document ?? "—"}</td>
|
||||||
|
<td className="py-2 pe-3 text-default">
|
||||||
|
{party.email ?? "—"}
|
||||||
|
{party.phone ? <span className="block text-xs">{party.phone}</span> : null}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-default">{party.role_title ?? "—"}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Row({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<dt className="text-default">{label}</dt>
|
||||||
|
<dd className="text-right font-semibold text-title">{value}</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border-color p-3">
|
||||||
|
<p className="text-xs text-default">{label}</p>
|
||||||
|
<p className={highlight ? "mt-1 text-lg font-bold text-primary" : "mt-1 text-lg font-bold text-title"}>
|
||||||
|
{value}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusTransitionCard({
|
||||||
|
contract,
|
||||||
|
accessToken,
|
||||||
|
onChanged,
|
||||||
|
}: {
|
||||||
|
contract: ContractWithDetails;
|
||||||
|
accessToken: string | null;
|
||||||
|
onChanged: () => void;
|
||||||
|
}) {
|
||||||
|
const validTargets = CONTRACT_STATUS_TRANSITIONS[contract.status] ?? [];
|
||||||
|
const [toStatus, setToStatus] = useState<string>(validTargets[0] ?? "");
|
||||||
|
const [reason, setReason] = useState("");
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setToStatus(validTargets[0] ?? "");
|
||||||
|
// Only re-sync the default selection when the contract's own status
|
||||||
|
// changes (i.e. after a successful transition) — not on every render.
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [contract.status]);
|
||||||
|
|
||||||
|
if (validTargets.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card title="Status">
|
||||||
|
<p className="text-sm text-default">
|
||||||
|
Status <strong>{CONTRACT_STATUS_LABEL[contract.status]}</strong> é final — nenhuma transição disponível.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!accessToken || !toStatus) return;
|
||||||
|
setError(null);
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
await updateContractStatus(contract.id, { toStatus: toStatus as ContractStatus, reason: reason || undefined }, accessToken);
|
||||||
|
setReason("");
|
||||||
|
onChanged();
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "Não foi possível atualizar o status.");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="Alterar status">
|
||||||
|
{error ? (
|
||||||
|
<Alert variant="error" className="mb-3">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
<form className="space-y-3" onSubmit={handleSubmit}>
|
||||||
|
<Select
|
||||||
|
label="Novo status"
|
||||||
|
options={validTargets.map((status) => ({ value: status, label: CONTRACT_STATUS_LABEL[status] }))}
|
||||||
|
value={toStatus}
|
||||||
|
onChange={(e) => setToStatus(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input label="Motivo (opcional)" value={reason} onChange={(e) => setReason(e.target.value)} />
|
||||||
|
<Button type="submit" loading={submitting}>
|
||||||
|
Atualizar status
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatusHistoryCard({ contract }: { contract: ContractWithDetails }) {
|
||||||
|
const history = contract.statusHistory;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card title="Histórico de status">
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<p className="text-sm text-default">Nenhuma alteração de status registrada ainda.</p>
|
||||||
|
) : (
|
||||||
|
<ol className="space-y-4">
|
||||||
|
{history
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => new Date(b.changed_at).getTime() - new Date(a.changed_at).getTime())
|
||||||
|
.map((entry) => (
|
||||||
|
<li key={entry.id} className="relative border-s-2 border-border-color ps-4">
|
||||||
|
<span className="absolute -start-[5px] top-1 size-2 rounded-full bg-primary" aria-hidden="true" />
|
||||||
|
<p className="text-sm font-semibold text-title">
|
||||||
|
{entry.from_status ? `${CONTRACT_STATUS_LABEL[entry.from_status]} → ` : ""}
|
||||||
|
{CONTRACT_STATUS_LABEL[entry.to_status]}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-default">{formatDateBR(entry.changed_at)}</p>
|
||||||
|
{entry.reason ? <p className="mt-1 text-sm text-default">{entry.reason}</p> : null}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
492
apps/core-web/src/routes/contracts/ContractFormPage.tsx
Normal file
492
apps/core-web/src/routes/contracts/ContractFormPage.tsx
Normal file
@@ -0,0 +1,492 @@
|
|||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import type { FormEvent } from "react";
|
||||||
|
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
|
import { Alert, Button, Card, Input, PageHeader, Select } from "@eden/ui";
|
||||||
|
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||||
|
import { ApiError } from "../../lib/api/http.js";
|
||||||
|
import { CONTRACT_PERIODS, createContract } from "../../lib/api/contracts.js";
|
||||||
|
import type { ContractPeriod, ContractWithDetails, PartyRole, PersonType } from "../../lib/api/contracts.js";
|
||||||
|
import { listResellers } from "../../lib/api/resellers.js";
|
||||||
|
import type { Reseller } from "../../lib/api/resellers.js";
|
||||||
|
import { listCompanies } from "../../lib/api/companies.js";
|
||||||
|
import type { Company } from "../../lib/api/companies.js";
|
||||||
|
import { listProducts } from "../../lib/api/products.js";
|
||||||
|
import type { Product } from "../../lib/api/products.js";
|
||||||
|
import { formatCurrencyBRL } from "../../lib/format.js";
|
||||||
|
import { Breadcrumb } from "../../components/Breadcrumb.js";
|
||||||
|
import { PRIMARY_LINK_BUTTON_CLASS } from "../../components/linkButtonClasses.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout reference: DreamsERP theme `.tmp/theme-reference/addSalesOrder.tsx`
|
||||||
|
* (details header + editable item table with add/remove rows) — the same
|
||||||
|
* reference already applied to `QuoteFormPage`, extended here with a second
|
||||||
|
* dynamic list for contract parties. Rebuilt with `@eden/ui` + plain
|
||||||
|
* Tailwind, no theme JSX copied. Creation only — editing is a future leva.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface ItemRow {
|
||||||
|
productId: string;
|
||||||
|
quantity: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PartyRow {
|
||||||
|
partyRole: PartyRole;
|
||||||
|
personType: PersonType | "";
|
||||||
|
fullName: string;
|
||||||
|
document: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
roleTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_ITEM: ItemRow = { productId: "", quantity: "1" };
|
||||||
|
|
||||||
|
function emptyParty(partyRole: PartyRole): PartyRow {
|
||||||
|
return { partyRole, personType: "", fullName: "", document: "", email: "", phone: "", roleTitle: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const PARTY_ROLE_OPTIONS = [
|
||||||
|
{ value: "contratada", label: "Contratada" },
|
||||||
|
{ value: "contratante", label: "Contratante" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PERSON_TYPE_OPTIONS = [
|
||||||
|
{ value: "pf", label: "Pessoa física" },
|
||||||
|
{ value: "pj", label: "Pessoa jurídica" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ContractFormPage() {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
|
||||||
|
// Pre-population from QuoteDetailPage's "Criar contrato a partir desta
|
||||||
|
// oferta" link (`/contracts/new?quoteId=...&clientRegistrationId=...`) —
|
||||||
|
// both are opaque UUIDs, sent through as-is on submit; not editable here.
|
||||||
|
const quoteId = searchParams.get("quoteId") ?? undefined;
|
||||||
|
const clientRegistrationId = searchParams.get("clientRegistrationId") ?? undefined;
|
||||||
|
|
||||||
|
const [resellers, setResellers] = useState<Reseller[] | null>(null);
|
||||||
|
const [companies, setCompanies] = useState<Company[] | null>(null);
|
||||||
|
const [products, setProducts] = useState<Product[] | null>(null);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [resellerId, setResellerId] = useState("");
|
||||||
|
const [companyId, setCompanyId] = useState("");
|
||||||
|
const [contractPeriod, setContractPeriod] = useState<string>("12");
|
||||||
|
const [fidelityPeriod, setFidelityPeriod] = useState("");
|
||||||
|
const [items, setItems] = useState<ItemRow[]>([{ ...EMPTY_ITEM }]);
|
||||||
|
const [parties, setParties] = useState<PartyRow[]>([emptyParty("contratada"), emptyParty("contratante")]);
|
||||||
|
const [implementationFee, setImplementationFee] = useState("");
|
||||||
|
const [notes, setNotes] = useState("");
|
||||||
|
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [createdContract, setCreatedContract] = useState<ContractWithDetails | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
setLoadError(null);
|
||||||
|
Promise.all([listResellers(accessToken), listCompanies(accessToken), listProducts(accessToken)])
|
||||||
|
.then(([resellerList, companyList, productList]) => {
|
||||||
|
setResellers(resellerList);
|
||||||
|
setCompanies(companyList);
|
||||||
|
setProducts(productList);
|
||||||
|
})
|
||||||
|
.catch((err) =>
|
||||||
|
setLoadError(err instanceof ApiError ? err.message : "Não foi possível carregar revendas/empresas/produtos."),
|
||||||
|
);
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
const productOptions = useMemo(
|
||||||
|
() => (products ?? []).map((p) => ({ value: p.id, label: `${p.name} (cód. ${p.product_code})` })),
|
||||||
|
[products],
|
||||||
|
);
|
||||||
|
|
||||||
|
const estimatedMonthly = useMemo(() => {
|
||||||
|
if (!products) return 0;
|
||||||
|
const period = Number(contractPeriod) as ContractPeriod;
|
||||||
|
const priceKey = `price_${period}` as `price_${ContractPeriod}`;
|
||||||
|
return items.reduce((sum, row) => {
|
||||||
|
const product = products.find((p) => p.id === row.productId);
|
||||||
|
const qty = Number(row.quantity) || 0;
|
||||||
|
if (!product) return sum;
|
||||||
|
const price = Number(product[priceKey] ?? 0);
|
||||||
|
return sum + price * qty;
|
||||||
|
}, 0);
|
||||||
|
}, [items, products, contractPeriod]);
|
||||||
|
|
||||||
|
function updateItem(index: number, patch: Partial<ItemRow>) {
|
||||||
|
setItems((prev) => prev.map((row, i) => (i === index ? { ...row, ...patch } : row)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addItem() {
|
||||||
|
setItems((prev) => [...prev, { ...EMPTY_ITEM }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItem(index: number) {
|
||||||
|
setItems((prev) => (prev.length > 1 ? prev.filter((_, i) => i !== index) : prev));
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateParty(index: number, patch: Partial<PartyRow>) {
|
||||||
|
setParties((prev) => prev.map((row, i) => (i === index ? { ...row, ...patch } : row)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function addParty(partyRole: PartyRole) {
|
||||||
|
setParties((prev) => [...prev, emptyParty(partyRole)]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeParty(index: number) {
|
||||||
|
setParties((prev) => (prev.length > 1 ? prev.filter((_, i) => i !== index) : prev));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit(event: FormEvent) {
|
||||||
|
event.preventDefault();
|
||||||
|
if (!accessToken) return;
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const validItems = items
|
||||||
|
.filter((row) => row.productId && Number(row.quantity) > 0)
|
||||||
|
.map((row) => ({ productId: row.productId, quantity: Number(row.quantity) }));
|
||||||
|
|
||||||
|
if (validItems.length === 0) {
|
||||||
|
setError("Adicione ao menos um item com produto e quantidade válidos.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const validParties = parties
|
||||||
|
.filter((row) => row.fullName.trim().length > 0)
|
||||||
|
.map((row) => ({
|
||||||
|
partyRole: row.partyRole,
|
||||||
|
personType: row.personType || undefined,
|
||||||
|
fullName: row.fullName.trim(),
|
||||||
|
document: row.document || undefined,
|
||||||
|
email: row.email || undefined,
|
||||||
|
phone: row.phone || undefined,
|
||||||
|
roleTitle: row.roleTitle || undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (validParties.length === 0) {
|
||||||
|
setError("Adicione ao menos uma parte (contratada ou contratante) com nome preenchido.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
try {
|
||||||
|
const contract = await createContract(
|
||||||
|
{
|
||||||
|
resellerId: resellerId || undefined,
|
||||||
|
companyId: companyId || undefined,
|
||||||
|
quoteId,
|
||||||
|
clientRegistrationId,
|
||||||
|
contractPeriod: Number(contractPeriod) as ContractPeriod,
|
||||||
|
fidelityPeriod: fidelityPeriod ? Number(fidelityPeriod) : undefined,
|
||||||
|
items: validItems,
|
||||||
|
parties: validParties,
|
||||||
|
implementationFee: implementationFee ? Number(implementationFee) : undefined,
|
||||||
|
notes: notes || undefined,
|
||||||
|
},
|
||||||
|
accessToken,
|
||||||
|
);
|
||||||
|
setCreatedContract(contract);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "Não foi possível criar o contrato.");
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (createdContract) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader title="Contrato criado" description={`Número ${createdContract.contract_number}`} />
|
||||||
|
<Alert variant="success" className="mb-4">
|
||||||
|
Contrato criado com sucesso. Confira os totais calculados pelo servidor abaixo.
|
||||||
|
</Alert>
|
||||||
|
<Card>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||||
|
<SummaryField label="Mensalidade" value={formatCurrencyBRL(createdContract.monthly_value)} />
|
||||||
|
<SummaryField label="Implantação" value={formatCurrencyBRL(createdContract.implementation_fee)} />
|
||||||
|
<SummaryField label="Total do contrato" value={formatCurrencyBRL(createdContract.contract_total)} />
|
||||||
|
</div>
|
||||||
|
<div className="mt-6 flex items-center gap-2">
|
||||||
|
<Link to={`/contracts/${createdContract.id}`} className={PRIMARY_LINK_BUTTON_CLASS}>
|
||||||
|
Ver contrato completo
|
||||||
|
</Link>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => navigate("/contracts")}>
|
||||||
|
Voltar para a lista
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Breadcrumb items={[{ label: "Contratos", href: "/contracts" }, { label: "Novo contrato" }]} />
|
||||||
|
<PageHeader title="Novo contrato" description="Contrato comercial" />
|
||||||
|
|
||||||
|
{loadError ? (
|
||||||
|
<Alert variant="error" className="mb-4">
|
||||||
|
{loadError}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
{error ? (
|
||||||
|
<Alert variant="error" className="mb-4">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
{quoteId || clientRegistrationId ? (
|
||||||
|
<Alert variant="info" className="mb-4">
|
||||||
|
Criando a partir de uma oferta fechada{quoteId ? ` (oferta ${quoteId})` : ""}
|
||||||
|
{clientRegistrationId ? ` — cadastro de cliente ${clientRegistrationId}` : ""}.
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
{!resellers || !companies || !products ? (
|
||||||
|
<p className="text-sm text-default">Carregando revendas, empresas e produtos…</p>
|
||||||
|
) : (
|
||||||
|
<form className="space-y-8" onSubmit={handleSubmit}>
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-4 text-sm font-semibold text-title">Vínculo comercial</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Select
|
||||||
|
label="Revenda (opcional)"
|
||||||
|
options={resellers.map((r) => ({ value: r.id, label: r.name }))}
|
||||||
|
placeholder="Selecione a revenda"
|
||||||
|
value={resellerId}
|
||||||
|
onChange={(e) => setResellerId(e.target.value)}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Empresa (opcional)"
|
||||||
|
options={companies.map((c) => ({ value: c.id, label: c.company_name }))}
|
||||||
|
placeholder="Selecione a empresa"
|
||||||
|
value={companyId}
|
||||||
|
onChange={(e) => setCompanyId(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-4 text-sm font-semibold text-title">Contrato</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Select
|
||||||
|
label="Período de contrato"
|
||||||
|
options={CONTRACT_PERIODS.map((period) => ({
|
||||||
|
value: String(period),
|
||||||
|
label: period === 0 ? "Sem fidelidade (0 meses)" : `${period} meses`,
|
||||||
|
}))}
|
||||||
|
value={contractPeriod}
|
||||||
|
onChange={(e) => setContractPeriod(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Fidelidade reduzida (opcional)"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
value={fidelityPeriod}
|
||||||
|
onChange={(e) => setFidelityPeriod(e.target.value)}
|
||||||
|
hint="Só preencha se for menor que o período do contrato — fica sem efeito legal até ser aprovada"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-title">Itens</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addItem}
|
||||||
|
className="rounded-md border border-border-color px-3 py-1.5 text-xs font-semibold text-title hover:bg-light"
|
||||||
|
>
|
||||||
|
+ Adicionar item
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border-color text-left">
|
||||||
|
<th className="py-2 pe-2 font-semibold text-title">Produto</th>
|
||||||
|
<th className="py-2 pe-2 font-semibold text-title">Quantidade</th>
|
||||||
|
<th className="py-2 pe-2" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((row, index) => (
|
||||||
|
<tr key={index} className="border-b border-border-color last:border-0">
|
||||||
|
<td className="py-2 pe-2 w-2/3">
|
||||||
|
<Select
|
||||||
|
options={productOptions}
|
||||||
|
placeholder="Selecione o produto"
|
||||||
|
value={row.productId}
|
||||||
|
onChange={(e) => updateItem(index, { productId: e.target.value })}
|
||||||
|
aria-label="Produto"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pe-2">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0.01}
|
||||||
|
step={0.01}
|
||||||
|
value={row.quantity}
|
||||||
|
onChange={(e) => updateItem(index, { quantity: e.target.value })}
|
||||||
|
aria-label="Quantidade"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pe-2 text-center">
|
||||||
|
{items.length > 1 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeItem(index)}
|
||||||
|
className="rounded-md border border-border-color px-2 py-1 text-xs font-semibold text-danger hover:bg-light"
|
||||||
|
>
|
||||||
|
Remover
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-xs text-default">
|
||||||
|
Mensalidade estimada para o período selecionado: {formatCurrencyBRL(estimatedMonthly)} — o servidor
|
||||||
|
recalcula e é a fonte da verdade.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-4 flex items-center justify-between">
|
||||||
|
<h2 className="text-sm font-semibold text-title">Partes</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addParty("contratada")}
|
||||||
|
className="rounded-md border border-border-color px-3 py-1.5 text-xs font-semibold text-title hover:bg-light"
|
||||||
|
>
|
||||||
|
+ Contratada
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => addParty("contratante")}
|
||||||
|
className="rounded-md border border-border-color px-3 py-1.5 text-xs font-semibold text-title hover:bg-light"
|
||||||
|
>
|
||||||
|
+ Contratante
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{parties.map((row, index) => (
|
||||||
|
<div key={index} className="rounded-md border border-border-color p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold uppercase text-default">
|
||||||
|
{row.partyRole === "contratada" ? "Contratada" : "Contratante"}
|
||||||
|
</span>
|
||||||
|
{parties.length > 1 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeParty(index)}
|
||||||
|
className="rounded-md border border-border-color px-2 py-1 text-xs font-semibold text-danger hover:bg-light"
|
||||||
|
>
|
||||||
|
Remover
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<Select
|
||||||
|
label="Papel"
|
||||||
|
options={PARTY_ROLE_OPTIONS}
|
||||||
|
value={row.partyRole}
|
||||||
|
onChange={(e) => updateParty(index, { partyRole: e.target.value as PartyRole })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label="Tipo de pessoa (opcional)"
|
||||||
|
options={PERSON_TYPE_OPTIONS}
|
||||||
|
placeholder="Selecione"
|
||||||
|
value={row.personType}
|
||||||
|
onChange={(e) => updateParty(index, { personType: e.target.value as PersonType | "" })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Nome completo / razão social"
|
||||||
|
value={row.fullName}
|
||||||
|
onChange={(e) => updateParty(index, { fullName: e.target.value })}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Documento (CPF/CNPJ)"
|
||||||
|
value={row.document}
|
||||||
|
onChange={(e) => updateParty(index, { document: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="E-mail"
|
||||||
|
type="email"
|
||||||
|
value={row.email}
|
||||||
|
onChange={(e) => updateParty(index, { email: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Telefone"
|
||||||
|
value={row.phone}
|
||||||
|
onChange={(e) => updateParty(index, { phone: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="Cargo (opcional)"
|
||||||
|
value={row.roleTitle}
|
||||||
|
onChange={(e) => updateParty(index, { roleTitle: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="mb-4 text-sm font-semibold text-title">Implantação e notas</h2>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||||
|
<Input
|
||||||
|
label="Taxa de implantação (opcional)"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={0.01}
|
||||||
|
value={implementationFee}
|
||||||
|
onChange={(e) => setImplementationFee(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<label className="mb-1 block text-sm font-semibold text-gray-900">Notas</label>
|
||||||
|
<textarea
|
||||||
|
className="w-full rounded-md border border-border-color bg-white px-3 py-2.5 text-sm text-title focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||||
|
rows={4}
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 border-t border-border-color pt-5">
|
||||||
|
<Button type="submit" loading={submitting}>
|
||||||
|
Criar contrato
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => navigate("/contracts")}>
|
||||||
|
Cancelar
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SummaryField({ label, value }: { label: string; value: string }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-md border border-border-color p-4">
|
||||||
|
<p className="text-xs text-default">{label}</p>
|
||||||
|
<p className="mt-1 text-lg font-bold text-title">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
apps/core-web/src/routes/contracts/ContractsListPage.tsx
Normal file
103
apps/core-web/src/routes/contracts/ContractsListPage.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { Alert, Badge, Card, PageHeader } from "@eden/ui";
|
||||||
|
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||||
|
import { ApiError } from "../../lib/api/http.js";
|
||||||
|
import { listContracts } from "../../lib/api/contracts.js";
|
||||||
|
import type { Contract } from "../../lib/api/contracts.js";
|
||||||
|
import { formatCurrencyBRL } from "../../lib/format.js";
|
||||||
|
import { PRIMARY_LINK_BUTTON_CLASS } from "../../components/linkButtonClasses.js";
|
||||||
|
import { CONTRACT_STATUS_LABEL, CONTRACT_STATUS_VARIANT } from "./contractStatus.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Layout reference: DreamsERP theme `.tmp/theme-reference/salesOrders.tsx`
|
||||||
|
* (header with title + "Add" action, flush data table in a bordered white
|
||||||
|
* card, colored status pill in the status column) — same shape already
|
||||||
|
* used by `QuotesListPage`. Rebuilt with `@eden/ui` + plain Tailwind.
|
||||||
|
*/
|
||||||
|
function periodLabel(period: number): string {
|
||||||
|
return period === 0 ? "Sem fidelidade" : `${period} meses`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContractsListPage() {
|
||||||
|
const { accessToken } = useAuth();
|
||||||
|
const [contracts, setContracts] = useState<Contract[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
let cancelled = false;
|
||||||
|
setError(null);
|
||||||
|
listContracts(accessToken)
|
||||||
|
.then((result) => {
|
||||||
|
if (!cancelled) setContracts(result);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) setError(err instanceof ApiError ? err.message : "Não foi possível carregar os contratos.");
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<PageHeader
|
||||||
|
title="Contratos"
|
||||||
|
description="Contratos comerciais firmados com clientes"
|
||||||
|
actions={
|
||||||
|
<Link to="/contracts/new" className={PRIMARY_LINK_BUTTON_CLASS}>
|
||||||
|
Novo contrato
|
||||||
|
</Link>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<Alert variant="error" className="mb-4">
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<Card bodyClassName="p-0">
|
||||||
|
{contracts === null && !error ? (
|
||||||
|
<p className="p-5 text-sm text-default">Carregando contratos…</p>
|
||||||
|
) : contracts && contracts.length === 0 ? (
|
||||||
|
<p className="p-5 text-sm text-default">Nenhum contrato cadastrado ainda.</p>
|
||||||
|
) : contracts ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border-color text-left">
|
||||||
|
<th className="px-5 py-3 font-semibold text-title">Contrato</th>
|
||||||
|
<th className="px-5 py-3 font-semibold text-title">Status</th>
|
||||||
|
<th className="px-5 py-3 font-semibold text-title">Período</th>
|
||||||
|
<th className="px-5 py-3 text-right font-semibold text-title">Mensalidade</th>
|
||||||
|
<th className="px-5 py-3 text-right font-semibold text-title">Total do contrato</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{contracts.map((contract) => (
|
||||||
|
<tr key={contract.id} className="border-b border-border-color last:border-0 hover:bg-light">
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<Link to={`/contracts/${contract.id}`} className="font-semibold text-title hover:text-primary">
|
||||||
|
{contract.contract_number}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3">
|
||||||
|
<Badge variant={CONTRACT_STATUS_VARIANT[contract.status]}>
|
||||||
|
{CONTRACT_STATUS_LABEL[contract.status]}
|
||||||
|
</Badge>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-3 text-default">{periodLabel(contract.contract_period)}</td>
|
||||||
|
<td className="px-5 py-3 text-right text-default">{formatCurrencyBRL(contract.monthly_value)}</td>
|
||||||
|
<td className="px-5 py-3 text-right text-default">{formatCurrencyBRL(contract.contract_total)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
25
apps/core-web/src/routes/contracts/contractStatus.ts
Normal file
25
apps/core-web/src/routes/contracts/contractStatus.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { BadgeVariant } from "@eden/ui";
|
||||||
|
import type { ContractStatus } from "../../lib/api/contracts.js";
|
||||||
|
|
||||||
|
/** Shared pt-BR labels/badge colors for `ContractStatus` — used by the list, detail, and status-transition UI. */
|
||||||
|
export const CONTRACT_STATUS_LABEL: Record<ContractStatus, string> = {
|
||||||
|
draft: "Rascunho",
|
||||||
|
pending_signature: "Aguardando assinatura",
|
||||||
|
active: "Ativo",
|
||||||
|
suspended: "Suspenso",
|
||||||
|
cancelled: "Cancelado",
|
||||||
|
terminated: "Rescindido",
|
||||||
|
expired: "Expirado",
|
||||||
|
renewed: "Renovado",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CONTRACT_STATUS_VARIANT: Record<ContractStatus, BadgeVariant> = {
|
||||||
|
draft: "neutral",
|
||||||
|
pending_signature: "warning",
|
||||||
|
active: "success",
|
||||||
|
suspended: "warning",
|
||||||
|
cancelled: "danger",
|
||||||
|
terminated: "danger",
|
||||||
|
expired: "neutral",
|
||||||
|
renewed: "info",
|
||||||
|
};
|
||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import type { FormEvent } from "react";
|
import type { FormEvent } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { Alert, Badge, Button, Card, Input, PageHeader } from "@eden/ui";
|
import { Link } from "react-router-dom";
|
||||||
|
import { Alert, Badge, Button, Card, Input, PageHeader, Select } from "@eden/ui";
|
||||||
import type { BadgeVariant } from "@eden/ui";
|
import type { BadgeVariant } from "@eden/ui";
|
||||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||||
import { ApiError } from "../../lib/api/http.js";
|
import { ApiError } from "../../lib/api/http.js";
|
||||||
@@ -9,7 +10,10 @@ import { closeQuoteDeal, decideQuoteApproval, getQuote } from "../../lib/api/quo
|
|||||||
import type { ApprovalStatus, DealStatus, Quote } from "../../lib/api/quotes.js";
|
import type { ApprovalStatus, DealStatus, Quote } from "../../lib/api/quotes.js";
|
||||||
import { listResellers } from "../../lib/api/resellers.js";
|
import { listResellers } from "../../lib/api/resellers.js";
|
||||||
import type { Reseller } from "../../lib/api/resellers.js";
|
import type { Reseller } from "../../lib/api/resellers.js";
|
||||||
|
import { listClientRegistrations } from "../../lib/api/client-registrations.js";
|
||||||
|
import type { ClientRegistration } from "../../lib/api/client-registrations.js";
|
||||||
import { formatCurrencyBRL, formatDateBR } from "../../lib/format.js";
|
import { formatCurrencyBRL, formatDateBR } from "../../lib/format.js";
|
||||||
|
import { PRIMARY_LINK_BUTTON_CLASS } from "../../components/linkButtonClasses.js";
|
||||||
import { Breadcrumb } from "../../components/Breadcrumb.js";
|
import { Breadcrumb } from "../../components/Breadcrumb.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -99,6 +103,18 @@ export function QuoteDetailPage() {
|
|||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant={APPROVAL_VARIANT[quote.approval_status]}>{APPROVAL_LABEL[quote.approval_status]}</Badge>
|
<Badge variant={APPROVAL_VARIANT[quote.approval_status]}>{APPROVAL_LABEL[quote.approval_status]}</Badge>
|
||||||
<Badge variant={DEAL_VARIANT[quote.deal_status]}>{DEAL_LABEL[quote.deal_status]}</Badge>
|
<Badge variant={DEAL_VARIANT[quote.deal_status]}>{DEAL_LABEL[quote.deal_status]}</Badge>
|
||||||
|
{quote.deal_status === "fechado" ? (
|
||||||
|
<Link
|
||||||
|
to={`/contracts/new?quoteId=${encodeURIComponent(quote.id)}${
|
||||||
|
quote.client_registration_id
|
||||||
|
? `&clientRegistrationId=${encodeURIComponent(quote.client_registration_id)}`
|
||||||
|
: ""
|
||||||
|
}`}
|
||||||
|
className={PRIMARY_LINK_BUTTON_CLASS}
|
||||||
|
>
|
||||||
|
Criar contrato a partir desta oferta
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -190,7 +206,17 @@ export function QuoteDetailPage() {
|
|||||||
<CloseDealCard quoteId={quote.id} accessToken={accessToken} onClosed={load} />
|
<CloseDealCard quoteId={quote.id} accessToken={accessToken} onClosed={load} />
|
||||||
) : quote.client_registration_id ? (
|
) : quote.client_registration_id ? (
|
||||||
<Card title="Negócio">
|
<Card title="Negócio">
|
||||||
<Row label="Cadastro de cliente" value={quote.client_registration_id} />
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<dt className="text-sm text-default">Cadastro de cliente</dt>
|
||||||
|
<dd>
|
||||||
|
<Link
|
||||||
|
to={`/client-registrations/${quote.client_registration_id}`}
|
||||||
|
className="text-sm font-semibold text-primary hover:underline"
|
||||||
|
>
|
||||||
|
Ver cadastro →
|
||||||
|
</Link>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -274,6 +300,15 @@ function ApprovalCard({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Was a free-text UUID field the user had to paste manually — replaced with
|
||||||
|
* a real `<select>` populated from `GET /v1/client-registrations` (name +
|
||||||
|
* code), matching every other reference field in the app (e.g. Reseller in
|
||||||
|
* the summary card above). When there's nothing to pick yet, a button
|
||||||
|
* navigates to `/client-registrations/new` in a new tab so the in-progress
|
||||||
|
* quote page isn't lost — the user comes back to this tab and reloads the
|
||||||
|
* list once the cadastro exists.
|
||||||
|
*/
|
||||||
function CloseDealCard({
|
function CloseDealCard({
|
||||||
quoteId,
|
quoteId,
|
||||||
accessToken,
|
accessToken,
|
||||||
@@ -283,13 +318,29 @@ function CloseDealCard({
|
|||||||
accessToken: string | null;
|
accessToken: string | null;
|
||||||
onClosed: () => void;
|
onClosed: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const [registrations, setRegistrations] = useState<ClientRegistration[] | null>(null);
|
||||||
|
const [loadError, setLoadError] = useState<string | null>(null);
|
||||||
const [clientRegistrationId, setClientRegistrationId] = useState("");
|
const [clientRegistrationId, setClientRegistrationId] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const loadRegistrations = useCallback(() => {
|
||||||
|
if (!accessToken) return;
|
||||||
|
setLoadError(null);
|
||||||
|
listClientRegistrations(accessToken)
|
||||||
|
.then(setRegistrations)
|
||||||
|
.catch((err) =>
|
||||||
|
setLoadError(err instanceof ApiError ? err.message : "Não foi possível carregar os cadastros de cliente."),
|
||||||
|
);
|
||||||
|
}, [accessToken]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRegistrations();
|
||||||
|
}, [loadRegistrations]);
|
||||||
|
|
||||||
async function handleSubmit(event: FormEvent) {
|
async function handleSubmit(event: FormEvent) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (!accessToken) return;
|
if (!accessToken || !clientRegistrationId) return;
|
||||||
setError(null);
|
setError(null);
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
@@ -302,6 +353,13 @@ function CloseDealCard({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const registrationOptions = (registrations ?? []).map((registration) => {
|
||||||
|
const name =
|
||||||
|
(registration.person_type === "pf" ? registration.pf_full_name : registration.pj_company_name) ??
|
||||||
|
"Sem nome preenchido";
|
||||||
|
return { value: registration.id, label: `#${registration.client_code} — ${name}` };
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card title="Fechar negócio">
|
<Card title="Fechar negócio">
|
||||||
{error ? (
|
{error ? (
|
||||||
@@ -309,19 +367,36 @@ function CloseDealCard({
|
|||||||
{error}
|
{error}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : null}
|
) : null}
|
||||||
<form className="flex flex-wrap items-end gap-3" onSubmit={handleSubmit}>
|
{loadError ? (
|
||||||
<Input
|
<Alert variant="error" className="mb-3">
|
||||||
label="UUID do cadastro de cliente"
|
{loadError}
|
||||||
value={clientRegistrationId}
|
</Alert>
|
||||||
onChange={(e) => setClientRegistrationId(e.target.value)}
|
) : null}
|
||||||
hint="Cole o UUID de um cadastro de cliente já existente"
|
|
||||||
className="min-w-72 flex-1"
|
{registrations !== null && registrations.length === 0 ? (
|
||||||
required
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
/>
|
<p className="text-sm text-default">Nenhum cadastro de cliente encontrado ainda.</p>
|
||||||
<Button type="submit" loading={submitting}>
|
<Button type="button" variant="secondary" onClick={() => window.open("/client-registrations/new", "_blank")}>
|
||||||
Fechar negócio
|
Criar novo cadastro de cliente
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</div>
|
||||||
|
) : (
|
||||||
|
<form className="flex flex-wrap items-end gap-3" onSubmit={handleSubmit}>
|
||||||
|
<Select
|
||||||
|
label="Cadastro de cliente"
|
||||||
|
options={registrationOptions}
|
||||||
|
value={clientRegistrationId}
|
||||||
|
onChange={(e) => setClientRegistrationId(e.target.value)}
|
||||||
|
placeholder={registrations === null ? "Carregando cadastros…" : "Selecione um cadastro"}
|
||||||
|
hint="Cadastro de cliente que receberá este negócio"
|
||||||
|
className="min-w-72 flex-1"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<Button type="submit" loading={submitting}>
|
||||||
|
Fechar negócio
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -331,3 +331,28 @@ Data: 2026-09-03. Usuário pediu para "configurar o proxy". Como a API não tem
|
|||||||
Com isso, o browser batendo em `https://dev-edencore.falehandix.com.br/api/v1/...` (uma vez que o proxy externo do operador já roteia o domínio inteiro para a porta 3001, como já estava configurado) chega ao nginx do container, que repassa para a API interna — fluxo 100% same-origin do ponto de vista do browser, sem CORS.
|
Com isso, o browser batendo em `https://dev-edencore.falehandix.com.br/api/v1/...` (uma vez que o proxy externo do operador já roteia o domínio inteiro para a porta 3001, como já estava configurado) chega ao nginx do container, que repassa para a API interna — fluxo 100% same-origin do ponto de vista do browser, sem CORS.
|
||||||
|
|
||||||
Testado ao vivo: `POST /api/v1/auth/login` com credenciais reais funcionando através da porta 3001 (`eden-core`), 3002 (`eden-parceiros`) e 3003 (`eden-assinante`) — os 3 containers reconstruídos e confirmados.
|
Testado ao vivo: `POST /api/v1/auth/login` com credenciais reais funcionando através da porta 3001 (`eden-core`), 3002 (`eden-parceiros`) e 3003 (`eden-assinante`) — os 3 containers reconstruídos e confirmados.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cadastro de Cliente e Contratos no EDEN Core
|
||||||
|
|
||||||
|
Data: 2026-09-03. Continuação direta da leva anterior (sem parar para autorização, a pedido do usuário). 2 subagentes `eden-frontend` em paralelo, mesmo padrão de referência do tema DreamsERP (`customers.tsx`/`contacts.tsx` para Cadastro de Cliente; `salesOrders*.tsx` para Contratos — a mais próxima estruturalmente, já que "pedido de venda" no tema tem exatamente o mesmo shape de itens+partes+status+histórico de um Contrato).
|
||||||
|
|
||||||
|
### Cadastro de Cliente (`apps/core-web/src/routes/client-registrations/`)
|
||||||
|
|
||||||
|
Lista, criação (fluxo direto: tipo PF/PJ + portabilidade), e uma tela de detalhe rica com edição por seção (Status, Endereço, Contatos principal/financeiro/técnico, Dados PF ou PJ condicionalmente — CPF/CNPJ sempre somente-leitura, nunca editável por este endpoint —, Portabilidade quando aplicável) mais uma sub-seção de Sócios (só PJ: lista + adicionar + editar os 3 campos administrativos).
|
||||||
|
|
||||||
|
**Fechou a pendência da leva anterior**: `QuoteDetailPage` não pede mais para colar um UUID à mão para fechar negócio — agora é um `<select>` real populado por `GET /v1/client-registrations` (código + nome), com atalho para criar um cadastro novo em outra aba quando a lista está vazia.
|
||||||
|
|
||||||
|
### Contratos (`apps/core-web/src/routes/contracts/`)
|
||||||
|
|
||||||
|
Lista, criação (revenda/empresa opcionais, período, fidelidade reduzida opcional, itens produto+quantidade, partes contratada/contratante) e detalhe rico: resumo financeiro, aviso explícito quando `fidelity_period` está preenchido mas `fidelity_approved=false` ("sem efeito legal ainda"), tabela de itens, lista de partes, histórico de status, e botões de transição — **só os alvos válidos a partir do status atual** (mesma tabela de transições do backend replicada no frontend só para guiar a UI; o servidor segue sendo a única fonte de verdade, confirmado com 409 num teste de transição inválida). `QuoteDetailPage` ganhou um link "Criar contrato a partir desta oferta" quando `deal_status === "fechado"`, pré-preenchendo `quoteId`/`clientRegistrationId` via query string.
|
||||||
|
|
||||||
|
### Integração
|
||||||
|
|
||||||
|
Ambos os agentes fizeram fast-forward do próprio branch para `main` antes de começar (mesmo padrão da leva anterior), então os diffs vieram limpos direto sobre o commit mais recente. Único ponto de reconciliação manual: os dois (mais o de Cadastro de Cliente) tocaram `QuoteDetailPage.tsx`/`App.tsx`/`AuthenticatedLayout.tsx` — mesclados um de cada vez, sempre confirmando `typecheck`/`build` limpo entre cada mesclagem antes de aceitar a próxima.
|
||||||
|
|
||||||
|
Verificado: `pnpm turbo run typecheck build` (28/28); containers `eden-api`/`eden-core` reconstruídos; todo o fluxo testado ao vivo **através do proxy same-origin da porta 3001** (não direto na 8080, simulando exatamente o caminho real do browser): login, criação de cadastro de cliente, criação de contrato, e transição de status inválida corretamente rejeitada com 409.
|
||||||
|
|
||||||
|
### Pendências
|
||||||
|
`ContractFormPage` não tem edição pós-criação (só criação + transição de status); nenhuma tela ainda para Inventory/Support/HR-Timeclock/Fiscal/Billing/Documents; `reseller-web`/`subscriber-web` seguem só com o shell de login.
|
||||||
|
|||||||
Reference in New Issue
Block a user