359 lines
16 KiB
TypeScript
359 lines
16 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect, Fragment } from 'react';
|
|
import {
|
|
PlusIcon,
|
|
MagnifyingGlassIcon,
|
|
UserIcon,
|
|
BriefcaseIcon,
|
|
TrashIcon,
|
|
PencilSquareIcon,
|
|
ArrowRightIcon,
|
|
XMarkIcon,
|
|
} from '@heroicons/react/24/outline';
|
|
import { erpApi, Entity } from '@/lib/api-erp';
|
|
import { useToast } from '@/components/layout/ToastContext';
|
|
import {
|
|
StatsCard,
|
|
DataTable,
|
|
Input,
|
|
Card,
|
|
CustomSelect,
|
|
PageHeader,
|
|
BulkActionBar,
|
|
ConfirmDialog,
|
|
} from "@/components/ui";
|
|
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
|
|
import { SolutionGuard } from '@/components/auth/SolutionGuard';
|
|
import Link from 'next/link';
|
|
|
|
interface CRMCustomer {
|
|
id: string;
|
|
name: string;
|
|
email: string;
|
|
company: string;
|
|
phone: string;
|
|
}
|
|
|
|
function EntidadesContent() {
|
|
const toast = useToast();
|
|
const [entities, setEntities] = useState<Entity[]>([]);
|
|
const [crmCustomers, setCrmCustomers] = useState<CRMCustomer[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [searchTerm, setSearchTerm] = useState('');
|
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false);
|
|
const [entityToDelete, setEntityToDelete] = useState<string | null>(null);
|
|
const [editingEntity, setEditingEntity] = useState<Partial<Entity> | null>(null);
|
|
const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
|
|
|
|
const [formData, setFormData] = useState<Partial<Entity>>({
|
|
name: '',
|
|
type: 'supplier',
|
|
document: '',
|
|
email: '',
|
|
phone: '',
|
|
address: '',
|
|
});
|
|
|
|
useEffect(() => {
|
|
fetchAllData();
|
|
}, []);
|
|
|
|
const fetchAllData = async (silent = false) => {
|
|
try {
|
|
if (!silent) setLoading(true);
|
|
const token = typeof window !== 'undefined' ? localStorage.getItem('token') : null;
|
|
const [erpData, crmResp] = await Promise.all([
|
|
erpApi.getEntities(),
|
|
fetch('/api/crm/customers', {
|
|
headers: { 'Authorization': `Bearer ${token}` }
|
|
}).then(res => res.ok ? res.json() : [])
|
|
]);
|
|
|
|
setEntities(erpData || []);
|
|
setCrmCustomers(crmResp?.customers || crmResp || []);
|
|
} catch (error) {
|
|
toast.error('Erro ao carregar', 'Não foi possível carregar os dados financeiros');
|
|
} finally {
|
|
setLoading(false);
|
|
setSelectedIds([]);
|
|
}
|
|
};
|
|
|
|
const handleSave = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
try {
|
|
if (editingEntity?.id) {
|
|
await erpApi.updateEntity(editingEntity.id, formData);
|
|
toast.success('Cadastro atualizado!');
|
|
} else {
|
|
await erpApi.createEntity(formData);
|
|
toast.success('Entidade cadastrada!');
|
|
}
|
|
setIsModalOpen(false);
|
|
setEditingEntity(null);
|
|
await fetchAllData();
|
|
} catch (error) {
|
|
toast.error('Erro ao salvar');
|
|
}
|
|
};
|
|
|
|
const handleConfirmDelete = async () => {
|
|
if (!entityToDelete) return;
|
|
|
|
const originalEntities = [...entities];
|
|
const idToDelete = String(entityToDelete);
|
|
|
|
// Dynamic: remove instantly
|
|
setEntities(prev => prev.filter(e => String(e.id) !== idToDelete));
|
|
|
|
try {
|
|
await erpApi.deleteEntity(idToDelete);
|
|
toast.success('Exclusão completa', 'A entidade foi removida com sucesso.');
|
|
setTimeout(() => fetchAllData(true), 500);
|
|
} catch (error) {
|
|
setEntities(originalEntities);
|
|
toast.error('Erro ao excluir', 'Ocorreu um erro ao excluir a entidade.');
|
|
} finally {
|
|
setConfirmOpen(false);
|
|
setEntityToDelete(null);
|
|
}
|
|
};
|
|
|
|
const handleBulkDelete = async () => {
|
|
if (selectedIds.length === 0) return;
|
|
setBulkConfirmOpen(true);
|
|
};
|
|
|
|
const handleConfirmBulkDelete = async () => {
|
|
const erpIds = selectedIds.filter(id => {
|
|
const item = combinedData.find(d => d.id === id);
|
|
return item?.source === 'ERP';
|
|
}).map(String);
|
|
|
|
if (erpIds.length === 0) return;
|
|
|
|
const originalEntities = [...entities];
|
|
|
|
// Dynamic: remove instantly
|
|
setEntities(prev => prev.filter(e => !erpIds.includes(String(e.id))));
|
|
|
|
try {
|
|
await Promise.all(erpIds.map(id => erpApi.deleteEntity(id)));
|
|
toast.success('Exclusão completa', `${erpIds.length} entidades excluídas com sucesso.`);
|
|
setTimeout(() => fetchAllData(true), 500);
|
|
} catch (error) {
|
|
setEntities(originalEntities);
|
|
toast.error('Erro ao excluir', 'Ocorreu um erro ao excluir algumas entidades.');
|
|
} finally {
|
|
setBulkConfirmOpen(false);
|
|
setSelectedIds([]);
|
|
}
|
|
};
|
|
|
|
// Combine both for searching
|
|
const combinedData = [
|
|
...crmCustomers.map(c => ({
|
|
id: c.id,
|
|
name: c.name,
|
|
email: c.email,
|
|
phone: c.phone,
|
|
source: 'CRM' as const,
|
|
type: 'Cliente (CRM)',
|
|
original: c
|
|
})),
|
|
...entities.map(e => ({
|
|
id: e.id,
|
|
name: e.name,
|
|
email: e.email,
|
|
phone: e.phone,
|
|
source: 'ERP' as const,
|
|
type: e.type === 'customer' ? 'Cliente (ERP)' : (e.type === 'supplier' ? 'Fornecedor (ERP)' : 'Ambos'),
|
|
original: e
|
|
}))
|
|
];
|
|
|
|
const filteredData = combinedData.filter(d =>
|
|
d.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
|
(d.email || '').toLowerCase().includes(searchTerm.toLowerCase())
|
|
);
|
|
|
|
if (loading && combinedData.length === 0) return (
|
|
<div className="p-6 max-w-[1600px] mx-auto">
|
|
<div className="text-center py-20 text-zinc-500">Carregando parceiros de negócio...</div>
|
|
</div>
|
|
);
|
|
|
|
return (
|
|
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
|
|
<PageHeader
|
|
title="Parceiros de Negócio"
|
|
description="Gerencie seus Clientes (CRM) e Fornecedores (ERP) em um único lugar."
|
|
primaryAction={{
|
|
label: "Novo Fornecedor",
|
|
icon: <PlusIcon className="w-5 h-5" />,
|
|
onClick: () => {
|
|
setEditingEntity(null);
|
|
setFormData({ name: '', type: 'supplier', document: '', email: '', phone: '', address: '' });
|
|
setIsModalOpen(true);
|
|
}
|
|
}}
|
|
secondaryAction={{
|
|
label: "Ir para CRM Clientes",
|
|
icon: <UserIcon className="w-5 h-5" />,
|
|
onClick: () => window.location.href = '/crm/clientes'
|
|
}}
|
|
/>
|
|
|
|
<div className="space-y-6">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
|
<StatsCard
|
|
title="Clientes no CRM"
|
|
value={crmCustomers.length}
|
|
icon={<UserIcon className="w-6 h-6 text-emerald-500" />}
|
|
/>
|
|
<StatsCard
|
|
title="Fornecedores no ERP"
|
|
value={entities.filter(e => e.type === 'supplier' || e.type === 'both').length}
|
|
icon={<BriefcaseIcon className="w-6 h-6 text-purple-500" />}
|
|
/>
|
|
</div>
|
|
|
|
<Card noPadding>
|
|
<div className="p-4 border-b border-zinc-100 dark:border-zinc-800">
|
|
<div className="max-w-md">
|
|
<Input
|
|
placeholder="Pesquisar por nome ou e-mail em toda a base..."
|
|
value={searchTerm}
|
|
onChange={(e) => setSearchTerm(e.target.value)}
|
|
leftIcon={<MagnifyingGlassIcon className="w-5 h-5 text-zinc-400" />}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DataTable
|
|
selectable
|
|
isLoading={loading}
|
|
selectedIds={selectedIds}
|
|
onSelectionChange={setSelectedIds}
|
|
data={filteredData}
|
|
columns={[
|
|
{
|
|
header: 'Nome / Razão Social',
|
|
accessor: (row) => (
|
|
<div className="flex flex-col">
|
|
<span className="font-bold text-zinc-900 dark:text-white">{row.name}</span>
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|
<span className={`text-[10px] px-1.5 py-0.5 rounded font-black uppercase ${row.source === 'CRM' ? 'bg-emerald-50 text-emerald-700' : 'bg-purple-50 text-purple-700'}`}>
|
|
{row.source}
|
|
</span>
|
|
<span className="text-[10px] text-zinc-400 font-medium">{row.type}</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
},
|
|
{
|
|
header: 'E-mail',
|
|
accessor: (row) => row.email || '-'
|
|
},
|
|
{
|
|
header: 'Telefone',
|
|
accessor: (row) => row.phone || '-'
|
|
},
|
|
{
|
|
header: '',
|
|
className: 'text-right',
|
|
accessor: (row) => (
|
|
<div className="flex justify-end gap-2">
|
|
{row.source === 'ERP' ? (
|
|
<>
|
|
<button onClick={(e) => { e.stopPropagation(); setEditingEntity(row.original as Entity); setFormData(row.original as Entity); setIsModalOpen(true); }} className="p-2 text-zinc-400 hover:text-brand-500">
|
|
<PencilSquareIcon className="w-5 h-5" />
|
|
</button>
|
|
<button onClick={(e) => { e.stopPropagation(); setEntityToDelete(row.id as string); setConfirmOpen(true); }} className="p-2 text-zinc-400 hover:text-rose-500">
|
|
<TrashIcon className="w-5 h-5" />
|
|
</button>
|
|
</>
|
|
) : (
|
|
<Link href={`/crm/clientes?id=${row.id}`} onClick={(e) => e.stopPropagation()} className="p-2 text-zinc-400 hover:text-brand-500 flex items-center gap-1 text-xs font-bold">
|
|
Ver no CRM <ArrowRightIcon className="w-4 h-4" />
|
|
</Link>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
]}
|
|
/>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Modal de Cadastro ERP (Fornecedores) */}
|
|
<Transition show={isModalOpen} as={Fragment}>
|
|
<Dialog as="div" className="relative z-50" onClose={() => setIsModalOpen(false)}>
|
|
<TransitionChild as={Fragment} 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/40 backdrop-blur-sm" />
|
|
</TransitionChild>
|
|
<div className="fixed inset-0 overflow-y-auto">
|
|
<div className="flex min-h-full items-center justify-center p-4">
|
|
<TransitionChild as={Fragment} 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">
|
|
<DialogPanel className="w-full max-w-lg transform overflow-hidden rounded-2xl bg-white dark:bg-zinc-900 p-8 shadow-xl transition-all border border-zinc-200 dark:border-zinc-800">
|
|
<DialogTitle className="text-xl font-bold mb-6">{editingEntity ? 'Editar Fornecedor' : 'Novo Fornecedor / Outros'}</DialogTitle>
|
|
<form onSubmit={handleSave} className="space-y-4">
|
|
<Input label="Nome / Razão Social" value={formData.name} onChange={e => setFormData({ ...formData, name: e.target.value })} required />
|
|
<CustomSelect label="Tipo" options={[{ label: 'Fornecedor', value: 'supplier' }, { label: 'Cliente (ERP Avulso)', value: 'customer' }, { label: 'Ambos', value: 'both' }]} value={formData.type || 'supplier'} onChange={val => setFormData({ ...formData, type: val as any })} />
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<Input label="Documento (CNPJ/CPF)" value={formData.document} onChange={e => setFormData({ ...formData, document: e.target.value })} />
|
|
<Input label="Telefone" value={formData.phone} onChange={e => setFormData({ ...formData, phone: e.target.value })} />
|
|
</div>
|
|
<Input label="E-mail" type="email" value={formData.email} onChange={e => setFormData({ ...formData, email: e.target.value })} />
|
|
<Input label="Endereço Completo" value={formData.address} onChange={e => setFormData({ ...formData, address: e.target.value })} />
|
|
|
|
<div className="flex justify-end gap-3 mt-8 pt-4 border-t border-zinc-100 dark:border-zinc-800">
|
|
<button type="button" onClick={() => setIsModalOpen(false)} className="px-4 py-2 text-zinc-500 font-bold">Cancelar</button>
|
|
<button type="submit" className="px-8 py-2 text-white rounded-xl font-bold" style={{ background: 'var(--gradient)' }}>Salvar Cadastro</button>
|
|
</div>
|
|
</form>
|
|
</DialogPanel>
|
|
</TransitionChild>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
</Transition>
|
|
|
|
<ConfirmDialog isOpen={confirmOpen} onClose={() => setConfirmOpen(false)} onConfirm={handleConfirmDelete} title="Excluir Cadastro" message="Tem certeza? Isso pode afetar lançamentos vinculados a esta entidade no ERP." confirmText="Excluir" />
|
|
|
|
<ConfirmDialog
|
|
isOpen={bulkConfirmOpen}
|
|
onClose={() => setBulkConfirmOpen(false)}
|
|
onConfirm={handleConfirmBulkDelete}
|
|
title="Excluir Itens Selecionados"
|
|
message={`Tem certeza que deseja excluir as entidades selecionadas? Esta ação não pode ser desfeita.`}
|
|
confirmText="Excluir Tudo"
|
|
variant="danger"
|
|
/>
|
|
|
|
<BulkActionBar
|
|
selectedCount={selectedIds.length}
|
|
onClearSelection={() => setSelectedIds([])}
|
|
actions={[
|
|
{
|
|
label: "Excluir Selecionados",
|
|
icon: <TrashIcon className="w-5 h-5" />,
|
|
onClick: handleBulkDelete,
|
|
variant: 'danger'
|
|
}
|
|
]}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function EntidadesPage() {
|
|
return (
|
|
<SolutionGuard requiredSolution="erp">
|
|
<EntidadesContent />
|
|
</SolutionGuard>
|
|
);
|
|
}
|