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:
@@ -0,0 +1,613 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { Alert, Badge, Button, Card, Input, PageHeader, Select } from "@eden/ui";
|
||||
import type { BadgeVariant } from "@eden/ui";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import {
|
||||
getClientRegistration,
|
||||
updateClientRegistration,
|
||||
REGISTRATION_STATUSES,
|
||||
} from "../../lib/api/client-registrations.js";
|
||||
import type { ClientRegistration, RegistrationStatus, UpdateClientRegistrationInput } from "../../lib/api/client-registrations.js";
|
||||
import { formatDocument } from "../../lib/format.js";
|
||||
import { Breadcrumb } from "../../components/Breadcrumb.js";
|
||||
import { PartnersSection } from "./PartnersSection.js";
|
||||
|
||||
/**
|
||||
* Layout reference: DreamsERP theme `.tmp/theme-reference/salesOrderDetails.tsx`
|
||||
* (breadcrumb + title/status header, grouped info blocks) and `customers.tsx`
|
||||
* (status badge) — rebuilt with `@eden/ui` Card sections, one per group of
|
||||
* fields, same pattern as QuoteDetailPage/CompanyFormPage. Unlike those two
|
||||
* (pure display or pure form), this screen mixes both: everything below the
|
||||
* header is one editable form (single PATCH on submit, matching how the
|
||||
* API accepts a fully partial `UpdateClientRegistrationDto`), plus a
|
||||
* separate Sócios sub-section with its own independent list/add/edit calls.
|
||||
*/
|
||||
const STATUS_LABEL: Record<RegistrationStatus, string> = {
|
||||
rascunho: "Rascunho",
|
||||
pendente_validacao: "Pendente de validação",
|
||||
ativo: "Ativo",
|
||||
bloqueado: "Bloqueado",
|
||||
inativo: "Inativo",
|
||||
};
|
||||
|
||||
const STATUS_VARIANT: Record<RegistrationStatus, BadgeVariant> = {
|
||||
rascunho: "neutral",
|
||||
pendente_validacao: "warning",
|
||||
ativo: "success",
|
||||
bloqueado: "danger",
|
||||
inativo: "neutral",
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS = REGISTRATION_STATUSES.map((status) => ({ value: status, label: STATUS_LABEL[status] }));
|
||||
|
||||
interface FormState {
|
||||
registrationStatus: RegistrationStatus;
|
||||
internalNotes: string;
|
||||
ixcClientId: string;
|
||||
ixcContractNumber: string;
|
||||
|
||||
addressZip: string;
|
||||
addressStreet: string;
|
||||
addressNumber: string;
|
||||
addressComplement: string;
|
||||
addressNeighborhood: string;
|
||||
addressCity: string;
|
||||
addressState: string;
|
||||
addressCountry: string;
|
||||
|
||||
contactPrincipalName: string;
|
||||
contactPrincipalEmail: string;
|
||||
contactPrincipalPhone: string;
|
||||
contactFinancialName: string;
|
||||
contactFinancialEmail: string;
|
||||
contactFinancialPhone: string;
|
||||
contactTechnicalName: string;
|
||||
contactTechnicalEmail: string;
|
||||
contactTechnicalPhone: string;
|
||||
|
||||
pfFullName: string;
|
||||
pfBirthDate: string;
|
||||
pfEmail: string;
|
||||
pfMobilePhone: string;
|
||||
pfAltPhone: string;
|
||||
pfSocialName: string;
|
||||
pfIdDocument: string;
|
||||
pfIdIssuer: string;
|
||||
|
||||
pjCompanyName: string;
|
||||
pjTradeName: string;
|
||||
pjStateRegistration: string;
|
||||
pjMunicipalRegistration: string;
|
||||
pjOpeningDate: string;
|
||||
pjLegalNature: string;
|
||||
pjCnpjSituation: string;
|
||||
pjMainActivity: string;
|
||||
pjRepFullName: string;
|
||||
pjRepRole: string;
|
||||
pjRepEmail: string;
|
||||
pjRepMobilePhone: string;
|
||||
pjRepAuthorizationAck: boolean;
|
||||
|
||||
/** One number per line — parsed into `portabilityNumbers: string[]` on submit. */
|
||||
portabilityNumbersText: string;
|
||||
}
|
||||
|
||||
function toDateInputValue(value: string | null): string {
|
||||
return value ? value.slice(0, 10) : "";
|
||||
}
|
||||
|
||||
function toFormState(registration: ClientRegistration): FormState {
|
||||
return {
|
||||
registrationStatus: registration.registration_status,
|
||||
internalNotes: registration.internal_notes ?? "",
|
||||
ixcClientId: registration.ixc_client_id ?? "",
|
||||
ixcContractNumber: registration.ixc_contract_number ?? "",
|
||||
|
||||
addressZip: registration.address_zip ?? "",
|
||||
addressStreet: registration.address_street ?? "",
|
||||
addressNumber: registration.address_number ?? "",
|
||||
addressComplement: registration.address_complement ?? "",
|
||||
addressNeighborhood: registration.address_neighborhood ?? "",
|
||||
addressCity: registration.address_city ?? "",
|
||||
addressState: registration.address_state ?? "",
|
||||
addressCountry: registration.address_country ?? "",
|
||||
|
||||
contactPrincipalName: registration.contact_principal_name ?? "",
|
||||
contactPrincipalEmail: registration.contact_principal_email ?? "",
|
||||
contactPrincipalPhone: registration.contact_principal_phone ?? "",
|
||||
contactFinancialName: registration.contact_financial_name ?? "",
|
||||
contactFinancialEmail: registration.contact_financial_email ?? "",
|
||||
contactFinancialPhone: registration.contact_financial_phone ?? "",
|
||||
contactTechnicalName: registration.contact_technical_name ?? "",
|
||||
contactTechnicalEmail: registration.contact_technical_email ?? "",
|
||||
contactTechnicalPhone: registration.contact_technical_phone ?? "",
|
||||
|
||||
pfFullName: registration.pf_full_name ?? "",
|
||||
pfBirthDate: toDateInputValue(registration.pf_birth_date),
|
||||
pfEmail: registration.pf_email ?? "",
|
||||
pfMobilePhone: registration.pf_mobile_phone ?? "",
|
||||
pfAltPhone: registration.pf_alt_phone ?? "",
|
||||
pfSocialName: registration.pf_social_name ?? "",
|
||||
pfIdDocument: registration.pf_id_document ?? "",
|
||||
pfIdIssuer: registration.pf_id_issuer ?? "",
|
||||
|
||||
pjCompanyName: registration.pj_company_name ?? "",
|
||||
pjTradeName: registration.pj_trade_name ?? "",
|
||||
pjStateRegistration: registration.pj_state_registration ?? "",
|
||||
pjMunicipalRegistration: registration.pj_municipal_registration ?? "",
|
||||
pjOpeningDate: toDateInputValue(registration.pj_opening_date),
|
||||
pjLegalNature: registration.pj_legal_nature ?? "",
|
||||
pjCnpjSituation: registration.pj_cnpj_situation ?? "",
|
||||
pjMainActivity: registration.pj_main_activity ?? "",
|
||||
pjRepFullName: registration.pj_rep_full_name ?? "",
|
||||
pjRepRole: registration.pj_rep_role ?? "",
|
||||
pjRepEmail: registration.pj_rep_email ?? "",
|
||||
pjRepMobilePhone: registration.pj_rep_mobile_phone ?? "",
|
||||
pjRepAuthorizationAck: registration.pj_rep_authorization_ack,
|
||||
|
||||
portabilityNumbersText: registration.portability_numbers.join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
function toOptional(value: string): string | undefined {
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? undefined : trimmed;
|
||||
}
|
||||
|
||||
function registrationName(registration: ClientRegistration): string {
|
||||
const name = registration.person_type === "pf" ? registration.pf_full_name : registration.pj_company_name;
|
||||
return name ?? `Cadastro #${registration.client_code}`;
|
||||
}
|
||||
|
||||
export function ClientRegistrationDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { accessToken } = useAuth();
|
||||
|
||||
const [registration, setRegistration] = useState<ClientRegistration | null>(null);
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!id || !accessToken) return;
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
getClientRegistration(id, accessToken)
|
||||
.then((data) => {
|
||||
setRegistration(data);
|
||||
setForm(toFormState(data));
|
||||
})
|
||||
.catch((err) =>
|
||||
setLoadError(err instanceof ApiError ? err.message : "Não foi possível carregar o cadastro de cliente."),
|
||||
)
|
||||
.finally(() => setLoading(false));
|
||||
}, [id, accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
function updateField<K extends keyof FormState>(field: K, value: FormState[K]) {
|
||||
setForm((prev) => (prev ? { ...prev, [field]: value } : prev));
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!accessToken || !id || !form || !registration) return;
|
||||
setSaveError(null);
|
||||
setSaveSuccess(false);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload: UpdateClientRegistrationInput = {
|
||||
registrationStatus: form.registrationStatus !== registration.registration_status ? form.registrationStatus : undefined,
|
||||
internalNotes: toOptional(form.internalNotes),
|
||||
ixcClientId: toOptional(form.ixcClientId),
|
||||
ixcContractNumber: toOptional(form.ixcContractNumber),
|
||||
|
||||
addressZip: toOptional(form.addressZip),
|
||||
addressStreet: toOptional(form.addressStreet),
|
||||
addressNumber: toOptional(form.addressNumber),
|
||||
addressComplement: toOptional(form.addressComplement),
|
||||
addressNeighborhood: toOptional(form.addressNeighborhood),
|
||||
addressCity: toOptional(form.addressCity),
|
||||
addressState: toOptional(form.addressState),
|
||||
addressCountry: toOptional(form.addressCountry),
|
||||
|
||||
contactPrincipalName: toOptional(form.contactPrincipalName),
|
||||
contactPrincipalEmail: toOptional(form.contactPrincipalEmail),
|
||||
contactPrincipalPhone: toOptional(form.contactPrincipalPhone),
|
||||
contactFinancialName: toOptional(form.contactFinancialName),
|
||||
contactFinancialEmail: toOptional(form.contactFinancialEmail),
|
||||
contactFinancialPhone: toOptional(form.contactFinancialPhone),
|
||||
contactTechnicalName: toOptional(form.contactTechnicalName),
|
||||
contactTechnicalEmail: toOptional(form.contactTechnicalEmail),
|
||||
contactTechnicalPhone: toOptional(form.contactTechnicalPhone),
|
||||
};
|
||||
|
||||
if (registration.person_type === "pf") {
|
||||
payload.pfFullName = toOptional(form.pfFullName);
|
||||
payload.pfBirthDate = toOptional(form.pfBirthDate);
|
||||
payload.pfEmail = toOptional(form.pfEmail);
|
||||
payload.pfMobilePhone = toOptional(form.pfMobilePhone);
|
||||
payload.pfAltPhone = toOptional(form.pfAltPhone);
|
||||
payload.pfSocialName = toOptional(form.pfSocialName);
|
||||
payload.pfIdDocument = toOptional(form.pfIdDocument);
|
||||
payload.pfIdIssuer = toOptional(form.pfIdIssuer);
|
||||
} else {
|
||||
payload.pjCompanyName = toOptional(form.pjCompanyName);
|
||||
payload.pjTradeName = toOptional(form.pjTradeName);
|
||||
payload.pjStateRegistration = toOptional(form.pjStateRegistration);
|
||||
payload.pjMunicipalRegistration = toOptional(form.pjMunicipalRegistration);
|
||||
payload.pjOpeningDate = toOptional(form.pjOpeningDate);
|
||||
payload.pjLegalNature = toOptional(form.pjLegalNature);
|
||||
payload.pjCnpjSituation = toOptional(form.pjCnpjSituation);
|
||||
payload.pjMainActivity = toOptional(form.pjMainActivity);
|
||||
payload.pjRepFullName = toOptional(form.pjRepFullName);
|
||||
payload.pjRepRole = toOptional(form.pjRepRole);
|
||||
payload.pjRepEmail = toOptional(form.pjRepEmail);
|
||||
payload.pjRepMobilePhone = toOptional(form.pjRepMobilePhone);
|
||||
payload.pjRepAuthorizationAck = form.pjRepAuthorizationAck;
|
||||
}
|
||||
|
||||
if (registration.is_portability) {
|
||||
payload.portabilityNumbers = form.portabilityNumbersText
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => line !== "");
|
||||
}
|
||||
|
||||
const updated = await updateClientRegistration(id, payload, accessToken);
|
||||
setRegistration(updated);
|
||||
setForm(toFormState(updated));
|
||||
setSaveSuccess(true);
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof ApiError ? err.message : "Não foi possível salvar o cadastro de cliente.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Cadastro de cliente" />
|
||||
<p className="text-sm text-default">Carregando cadastro…</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError || !registration || !form) {
|
||||
return (
|
||||
<div>
|
||||
<PageHeader title="Cadastro de cliente" />
|
||||
<Alert variant="error">{loadError ?? "Cadastro não encontrado."}</Alert>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumb
|
||||
items={[{ label: "Clientes", href: "/client-registrations" }, { label: registrationName(registration) }]}
|
||||
/>
|
||||
<PageHeader
|
||||
title={registrationName(registration)}
|
||||
description={`#${registration.client_code} · ${registration.person_type === "pf" ? "Pessoa Física" : "Pessoa Jurídica"}`}
|
||||
actions={
|
||||
<Badge variant={STATUS_VARIANT[registration.registration_status]}>
|
||||
{STATUS_LABEL[registration.registration_status]}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
|
||||
{saveError ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{saveError}
|
||||
</Alert>
|
||||
) : null}
|
||||
{saveSuccess ? (
|
||||
<Alert variant="success" className="mb-4">
|
||||
Cadastro salvo com sucesso.
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
<Card title="Status">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
|
||||
<Select
|
||||
label="Status do cadastro"
|
||||
options={STATUS_OPTIONS}
|
||||
value={form.registrationStatus}
|
||||
onChange={(e) => updateField("registrationStatus", e.target.value as RegistrationStatus)}
|
||||
/>
|
||||
<Input
|
||||
label="ID do cliente no IXC"
|
||||
value={form.ixcClientId}
|
||||
onChange={(e) => updateField("ixcClientId", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Número do contrato no IXC"
|
||||
value={form.ixcContractNumber}
|
||||
onChange={(e) => updateField("ixcContractNumber", e.target.value)}
|
||||
/>
|
||||
<div className="sm:col-span-2 flex flex-col gap-1">
|
||||
<label className="text-sm font-semibold text-gray-900">Notas internas</label>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-border-color bg-white px-3 py-2.5 text-sm text-title focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
rows={3}
|
||||
value={form.internalNotes}
|
||||
onChange={(e) => updateField("internalNotes", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Endereço">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Input label="CEP" value={form.addressZip} onChange={(e) => updateField("addressZip", e.target.value)} />
|
||||
<div className="sm:col-span-2">
|
||||
<Input
|
||||
label="Logradouro"
|
||||
value={form.addressStreet}
|
||||
onChange={(e) => updateField("addressStreet", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Input
|
||||
label="Número"
|
||||
value={form.addressNumber}
|
||||
onChange={(e) => updateField("addressNumber", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Complemento"
|
||||
value={form.addressComplement}
|
||||
onChange={(e) => updateField("addressComplement", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Bairro"
|
||||
value={form.addressNeighborhood}
|
||||
onChange={(e) => updateField("addressNeighborhood", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Cidade"
|
||||
value={form.addressCity}
|
||||
onChange={(e) => updateField("addressCity", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="UF"
|
||||
maxLength={2}
|
||||
value={form.addressState}
|
||||
onChange={(e) => updateField("addressState", e.target.value.toUpperCase())}
|
||||
/>
|
||||
<Input
|
||||
label="País"
|
||||
value={form.addressCountry}
|
||||
onChange={(e) => updateField("addressCountry", e.target.value)}
|
||||
placeholder="Brasil"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card title="Contatos">
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-3">
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold uppercase text-default">Principal</p>
|
||||
<Input
|
||||
label="Nome"
|
||||
value={form.contactPrincipalName}
|
||||
onChange={(e) => updateField("contactPrincipalName", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="E-mail"
|
||||
type="email"
|
||||
value={form.contactPrincipalEmail}
|
||||
onChange={(e) => updateField("contactPrincipalEmail", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Telefone"
|
||||
value={form.contactPrincipalPhone}
|
||||
onChange={(e) => updateField("contactPrincipalPhone", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold uppercase text-default">Financeiro</p>
|
||||
<Input
|
||||
label="Nome"
|
||||
value={form.contactFinancialName}
|
||||
onChange={(e) => updateField("contactFinancialName", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="E-mail"
|
||||
type="email"
|
||||
value={form.contactFinancialEmail}
|
||||
onChange={(e) => updateField("contactFinancialEmail", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Telefone"
|
||||
value={form.contactFinancialPhone}
|
||||
onChange={(e) => updateField("contactFinancialPhone", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
<p className="text-xs font-semibold uppercase text-default">Técnico</p>
|
||||
<Input
|
||||
label="Nome"
|
||||
value={form.contactTechnicalName}
|
||||
onChange={(e) => updateField("contactTechnicalName", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="E-mail"
|
||||
type="email"
|
||||
value={form.contactTechnicalEmail}
|
||||
onChange={(e) => updateField("contactTechnicalEmail", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Telefone"
|
||||
value={form.contactTechnicalPhone}
|
||||
onChange={(e) => updateField("contactTechnicalPhone", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{registration.person_type === "pf" ? (
|
||||
<Card title="Dados PF">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Input label="CPF" value={formatDocument(registration.pf_cpf)} readOnly disabled />
|
||||
<Input
|
||||
label="Nome completo"
|
||||
value={form.pfFullName}
|
||||
onChange={(e) => updateField("pfFullName", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Nome social"
|
||||
value={form.pfSocialName}
|
||||
onChange={(e) => updateField("pfSocialName", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Data de nascimento"
|
||||
type="date"
|
||||
value={form.pfBirthDate}
|
||||
onChange={(e) => updateField("pfBirthDate", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="E-mail"
|
||||
type="email"
|
||||
value={form.pfEmail}
|
||||
onChange={(e) => updateField("pfEmail", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Celular"
|
||||
value={form.pfMobilePhone}
|
||||
onChange={(e) => updateField("pfMobilePhone", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Telefone alternativo"
|
||||
value={form.pfAltPhone}
|
||||
onChange={(e) => updateField("pfAltPhone", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Documento de identidade"
|
||||
value={form.pfIdDocument}
|
||||
onChange={(e) => updateField("pfIdDocument", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Órgão emissor"
|
||||
value={form.pfIdIssuer}
|
||||
onChange={(e) => updateField("pfIdIssuer", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<Card title="Dados PJ">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<Input label="CNPJ" value={formatDocument(registration.pj_cnpj)} readOnly disabled />
|
||||
<Input
|
||||
label="Razão social"
|
||||
value={form.pjCompanyName}
|
||||
onChange={(e) => updateField("pjCompanyName", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Nome fantasia"
|
||||
value={form.pjTradeName}
|
||||
onChange={(e) => updateField("pjTradeName", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Inscrição estadual"
|
||||
value={form.pjStateRegistration}
|
||||
onChange={(e) => updateField("pjStateRegistration", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Inscrição municipal"
|
||||
value={form.pjMunicipalRegistration}
|
||||
onChange={(e) => updateField("pjMunicipalRegistration", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Data de abertura"
|
||||
type="date"
|
||||
value={form.pjOpeningDate}
|
||||
onChange={(e) => updateField("pjOpeningDate", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Natureza jurídica"
|
||||
value={form.pjLegalNature}
|
||||
onChange={(e) => updateField("pjLegalNature", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Situação do CNPJ"
|
||||
value={form.pjCnpjSituation}
|
||||
onChange={(e) => updateField("pjCnpjSituation", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Atividade principal"
|
||||
value={form.pjMainActivity}
|
||||
onChange={(e) => updateField("pjMainActivity", e.target.value)}
|
||||
/>
|
||||
<Input label="CPF do representante" value={formatDocument(registration.pj_rep_cpf)} readOnly disabled />
|
||||
<Input
|
||||
label="Nome do representante"
|
||||
value={form.pjRepFullName}
|
||||
onChange={(e) => updateField("pjRepFullName", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Cargo do representante"
|
||||
value={form.pjRepRole}
|
||||
onChange={(e) => updateField("pjRepRole", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="E-mail do representante"
|
||||
type="email"
|
||||
value={form.pjRepEmail}
|
||||
onChange={(e) => updateField("pjRepEmail", e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
label="Celular do representante"
|
||||
value={form.pjRepMobilePhone}
|
||||
onChange={(e) => updateField("pjRepMobilePhone", e.target.value)}
|
||||
/>
|
||||
<label className="flex items-center gap-2 py-2.5 text-sm text-title">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="shrink-0 rounded-sm border-border-color text-primary focus:ring-primary"
|
||||
checked={form.pjRepAuthorizationAck}
|
||||
onChange={(e) => updateField("pjRepAuthorizationAck", e.target.checked)}
|
||||
/>
|
||||
Representante confirmou autorização
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{registration.is_portability ? (
|
||||
<Card title="Portabilidade" bodyClassName="p-5">
|
||||
<p className="mb-2 text-xs text-default">Um número de telefone por linha.</p>
|
||||
<textarea
|
||||
className="w-full rounded-md border border-border-color bg-white px-3 py-2.5 text-sm text-title focus:outline-none focus:ring-2 focus:ring-primary/30"
|
||||
rows={4}
|
||||
value={form.portabilityNumbersText}
|
||||
onChange={(e) => updateField("portabilityNumbersText", e.target.value)}
|
||||
placeholder={"(11) 91234-5678\n(11) 98765-4321"}
|
||||
/>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="submit" loading={submitting}>
|
||||
Salvar alterações
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{registration.person_type === "pj" ? (
|
||||
<div className="mt-4">
|
||||
<PartnersSection registrationId={registration.id} accessToken={accessToken} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Alert, Button, Card, PageHeader, Select } from "@eden/ui";
|
||||
import { createDirectClientRegistration } from "../../lib/api/client-registrations.js";
|
||||
import type { PersonType } from "../../lib/api/client-registrations.js";
|
||||
import { listResellers } from "../../lib/api/resellers.js";
|
||||
import type { Reseller } from "../../lib/api/resellers.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
import { Breadcrumb } from "../../components/Breadcrumb.js";
|
||||
|
||||
const PERSON_TYPE_OPTIONS = [
|
||||
{ value: "pf", label: "Pessoa Física" },
|
||||
{ value: "pj", label: "Pessoa Jurídica" },
|
||||
];
|
||||
|
||||
const PORTABILITY_OPTIONS = [
|
||||
{ value: "false", label: "Não" },
|
||||
{ value: "true", label: "Sim" },
|
||||
];
|
||||
|
||||
/**
|
||||
* Only creation path in this MVP is "cadastro direto" (`POST
|
||||
* /client-registrations/direct`) — 2-3 fields, then straight to the rich
|
||||
* detail page to fill everything else in. `resellerId` is always shown;
|
||||
* the backend only honors it for an actor with `all` edit scope and
|
||||
* silently forces the actor's own reseller otherwise (see
|
||||
* client-registrations.ts doc comment).
|
||||
*/
|
||||
export function ClientRegistrationFormPage() {
|
||||
const { accessToken } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [personType, setPersonType] = useState<PersonType>("pf");
|
||||
const [isPortability, setIsPortability] = useState(false);
|
||||
const [resellerId, setResellerId] = useState("");
|
||||
|
||||
const [resellers, setResellers] = useState<Reseller[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) return;
|
||||
let cancelled = false;
|
||||
listResellers(accessToken)
|
||||
.then((data) => {
|
||||
if (!cancelled) setResellers(data);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setResellers([]);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [accessToken]);
|
||||
|
||||
async function handleSubmit(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const registration = await createDirectClientRegistration(
|
||||
{ personType, isPortability, resellerId: resellerId || undefined },
|
||||
accessToken,
|
||||
);
|
||||
navigate(`/client-registrations/${registration.id}`, { replace: true });
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível criar o cadastro de cliente.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
const resellerOptions = (resellers ?? []).map((reseller) => ({ value: reseller.id, label: reseller.name }));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Breadcrumb items={[{ label: "Clientes", href: "/client-registrations" }, { label: "Novo cadastro" }]} />
|
||||
<PageHeader
|
||||
title="Novo cadastro de cliente"
|
||||
description="Depois de criar, o cadastro é aberto para você completar endereço, contatos e documentos."
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
<form className="grid grid-cols-1 gap-4 sm:grid-cols-2" onSubmit={handleSubmit}>
|
||||
<Select
|
||||
label="Tipo de pessoa"
|
||||
options={PERSON_TYPE_OPTIONS}
|
||||
value={personType}
|
||||
onChange={(e) => setPersonType(e.target.value as PersonType)}
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label="Portabilidade"
|
||||
options={PORTABILITY_OPTIONS}
|
||||
value={String(isPortability)}
|
||||
onChange={(e) => setIsPortability(e.target.value === "true")}
|
||||
hint="Cliente deseja portar números de outra operadora"
|
||||
/>
|
||||
<div className="sm:col-span-2">
|
||||
<Select
|
||||
label="Revenda"
|
||||
options={resellerOptions}
|
||||
value={resellerId}
|
||||
onChange={(e) => setResellerId(e.target.value)}
|
||||
placeholder={resellers === null ? "Carregando revendas…" : "Usar a revenda do usuário logado"}
|
||||
hint="Só é aplicado se você tiver permissão para cadastrar em qualquer revenda; caso contrário, a revenda do seu usuário é usada automaticamente."
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-2 flex items-center gap-2 pt-2">
|
||||
<Button type="submit" loading={submitting}>
|
||||
Criar cadastro
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => navigate("/client-registrations")}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { Alert, Badge, Button, Card, PageHeader, Table } from "@eden/ui";
|
||||
import type { BadgeVariant, TableColumn } from "@eden/ui";
|
||||
import { listClientRegistrations } from "../../lib/api/client-registrations.js";
|
||||
import type {
|
||||
ClientRegistration,
|
||||
PersonType,
|
||||
RegistrationStatus,
|
||||
} from "../../lib/api/client-registrations.js";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import { formatDateBR } from "../../lib/format.js";
|
||||
import { useAuth } from "../../lib/auth/AuthContext.js";
|
||||
|
||||
/**
|
||||
* Layout reference: DreamsERP theme `.tmp/theme-reference/customers.tsx`
|
||||
* (code column + name + status badge list) — rebuilt with `@eden/ui`'s
|
||||
* `Card`/`Table`/`Badge` instead of the theme's antd Datatable, matching
|
||||
* the pattern already used by CompaniesListPage/QuotesListPage.
|
||||
*/
|
||||
const PERSON_TYPE_LABEL: Record<PersonType, string> = {
|
||||
pf: "Pessoa Física",
|
||||
pj: "Pessoa Jurídica",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<RegistrationStatus, string> = {
|
||||
rascunho: "Rascunho",
|
||||
pendente_validacao: "Pendente de validação",
|
||||
ativo: "Ativo",
|
||||
bloqueado: "Bloqueado",
|
||||
inativo: "Inativo",
|
||||
};
|
||||
|
||||
const STATUS_VARIANT: Record<RegistrationStatus, BadgeVariant> = {
|
||||
rascunho: "neutral",
|
||||
pendente_validacao: "warning",
|
||||
ativo: "success",
|
||||
bloqueado: "danger",
|
||||
inativo: "neutral",
|
||||
};
|
||||
|
||||
/** PF uses `pf_full_name`, PJ uses `pj_company_name` — falls back to a placeholder while the cadastro is still a `rascunho` with no name entered yet. */
|
||||
function registrationName(registration: ClientRegistration): string {
|
||||
const name = registration.person_type === "pf" ? registration.pf_full_name : registration.pj_company_name;
|
||||
return name ?? "Sem nome preenchido";
|
||||
}
|
||||
|
||||
export function ClientRegistrationsListPage() {
|
||||
const { accessToken } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [registrations, setRegistrations] = useState<ClientRegistration[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) return;
|
||||
let cancelled = false;
|
||||
setError(null);
|
||||
listClientRegistrations(accessToken)
|
||||
.then((data) => {
|
||||
if (!cancelled) setRegistrations(data);
|
||||
})
|
||||
.catch((err) => {
|
||||
if (cancelled) return;
|
||||
setError(err instanceof ApiError ? err.message : "Não foi possível carregar os cadastros de cliente.");
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [accessToken]);
|
||||
|
||||
const columns: Array<TableColumn<ClientRegistration>> = [
|
||||
{
|
||||
key: "code",
|
||||
header: "Código",
|
||||
render: (registration) => <span className="font-semibold text-title">#{registration.client_code}</span>,
|
||||
},
|
||||
{
|
||||
key: "name",
|
||||
header: "Nome",
|
||||
render: (registration) => (
|
||||
<div>
|
||||
<p className="font-semibold text-title">{registrationName(registration)}</p>
|
||||
{registration.person_type === "pj" && registration.pj_trade_name ? (
|
||||
<p className="text-xs text-default">{registration.pj_trade_name}</p>
|
||||
) : null}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "personType",
|
||||
header: "Tipo",
|
||||
render: (registration) => <Badge variant="info">{PERSON_TYPE_LABEL[registration.person_type]}</Badge>,
|
||||
},
|
||||
{
|
||||
key: "status",
|
||||
header: "Status",
|
||||
render: (registration) => (
|
||||
<Badge variant={STATUS_VARIANT[registration.registration_status]}>
|
||||
{STATUS_LABEL[registration.registration_status]}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "createdAt",
|
||||
header: "Criado em",
|
||||
render: (registration) => formatDateBR(registration.created_at),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "",
|
||||
className: "text-right",
|
||||
render: (registration) => (
|
||||
<Button variant="secondary" size="sm" onClick={() => navigate(`/client-registrations/${registration.id}`)}>
|
||||
Ver
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<PageHeader
|
||||
title="Clientes"
|
||||
description="Cadastros de cliente (Customer 360) usados para fechar negócios e integrar com o provisionamento."
|
||||
actions={<Button onClick={() => navigate("/client-registrations/new")}>Novo cadastro</Button>}
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-4">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<Card>
|
||||
{registrations === null && !error ? (
|
||||
<p className="text-sm text-default">Carregando…</p>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={registrations ?? []}
|
||||
rowKey={(registration) => registration.id}
|
||||
emptyMessage="Nenhum cadastro de cliente encontrado."
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { FormEvent } from "react";
|
||||
import { Alert, Badge, Button, Card, Input, Table } from "@eden/ui";
|
||||
import type { TableColumn } from "@eden/ui";
|
||||
import { ApiError } from "../../lib/api/http.js";
|
||||
import {
|
||||
createClientRegistrationPartner,
|
||||
listClientRegistrationPartners,
|
||||
updateClientRegistrationPartner,
|
||||
} from "../../lib/api/client-registrations.js";
|
||||
import type { ClientRegistrationPartner, CreatePartnerInput } from "../../lib/api/client-registrations.js";
|
||||
|
||||
/**
|
||||
* Sócios/QSA sub-section, PJ cadastros only (eden.md §1.2, §4). Structural
|
||||
* reference: DreamsERP theme `.tmp/theme-reference/addSalesOrder.tsx` /
|
||||
* `salesOrders.tsx` (a "detail + editable sub-list" pattern — order header
|
||||
* plus a line-items table with inline add) — rebuilt with `@eden/ui`
|
||||
* `Card`/`Table`/`Input`/`Button`, own list/add/edit state independent
|
||||
* from the parent registration form (each partner is its own PATCH/POST).
|
||||
*/
|
||||
const EMPTY_NEW_PARTNER: CreatePartnerInput = {
|
||||
name: "",
|
||||
document: "",
|
||||
qualification: "",
|
||||
cpf: "",
|
||||
willSign: false,
|
||||
signatureEmail: "",
|
||||
};
|
||||
|
||||
function toOptional(value: string | undefined): string | undefined {
|
||||
const trimmed = (value ?? "").trim();
|
||||
return trimmed === "" ? undefined : trimmed;
|
||||
}
|
||||
|
||||
interface EditState {
|
||||
cpf: string;
|
||||
willSign: boolean;
|
||||
signatureEmail: string;
|
||||
}
|
||||
|
||||
export function PartnersSection({
|
||||
registrationId,
|
||||
accessToken,
|
||||
}: {
|
||||
registrationId: string;
|
||||
accessToken: string | null;
|
||||
}) {
|
||||
const [partners, setPartners] = useState<ClientRegistrationPartner[] | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [newPartner, setNewPartner] = useState<CreatePartnerInput>(EMPTY_NEW_PARTNER);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editState, setEditState] = useState<EditState>({ cpf: "", willSign: false, signatureEmail: "" });
|
||||
const [savingEdit, setSavingEdit] = useState(false);
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!accessToken) return;
|
||||
setError(null);
|
||||
listClientRegistrationPartners(registrationId, accessToken)
|
||||
.then(setPartners)
|
||||
.catch((err) => setError(err instanceof ApiError ? err.message : "Não foi possível carregar os sócios."));
|
||||
}, [registrationId, accessToken]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
async function handleCreate(event: FormEvent) {
|
||||
event.preventDefault();
|
||||
if (!accessToken) return;
|
||||
setCreateError(null);
|
||||
setCreating(true);
|
||||
try {
|
||||
await createClientRegistrationPartner(
|
||||
registrationId,
|
||||
{
|
||||
name: newPartner.name.trim(),
|
||||
document: toOptional(newPartner.document),
|
||||
qualification: toOptional(newPartner.qualification),
|
||||
cpf: toOptional(newPartner.cpf),
|
||||
willSign: newPartner.willSign,
|
||||
signatureEmail: toOptional(newPartner.signatureEmail),
|
||||
},
|
||||
accessToken,
|
||||
);
|
||||
setNewPartner(EMPTY_NEW_PARTNER);
|
||||
load();
|
||||
} catch (err) {
|
||||
setCreateError(err instanceof ApiError ? err.message : "Não foi possível adicionar o sócio.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(partner: ClientRegistrationPartner) {
|
||||
setEditingId(partner.id);
|
||||
setEditError(null);
|
||||
setEditState({
|
||||
cpf: partner.cpf ?? "",
|
||||
willSign: partner.will_sign,
|
||||
signatureEmail: partner.signature_email ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSaveEdit(partnerId: string) {
|
||||
if (!accessToken) return;
|
||||
setEditError(null);
|
||||
setSavingEdit(true);
|
||||
try {
|
||||
await updateClientRegistrationPartner(
|
||||
registrationId,
|
||||
partnerId,
|
||||
{
|
||||
cpf: toOptional(editState.cpf),
|
||||
willSign: editState.willSign,
|
||||
signatureEmail: toOptional(editState.signatureEmail),
|
||||
},
|
||||
accessToken,
|
||||
);
|
||||
setEditingId(null);
|
||||
load();
|
||||
} catch (err) {
|
||||
setEditError(err instanceof ApiError ? err.message : "Não foi possível salvar o sócio.");
|
||||
} finally {
|
||||
setSavingEdit(false);
|
||||
}
|
||||
}
|
||||
|
||||
const columns: Array<TableColumn<ClientRegistrationPartner>> = [
|
||||
{ key: "name", header: "Nome", render: (p) => <span className="font-semibold text-title">{p.name}</span> },
|
||||
{ key: "document", header: "Documento", render: (p) => p.document ?? "—" },
|
||||
{ key: "qualification", header: "Qualificação", render: (p) => p.qualification ?? "—" },
|
||||
{
|
||||
key: "cpf",
|
||||
header: "CPF",
|
||||
render: (p) =>
|
||||
editingId === p.id ? (
|
||||
<Input value={editState.cpf} onChange={(e) => setEditState((s) => ({ ...s, cpf: e.target.value }))} />
|
||||
) : (
|
||||
p.cpf ?? "—"
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "willSign",
|
||||
header: "Vai assinar",
|
||||
render: (p) =>
|
||||
editingId === p.id ? (
|
||||
<label className="flex items-center gap-2 text-sm text-title">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="shrink-0 rounded-sm border-border-color text-primary focus:ring-primary"
|
||||
checked={editState.willSign}
|
||||
onChange={(e) => setEditState((s) => ({ ...s, willSign: e.target.checked }))}
|
||||
/>
|
||||
Sim
|
||||
</label>
|
||||
) : (
|
||||
<Badge variant={p.will_sign ? "success" : "neutral"}>{p.will_sign ? "Sim" : "Não"}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "signatureEmail",
|
||||
header: "E-mail p/ assinatura",
|
||||
render: (p) =>
|
||||
editingId === p.id ? (
|
||||
<Input
|
||||
type="email"
|
||||
value={editState.signatureEmail}
|
||||
onChange={(e) => setEditState((s) => ({ ...s, signatureEmail: e.target.value }))}
|
||||
/>
|
||||
) : (
|
||||
p.signature_email ?? "—"
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "",
|
||||
className: "text-right",
|
||||
render: (p) =>
|
||||
editingId === p.id ? (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" size="sm" loading={savingEdit} onClick={() => handleSaveEdit(p.id)}>
|
||||
Salvar
|
||||
</Button>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => setEditingId(null)}>
|
||||
Cancelar
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => startEdit(p)}>
|
||||
Editar
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title="Sócios">
|
||||
{error ? (
|
||||
<Alert variant="error" className="mb-3">
|
||||
{error}
|
||||
</Alert>
|
||||
) : null}
|
||||
{editError ? (
|
||||
<Alert variant="error" className="mb-3">
|
||||
{editError}
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
{partners === null && !error ? (
|
||||
<p className="text-sm text-default">Carregando sócios…</p>
|
||||
) : (
|
||||
<Table
|
||||
columns={columns}
|
||||
data={partners ?? []}
|
||||
rowKey={(p) => p.id}
|
||||
emptyMessage="Nenhum sócio cadastrado."
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="mt-4 border-t border-border-color pt-4">
|
||||
<p className="mb-3 text-sm font-semibold text-title">Adicionar sócio</p>
|
||||
{createError ? (
|
||||
<Alert variant="error" className="mb-3">
|
||||
{createError}
|
||||
</Alert>
|
||||
) : null}
|
||||
<form className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3" onSubmit={handleCreate}>
|
||||
<Input
|
||||
label="Nome"
|
||||
value={newPartner.name}
|
||||
onChange={(e) => setNewPartner((s) => ({ ...s, name: e.target.value }))}
|
||||
required
|
||||
/>
|
||||
<Input
|
||||
label="Documento"
|
||||
value={newPartner.document}
|
||||
onChange={(e) => setNewPartner((s) => ({ ...s, document: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label="Qualificação"
|
||||
value={newPartner.qualification}
|
||||
onChange={(e) => setNewPartner((s) => ({ ...s, qualification: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label="CPF"
|
||||
value={newPartner.cpf}
|
||||
onChange={(e) => setNewPartner((s) => ({ ...s, cpf: e.target.value }))}
|
||||
/>
|
||||
<Input
|
||||
label="E-mail p/ assinatura"
|
||||
type="email"
|
||||
value={newPartner.signatureEmail}
|
||||
onChange={(e) => setNewPartner((s) => ({ ...s, signatureEmail: e.target.value }))}
|
||||
/>
|
||||
<label className="flex items-center gap-2 self-end py-2.5 text-sm text-title">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="shrink-0 rounded-sm border-border-color text-primary focus:ring-primary"
|
||||
checked={newPartner.willSign ?? false}
|
||||
onChange={(e) => setNewPartner((s) => ({ ...s, willSign: e.target.checked }))}
|
||||
/>
|
||||
Vai assinar
|
||||
</label>
|
||||
<div className="sm:col-span-2 lg:col-span-3">
|
||||
<Button type="submit" loading={creating}>
|
||||
Adicionar sócio
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user