first commit

This commit is contained in:
rodrigo committed 2025-04-10 23:07:21 -03:00
commit c9fb67e751
423 files changed
+53287

No files matched your search

+390
View File
@@ -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>
);
};
+611
View File
@@ -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>
);
};
+146
View File
@@ -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>
);
};
+66
View File
@@ -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>
);
};