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

Continuação direta da leva anterior, sem parar para autorização (a
pedido do usuário). 2 subagentes eden-frontend em paralelo.

- Cadastro de Cliente: lista, criação direta (PF/PJ + portabilidade),
  detalhe rico editável por seção (status, endereço, contatos, dados
  PF/PJ com CPF/CNPJ sempre somente-leitura, portabilidade condicional)
  + sub-seção de Sócios para PJ. Fecha a pendência da leva anterior:
  QuoteDetailPage não pede mais UUID colado à mão para fechar negócio,
  agora é um select real com atalho para criar cadastro novo.
- Contratos: lista, criação (itens produto+quantidade, partes
  contratada/contratante), detalhe com resumo financeiro, aviso de
  fidelidade sem efeito legal quando não aprovada, histórico de status,
  e botões de transição limitados aos alvos válidos a partir do status
  atual (servidor continua sendo a única fonte de verdade real).
  QuoteDetailPage ganha link "Criar contrato a partir desta oferta"
  quando a oferta está fechada.

Reconciliação manual de App.tsx/AuthenticatedLayout.tsx/QuoteDetailPage.tsx
entre os 2 agentes (mais o de Empresas/CRM/Ofertas da leva anterior),
um de cada vez, confirmando typecheck/build limpo a cada mesclagem.

Verificado: pnpm turbo run typecheck build (28/28); containers
eden-api/eden-core reconstruídos; fluxo completo testado ao vivo
ATRAVÉS DO PROXY same-origin (porta 3001, não direto na API) — login,
criação de cadastro, criação de contrato, transição de status inválida
corretamente rejeitada com 409.

Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-03 22:51:40 -03:00
parent abe056c8b6
commit 19e67dbe63
14 changed files with 2732 additions and 16 deletions

View File

@@ -18,6 +18,12 @@ import { OpportunityFormPage } from "./routes/crm/OpportunityFormPage.js";
import { QuotesListPage } from "./routes/quotes/QuotesListPage.js";
import { QuoteFormPage } from "./routes/quotes/QuoteFormPage.js";
import { QuoteDetailPage } from "./routes/quotes/QuoteDetailPage.js";
import { ClientRegistrationsListPage } from "./routes/client-registrations/ClientRegistrationsListPage.js";
import { ClientRegistrationFormPage } from "./routes/client-registrations/ClientRegistrationFormPage.js";
import { ClientRegistrationDetailPage } from "./routes/client-registrations/ClientRegistrationDetailPage.js";
import { ContractsListPage } from "./routes/contracts/ContractsListPage.js";
import { ContractFormPage } from "./routes/contracts/ContractFormPage.js";
import { ContractDetailPage } from "./routes/contracts/ContractDetailPage.js";
export function App() {
return (
@@ -53,6 +59,14 @@ export function App() {
<Route path="/quotes" element={<QuotesListPage />} />
<Route path="/quotes/new" element={<QuoteFormPage />} />
<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>
</Routes>

View File

@@ -0,0 +1,266 @@
/**
* Client for `apps/api` `/v1/client-registrations` (see
* apps/api/src/client-registrations/client-registrations.controller.ts,
* client-registrations.service.ts, dto/*, and
* packages/database/src/repositories/client-registrations.ts +
* client-registration-partners.ts for the source of truth — shapes below
* are mirrored, not invented).
*
* Like Companies/Resellers, the response is the DB row passed straight
* through — snake_case, no repository-level mapper. Every route here
* (including reads) requires authentication + the `client-registrations`
* resource permission, since this data carries customer PII.
*/
import { apiRequest } from "./http.js";
export const PERSON_TYPES = ["pf", "pj"] as const;
export type PersonType = (typeof PERSON_TYPES)[number];
export const REGISTRATION_STATUSES = [
"rascunho",
"pendente_validacao",
"ativo",
"bloqueado",
"inativo",
] as const;
export type RegistrationStatus = (typeof REGISTRATION_STATUSES)[number];
export interface PortabilityRange {
first: string;
last: string;
}
/** Response shape — snake_case, exactly as returned by the `client_registrations` table. */
export interface ClientRegistration {
id: string;
created_at: string;
updated_at: string;
created_by: string | null;
quote_id: string | null;
reseller_id: string | null;
token: string;
token_created_at: string;
/** Sequential human-readable code, e.g. display as `#123`. */
client_code: number;
is_portability: boolean;
person_type: PersonType;
registration_status: RegistrationStatus;
submitted_at: string | null;
activated_at: string | null;
internal_notes: string | null;
address_zip: string | null;
address_street: string | null;
address_number: string | null;
address_complement: string | null;
address_neighborhood: string | null;
address_city: string | null;
address_state: string | null;
address_country: string;
contact_principal_name: string | null;
contact_principal_email: string | null;
contact_principal_phone: string | null;
contact_financial_name: string | null;
contact_financial_email: string | null;
contact_financial_phone: string | null;
contact_technical_name: string | null;
contact_technical_email: string | null;
contact_technical_phone: string | null;
pf_full_name: string | null;
pf_cpf: string | null;
pf_birth_date: string | null;
pf_email: string | null;
pf_mobile_phone: string | null;
pf_alt_phone: string | null;
pf_social_name: string | null;
pf_id_document: string | null;
pf_id_issuer: string | null;
pj_cnpj: string | null;
pj_company_name: string | null;
pj_trade_name: string | null;
pj_state_registration: string | null;
pj_municipal_registration: string | null;
pj_opening_date: string | null;
pj_legal_nature: string | null;
pj_cnpj_situation: string | null;
pj_main_activity: string | null;
pj_rep_full_name: string | null;
pj_rep_cpf: string | null;
pj_rep_role: string | null;
pj_rep_email: string | null;
pj_rep_mobile_phone: string | null;
pj_rep_authorization_ack: boolean;
cpf_format_valid: boolean | null;
cnpj_format_valid: boolean | null;
portability_numbers: string[];
portability_ranges: PortabilityRange[];
ixc_client_id: string | null;
ixc_contract_number: string | null;
}
/** Request body of `POST /v1/client-registrations/direct` — matches `CreateDirectClientRegistrationDto`. */
export interface CreateDirectClientRegistrationInput {
personType: PersonType;
isPortability: boolean;
/**
* Only actually honored server-side when the actor holds `all` edit
* scope for client-registrations; for a narrower (e.g. reseller) actor
* the backend always forces their own reseller — safe to always show
* the field and let the backend decide/ignore it.
*/
resellerId?: string;
}
/**
* Body of `PATCH /v1/client-registrations/:id` — matches
* `UpdateClientRegistrationDto` exactly. `pfCpf`/`pjCnpj`/`pjRepCpf` are
* deliberately NOT here — not editable via this endpoint.
*/
export interface UpdateClientRegistrationInput {
registrationStatus?: RegistrationStatus;
internalNotes?: string;
ixcClientId?: string;
ixcContractNumber?: string;
addressZip?: string;
addressStreet?: string;
addressNumber?: string;
addressComplement?: string;
addressNeighborhood?: string;
addressCity?: string;
addressState?: string;
addressCountry?: string;
contactPrincipalName?: string;
contactPrincipalEmail?: string;
contactPrincipalPhone?: string;
contactFinancialName?: string;
contactFinancialEmail?: string;
contactFinancialPhone?: string;
contactTechnicalName?: string;
contactTechnicalEmail?: string;
contactTechnicalPhone?: string;
pfFullName?: string;
pfBirthDate?: string;
pfEmail?: string;
pfMobilePhone?: string;
pfAltPhone?: string;
pfSocialName?: string;
pfIdDocument?: string;
pfIdIssuer?: string;
pjCompanyName?: string;
pjTradeName?: string;
pjStateRegistration?: string;
pjMunicipalRegistration?: string;
pjOpeningDate?: string;
pjLegalNature?: string;
pjCnpjSituation?: string;
pjMainActivity?: string;
pjRepFullName?: string;
pjRepRole?: string;
pjRepEmail?: string;
pjRepMobilePhone?: string;
pjRepAuthorizationAck?: boolean;
portabilityNumbers?: string[];
portabilityRanges?: PortabilityRange[];
}
/** `client_registration_partners` row — see packages/database/src/repositories/client-registration-partners.ts. */
export interface ClientRegistrationPartner {
id: string;
client_registration_id: string;
name: string;
document: string | null;
qualification: string | null;
source: string;
cpf: string | null;
will_sign: boolean;
signature_email: string | null;
created_at: string;
}
/** Body of `POST /v1/client-registrations/:id/partners` — matches `CreatePartnerDto`. */
export interface CreatePartnerInput {
name: string;
document?: string;
qualification?: string;
cpf?: string;
willSign?: boolean;
signatureEmail?: string;
}
/** Body of `PATCH /v1/client-registrations/:id/partners/:partnerId` — matches `UpdatePartnerDto`, the only 3 admin-editable fields. */
export interface UpdatePartnerInput {
cpf?: string;
willSign?: boolean;
signatureEmail?: string;
}
export function listClientRegistrations(accessToken: string): Promise<ClientRegistration[]> {
return apiRequest<ClientRegistration[]>("/client-registrations", { method: "GET" }, { accessToken });
}
export function getClientRegistration(id: string, accessToken: string): Promise<ClientRegistration> {
return apiRequest<ClientRegistration>(`/client-registrations/${id}`, { method: "GET" }, { accessToken });
}
export function createDirectClientRegistration(
input: CreateDirectClientRegistrationInput,
accessToken: string,
): Promise<ClientRegistration> {
return apiRequest<ClientRegistration>(
"/client-registrations/direct",
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
export function updateClientRegistration(
id: string,
input: UpdateClientRegistrationInput,
accessToken: string,
): Promise<ClientRegistration> {
return apiRequest<ClientRegistration>(
`/client-registrations/${id}`,
{ method: "PATCH", body: JSON.stringify(input) },
{ accessToken },
);
}
export function listClientRegistrationPartners(
id: string,
accessToken: string,
): Promise<ClientRegistrationPartner[]> {
return apiRequest<ClientRegistrationPartner[]>(
`/client-registrations/${id}/partners`,
{ method: "GET" },
{ accessToken },
);
}
export function createClientRegistrationPartner(
id: string,
input: CreatePartnerInput,
accessToken: string,
): Promise<ClientRegistrationPartner> {
return apiRequest<ClientRegistrationPartner>(
`/client-registrations/${id}/partners`,
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
export function updateClientRegistrationPartner(
id: string,
partnerId: string,
input: UpdatePartnerInput,
accessToken: string,
): Promise<ClientRegistrationPartner> {
return apiRequest<ClientRegistrationPartner>(
`/client-registrations/${id}/partners/${partnerId}`,
{ method: "PATCH", body: JSON.stringify(input) },
{ accessToken },
);
}

View File

@@ -0,0 +1,198 @@
/**
* Client for `apps/api` `/v1/contracts` (see apps/api/src/contracts/contracts.controller.ts,
* contracts.service.ts, dto/*, and packages/database/src/repositories/contracts.ts
* for the source of truth — shapes below are mirrored, not invented).
*
* Like Quotes, the top-level response is snake_case straight off the
* `contracts` table. Money fields (`monthly_value`, `contract_total`,
* `implementation_fee`) and item `quantity`/`unit_price`/`total` come back
* as NUMERIC-typed strings from Postgres — always format via
* `formatCurrencyBRL` (or `Number(...)` for `quantity`), never coerce to
* `number` for storage.
*/
import { apiRequest } from "./http.js";
export const CONTRACT_PERIODS = [0, 12, 24, 36, 48] as const;
export type ContractPeriod = (typeof CONTRACT_PERIODS)[number];
export type ContractStatus =
| "draft"
| "pending_signature"
| "active"
| "suspended"
| "cancelled"
| "terminated"
| "expired"
| "renewed";
export type PartyRole = "contratada" | "contratante";
export type PersonType = "pf" | "pj";
/**
* Valid `toStatus` targets from the current status — terminal statuses map
* to `[]`. Mirrors packages/database/src/repositories/contracts.ts's
* `CONTRACT_STATUS_TRANSITIONS` exactly — the server is the real
* enforcement (409 on an invalid transition), this only drives which
* buttons the UI offers.
*/
export const CONTRACT_STATUS_TRANSITIONS: Record<ContractStatus, ContractStatus[]> = {
draft: ["pending_signature", "cancelled"],
pending_signature: ["active", "cancelled"],
active: ["suspended", "cancelled", "terminated", "expired"],
suspended: ["active", "cancelled", "terminated"],
cancelled: [],
terminated: [],
expired: [],
renewed: [],
};
export interface ContractItem {
id: string;
product_id: string | null;
product_name: string;
quantity: string;
unit_price: string;
total: string;
sort_order: number;
}
export interface ContractParty {
id: string;
party_role: PartyRole;
person_type: PersonType | null;
full_name: string;
document: string | null;
email: string | null;
phone: string | null;
role_title: string | null;
}
export interface ContractStatusHistoryEntry {
id: string;
from_status: ContractStatus | null;
to_status: ContractStatus;
reason: string | null;
changed_by: string | null;
changed_at: string;
}
/** Response shape for `GET /contracts` (list) and the status-transition endpoint — no detail arrays. */
export interface Contract {
id: string;
created_at: string;
updated_at: string;
created_by: string | null;
contract_number: string;
quote_id: string | null;
client_registration_id: string | null;
reseller_id: string | null;
company_id: string | null;
status: ContractStatus;
contract_period: number;
fidelity_period: number | null;
fidelity_approved: boolean;
monthly_value: string | null;
contract_total: string | null;
implementation_fee: string;
signed_at: string | null;
activated_at: string | null;
notes: string | null;
}
/** Response shape for `GET /contracts/:id` and `POST /contracts` — adds detail arrays. */
export interface ContractWithDetails extends Contract {
items: ContractItem[];
parties: ContractParty[];
statusHistory: ContractStatusHistoryEntry[];
}
export interface ContractItemInput {
productId: string;
quantity: number;
}
export interface ContractPartyInput {
partyRole: PartyRole;
personType?: PersonType;
fullName: string;
document?: string;
email?: string;
phone?: string;
roleTitle?: string;
}
export interface CreateContractInput {
resellerId?: string;
companyId?: string;
quoteId?: string;
clientRegistrationId?: string;
contractPeriod: ContractPeriod;
fidelityPeriod?: number;
items: ContractItemInput[];
parties: ContractPartyInput[];
implementationFee?: number;
notes?: string;
}
export interface UpdateContractStatusInput {
toStatus: ContractStatus;
reason?: string;
}
export function listContracts(accessToken: string): Promise<Contract[]> {
return apiRequest<Contract[]>("/contracts", { method: "GET" }, { accessToken });
}
export function getContract(id: string, accessToken: string): Promise<ContractWithDetails> {
return apiRequest<ContractWithDetails>(`/contracts/${id}`, { method: "GET" }, { accessToken });
}
export function createContract(
input: CreateContractInput,
accessToken: string,
): Promise<ContractWithDetails> {
return apiRequest<ContractWithDetails>(
"/contracts",
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
/** Returns only the bare `Contract` row (no items/parties/statusHistory) — reload via `getContract` to refresh those. */
export function updateContractStatus(
id: string,
input: UpdateContractStatusInput,
accessToken: string,
): Promise<Contract> {
return apiRequest<Contract>(
`/contracts/${id}/status`,
{ method: "PATCH", body: JSON.stringify(input) },
{ accessToken },
);
}
/** Returns only the newly created `ContractItem` row — reload via `getContract` to refresh totals. */
export function addContractItem(
id: string,
input: ContractItemInput,
accessToken: string,
): Promise<ContractItem> {
return apiRequest<ContractItem>(
`/contracts/${id}/items`,
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}
/** Returns only the newly created `ContractParty` row — reload via `getContract` to refresh the parties list. */
export function addContractParty(
id: string,
input: ContractPartyInput,
accessToken: string,
): Promise<ContractParty> {
return apiRequest<ContractParty>(
`/contracts/${id}/parties`,
{ method: "POST", body: JSON.stringify(input) },
{ accessToken },
);
}

View File

@@ -22,6 +22,8 @@ export function AuthenticatedLayout() {
{ key: "resellers", label: "Revendas", href: "/resellers" },
{ key: "leads", label: "CRM", href: "/leads" },
{ key: "quotes", label: "Ofertas", href: "/quotes" },
{ key: "client-registrations", label: "Clientes", href: "/client-registrations" },
{ key: "contracts", label: "Contratos", href: "/contracts" },
].map((item) => ({ ...item, active: isNavItemActive(item.href, location.pathname) }));
async function handleLogout() {

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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",
};

View File

@@ -1,7 +1,8 @@
import { useCallback, useEffect, useState } from "react";
import type { FormEvent } from "react";
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 { useAuth } from "../../lib/auth/AuthContext.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 { listResellers } 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 { PRIMARY_LINK_BUTTON_CLASS } from "../../components/linkButtonClasses.js";
import { Breadcrumb } from "../../components/Breadcrumb.js";
/**
@@ -99,6 +103,18 @@ export function QuoteDetailPage() {
<div className="flex items-center gap-2">
<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>
{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>
}
/>
@@ -190,7 +206,17 @@ export function QuoteDetailPage() {
<CloseDealCard quoteId={quote.id} accessToken={accessToken} onClosed={load} />
) : quote.client_registration_id ? (
<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>
) : null}
</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({
quoteId,
accessToken,
@@ -283,13 +318,29 @@ function CloseDealCard({
accessToken: string | null;
onClosed: () => void;
}) {
const [registrations, setRegistrations] = useState<ClientRegistration[] | null>(null);
const [loadError, setLoadError] = useState<string | null>(null);
const [clientRegistrationId, setClientRegistrationId] = useState("");
const [submitting, setSubmitting] = useState(false);
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) {
event.preventDefault();
if (!accessToken) return;
if (!accessToken || !clientRegistrationId) return;
setError(null);
setSubmitting(true);
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 (
<Card title="Fechar negócio">
{error ? (
@@ -309,19 +367,36 @@ function CloseDealCard({
{error}
</Alert>
) : null}
<form className="flex flex-wrap items-end gap-3" onSubmit={handleSubmit}>
<Input
label="UUID do cadastro de cliente"
value={clientRegistrationId}
onChange={(e) => setClientRegistrationId(e.target.value)}
hint="Cole o UUID de um cadastro de cliente já existente"
className="min-w-72 flex-1"
required
/>
<Button type="submit" loading={submitting}>
Fechar negócio
</Button>
</form>
{loadError ? (
<Alert variant="error" className="mb-3">
{loadError}
</Alert>
) : null}
{registrations !== null && registrations.length === 0 ? (
<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="button" variant="secondary" onClick={() => window.open("/client-registrations/new", "_blank")}>
Criar novo cadastro de cliente
</Button>
</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>
);
}

View File

@@ -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.
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.