first commit
This commit is contained in:
commit
c9fb67e751
423 files changed
+53287
No files matched your search
@@ -0,0 +1,390 @@
|
||||
import React, { useState } from "react";
|
||||
import { useParams, useNavigate } from "react-router-dom";
|
||||
import { useClientes } from "../../hooks/useClientes";
|
||||
import { Tab } from "@headlessui/react";
|
||||
import {
|
||||
ArrowLeftIcon,
|
||||
PencilIcon,
|
||||
UserIcon,
|
||||
HomeIcon,
|
||||
BanknotesIcon,
|
||||
UserGroupIcon,
|
||||
DocumentTextIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { ClienteModal } from "./ClienteModal";
|
||||
import {
|
||||
formatCPF,
|
||||
formatTelefone,
|
||||
formatNome,
|
||||
formatEmail,
|
||||
formatMoeda,
|
||||
formatTextoSimples,
|
||||
formatData,
|
||||
} from "../../utils/formatters";
|
||||
|
||||
function classNames(...classes: string[]) {
|
||||
return classes.filter(Boolean).join(" ");
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ id: "info", name: "Informações Básicas", icon: UserIcon },
|
||||
{ id: "endereco", name: "Endereço", icon: HomeIcon },
|
||||
{ id: "financeiro", name: "Informações Financeiras", icon: BanknotesIcon },
|
||||
{ id: "familia", name: "Informações Familiares", icon: UserGroupIcon },
|
||||
{ id: "processos", name: "Processos", icon: DocumentTextIcon },
|
||||
];
|
||||
|
||||
interface InfoItemProps {
|
||||
label: string;
|
||||
value: string;
|
||||
onEdit?: () => void;
|
||||
isEditable?: boolean;
|
||||
}
|
||||
|
||||
const InfoItem: React.FC<InfoItemProps> = ({
|
||||
label,
|
||||
value,
|
||||
onEdit,
|
||||
isEditable = true,
|
||||
}) => (
|
||||
<div className="relative group">
|
||||
<dt className="text-sm font-medium text-gray-500">{label}</dt>
|
||||
<dd className="mt-1 text-sm text-gray-900 flex items-center justify-between">
|
||||
<span>{value}</span>
|
||||
{isEditable && (
|
||||
<button
|
||||
onClick={onEdit}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity p-1 hover:bg-gray-100 rounded"
|
||||
>
|
||||
<PencilIcon className="h-4 w-4 text-blue-600" />
|
||||
</button>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
);
|
||||
|
||||
export const ClienteDetalhes: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { clientes, isLoading } = useClientes();
|
||||
const [selectedTab, setSelectedTab] = useState(0);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [editingField, setEditingField] = useState<string | null>(null);
|
||||
const cliente = clientes.find((c) => c.id === id);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!cliente) {
|
||||
return <div>Cliente não encontrado</div>;
|
||||
}
|
||||
|
||||
// Funções de formatação simplificadas
|
||||
const formataCurrency = (valor: any): string => {
|
||||
if (!valor) return "Não informado";
|
||||
try {
|
||||
return new Intl.NumberFormat("pt-BR", {
|
||||
style: "currency",
|
||||
currency: "BRL",
|
||||
}).format(Number(valor));
|
||||
} catch {
|
||||
return "Não informado";
|
||||
}
|
||||
};
|
||||
|
||||
const formataTexto = (texto: any): string => {
|
||||
if (!texto && texto !== false) return "Não informado";
|
||||
if (typeof texto === "boolean") return texto ? "Sim" : "Não";
|
||||
return String(texto);
|
||||
};
|
||||
|
||||
const getTabForField = (field: string | null) => {
|
||||
switch (field) {
|
||||
case "nome":
|
||||
case "cpfCnpj":
|
||||
case "rg":
|
||||
case "email":
|
||||
case "telefone":
|
||||
case "instagram":
|
||||
case "estadoCivil":
|
||||
case "dataNascimento":
|
||||
return 0;
|
||||
case "cep":
|
||||
case "endereco":
|
||||
case "complemento":
|
||||
case "bairro":
|
||||
case "cidade":
|
||||
case "estado":
|
||||
return 1;
|
||||
case "profissao":
|
||||
case "vinculoEmpregaticio":
|
||||
case "renda":
|
||||
case "declaraIR":
|
||||
case "tipoPrevidencia":
|
||||
case "contribuiPrevidencia":
|
||||
return 2;
|
||||
case "nomePai":
|
||||
case "nomeMae":
|
||||
case "conjugeNome":
|
||||
case "conjugeCpf":
|
||||
return 3;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<button
|
||||
onClick={() => navigate("/clientes")}
|
||||
className="p-2 hover:bg-gray-100 rounded-full"
|
||||
>
|
||||
<ArrowLeftIcon className="h-5 w-5 text-gray-500" />
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-gray-800">
|
||||
{formatNome(cliente.nome)}
|
||||
</h1>
|
||||
<p className="text-sm text-gray-500">
|
||||
Cadastrado em {new Date(cliente.dataCriacao).toLocaleDateString()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setIsEditModalOpen(true)}
|
||||
className="flex items-center px-4 py-2 text-sm text-blue-600 hover:bg-blue-50 rounded-lg"
|
||||
>
|
||||
<PencilIcon className="h-4 w-4 mr-2" />
|
||||
Editar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tab.Group selectedIndex={selectedTab} onChange={setSelectedTab}>
|
||||
<Tab.List className="flex space-x-1 rounded-xl bg-blue-900/20 p-1">
|
||||
{tabs.map((tab) => (
|
||||
<Tab
|
||||
key={tab.id}
|
||||
className={({ selected }) =>
|
||||
classNames(
|
||||
"w-full rounded-lg py-2.5 text-sm font-medium leading-5",
|
||||
"flex items-center justify-center",
|
||||
selected
|
||||
? "bg-white text-blue-700 shadow"
|
||||
: "text-gray-600 hover:bg-white/[0.12] hover:text-blue-600",
|
||||
)
|
||||
}
|
||||
>
|
||||
<tab.icon className="h-4 w-4 mr-2" />
|
||||
{tab.name}
|
||||
</Tab>
|
||||
))}
|
||||
</Tab.List>
|
||||
<Tab.Panels className="mt-2">
|
||||
{/* Informações Básicas */}
|
||||
<Tab.Panel className="bg-white rounded-xl p-6">
|
||||
<dl className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<InfoItem
|
||||
label="Nome"
|
||||
value={formatNome(cliente.nome)}
|
||||
onEdit={() => {
|
||||
setEditingField("nome");
|
||||
setIsEditModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
<InfoItem
|
||||
label="CPF/CNPJ"
|
||||
value={formatCPF(cliente.cpfCnpj)}
|
||||
onEdit={() => {
|
||||
setEditingField("cpfCnpj");
|
||||
setIsEditModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
<InfoItem label="RG" value={formatTextoSimples(cliente.rg)} />
|
||||
<InfoItem label="Email" value={formatEmail(cliente.email)} />
|
||||
<InfoItem
|
||||
label="Telefone"
|
||||
value={formatTelefone(cliente.telefone)}
|
||||
/>
|
||||
<InfoItem
|
||||
label="Instagram"
|
||||
value={formatTextoSimples(cliente.instagram)}
|
||||
/>
|
||||
<InfoItem
|
||||
label="Estado Civil"
|
||||
value={formatTextoSimples(cliente.estadoCivil)}
|
||||
/>
|
||||
<InfoItem
|
||||
label="Data de Nascimento"
|
||||
value={formatData(cliente.dataNascimento)}
|
||||
/>
|
||||
</dl>
|
||||
</Tab.Panel>
|
||||
|
||||
{/* Endereço */}
|
||||
<Tab.Panel className="bg-white rounded-xl p-6">
|
||||
<dl className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<InfoItem label="CEP" value={formataTexto(cliente.cep)} />
|
||||
<InfoItem
|
||||
label="Endereço"
|
||||
value={formataTexto(`${cliente.endereco}, ${cliente.numero}`)}
|
||||
/>
|
||||
<InfoItem
|
||||
label="Complemento"
|
||||
value={formataTexto(cliente.complemento)}
|
||||
/>
|
||||
<InfoItem label="Bairro" value={formataTexto(cliente.bairro)} />
|
||||
<InfoItem label="Cidade" value={formataTexto(cliente.cidade)} />
|
||||
<InfoItem label="Estado" value={formataTexto(cliente.estado)} />
|
||||
</dl>
|
||||
</Tab.Panel>
|
||||
|
||||
{/* Informações Financeiras */}
|
||||
<Tab.Panel className="bg-white rounded-xl p-6">
|
||||
<dl className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<InfoItem
|
||||
label="Profissão"
|
||||
value={formatTextoSimples(cliente.profissao)}
|
||||
/>
|
||||
<InfoItem
|
||||
label="Vínculo"
|
||||
value={formatTextoSimples(cliente.vinculoEmpregaticio)}
|
||||
/>
|
||||
<InfoItem
|
||||
label="Renda Mensal"
|
||||
value={formatMoeda(Number(cliente.renda))}
|
||||
/>
|
||||
<InfoItem
|
||||
label="Declara IR"
|
||||
value={cliente.declaraIR ? "Sim" : "Não"}
|
||||
/>
|
||||
<InfoItem
|
||||
label="Tipo Previdência"
|
||||
value={formatTextoSimples(cliente.tipoPrevidencia)}
|
||||
/>
|
||||
<InfoItem
|
||||
label="Contribui Previdência"
|
||||
value={cliente.contribuiPrevidencia ? "Sim" : "Não"}
|
||||
/>
|
||||
</dl>
|
||||
</Tab.Panel>
|
||||
|
||||
{/* Informações Familiares */}
|
||||
<Tab.Panel className="bg-white rounded-xl p-6">
|
||||
<div className="space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<InfoItem
|
||||
label="Nome do Pai"
|
||||
value={formatNome(cliente.nomePai || "")}
|
||||
/>
|
||||
<InfoItem
|
||||
label="Nome da Mãe"
|
||||
value={formatNome(cliente.nomeMae || "")}
|
||||
/>
|
||||
<InfoItem
|
||||
label="Nome do Cônjuge"
|
||||
value={formatNome(cliente.conjugeNome || "")}
|
||||
/>
|
||||
<InfoItem
|
||||
label="CPF do Cônjuge"
|
||||
value={
|
||||
cliente.conjugeCpf
|
||||
? formatCPF(cliente.conjugeCpf)
|
||||
: "Não informado"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Lista de Filhos */}
|
||||
<div>
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-4">
|
||||
Filhos
|
||||
</h3>
|
||||
{cliente.filhos && cliente.filhos.length > 0 ? (
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{cliente.filhos.map((filho) => (
|
||||
<li key={filho.id} className="py-3">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{filho.nome}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Nascimento:{" "}
|
||||
{new Date(filho.dataNascimento).toLocaleDateString()}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">
|
||||
Nenhum filho cadastrado
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Tab.Panel>
|
||||
|
||||
{/* Processos */}
|
||||
<Tab.Panel className="bg-white rounded-xl p-6">
|
||||
{cliente.processos && cliente.processos.length > 0 ? (
|
||||
<ul className="divide-y divide-gray-200">
|
||||
{cliente.processos.map((processo) => (
|
||||
<li key={processo.id} className="py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{processo.titulo}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">
|
||||
Nº {processo.numero}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
className={classNames(
|
||||
"px-2 py-1 text-xs rounded-full",
|
||||
processo.status === "em_andamento"
|
||||
? "bg-blue-100 text-blue-800"
|
||||
: processo.status === "concluido"
|
||||
? "bg-green-100 text-green-800"
|
||||
: "bg-gray-100 text-gray-800",
|
||||
)}
|
||||
>
|
||||
{processo.status}
|
||||
</span>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-sm text-gray-500">
|
||||
Nenhum processo cadastrado
|
||||
</p>
|
||||
)}
|
||||
</Tab.Panel>
|
||||
</Tab.Panels>
|
||||
</Tab.Group>
|
||||
|
||||
{/* Modal de Edição */}
|
||||
<ClienteModal
|
||||
isOpen={isEditModalOpen}
|
||||
cliente={cliente}
|
||||
onClose={() => {
|
||||
setIsEditModalOpen(false);
|
||||
setEditingField(null);
|
||||
}}
|
||||
onSuccess={() => {
|
||||
setIsEditModalOpen(false);
|
||||
setEditingField(null);
|
||||
}}
|
||||
initialTab={getTabForField(editingField)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,611 @@
|
||||
import React, {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
forwardRef,
|
||||
useRef,
|
||||
} from "react";
|
||||
import { Dialog, Transition } from "@headlessui/react";
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import { useForm } from "react-hook-form";
|
||||
import axios from "axios";
|
||||
import { Cliente } from "../../types/entities";
|
||||
import { useClientes } from "../../hooks/useClientes";
|
||||
import { ORGAOS_EMISSORES, PREVIDENCIA_OPTIONS } from "../../utils/constants";
|
||||
|
||||
const estadosCivis = [
|
||||
{ value: "solteiro", label: "Solteiro(a)" },
|
||||
{ value: "casado", label: "Casado(a)" },
|
||||
{ value: "divorciado", label: "Divorciado(a)" },
|
||||
{ value: "uniao_estavel", label: "União Estável" },
|
||||
{ value: "viuvo", label: "Viúvo(a)" },
|
||||
];
|
||||
|
||||
const estados = [
|
||||
"AC",
|
||||
"AL",
|
||||
"AP",
|
||||
"AM",
|
||||
"BA",
|
||||
"CE",
|
||||
"DF",
|
||||
"ES",
|
||||
"GO",
|
||||
"MA",
|
||||
"MT",
|
||||
"MS",
|
||||
"MG",
|
||||
"PA",
|
||||
"PB",
|
||||
"PR",
|
||||
"PE",
|
||||
"PI",
|
||||
"RJ",
|
||||
"RN",
|
||||
"RS",
|
||||
"RO",
|
||||
"RR",
|
||||
"SC",
|
||||
"SP",
|
||||
"SE",
|
||||
"TO",
|
||||
];
|
||||
|
||||
const VINCULOS_EMPREGATICIO = [
|
||||
{ value: "mei", label: "MEI" },
|
||||
{ value: "pj", label: "Pessoa Jurídica" },
|
||||
{ value: "clt", label: "CLT" },
|
||||
{ value: "autonomo", label: "Autônomo" },
|
||||
{ value: "servidor_publico", label: "Servidor Público" },
|
||||
];
|
||||
|
||||
// Atualizar estados para o formato de options
|
||||
const estadosOptions = estados.map((uf) => ({ value: uf, label: uf }));
|
||||
|
||||
// Componente de input com forwardRef
|
||||
const FormInput = forwardRef<HTMLInputElement, any>(
|
||||
({ label, error, className = "", ...props }, ref) => (
|
||||
<div className={`${className} relative`}>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
{label}
|
||||
{props.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
<input
|
||||
ref={ref}
|
||||
className={`
|
||||
w-full px-4 py-3 rounded-lg
|
||||
border border-gray-300
|
||||
bg-white text-gray-900
|
||||
transition duration-150 ease-in-out
|
||||
placeholder-gray-400
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
hover:border-gray-400
|
||||
${error ? "border-red-300 focus:ring-red-500" : ""}
|
||||
`}
|
||||
{...props}
|
||||
/>
|
||||
{error && (
|
||||
<div className="absolute -bottom-5 left-0">
|
||||
<p className="text-xs text-red-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
// Componente de select com forwardRef
|
||||
const FormSelect = forwardRef<HTMLSelectElement, any>(
|
||||
({ label, error, options, className = "", ...props }, ref) => (
|
||||
<div className={`${className} relative`}>
|
||||
<label className="block text-sm font-semibold text-gray-700 mb-2">
|
||||
{label}
|
||||
{props.required && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
<select
|
||||
ref={ref}
|
||||
className={`
|
||||
w-full px-4 py-3 rounded-lg
|
||||
border border-gray-300
|
||||
bg-white text-gray-900
|
||||
transition duration-150 ease-in-out
|
||||
focus:ring-2 focus:ring-blue-500 focus:border-transparent
|
||||
hover:border-gray-400
|
||||
appearance-none
|
||||
${error ? "border-red-300 focus:ring-red-500" : ""}
|
||||
`}
|
||||
{...props}
|
||||
>
|
||||
<option value="">Selecione...</option>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && (
|
||||
<div className="absolute -bottom-5 left-0">
|
||||
<p className="text-xs text-red-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
);
|
||||
|
||||
// Componente de checkbox com forwardRef
|
||||
const FormCheckbox = forwardRef<HTMLInputElement, any>(
|
||||
({ label, ...props }, ref) => (
|
||||
<label className="flex items-center space-x-3 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
ref={ref}
|
||||
className="
|
||||
w-5 h-5 rounded
|
||||
border-gray-300 text-blue-600
|
||||
focus:ring-2 focus:ring-blue-500
|
||||
transition duration-150 ease-in-out
|
||||
cursor-pointer
|
||||
"
|
||||
{...props}
|
||||
/>
|
||||
<span className="text-sm text-gray-700 group-hover:text-gray-900">
|
||||
{label}
|
||||
</span>
|
||||
</label>
|
||||
),
|
||||
);
|
||||
|
||||
// Adicionar displayName para os componentes
|
||||
FormInput.displayName = "FormInput";
|
||||
FormSelect.displayName = "FormSelect";
|
||||
FormCheckbox.displayName = "FormCheckbox";
|
||||
|
||||
interface ClienteModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
cliente: Cliente | null;
|
||||
onSuccess?: () => void;
|
||||
initialTab?: number;
|
||||
}
|
||||
|
||||
export const ClienteModal: React.FC<ClienteModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
cliente,
|
||||
onSuccess,
|
||||
initialTab = 0,
|
||||
}) => {
|
||||
const [activeTab, setActiveTab] = useState(initialTab);
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
reset,
|
||||
setValue,
|
||||
watch,
|
||||
} = useForm();
|
||||
const { createCliente, updateCliente } = useClientes();
|
||||
const formPopulated = useRef(false);
|
||||
|
||||
// Função para preencher o formulário com os dados do cliente
|
||||
const populateForm = useCallback(
|
||||
(clienteData: Cliente) => {
|
||||
console.log("Populando formulário com:", clienteData);
|
||||
Object.keys(clienteData).forEach((key) => {
|
||||
if (key === "dataNascimento" && clienteData[key]) {
|
||||
// Formatar a data para o formato YYYY-MM-DD esperado pelo input type="date"
|
||||
const data = new Date(clienteData[key]);
|
||||
setValue(key, data.toISOString().split("T")[0]);
|
||||
} else if (
|
||||
key !== "id" &&
|
||||
key !== "dataCriacao" &&
|
||||
key !== "processos" &&
|
||||
key !== "filhos"
|
||||
) {
|
||||
setValue(key, clienteData[key]);
|
||||
}
|
||||
});
|
||||
},
|
||||
[setValue],
|
||||
);
|
||||
|
||||
// Efeito para carregar os dados quando o modal abrir ou o cliente mudar
|
||||
useEffect(() => {
|
||||
if (isOpen && cliente && !formPopulated.current) {
|
||||
console.log("Populando formulário com:", cliente);
|
||||
Object.keys(cliente).forEach((key) => {
|
||||
if (key === "dataNascimento" && cliente[key]) {
|
||||
const data = new Date(cliente[key]);
|
||||
setValue(key, data.toISOString().split("T")[0]);
|
||||
} else if (
|
||||
key !== "id" &&
|
||||
key !== "dataCriacao" &&
|
||||
key !== "processos" &&
|
||||
key !== "filhos"
|
||||
) {
|
||||
setValue(key, cliente[key]);
|
||||
}
|
||||
});
|
||||
formPopulated.current = true;
|
||||
}
|
||||
|
||||
if (!isOpen) {
|
||||
formPopulated.current = false;
|
||||
reset();
|
||||
}
|
||||
}, [isOpen, cliente, setValue, reset]);
|
||||
|
||||
const onSubmit = async (data: Partial<Cliente>) => {
|
||||
try {
|
||||
// Converter nome para maiúsculas
|
||||
const cleanData = Object.fromEntries(
|
||||
Object.entries(data).filter(([_, value]) => {
|
||||
if (value === "") return false;
|
||||
if (value === null) return false;
|
||||
if (value === undefined) return false;
|
||||
return true;
|
||||
}),
|
||||
);
|
||||
|
||||
// Formatar campos específicos e converter nome para maiúsculas
|
||||
const formattedData = {
|
||||
...cleanData,
|
||||
nome: cleanData.nome?.toUpperCase(),
|
||||
nomeMae: cleanData.nomeMae?.toUpperCase(),
|
||||
nomePai: cleanData.nomePai?.toUpperCase(),
|
||||
conjugeNome: cleanData.conjugeNome?.toUpperCase(),
|
||||
// Converter campos numéricos
|
||||
renda: cleanData.renda ? Number(cleanData.renda) : null,
|
||||
// Formatar data de nascimento para ISO String
|
||||
dataNascimento: cleanData.dataNascimento
|
||||
? new Date(cleanData.dataNascimento).toISOString()
|
||||
: null,
|
||||
// Garantir campos obrigatórios
|
||||
email: cleanData.email,
|
||||
telefone: cleanData.telefone,
|
||||
cpfCnpj: cleanData.cpfCnpj,
|
||||
endereco: cleanData.endereco,
|
||||
cep: cleanData.cep,
|
||||
numero: cleanData.numero,
|
||||
bairro: cleanData.bairro,
|
||||
cidade: cleanData.cidade,
|
||||
estado: cleanData.estado,
|
||||
// Converter booleanos
|
||||
declaraIR: Boolean(cleanData.declaraIR),
|
||||
contribuiPrevidencia: Boolean(cleanData.contribuiPrevidencia),
|
||||
usaDNI: Boolean(cleanData.usaDNI),
|
||||
possuiFilhos: Boolean(cleanData.possuiFilhos),
|
||||
};
|
||||
|
||||
console.log("Dados enviados:", formattedData); // Debug
|
||||
|
||||
if (cliente) {
|
||||
await updateCliente({
|
||||
id: cliente.id,
|
||||
data: formattedData,
|
||||
});
|
||||
} else {
|
||||
await createCliente(formattedData);
|
||||
}
|
||||
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Erro detalhado:", error);
|
||||
if (axios.isAxiosError(error)) {
|
||||
alert(error.response?.data?.error || "Erro ao salvar cliente");
|
||||
} else {
|
||||
alert("Erro ao salvar cliente");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Reset form on modal close
|
||||
const handleClose = () => {
|
||||
reset();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const tabs = [
|
||||
{ id: "pessoal", label: "Dados Pessoais" },
|
||||
{ id: "endereco", label: "Endereço" },
|
||||
{ id: "financeiro", label: "Dados Financeiros" },
|
||||
{ id: "familia", label: "Filiação" },
|
||||
];
|
||||
|
||||
return (
|
||||
<Transition show={isOpen} as={React.Fragment}>
|
||||
<Dialog onClose={handleClose} className="relative z-50">
|
||||
<Transition.Child
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/30 backdrop-blur-sm" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 overflow-y-auto">
|
||||
<div className="flex min-h-full items-center justify-center p-4">
|
||||
<Transition.Child
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel className="mx-auto max-w-4xl w-full bg-white dark:bg-gray-800 rounded-xl shadow-2xl">
|
||||
{/* Header com abas */}
|
||||
<div className="border-b border-gray-200 dark:border-gray-700 p-6 bg-gray-50 dark:bg-gray-900 rounded-t-xl">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<Dialog.Title className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{cliente ? "Editar Cliente" : "Novo Cliente"}
|
||||
</Dialog.Title>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-gray-400 dark:text-gray-500 hover:text-gray-500 dark:hover:text-gray-400"
|
||||
>
|
||||
<XMarkIcon className="h-6 w-6" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex space-x-1">
|
||||
{tabs.map((tab, idx) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setActiveTab(idx)}
|
||||
className={`
|
||||
flex items-center px-6 py-3 text-sm font-medium rounded-t-lg
|
||||
transition-all duration-200 ease-in-out
|
||||
${
|
||||
activeTab === idx
|
||||
? "bg-white text-blue-600 border-t-2 border-blue-600 shadow-sm"
|
||||
: "text-gray-500 hover:text-gray-700 hover:bg-gray-100"
|
||||
}
|
||||
`}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Formulário */}
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="p-6 bg-white dark:bg-gray-800">
|
||||
<div className="space-y-8 mb-8">
|
||||
{/* Conteúdo das abas */}
|
||||
{activeTab === 0 && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormInput
|
||||
label="Nome"
|
||||
{...register("nome", {
|
||||
required: "Nome é obrigatório",
|
||||
})}
|
||||
error={errors.nome?.message}
|
||||
/>
|
||||
<FormInput
|
||||
label="CPF/CNPJ"
|
||||
{...register("cpfCnpj", {
|
||||
required: "CPF/CNPJ é obrigatório",
|
||||
})}
|
||||
error={errors.cpfCnpj?.message}
|
||||
/>
|
||||
<div className="col-span-2 mb-4">
|
||||
<FormCheckbox
|
||||
label="Usar DNI (Documento Nacional de Identidade)"
|
||||
{...register("usaDNI")}
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setValue("rg", watch("cpfCnpj"));
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<FormInput
|
||||
label="RG"
|
||||
{...register("rg")}
|
||||
disabled={watch("usaDNI")}
|
||||
/>
|
||||
<div className="col-span-2 grid grid-cols-2 gap-4">
|
||||
<FormSelect
|
||||
label="Órgão Emissor"
|
||||
{...register("orgaoEmissor")}
|
||||
options={ORGAOS_EMISSORES}
|
||||
/>
|
||||
<FormSelect
|
||||
label="Estado do RG"
|
||||
{...register("estadoRg")}
|
||||
options={estadosOptions}
|
||||
/>
|
||||
</div>
|
||||
<FormInput
|
||||
label="Email"
|
||||
type="email"
|
||||
{...register("email", {
|
||||
required: "Email é obrigatório",
|
||||
})}
|
||||
error={errors.email?.message}
|
||||
/>
|
||||
<FormInput
|
||||
label="Telefone"
|
||||
{...register("telefone", {
|
||||
required: "Telefone é obrigatório",
|
||||
})}
|
||||
error={errors.telefone?.message}
|
||||
/>
|
||||
<FormSelect
|
||||
label="Estado Civil"
|
||||
{...register("estadoCivil")}
|
||||
options={estadosCivis}
|
||||
/>
|
||||
<FormInput
|
||||
label="Instagram"
|
||||
{...register("instagram")}
|
||||
placeholder="@"
|
||||
/>
|
||||
<FormInput
|
||||
label="Profissão"
|
||||
{...register("profissao")}
|
||||
/>
|
||||
<FormInput
|
||||
label="Data de Nascimento"
|
||||
type="date"
|
||||
{...register("dataNascimento")}
|
||||
max={new Date().toISOString().split("T")[0]} // Impede datas futuras
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 1 && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormInput
|
||||
label="CEP"
|
||||
{...register("cep", {
|
||||
required: "CEP é obrigatório",
|
||||
})}
|
||||
error={errors.cep?.message}
|
||||
/>
|
||||
<FormInput
|
||||
className="md:col-span-2"
|
||||
label="Endereço"
|
||||
{...register("endereco", {
|
||||
required: "Endereço é obrigatório",
|
||||
})}
|
||||
error={errors.endereco?.message}
|
||||
/>
|
||||
<FormInput
|
||||
label="Número"
|
||||
{...register("numero", {
|
||||
required: "Número é obrigatório",
|
||||
})}
|
||||
error={errors.numero?.message}
|
||||
/>
|
||||
<FormInput
|
||||
label="Complemento"
|
||||
{...register("complemento")}
|
||||
/>
|
||||
<FormInput
|
||||
label="Bairro"
|
||||
{...register("bairro", {
|
||||
required: "Bairro é obrigatório",
|
||||
})}
|
||||
error={errors.bairro?.message}
|
||||
/>
|
||||
<FormInput
|
||||
label="Cidade"
|
||||
{...register("cidade", {
|
||||
required: "Cidade é obrigatória",
|
||||
})}
|
||||
error={errors.cidade?.message}
|
||||
/>
|
||||
<FormSelect
|
||||
label="Estado"
|
||||
{...register("estado", {
|
||||
required: "Estado é obrigatório",
|
||||
})}
|
||||
options={estadosOptions}
|
||||
error={errors.estado?.message}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 2 && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<FormSelect
|
||||
label="Vínculo Empregatício"
|
||||
{...register("vinculoEmpregaticio")}
|
||||
options={VINCULOS_EMPREGATICIO}
|
||||
/>
|
||||
<FormInput
|
||||
label="Renda Mensal"
|
||||
type="number"
|
||||
step="0.01"
|
||||
{...register("renda")}
|
||||
/>
|
||||
<div className="col-span-2">
|
||||
<FormCheckbox
|
||||
label="Declara Imposto de Renda"
|
||||
{...register("declaraIR")}
|
||||
/>
|
||||
</div>
|
||||
<FormSelect
|
||||
label="Tipo de Previdência"
|
||||
{...register("tipoPrevidencia")}
|
||||
options={PREVIDENCIA_OPTIONS}
|
||||
/>
|
||||
<div className="col-span-2">
|
||||
<FormCheckbox
|
||||
label="Contribui para Previdência"
|
||||
{...register("contribuiPrevidencia")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 3 && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="col-span-2 mb-4">
|
||||
<FormCheckbox
|
||||
label="Possui Filhos?"
|
||||
{...register("possuiFilhos")}
|
||||
/>
|
||||
</div>
|
||||
<FormInput
|
||||
label="Nome do Pai"
|
||||
{...register("nomePai")}
|
||||
/>
|
||||
<FormInput
|
||||
label="Nome da Mãe"
|
||||
{...register("nomeMae")}
|
||||
/>
|
||||
<FormInput
|
||||
label="Nome do Cônjuge"
|
||||
{...register("conjugeNome")}
|
||||
/>
|
||||
<FormInput
|
||||
label="CPF do Cônjuge"
|
||||
{...register("conjugeCpf")}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Botões de ação */}
|
||||
<div className="flex justify-end space-x-3 pt-6 border-t">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="
|
||||
px-6 py-2.5 text-sm font-medium
|
||||
text-gray-700 bg-white
|
||||
border border-gray-300 rounded-lg
|
||||
hover:bg-gray-50
|
||||
focus:outline-none focus:ring-2 focus:ring-gray-200
|
||||
transition duration-200
|
||||
"
|
||||
>
|
||||
Cancelar
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="
|
||||
px-6 py-2.5 text-sm font-medium
|
||||
text-white bg-blue-600 rounded-lg
|
||||
hover:bg-blue-700
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
|
||||
transition duration-200
|
||||
"
|
||||
>
|
||||
{cliente ? "Salvar" : "Criar"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,146 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { PencilIcon } from '@heroicons/react/24/outline';
|
||||
import api from '../../services/api';
|
||||
import { formatCPF, formatTelefone } from '../../utils/formatters';
|
||||
|
||||
interface ClientesListProps {
|
||||
searchTerm: string;
|
||||
onEdit: (cliente: any) => void;
|
||||
}
|
||||
|
||||
export const ClientesList: React.FC<ClientesListProps> = ({ searchTerm, onEdit }) => {
|
||||
const navigate = useNavigate();
|
||||
const [debouncedTerm, setDebouncedTerm] = useState(searchTerm);
|
||||
|
||||
// Implementar debounce para a busca
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => {
|
||||
setDebouncedTerm(searchTerm);
|
||||
}, 300); // 300ms de delay
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [searchTerm]);
|
||||
|
||||
const { data: clientes = [], isLoading } = useQuery(
|
||||
['clientes', debouncedTerm],
|
||||
async () => {
|
||||
const response = await api.get('/clientes', {
|
||||
params: {
|
||||
search: debouncedTerm.toUpperCase()
|
||||
}
|
||||
});
|
||||
|
||||
// Filtrar localmente para melhor resposta
|
||||
let filteredClientes = response.data;
|
||||
if (debouncedTerm) {
|
||||
const searchTerms = debouncedTerm.toUpperCase().split(' ');
|
||||
filteredClientes = response.data.filter((cliente: any) =>
|
||||
searchTerms.every(term =>
|
||||
cliente.nome.includes(term) ||
|
||||
cliente.cpfCnpj.includes(term) ||
|
||||
cliente.email?.includes(term.toLowerCase())
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Ordenar por nome
|
||||
return filteredClientes.sort((a: any, b: any) =>
|
||||
a.nome.localeCompare(b.nome)
|
||||
);
|
||||
},
|
||||
{
|
||||
staleTime: 1000 * 60, // 1 minuto
|
||||
cacheTime: 1000 * 60 * 5, // 5 minutos
|
||||
keepPreviousData: true // Manter dados anteriores enquanto carrega novos
|
||||
}
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||
<div className="animate-pulse flex space-x-4">
|
||||
<div className="flex-1 space-y-4">
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-3/4"></div>
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded"></div>
|
||||
<div className="h-4 bg-gray-200 dark:bg-gray-700 rounded w-5/6"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden">
|
||||
{clientes.length === 0 ? (
|
||||
<div className="p-6 text-center text-gray-500 dark:text-gray-400">
|
||||
Nenhum cliente encontrado
|
||||
</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead className="bg-gray-50 dark:bg-gray-900">
|
||||
<tr>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Nome
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
CPF/CNPJ
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Telefone
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">
|
||||
Email
|
||||
</th>
|
||||
<th className="relative px-6 py-3">
|
||||
<span className="sr-only">Ações</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{clientes.map((cliente: any) => (
|
||||
<tr
|
||||
key={cliente.id}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors cursor-pointer bg-white dark:bg-gray-800"
|
||||
onClick={() => navigate(`/clientes/${cliente.id}`)}
|
||||
>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{cliente.nome}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{formatCPF(cliente.cpfCnpj)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{formatTelefone(cliente.telefone)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{cliente.email}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onEdit(cliente);
|
||||
}}
|
||||
className="text-blue-600 dark:text-blue-400 hover:text-blue-900 dark:hover:text-blue-300"
|
||||
>
|
||||
<PencilIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import React, { useState } from "react";
|
||||
import { PlusIcon, MagnifyingGlassIcon } from "@heroicons/react/24/outline";
|
||||
import { ClientesList } from "./ClientesList.tsx";
|
||||
import { ClienteModal } from "./ClienteModal.tsx";
|
||||
import { Cliente } from "../../types/entities";
|
||||
|
||||
export const ClientesPage: React.FC = () => {
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [selectedCliente, setSelectedCliente] = useState<Cliente | null>(null);
|
||||
|
||||
const handleSearchChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchTerm(e.target.value);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<MagnifyingGlassIcon className="absolute left-3 top-1/2 transform -translate-y-1/2 h-5 w-5 text-gray-400 dark:text-gray-500" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Buscar clientes..."
|
||||
value={searchTerm}
|
||||
onChange={handleSearchChange}
|
||||
className="w-full pl-10 pr-4 py-2 rounded-lg
|
||||
border border-gray-300 dark:border-gray-600
|
||||
bg-white dark:bg-gray-700
|
||||
text-gray-900 dark:text-white
|
||||
placeholder-gray-400 dark:placeholder-gray-500
|
||||
focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400
|
||||
focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
setSelectedCliente(null);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
className="flex items-center px-4 py-2 bg-blue-600 text-white rounded-lg
|
||||
hover:bg-blue-700 dark:hover:bg-blue-500 transition-colors"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5 mr-2" />
|
||||
Novo Cliente
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ClientesList
|
||||
searchTerm={searchTerm}
|
||||
onEdit={(cliente) => {
|
||||
setSelectedCliente(cliente);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ClienteModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => {
|
||||
setIsModalOpen(false);
|
||||
setSelectedCliente(null);
|
||||
}}
|
||||
cliente={selectedCliente}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in new issue
Block a user