fix(erp): enable erp pages and menu items

This commit is contained in:
Erik Silva
2025-12-29 17:23:59 -03:00
parent e124a64a5d
commit adbff9bb1e
13990 changed files with 1110936 additions and 59 deletions

View File

@@ -5,6 +5,7 @@ import { AgencyBranding } from '@/components/layout/AgencyBranding';
import AuthGuard from '@/components/auth/AuthGuard';
import { CRMFilterProvider } from '@/contexts/CRMFilterContext';
import { useState, useEffect } from 'react';
import { getUser } from '@/lib/auth';
import {
HomeIcon,
RocketLaunchIcon,
@@ -12,10 +13,30 @@ import {
RectangleStackIcon,
UsersIcon,
MegaphoneIcon,
BanknotesIcon,
CubeIcon,
ShoppingCartIcon,
ArrowDownCircleIcon,
ChartBarIcon,
WalletIcon,
UserGroupIcon,
ArchiveBoxIcon,
AdjustmentsHorizontalIcon,
ArrowTrendingUpIcon,
ArrowTrendingDownIcon,
DocumentTextIcon,
ShoppingBagIcon
} from '@heroicons/react/24/outline';
const AGENCY_MENU_ITEMS = [
{ id: 'dashboard', label: 'Dashboard', href: '/dashboard', icon: HomeIcon },
{
id: 'documentos',
label: 'Documentos',
href: '/documentos',
icon: DocumentTextIcon,
requiredSolution: 'documentos'
},
{
id: 'crm',
label: 'CRM',
@@ -30,6 +51,21 @@ const AGENCY_MENU_ITEMS = [
{ label: 'Leads', href: '/crm/leads', icon: UserPlusIcon },
]
},
{
id: 'erp',
label: 'ERP',
href: '/erp',
icon: BanknotesIcon,
requiredSolution: 'erp',
subItems: [
{ label: 'Visão Geral', href: '/erp', icon: ChartBarIcon },
{ label: 'Produtos e Estoque', href: '/erp/estoque', icon: ArchiveBoxIcon },
{ label: 'Pedidos e Vendas', href: '/erp/pedidos', icon: ShoppingBagIcon },
{ label: 'Caixa', href: '/erp/caixa', icon: WalletIcon },
{ label: 'Contas a Receber', href: '/erp/receber', icon: ArrowTrendingUpIcon },
{ label: 'Contas a Pagar', href: '/erp/pagar', icon: ArrowTrendingDownIcon },
]
},
];
interface AgencyLayoutClientProps {
@@ -67,10 +103,23 @@ export function AgencyLayoutClient({ children, colors }: AgencyLayoutClientProps
console.log('🏷️ Slugs das soluções:', solutionSlugs);
// Sempre mostrar dashboard + soluções disponíveis
// Segurança Máxima: ERP só para ADMIN_AGENCIA
const user = getUser();
const filtered = AGENCY_MENU_ITEMS.filter(item => {
if (item.id === 'dashboard') return true;
// ERP restrito a administradores da agência
if (item.id === 'erp' && user?.role !== 'ADMIN_AGENCIA') {
return false;
}
const requiredSolution = (item as any).requiredSolution;
return solutionSlugs.includes((requiredSolution || item.id).toLowerCase());
const hasSolution = solutionSlugs.includes((requiredSolution || item.id).toLowerCase());
// Temporariamente forçar a exibição de Documentos para debug
if (item.id === 'documentos') return true;
return hasSolution;
});
console.log('📋 Menu filtrado:', filtered.map(i => i.id));

View File

@@ -2,7 +2,7 @@
import { Fragment, useEffect, useMemo, useState } from 'react';
import { Menu, Transition, Tab } from '@headlessui/react';
import ConfirmDialog from '@/components/layout/ConfirmDialog';
import { ConfirmDialog, BulkActionBar } from "@/components/ui";
import { useToast } from '@/components/layout/ToastContext';
import {
UserIcon,
@@ -83,12 +83,14 @@ export default function CustomersPage() {
const [editingCustomer, setEditingCustomer] = useState<Customer | null>(null);
const [confirmOpen, setConfirmOpen] = useState(false);
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false);
const [customerToDelete, setCustomerToDelete] = useState<string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [showPendingOnly, setShowPendingOnly] = useState(false);
const [detailsCustomer, setDetailsCustomer] = useState<Customer | null>(null);
const [isApprovingId, setIsApprovingId] = useState<string | null>(null);
const [selectedIds, setSelectedIds] = useState<string[]>([]);
// Portal Access States
const [isPortalModalOpen, setIsPortalModalOpen] = useState(false);
@@ -179,6 +181,93 @@ export default function CustomersPage() {
}
} catch (error) {
console.error('[Clientes] Error fetching customers:', error);
} finally {
setLoading(false);
setSelectedIds([]);
}
};
const handleBulkDelete = async () => {
if (selectedIds.length === 0) return;
setBulkConfirmOpen(true);
};
const handleConfirmBulkDelete = async () => {
try {
setLoading(true);
const results = await Promise.all(selectedIds.map(async (id) => {
const response = await fetch(`/api/crm/customers/${id}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
},
});
return { id, ok: response.ok };
}));
const successful = results.filter(r => r.ok).length;
if (successful > 0) {
toast.success('Exclusão completa', `${successful} clientes excluídos com sucesso.`);
}
if (successful < selectedIds.length) {
toast.error('Erro', 'Não foi possível excluir alguns clientes.');
}
await fetchCustomers();
} catch (error) {
toast.error('Erro', 'Ocorreu um erro na exclusão em lote.');
} finally {
setLoading(false);
setBulkConfirmOpen(false);
setSelectedIds([]);
}
};
const handleBulkApprove = async () => {
const pendingToApprove = customers.filter(c => selectedIds.includes(c.id) && isPendingApproval(c));
if (pendingToApprove.length === 0) {
toast.error('Nenhum dos clientes selecionados está pendente de aprovação.');
return;
}
try {
setLoading(true);
await Promise.all(pendingToApprove.map(async (customer) => {
const updatedTags = (customer.tags || []).filter(tag => tag !== PENDING_APPROVAL_TAG);
let logoUrl = customer.logo_url;
const publicData = parsePublicNotes(customer.notes);
if (publicData?.logo_path && !logoUrl) {
logoUrl = publicData.logo_path;
}
return fetch(`/api/crm/customers/${customer.id}`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${localStorage.getItem('token')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
name: customer.name,
email: customer.email,
phone: customer.phone,
company: customer.company,
position: customer.position,
address: customer.address,
city: customer.city,
state: customer.state,
zip_code: customer.zip_code,
country: customer.country,
notes: customer.notes,
tags: updatedTags,
is_active: customer.is_active ?? true,
logo_url: logoUrl,
}),
});
}));
toast.success('Sucesso', `${pendingToApprove.length} clientes aprovados.`);
await fetchCustomers();
} catch (error) {
toast.error('Erro ao aprovar clientes');
} finally {
setLoading(false);
}
@@ -213,7 +302,7 @@ export default function CustomersPage() {
editingCustomer ? 'Cliente atualizado' : 'Cliente criado',
editingCustomer ? 'O cliente foi atualizado com sucesso.' : 'O novo cliente foi criado com sucesso.'
);
fetchCustomers();
await fetchCustomers();
handleCloseModal();
} else {
const error = await response.json();
@@ -262,7 +351,7 @@ export default function CustomersPage() {
});
if (response.ok) {
setCustomers(customers.filter(c => c.id !== customerToDelete));
await fetchCustomers();
toast.success('Cliente excluído', 'O cliente foi excluído com sucesso.');
} else {
toast.error('Erro ao excluir', 'Não foi possível excluir o cliente.');
@@ -361,7 +450,7 @@ export default function CustomersPage() {
if (response.ok) {
toast.success('Acesso liberado!', 'O cliente já pode fazer login no portal com a senha cadastrada.');
fetchCustomers();
await fetchCustomers();
setDetailsCustomer(prev => prev && prev.id === customer.id ? { ...prev, tags: updatedTags } : prev);
} else {
const error = await response.json();
@@ -524,6 +613,22 @@ export default function CustomersPage() {
<table className="w-full">
<thead>
<tr className="bg-zinc-50/50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-800">
<th className="px-6 py-4 w-10 text-left">
<div className="flex items-center">
<input
type="checkbox"
checked={filteredCustomers.length > 0 && selectedIds.length === filteredCustomers.length}
onChange={(e) => {
if (e.target.checked) {
setSelectedIds(filteredCustomers.map(c => c.id));
} else {
setSelectedIds([]);
}
}}
className="w-4 h-4 rounded border-zinc-300 text-brand-600 focus:ring-brand-500 cursor-pointer"
/>
</div>
</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Cliente</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Empresa</th>
<th className="px-6 py-4 text-left text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Contato</th>
@@ -540,11 +645,28 @@ export default function CustomersPage() {
return (
<tr
key={customer.id}
className={`group transition-colors ${pendingApproval
onClick={() => openDetails(customer)}
className={`group transition-colors cursor-pointer ${pendingApproval
? 'bg-amber-50/40 dark:bg-amber-900/10 hover:bg-amber-50/70 dark:hover:bg-amber-900/20'
: 'hover:bg-zinc-50 dark:hover:bg-zinc-800/50'
}`}
} ${selectedIds.includes(customer.id) ? 'bg-brand-50/30 dark:bg-brand-500/5' : ''}`}
>
<td className="px-6 py-4 w-10" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center">
<input
type="checkbox"
checked={selectedIds.includes(customer.id)}
onChange={() => {
if (selectedIds.includes(customer.id)) {
setSelectedIds(selectedIds.filter(id => id !== customer.id));
} else {
setSelectedIds([...selectedIds, customer.id]);
}
}}
className="w-4 h-4 rounded border-zinc-300 text-brand-600 focus:ring-brand-500 cursor-pointer"
/>
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<div className="flex items-center gap-3">
{customer.logo_url ? (
@@ -1010,20 +1132,38 @@ export default function CustomersPage() {
</div>
)}
<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" cancelText="Cancelar" variant="danger" />
<ConfirmDialog
isOpen={confirmOpen}
onClose={() => {
setConfirmOpen(false);
setCustomerToDelete(null);
}}
onConfirm={handleConfirmDelete}
title="Excluir Cliente"
message="Tem certeza que deseja excluir este cliente? Esta ação não pode ser desfeita."
confirmText="Excluir"
isOpen={bulkConfirmOpen}
onClose={() => setBulkConfirmOpen(false)}
onConfirm={handleConfirmBulkDelete}
title="Excluir Clientes Selecionados"
message={`Tem certeza que deseja excluir os ${selectedIds.length} clientes selecionados? Esta ação não pode ser desfeita.`}
confirmText="Excluir Tudo"
cancelText="Cancelar"
variant="danger"
/>
<BulkActionBar
selectedCount={selectedIds.length}
onClearSelection={() => setSelectedIds([])}
actions={[
...(customers.some(c => selectedIds.includes(c.id) && isPendingApproval(c)) ? [{
label: "Aprovar Selecionados",
icon: <CheckCircleIcon className="w-5 h-5" />,
onClick: handleBulkApprove,
variant: 'primary' as const
}] : []),
{
label: "Excluir Selecionados",
icon: <TrashIcon className="w-5 h-5" />,
onClick: handleBulkDelete,
variant: 'danger'
}
]}
/>
{/* Modal de Acesso ao Portal */}
{isPortalModalOpen && selectedCustomerForPortal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">

View File

@@ -1,15 +1,251 @@
'use client';
import React, { useState, useEffect, useMemo } from 'react';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
import { PageHeader, DataTable, Card, Badge } from '@/components/ui';
import {
PlusIcon,
MagnifyingGlassIcon,
DocumentTextIcon,
PencilSquareIcon,
TrashIcon,
ArrowPathIcon,
EyeIcon,
ClockIcon
} from '@heroicons/react/24/outline';
import { docApi, Document } from '@/lib/api-docs';
import { toast } from 'react-hot-toast';
import DocumentEditor from '@/components/documentos/DocumentEditor';
import { format, parseISO } from 'date-fns';
import { ptBR } from 'date-fns/locale';
export default function DocumentosPage() {
const [documents, setDocuments] = useState<Document[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [isEditing, setIsEditing] = useState(false);
const [currentDoc, setCurrentDoc] = useState<Partial<Document> | null>(null);
// Pagination
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 8;
useEffect(() => {
fetchDocuments();
}, []);
const fetchDocuments = async () => {
try {
setLoading(true);
const data = await docApi.getDocuments();
setDocuments(data || []);
} catch (error) {
toast.error('Erro ao carregar documentos');
} finally {
setLoading(false);
}
};
const handleCreate = async () => {
try {
const newDoc = await docApi.createDocument({
title: 'Novo Documento',
content: '{"type":"doc","content":[{"type":"paragraph"}]}',
status: 'published',
parent_id: null
});
setCurrentDoc(newDoc);
setIsEditing(true);
fetchDocuments();
} catch (error) {
toast.error('Erro ao iniciar novo documento');
}
};
const handleEdit = (doc: Document) => {
setCurrentDoc(doc);
setIsEditing(true);
};
const handleSave = async (docData: Partial<Document>) => {
try {
if (docData.id) {
await docApi.updateDocument(docData.id, docData);
// toast.success('Documento atualizado!'); // Auto-save já acontece
} else {
await docApi.createDocument(docData);
toast.success('Documento criado!');
}
setIsEditing(false);
fetchDocuments();
} catch (error) {
toast.error('Erro ao salvar documento');
}
};
const handleDelete = async (id: string) => {
if (!confirm('Tem certeza que deseja excluir este documento e todas as suas subpáginas?')) return;
try {
await docApi.deleteDocument(id);
toast.success('Documento excluído!');
fetchDocuments();
} catch (error) {
toast.error('Erro ao excluir documento');
}
};
const filteredDocuments = useMemo(() => {
return documents.filter(doc =>
(doc.title || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
(doc.content || '').toLowerCase().includes(searchTerm.toLowerCase())
);
}, [documents, searchTerm]);
const paginatedDocuments = useMemo(() => {
const start = (currentPage - 1) * itemsPerPage;
return filteredDocuments.slice(start, start + itemsPerPage);
}, [filteredDocuments, currentPage]);
const columns = [
{
header: 'Documento',
accessor: (doc: Document) => (
<div className="flex items-center gap-3 py-1">
<div className="p-2.5 bg-zinc-50 dark:bg-zinc-800 rounded-xl border border-zinc-100 dark:border-zinc-700 shadow-sm">
<DocumentTextIcon className="w-5 h-5 text-zinc-500" />
</div>
<div>
<p className="font-bold text-zinc-900 dark:text-white group-hover:text-brand-500 transition-colors uppercase tracking-tight text-sm">
{doc.title || 'Sem título'}
</p>
<div className="flex items-center gap-2 mt-0.5">
<Badge variant="info" className="text-[8px] px-1.5 font-black">v{doc.version || 1}</Badge>
<span className="text-[10px] text-zinc-400 font-medium">#{doc.id.substring(0, 8)}</span>
</div>
</div>
</div>
)
},
{
header: 'Última Modificação',
accessor: (doc: Document) => (
<div className="flex items-center gap-3">
<ClockIcon className="w-4 h-4 text-zinc-300" />
<div className="flex flex-col">
<span className="text-xs font-bold text-zinc-600 dark:text-zinc-400">
{format(parseISO(doc.updated_at), "dd 'de' MMM", { locale: ptBR })}
</span>
<span className="text-[9px] text-zinc-400 uppercase font-black tracking-tighter">
às {format(parseISO(doc.updated_at), "HH:mm")}
</span>
</div>
</div>
)
},
{
header: 'Ações',
align: 'right' as const,
accessor: (doc: Document) => (
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleEdit(doc)}
className="flex items-center gap-2 px-4 py-2 text-xs font-black uppercase tracking-widest text-zinc-600 dark:text-zinc-400 hover:text-brand-500 hover:bg-brand-50 dark:hover:bg-brand-500/10 rounded-xl transition-all"
>
<PencilSquareIcon className="w-4 h-4" />
Abrir
</button>
<button
onClick={() => handleDelete(doc.id)}
className="p-2 text-zinc-300 hover:text-rose-500 hover:bg-rose-50 dark:hover:bg-rose-500/10 rounded-xl transition-all"
title="Excluir"
>
<TrashIcon className="w-4 h-4" />
</button>
</div>
)
}
];
return (
<SolutionGuard requiredSolution="documentos">
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">Documentos</h1>
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-8 text-center">
<p className="text-gray-500">Gestão Eletrônica de Documentos (GED) em breve</p>
<div className="p-6 max-w-[1600px] mx-auto space-y-8 animate-in fade-in duration-700">
<PageHeader
title="Wiki & Base de Conhecimento"
description="Organize processos, manuais e documentação técnica da agência."
primaryAction={{
label: "Criar Novo",
icon: <PlusIcon className="w-5 h-5" />,
onClick: handleCreate
}}
/>
<div className="flex flex-col md:flex-row gap-4 items-center justify-between bg-white dark:bg-zinc-900/50 p-4 rounded-[28px] border border-zinc-200 dark:border-zinc-800 shadow-sm">
<div className="w-full md:w-96 relative">
<MagnifyingGlassIcon className="absolute left-4 top-1/2 -translate-y-1/2 w-5 h-5 text-zinc-300" />
<input
type="text"
placeholder="Pesquisar wiki..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-zinc-50 dark:bg-zinc-950 border border-zinc-100 dark:border-zinc-800 rounded-2xl pl-12 pr-4 py-3 text-sm outline-none focus:ring-2 ring-brand-500/20 transition-all font-semibold placeholder:text-zinc-400"
/>
</div>
<div className="flex items-center gap-4">
<button
onClick={fetchDocuments}
className="p-3 text-zinc-400 hover:text-zinc-900 dark:hover:text-white transition-colors"
>
<ArrowPathIcon className={`w-5 h-5 ${loading ? 'animate-spin' : ''}`} />
</button>
<div className="h-6 w-px bg-zinc-200 dark:border-zinc-800" />
<div className="flex items-center gap-2 px-5 py-2.5 bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-2xl text-[10px] font-black uppercase tracking-widest shadow-lg shadow-zinc-200 dark:shadow-none">
{filteredDocuments.length} Documentos
</div>
</div>
</div>
<Card noPadding allowOverflow className="border-none shadow-2xl shadow-black/5 overflow-hidden rounded-[32px]">
<DataTable
columns={columns}
data={paginatedDocuments}
isLoading={loading}
/>
{/* Pagination */}
<div className="p-6 border-t border-zinc-50 dark:border-zinc-800 flex items-center justify-between bg-zinc-50/50 dark:bg-zinc-900/50">
<p className="text-[10px] font-black text-zinc-400 uppercase tracking-widest">
{filteredDocuments.length} itens no total
</p>
<div className="flex gap-2">
<button
disabled={currentPage === 1}
onClick={() => setCurrentPage(p => p - 1)}
className="px-6 py-2.5 text-[10px] font-black uppercase tracking-widest bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-xl disabled:opacity-30 hover:bg-zinc-50 dark:hover:bg-zinc-700 transition-all shadow-sm"
>
Anterior
</button>
<button
disabled={currentPage * itemsPerPage >= filteredDocuments.length}
onClick={() => setCurrentPage(p => p + 1)}
className="px-6 py-2.5 text-[10px] font-black uppercase tracking-widest bg-zinc-900 dark:bg-zinc-100 text-white dark:text-zinc-900 rounded-xl disabled:opacity-30 hover:bg-black dark:hover:bg-white transition-all shadow-lg active:scale-95"
>
Próximo
</button>
</div>
</div>
</Card>
{isEditing && (
<DocumentEditor
initialDocument={currentDoc}
onSave={handleSave}
onCancel={() => {
setIsEditing(false);
fetchDocuments();
}}
/>
)}
</div>
</SolutionGuard>
);

View File

@@ -0,0 +1,211 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
PlusIcon,
BanknotesIcon,
TagIcon,
CheckIcon,
XMarkIcon,
PencilSquareIcon,
TrashIcon,
BuildingLibraryIcon
} from '@heroicons/react/24/outline';
import { erpApi, FinancialCategory, BankAccount } from '@/lib/api-erp';
import { formatCurrency } from '@/lib/format';
import { toast } from 'react-hot-toast';
import {
PageHeader,
DataTable,
Input,
Card,
Tabs
} from "@/components/ui";
export default function ERPSettingsPage() {
return (
<div className="space-y-6">
<PageHeader
title="Configurações do ERP"
description="Gerencie categorias financeiras, contas bancárias e outras preferências do sistema."
/>
<Tabs
variant="pills"
items={[
{
label: 'Categorias Financeiras',
icon: <TagIcon className="w-4 h-4" />,
content: <CategorySettings />
},
{
label: 'Contas Bancárias',
icon: <BuildingLibraryIcon className="w-4 h-4" />,
content: <AccountSettings />
}
]}
/>
</div>
);
}
function CategorySettings() {
const [categories, setCategories] = useState<FinancialCategory[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
const data = await erpApi.getFinancialCategories();
setCategories(data || []);
} catch (error) {
toast.error('Erro ao carregar categorias');
} finally {
setLoading(false);
}
};
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">Categorias</h3>
<button
className="flex items-center gap-2 px-4 py-2 text-white rounded-xl font-bold shadow-lg hover:opacity-90 transition-all text-sm"
style={{ background: 'var(--gradient)' }}
>
<PlusIcon className="w-4 h-4" />
Nova Categoria
</button>
</div>
<Card noPadding className="overflow-hidden">
<DataTable
isLoading={loading}
data={categories}
columns={[
{
header: 'Nome',
accessor: (row) => (
<div className="flex items-center gap-3">
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: row.color }} />
<span className="font-bold text-zinc-900 dark:text-white">{row.name}</span>
</div>
)
},
{
header: 'Tipo',
accessor: (row) => (
<span className={`px-2 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider ${row.type === 'income' ? 'bg-emerald-100 text-emerald-700' : 'bg-rose-100 text-rose-700'}`}>
{row.type === 'income' ? 'Receita' : 'Despesa'}
</span>
)
},
{
header: 'Status',
accessor: (row) => (
<span className={`text-xs font-bold ${row.is_active ? 'text-emerald-500' : 'text-zinc-400'}`}>
{row.is_active ? 'Ativo' : 'Inativo'}
</span>
)
},
{
header: '',
className: 'text-right',
accessor: () => (
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-all">
<button className="p-2 text-zinc-400 hover:text-brand-600 dark:hover:text-brand-400">
<PencilSquareIcon className="w-4 h-4" />
</button>
</div>
)
}
]}
/>
</Card>
</div>
);
}
function AccountSettings() {
const [accounts, setAccounts] = useState<BankAccount[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchData();
}, []);
const fetchData = async () => {
try {
const data = await erpApi.getBankAccounts();
setAccounts(data || []);
} catch (error) {
toast.error('Erro ao carregar contas');
} finally {
setLoading(false);
}
};
return (
<div className="space-y-4">
<div className="flex justify-between items-center">
<h3 className="text-lg font-bold text-zinc-900 dark:text-white">Contas Bancárias</h3>
<button
className="flex items-center gap-2 px-4 py-2 text-white rounded-xl font-bold shadow-lg hover:opacity-90 transition-all text-sm"
style={{ background: 'var(--gradient)' }}
>
<PlusIcon className="w-4 h-4" />
Nova Conta
</button>
</div>
<Card noPadding className="overflow-hidden">
<DataTable
isLoading={loading}
data={accounts}
columns={[
{
header: 'Nome da Conta',
accessor: (row) => (
<div className="flex flex-col">
<span className="font-bold text-zinc-900 dark:text-white">{row.name}</span>
<span className="text-xs text-zinc-400 font-bold uppercase">{row.bank_name}</span>
</div>
)
},
{
header: 'Saldo Atual',
className: 'text-right',
accessor: (row) => (
<span className="font-black text-zinc-900 dark:text-white">
{formatCurrency(row.current_balance)}
</span>
)
},
{
header: 'Status',
accessor: (row) => (
<span className={`text-xs font-bold ${row.is_active ? 'text-emerald-500' : 'text-zinc-400'}`}>
{row.is_active ? 'Ativo' : 'Inativo'}
</span>
)
},
{
header: '',
className: 'text-right',
accessor: () => (
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-all">
<button className="p-2 text-zinc-400 hover:text-brand-600 dark:hover:text-brand-400">
<PencilSquareIcon className="w-4 h-4" />
</button>
</div>
)
}
]}
/>
</Card>
</div>
);
}

View File

@@ -0,0 +1,309 @@
'use client';
import React, { useState, useEffect, Fragment } from 'react';
import {
PlusIcon,
MagnifyingGlassIcon,
FunnelIcon,
ShoppingBagIcon,
CalendarIcon,
CurrencyDollarIcon,
UserIcon,
CheckCircleIcon,
ClockIcon,
XMarkIcon,
EyeIcon,
TrashIcon,
ExclamationTriangleIcon
} from '@heroicons/react/24/outline';
import { ConfirmDialog } from "@/components/ui";
import { erpApi, Order, Entity } from '@/lib/api-erp';
import { formatCurrency } from '@/lib/format';
import { useToast } from '@/components/layout/ToastContext';
import {
PageHeader,
StatsCard,
DataTable,
Input,
Card,
BulkActionBar,
} from "@/components/ui";
import { format } from 'date-fns';
export default function OrdersPage() {
const toast = useToast();
const [orders, setOrders] = useState<Order[]>([]);
const [entities, setEntities] = useState<Entity[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
const [confirmOpen, setConfirmOpen] = useState(false);
const [bulkConfirmOpen, setBulkConfirmOpen] = useState(false);
const [orderToDelete, setOrderToDelete] = useState<string | null>(null);
useEffect(() => {
fetchData();
}, []);
const fetchData = async (silent = false) => {
try {
if (!silent) setLoading(true);
const [ordersData, entitiesData] = await Promise.all([
erpApi.getOrders(),
erpApi.getEntities()
]);
setOrders(ordersData || []);
setEntities(entitiesData || []);
} catch (error) {
toast.error('Erro ao carregar', 'Não foi possível carregar os pedidos');
} finally {
setLoading(false);
setSelectedIds([]);
}
};
const handleBulkDelete = async () => {
if (selectedIds.length === 0) return;
setBulkConfirmOpen(true);
};
const handleConfirmBulkDelete = async () => {
if (selectedIds.length === 0) return;
const originalOrders = [...orders];
const idsToDelete = selectedIds.map(String);
// Dynamic: remove instantly
setOrders(prev => prev.filter(o => !idsToDelete.includes(String(o.id))));
const deletedCount = selectedIds.length;
try {
await Promise.all(idsToDelete.map(id => erpApi.deleteOrder(id)));
toast.success('Exclusão completa', `${deletedCount} pedidos excluídos com sucesso.`);
setTimeout(() => fetchData(true), 500);
} catch (error) {
setOrders(originalOrders);
toast.error('Erro ao excluir', 'Ocorreu um erro ao excluir alguns pedidos.');
} finally {
setBulkConfirmOpen(false);
setSelectedIds([]);
}
};
const handleDelete = (id: string) => {
setOrderToDelete(id);
setConfirmOpen(true);
};
const handleConfirmDelete = async () => {
if (!orderToDelete) return;
const originalOrders = [...orders];
const idToDelete = String(orderToDelete);
// Dynamic: remove instantly
setOrders(prev => prev.filter(o => String(o.id) !== idToDelete));
try {
await erpApi.deleteOrder(idToDelete);
toast.success('Exclusão completa', 'O pedido foi removido com sucesso.');
setTimeout(() => fetchData(true), 500);
} catch (error) {
setOrders(originalOrders);
toast.error('Erro ao excluir', 'Ocorreu um erro ao excluir o pedido.');
} finally {
setConfirmOpen(false);
setOrderToDelete(null);
}
};
const filteredOrders = orders.filter(o => {
const entityName = entities.find(e => e.id === o.entity_id)?.name || '';
const searchStr = searchTerm.toLowerCase();
return String(o.id).toLowerCase().includes(searchStr) ||
entityName.toLowerCase().includes(searchStr);
});
const totalRevenue = orders.filter(o => o.status !== 'cancelled').reduce((sum, o) => sum + Number(o.total_amount), 0);
const pendingOrders = orders.filter(o => o.status === 'confirmed').length;
const completedOrders = orders.filter(o => o.status === 'completed').length;
const columns = [
{
header: 'Pedido / Data',
accessor: (row: Order) => (
<div className="flex flex-col">
<span className="font-bold text-zinc-900 dark:text-white uppercase text-xs">#{row.id.slice(0, 8)}</span>
<div className="flex items-center gap-1 text-[10px] text-zinc-400 font-bold">
<CalendarIcon className="w-3 h-3" />
{row.created_at ? format(new Date(row.created_at), 'dd/MM/yyyy HH:mm') : '-'}
</div>
</div>
)
},
{
header: 'Cliente',
accessor: (row: Order) => (
<div className="flex items-center gap-2">
<div className="p-1.5 rounded-lg bg-zinc-100 dark:bg-zinc-800 text-zinc-500">
<UserIcon className="w-4 h-4" />
</div>
<span className="text-sm font-semibold text-zinc-900 dark:text-white">
{entities.find(e => e.id === row.entity_id)?.name || 'Consumidor Final'}
</span>
</div>
)
},
{
header: 'Status',
accessor: (row: Order) => {
const colors = {
draft: 'bg-zinc-100 text-zinc-700',
confirmed: 'bg-blue-100 text-blue-700',
completed: 'bg-emerald-100 text-emerald-700',
cancelled: 'bg-rose-100 text-rose-700'
};
const labels = {
draft: 'Rascunho',
confirmed: 'Confirmado',
completed: 'Concluído',
cancelled: 'Cancelado'
};
return (
<span className={`px-2.5 py-0.5 rounded-full text-[10px] font-black uppercase tracking-wider ${colors[row.status as keyof typeof colors]}`}>
{labels[row.status as keyof typeof labels]}
</span>
);
}
},
{
header: 'Total',
className: 'text-right',
accessor: (row: Order) => (
<span className="font-black text-zinc-900 dark:text-white">
{formatCurrency(row.total_amount)}
</span>
)
},
{
header: '',
className: 'text-right',
accessor: (row: Order) => (
<div className="flex justify-end gap-2">
<button className="p-2 text-zinc-400 hover:text-brand-600 dark:hover:text-brand-400">
<EyeIcon className="w-5 h-5" />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete(row.id); }}
className="p-2 text-zinc-400 hover:text-rose-600 dark:hover:text-rose-400 transition-all"
>
<TrashIcon className="w-5 h-5" />
</button>
</div>
)
}
];
return (
<div className="space-y-6">
<PageHeader
title="Pedidos & Vendas"
description="Acompanhe suas vendas, gerencie orçamentos e controle o fluxo de pedidos."
primaryAction={{
label: "Novo Pedido",
icon: <PlusIcon className="w-5 h-5" />,
onClick: () => toast.error('Funcionalidade em desenvolvimento')
}}
/>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatsCard
title="Receita de Vendas"
value={formatCurrency(totalRevenue)}
icon={<CurrencyDollarIcon className="w-6 h-6 text-emerald-500" />}
/>
<StatsCard
title="Pedidos Pendentes"
value={pendingOrders}
icon={<ClockIcon className="w-6 h-6 text-blue-500" />}
/>
<StatsCard
title="Pedidos Concluídos"
value={completedOrders}
icon={<CheckCircleIcon className="w-6 h-6 text-emerald-500" />}
/>
<StatsCard
title="Total de Pedidos"
value={orders.length}
icon={<ShoppingBagIcon className="w-6 h-6 text-indigo-500" />}
/>
</div>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div className="relative w-full sm:w-96">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-400" />
<Input
placeholder="Buscar por cliente ou ID do pedido..."
className="pl-10 h-10 border-zinc-200 dark:border-zinc-800"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex gap-2 w-full sm:w-auto">
<button className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-xl text-sm font-bold text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-all">
<FunnelIcon className="w-4 h-4" />
Filtros
</button>
</div>
</div>
<Card noPadding className="overflow-hidden">
<DataTable
selectable
isLoading={loading}
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
columns={columns}
data={filteredOrders}
/>
</Card>
<ConfirmDialog
isOpen={bulkConfirmOpen}
onClose={() => setBulkConfirmOpen(false)}
onConfirm={handleConfirmBulkDelete}
title="Excluir Pedidos Selecionados"
message={`Tem certeza que deseja excluir os ${selectedIds.length} pedidos selecionados? Esta ação não pode ser desfeita.`}
confirmText="Excluir Tudo"
variant="danger"
/>
<ConfirmDialog
isOpen={confirmOpen}
onClose={() => {
setConfirmOpen(false);
setOrderToDelete(null);
}}
onConfirm={handleConfirmDelete}
title="Excluir Pedido"
message="Tem certeza que deseja excluir este pedido? Esta ação não pode ser desfeita."
confirmText="Excluir"
cancelText="Cancelar"
variant="danger"
/>
<BulkActionBar
selectedCount={selectedIds.length}
onClearSelection={() => setSelectedIds([])}
actions={[
{
label: "Excluir Selecionados",
icon: <TrashIcon className="w-5 h-5" />,
onClick: handleBulkDelete,
variant: 'danger'
}
]}
/>
</div>
);
}

View File

@@ -0,0 +1,503 @@
'use client';
import React, { useState, useEffect, Fragment } from 'react';
import {
PlusIcon,
MagnifyingGlassIcon,
FunnelIcon,
Square3Stack3DIcon as PackageIcon,
CurrencyDollarIcon,
ExclamationTriangleIcon,
TrashIcon,
PencilSquareIcon,
XMarkIcon,
CheckIcon,
TagIcon,
} from '@heroicons/react/24/outline';
import { erpApi, Product } from '@/lib/api-erp';
import { formatCurrency } from '@/lib/format';
import { useToast } from '@/components/layout/ToastContext';
import {
PageHeader,
StatsCard,
DataTable,
Input,
Card,
BulkActionBar,
ConfirmDialog,
} from "@/components/ui";
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from '@headlessui/react';
export default function ProductsPage() {
const toast = useToast();
const [products, setProducts] = useState<Product[]>([]);
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 [productToDelete, setProductToDelete] = useState<string | null>(null);
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
const [selectedIds, setSelectedIds] = useState<(string | number)[]>([]);
const [formData, setFormData] = useState<Partial<Product>>({
name: '',
sku: '',
description: '',
price: 0,
cost_price: 0,
type: 'product',
stock_quantity: 0,
is_active: true
});
useEffect(() => {
fetchProducts();
}, []);
const fetchProducts = async (silent = false) => {
try {
if (!silent) setLoading(true);
const data = await erpApi.getProducts();
setProducts(data || []);
} catch (error) {
toast.error('Erro ao carregar', 'Não foi possível carregar os produtos');
} finally {
setLoading(false);
setSelectedIds([]);
}
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingProduct?.id) {
await erpApi.updateProduct(editingProduct.id, formData);
toast.success('Produto atualizado com sucesso!');
} else {
await erpApi.createProduct(formData);
toast.success('Produto cadastrado com sucesso!');
}
setIsModalOpen(false);
setEditingProduct(null);
resetForm();
await fetchProducts(true);
} catch (error) {
toast.error(editingProduct ? 'Erro ao atualizar produto' : 'Erro ao salvar produto');
}
};
const handleDelete = (id: string) => {
setProductToDelete(id);
setConfirmOpen(true);
};
const handleConfirmDelete = async () => {
if (!productToDelete) return;
const originalProducts = [...products];
const idToDelete = String(productToDelete);
// Dynamic: remove instantly
setProducts(prev => prev.filter(p => String(p.id) !== idToDelete));
try {
await erpApi.deleteProduct(idToDelete);
toast.success('Exclusão completa', 'O item foi removido com sucesso.');
setTimeout(() => fetchProducts(true), 500);
} catch (error) {
setProducts(originalProducts);
toast.error('Erro ao excluir', 'Ocorreu um erro ao excluir o produto.');
} finally {
setConfirmOpen(false);
setProductToDelete(null);
}
};
const handleBulkDelete = async () => {
if (selectedIds.length === 0) return;
setBulkConfirmOpen(true);
};
const handleConfirmBulkDelete = async () => {
if (selectedIds.length === 0) return;
const originalProducts = [...products];
const idsToDelete = selectedIds.map(String);
// Dynamic: remove instantly
setProducts(prev => prev.filter(p => !idsToDelete.includes(String(p.id))));
const deletedCount = selectedIds.length;
try {
await Promise.all(idsToDelete.map(id => erpApi.deleteProduct(id)));
toast.success('Exclusão completa', `${deletedCount} produtos excluídos com sucesso.`);
setTimeout(() => fetchProducts(true), 500);
} catch (error) {
setProducts(originalProducts);
toast.error('Erro ao excluir', 'Ocorreu um erro ao excluir alguns produtos.');
} finally {
setBulkConfirmOpen(false);
setSelectedIds([]);
}
};
const handleEdit = (product: Product) => {
setEditingProduct(product);
setFormData({
name: product.name,
sku: product.sku,
description: product.description,
price: Number(product.price),
cost_price: Number(product.cost_price),
type: product.type,
stock_quantity: Number(product.stock_quantity),
is_active: product.is_active
});
setIsModalOpen(true);
};
const resetForm = () => {
setFormData({
name: '',
sku: '',
description: '',
price: 0,
cost_price: 0,
type: 'product',
stock_quantity: 0,
is_active: true
});
};
const filteredProducts = products.filter(p =>
p.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
(p.sku || '').toLowerCase().includes(searchTerm.toLowerCase())
);
const totalStockValue = products.reduce((sum, p) => sum + (Number(p.price) * Number(p.stock_quantity)), 0);
const lowStockItems = products.filter(p => p.type === 'product' && p.stock_quantity < 5).length;
const servicesCount = products.filter(p => p.type === 'service').length;
const columns = [
{
header: 'Produto / SKU',
accessor: (row: Product) => (
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${row.type === 'product' ? 'bg-indigo-50 text-indigo-600' : 'bg-amber-50 text-amber-600'}`}>
{row.type === 'product' ? <PackageIcon className="w-5 h-5" /> : <TagIcon className="w-5 h-5" />}
</div>
<div className="flex flex-col">
<span className="font-bold text-zinc-900 dark:text-white uppercase tracking-tight">{row.name}</span>
<span className="text-xs text-zinc-400 font-black tracking-widest">{row.sku || 'SEM SKU'}</span>
</div>
</div>
)
},
{
header: 'Tipo',
accessor: (row: Product) => (
<span className={`px-2.5 py-0.5 rounded-full text-[10px] font-bold uppercase tracking-wider ${row.type === 'product' ? 'bg-indigo-100 text-indigo-700' : 'bg-amber-100 text-amber-700'}`}>
{row.type === 'product' ? 'Produto' : 'Serviço'}
</span>
)
},
{
header: 'Estoque',
accessor: (row: Product) => (
row.type === 'product' ? (
<div className="flex items-center gap-2">
<span className={`font-black text-sm ${row.stock_quantity < 5 ? 'text-rose-500' : 'text-zinc-900 dark:text-white'}`}>
{row.stock_quantity}
</span>
{row.stock_quantity < 5 && (
<ExclamationTriangleIcon className="w-4 h-4 text-rose-500" />
)}
</div>
) : (
<span className="text-zinc-400 text-xs">N/A</span>
)
)
},
{
header: 'Preço de Venda',
className: 'text-right',
accessor: (row: Product) => (
<span className="font-black text-zinc-900 dark:text-white">
{formatCurrency(row.price)}
</span>
)
},
{
header: '',
className: 'text-right',
accessor: (row: Product) => (
<div className="flex justify-end gap-2">
<button
onClick={(e) => { e.stopPropagation(); handleEdit(row); }}
className="p-2 text-zinc-400 hover:text-brand-600 dark:hover:text-brand-400 transition-all"
>
<PencilSquareIcon className="w-5 h-5" />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete(row.id); }}
className="p-2 text-zinc-400 hover:text-rose-600 dark:hover:text-rose-400 transition-all"
>
<TrashIcon className="w-5 h-5" />
</button>
</div>
)
}
];
return (
<div className="space-y-6">
<PageHeader
title="Produtos & Estoque"
description="Controle seu inventário, gerencie preços e acompanhe a disponibilidade de itens."
primaryAction={{
label: "Novo Item",
icon: <PlusIcon className="w-5 h-5" />,
onClick: () => {
setEditingProduct(null);
resetForm();
setIsModalOpen(true);
}
}}
/>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatsCard
title="Total em Estoque"
value={formatCurrency(totalStockValue)}
icon={<CurrencyDollarIcon className="w-6 h-6 text-emerald-500" />}
/>
<StatsCard
title="Itens com Estoque Baixo"
value={lowStockItems}
icon={<ExclamationTriangleIcon className="w-6 h-6 text-rose-500" />}
/>
<StatsCard
title="Total de Produtos"
value={products.filter(p => p.type === 'product').length}
icon={<PackageIcon className="w-6 h-6 text-indigo-500" />}
/>
<StatsCard
title="Total de Serviços"
value={servicesCount}
icon={<TagIcon className="w-6 h-6 text-amber-500" />}
/>
</div>
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div className="relative w-full sm:w-96">
<MagnifyingGlassIcon className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-zinc-400" />
<Input
placeholder="Buscar por nome ou SKU..."
className="pl-10 h-10 border-zinc-200 dark:border-zinc-800"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex gap-2 w-full sm:w-auto">
<button className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-zinc-900 border border-zinc-200 dark:border-zinc-800 rounded-xl text-sm font-bold text-zinc-600 dark:text-zinc-400 hover:bg-zinc-50 dark:hover:bg-zinc-800 transition-all">
<FunnelIcon className="w-4 h-4" />
Filtros
</button>
</div>
</div>
<Card noPadding className="overflow-hidden">
<DataTable
selectable
selectedIds={selectedIds}
onSelectionChange={setSelectedIds}
columns={columns}
data={filteredProducts}
isLoading={loading}
/>
</Card>
<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 text-center">
<TransitionChild
as={Fragment}
enter="ease-out duration-300"
enterFrom="opacity-0 scale-95 translate-y-4"
enterTo="opacity-100 scale-100 translate-y-0"
leave="ease-in duration-200"
leaveFrom="opacity-100 scale-100 translate-y-0"
leaveTo="opacity-0 scale-95 translate-y-4"
>
<DialogPanel className="w-full max-w-2xl transform overflow-hidden rounded-[32px] bg-white dark:bg-zinc-900 p-8 text-left align-middle shadow-2xl transition-all border border-gray-100 dark:border-zinc-800">
<div className="flex justify-between items-center mb-8">
<DialogTitle as="h3" className="text-xl font-bold text-zinc-900 dark:text-white">
{editingProduct ? 'Editar Item' : 'Novo Produto/Serviço'}
</DialogTitle>
<button
onClick={() => setIsModalOpen(false)}
className="p-2 hover:bg-zinc-100 dark:hover:bg-zinc-800 rounded-full transition-all"
>
<XMarkIcon className="w-6 h-6 text-zinc-400" />
</button>
</div>
<form onSubmit={handleSave} className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="md:col-span-2">
<div className="flex gap-4">
<button
type="button"
onClick={() => setFormData({ ...formData, type: 'product' })}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-2xl border transition-all font-bold text-sm ${formData.type === 'product' ? 'border-indigo-500 bg-indigo-50 text-indigo-600' : 'border-zinc-200 dark:border-zinc-700 text-zinc-400'}`}
>
<PackageIcon className="w-5 h-5" />
Produto
</button>
<button
type="button"
onClick={() => setFormData({ ...formData, type: 'service' })}
className={`flex-1 flex items-center justify-center gap-2 py-3 rounded-2xl border transition-all font-bold text-sm ${formData.type === 'service' ? 'border-amber-500 bg-amber-50 text-amber-600' : 'border-zinc-200 dark:border-zinc-700 text-zinc-400'}`}
>
<TagIcon className="w-5 h-5" />
Serviço
</button>
</div>
</div>
<div className="md:col-span-2">
<Input
label="Nome do Item"
required
placeholder="Ex: Teclado Mecânico RGB"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="bg-zinc-50 dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700"
/>
</div>
<Input
label="SKU / Código"
placeholder="Ex: PROD-001"
value={formData.sku}
onChange={(e) => setFormData({ ...formData, sku: e.target.value })}
className="bg-zinc-50 dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700"
/>
<Input
label="Estoque Inicial"
type="number"
disabled={formData.type === 'service'}
placeholder="0"
value={formData.stock_quantity}
onChange={(e) => setFormData({ ...formData, stock_quantity: Number(e.target.value) })}
className="bg-zinc-50 dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700"
/>
<Input
label="Preço de Venda"
type="number"
step="0.01"
required
placeholder="0,00"
value={formData.price}
onChange={(e) => setFormData({ ...formData, price: Number(e.target.value) })}
className="bg-zinc-50 dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700"
/>
<Input
label="Preço de Custo"
type="number"
step="0.01"
placeholder="0,00"
value={formData.cost_price}
onChange={(e) => setFormData({ ...formData, cost_price: Number(e.target.value) })}
className="bg-zinc-50 dark:bg-zinc-800 border-zinc-200 dark:border-zinc-700"
/>
<div className="md:col-span-2">
<label className="block text-xs font-black text-zinc-400 uppercase tracking-widest mb-2">Descrição</label>
<textarea
className="w-full px-4 py-3 bg-zinc-50 dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-700 rounded-2xl focus:ring-2 focus:ring-brand-500/20 outline-none transition-all placeholder:text-zinc-400 text-sm h-32 resize-none"
placeholder="Detalhes sobre o produto ou serviço..."
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
/>
</div>
<div className="md:col-span-2 pt-6 flex justify-end gap-3">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="px-6 py-3 text-zinc-500 hover:text-zinc-700 dark:text-zinc-400 font-bold transition-all"
>
Cancelar
</button>
<button
type="submit"
className="px-8 py-3 text-white rounded-2xl font-bold shadow-lg hover:opacity-90 transition-all flex items-center gap-2"
style={{ background: 'var(--gradient)' }}
>
<CheckIcon className="w-5 h-5" />
Salvar Item
</button>
</div>
</form>
</DialogPanel>
</TransitionChild>
</div>
</div>
</Dialog>
</Transition>
<ConfirmDialog
isOpen={bulkConfirmOpen}
onClose={() => setBulkConfirmOpen(false)}
onConfirm={handleConfirmBulkDelete}
title="Excluir Produtos Selecionados"
message={`Tem certeza que deseja excluir os ${selectedIds.length} produtos selecionados? Esta ação não pode ser desfeita.`}
confirmText="Excluir Tudo"
variant="danger"
/>
<ConfirmDialog
isOpen={confirmOpen}
onClose={() => {
setConfirmOpen(false);
setProductToDelete(null);
}}
onConfirm={handleConfirmDelete}
title="Excluir Item"
message="Tem certeza que deseja excluir este produto ou serviço? Esta ação não pode ser desfeita."
confirmText="Excluir"
cancelText="Cancelar"
variant="danger"
/>
<BulkActionBar
selectedCount={selectedIds.length}
onClearSelection={() => setSelectedIds([])}
actions={[
{
label: "Excluir Selecionados",
icon: <TrashIcon className="w-5 h-5" />,
onClick: handleBulkDelete,
variant: 'danger'
}
]}
/>
</div>
);
}

View File

@@ -0,0 +1,12 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
import FinanceContent from '@/components/erp/FinanceContent';
export default function CaixaPage() {
return (
<SolutionGuard requiredSolution="erp">
<FinanceContent />
</SolutionGuard>
);
}

View File

@@ -0,0 +1,23 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
import { AdjustmentsHorizontalIcon } from "@heroicons/react/24/outline";
import { PageHeader } from "@/components/ui";
export default function ConfiguracoesPage() {
return (
<SolutionGuard requiredSolution="erp">
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
<PageHeader
title="Configurações do ERP"
description="Personalize as categorias financeiras, contas e parâmetros do sistema."
/>
<div className="flex flex-col items-center justify-center py-20 bg-white dark:bg-zinc-900 rounded-[32px] border border-zinc-200 dark:border-zinc-800">
<AdjustmentsHorizontalIcon className="w-16 h-16 text-zinc-300 mb-4" />
<h3 className="text-xl font-bold text-zinc-900 dark:text-white">Módulo em Desenvolvimento</h3>
<p className="text-zinc-500 max-w-sm text-center mt-2">Em breve você poderá configurar suas categorias, contas bancárias e fluxos operacionais aqui.</p>
</div>
</div>
</SolutionGuard>
);
}

View File

@@ -0,0 +1,358 @@
'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>
);
}

View File

@@ -0,0 +1,14 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
import ProductsPage from '../ProductsPage';
export default function EstoquePage() {
return (
<SolutionGuard requiredSolution="erp">
<div className="p-6 max-w-[1600px] mx-auto">
<ProductsPage />
</div>
</SolutionGuard>
);
}

View File

@@ -0,0 +1,12 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
import FinanceContent from '@/components/erp/FinanceContent';
export default function ContasPagarPage() {
return (
<SolutionGuard requiredSolution="erp">
<FinanceContent type="pagar" />
</SolutionGuard>
);
}

View File

@@ -1,16 +1,315 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
AreaChart, Area, PieChart, Pie, Cell, ResponsiveContainer, CartesianGrid, XAxis, YAxis, Tooltip, Legend
} from 'recharts';
import {
ArrowTrendingUpIcon,
ArrowTrendingDownIcon,
CubeIcon,
CurrencyDollarIcon,
CreditCardIcon,
ClockIcon,
} from "@heroicons/react/24/outline";
import { erpApi, FinancialTransaction, Order, FinancialCategory, Entity } from '@/lib/api-erp';
import { formatCurrency } from '@/lib/format';
import { PageHeader, StatsCard, Card } from "@/components/ui";
import { SolutionGuard } from '@/components/auth/SolutionGuard';
const COLORS = ['#8b5cf6', '#ec4899', '#f43f5e', '#f59e0b', '#10b981', '#3b82f6'];
function ERPDashboardContent() {
const [transactions, setTransactions] = useState<FinancialTransaction[]>([]);
const [orders, setOrders] = useState<Order[]>([]);
const [categories, setCategories] = useState<FinancialCategory[]>([]);
const [entities, setEntities] = useState<Entity[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
const fetchData = async () => {
try {
const [txData, orderData, categoriesData, entitiesData] = await Promise.all([
erpApi.getTransactions(),
erpApi.getOrders(),
erpApi.getFinancialCategories(),
erpApi.getEntities()
]);
setTransactions(Array.isArray(txData) ? txData : []);
setOrders(Array.isArray(orderData) ? orderData : []);
setCategories(Array.isArray(categoriesData) ? categoriesData : []);
setEntities(Array.isArray(entitiesData) ? entitiesData : []);
} catch (error) {
console.error('Error fetching dashboard data:', error);
} finally {
setLoading(false);
}
};
fetchData();
}, []);
const paidTransactions = (transactions || []).filter(t => t.status === 'paid');
const totalIncome = paidTransactions
.filter(t => t.type === 'income')
.reduce((sum, t) => sum + Number(t.amount || 0), 0);
const totalExpense = paidTransactions
.filter(t => t.type === 'expense')
.reduce((sum, t) => sum + Number(t.amount || 0), 0);
const pendingIncome = (transactions || [])
.filter(t => t.type === 'income' && t.status === 'pending')
.reduce((sum, t) => sum + Number(t.amount || 0), 0);
const pendingExpense = (transactions || [])
.filter(t => t.type === 'expense' && t.status === 'pending')
.reduce((sum, t) => sum + Number(t.amount || 0), 0);
const balance = totalIncome - totalExpense;
// Process chart data (Income vs Expense by Month)
const getChartData = () => {
const months = ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'];
const currentYear = new Date().getFullYear();
const data = months.map((month, index) => {
const monthTransactions = paidTransactions.filter(t => {
const date = new Date(t.payment_date || t.due_date || '');
return date.getMonth() === index && date.getFullYear() === currentYear;
});
const income = monthTransactions
.filter(t => t.type === 'income')
.reduce((sum, t) => sum + Number(t.amount || 0), 0);
const expense = monthTransactions
.filter(t => t.type === 'expense')
.reduce((sum, t) => sum + Number(t.amount || 0), 0);
return { name: month, income, expense };
});
const currentMonthIndex = new Date().getMonth();
// Mostrar pelo menos os últimos 6 meses ou o ano todo se for o caso
return data.slice(Math.max(0, currentMonthIndex - 5), currentMonthIndex + 1);
};
// Process category data (Expenses by Category)
const getCategoryData = () => {
const expenseTransactions = paidTransactions.filter(t => t.type === 'expense');
const breakdown: Record<string, number> = {};
expenseTransactions.forEach(t => {
const category = categories.find(c => c.id === t.category_id)?.name || 'Outros';
breakdown[category] = (breakdown[category] || 0) + Number(t.amount || 0);
});
return Object.entries(breakdown)
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value)
.slice(0, 6);
};
const chartData = getChartData();
const categoryData = getCategoryData();
if (loading) return (
<div className="p-6 max-w-[1600px] mx-auto">
<div className="flex items-center justify-center h-[600px]">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-brand-500"></div>
</div>
</div>
);
return (
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
<PageHeader
title="Dashboard ERP"
description="Visão geral financeira e operacional em tempo real"
/>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
<StatsCard
title="Receitas pagas"
value={formatCurrency(totalIncome)}
icon={<ArrowTrendingUpIcon className="w-6 h-6 text-emerald-500" />}
trend={{ value: formatCurrency(pendingIncome), label: 'pendente', type: 'up' }}
/>
<StatsCard
title="Despesas pagas"
value={formatCurrency(totalExpense)}
icon={<ArrowTrendingDownIcon className="w-6 h-6 text-rose-500" />}
trend={{ value: formatCurrency(pendingExpense), label: 'pendente', type: 'down' }}
/>
<StatsCard
title="Saldo em Caixa"
value={formatCurrency(balance)}
icon={<CurrencyDollarIcon className="w-6 h-6 text-brand-500" />}
/>
<StatsCard
title="Pedidos (Mês)"
value={(orders?.length || 0).toString()}
icon={<CubeIcon className="w-6 h-6 text-purple-500" />}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<Card title="Evolução Financeira" description="Diferença entre entradas e saídas pagas nos últimos meses.">
<div className="h-[350px] w-full mt-4">
{chartData.some(d => d.income > 0 || d.expense > 0) ? (
<ResponsiveContainer width="100%" height="100%">
<AreaChart data={chartData}>
<defs>
<linearGradient id="colorIncome" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#10b981" stopOpacity={0.1} />
<stop offset="95%" stopColor="#10b981" stopOpacity={0} />
</linearGradient>
<linearGradient id="colorExpense" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#ef4444" stopOpacity={0.1} />
<stop offset="95%" stopColor="#ef4444" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#88888820" />
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: '#888' }} />
<YAxis axisLine={false} tickLine={false} tick={{ fontSize: 12, fill: '#888' }} tickFormatter={(val) => `R$${val}`} />
<Tooltip
contentStyle={{
borderRadius: '16px',
border: 'none',
boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)',
backgroundColor: 'rgba(255, 255, 255, 0.9)'
}}
formatter={(value: any) => formatCurrency(value || 0)}
/>
<Legend verticalAlign="top" height={36} />
<Area
name="Receitas"
type="monotone"
dataKey="income"
stroke="#10b981"
fillOpacity={1}
fill="url(#colorIncome)"
strokeWidth={3}
/>
<Area
name="Despesas"
type="monotone"
dataKey="expense"
stroke="#ef4444"
fillOpacity={1}
fill="url(#colorExpense)"
strokeWidth={3}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<div className="flex items-center justify-center h-full text-zinc-400 text-sm italic">
Ainda não dados financeiros suficientes para exibir o gráfico.
</div>
)}
</div>
</Card>
</div>
<div className="lg:col-span-1">
<Card title="Despesas por Categoria" description="Distribuição dos gastos pagos.">
<div className="h-[350px] w-full mt-4">
{categoryData.length > 0 ? (
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={categoryData}
cx="50%"
cy="50%"
innerRadius={60}
outerRadius={80}
paddingAngle={5}
dataKey="value"
>
{categoryData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip
contentStyle={{
borderRadius: '16px',
border: 'none',
boxShadow: '0 10px 15px -3px rgb(0 0 0 / 0.1)'
}}
formatter={(value: any) => formatCurrency(value || 0)}
/>
<Legend />
</PieChart>
</ResponsiveContainer>
) : (
<div className="flex items-center justify-center h-full text-zinc-400 text-sm italic">
Ainda não despesas pagas registradas.
</div>
)}
</div>
</Card>
</div>
<div className="lg:col-span-3">
<Card title="Transações Recentes" description="Últimos lançamentos financeiros registrados no sistema.">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-zinc-100 dark:border-zinc-800">
<th className="py-4 font-semibold text-zinc-900 dark:text-white">Descrição</th>
<th className="py-4 font-semibold text-zinc-900 dark:text-white">Categoria</th>
<th className="py-4 font-semibold text-zinc-900 dark:text-white">Data</th>
<th className="py-4 font-semibold text-zinc-900 dark:text-white text-right">Valor</th>
<th className="py-4 font-semibold text-zinc-900 dark:text-white text-right">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-50 dark:divide-zinc-900">
{transactions.slice(0, 5).map((t) => (
<tr key={t.id} className="hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-colors">
<td className="py-4 text-zinc-600 dark:text-zinc-400">{t.description}</td>
<td className="py-4 text-zinc-600 dark:text-zinc-400">
{categories.find(c => c.id === t.category_id)?.name || 'Outros'}
</td>
<td className="py-4 text-zinc-600 dark:text-zinc-400">
{new Date(t.payment_date || t.due_date || '').toLocaleDateString('pt-BR')}
</td>
<td className={`py-4 text-right font-medium ${t.type === 'income' ? 'text-emerald-600' : 'text-rose-600'}`}>
{t.type === 'income' ? '+' : '-'} {formatCurrency(t.amount)}
</td>
<td className="py-4 text-right">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${t.status === 'paid' ? 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/20 dark:text-emerald-400' :
t.status === 'pending' ? 'bg-amber-100 text-amber-700 dark:bg-amber-900/20 dark:text-amber-400' :
'bg-zinc-100 text-zinc-700 dark:bg-zinc-900/20 dark:text-zinc-400'
}`}>
{t.status === 'paid' ? 'Pago' : t.status === 'pending' ? 'Pendente' : 'Cancelado'}
</span>
</td>
</tr>
))}
{transactions.length === 0 && (
<tr>
<td colSpan={5} className="py-8 text-center text-zinc-400 italic">
Nenhuma transação encontrada.
</td>
</tr>
)}
</tbody>
</table>
</div>
</Card>
</div>
</div>
</div>
);
}
export default function ERPPage() {
return (
<SolutionGuard requiredSolution="erp">
<div className="p-6">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-4">ERP</h1>
<div className="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-800 p-8 text-center">
<p className="text-gray-500">Sistema Integrado de Gestão Empresarial em breve</p>
</div>
</div>
<ERPDashboardContent />
</SolutionGuard>
);
}

View File

@@ -0,0 +1,14 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
import OrdersPage from '../OrdersPage';
export default function PedidosPage() {
return (
<SolutionGuard requiredSolution="erp">
<div className="p-6 max-w-[1600px] mx-auto">
<OrdersPage />
</div>
</SolutionGuard>
);
}

View File

@@ -0,0 +1,12 @@
'use client';
import { SolutionGuard } from '@/components/auth/SolutionGuard';
import FinanceContent from '@/components/erp/FinanceContent';
export default function ContasReceberPage() {
return (
<SolutionGuard requiredSolution="erp">
<FinanceContent type="receber" />
</SolutionGuard>
);
}

View File

@@ -0,0 +1,198 @@
"use client";
import { useState } from 'react';
import {
CalendarIcon,
MagnifyingGlassIcon,
PlusIcon,
FunnelIcon,
ArrowPathIcon,
EllipsisVerticalIcon
} from "@heroicons/react/24/outline";
import { Button, Input, Select, PageHeader, Card, StatsCard, Tabs, DatePicker, CustomSelect } from "@/components/ui";
import {
UsersIcon,
CurrencyDollarIcon,
BriefcaseIcon as BriefcaseSolidIcon,
ArrowTrendingUpIcon,
TableCellsIcon,
ChartPieIcon,
Cog6ToothIcon as CogIcon
} from "@heroicons/react/24/outline";
export default function TestPage() {
const [searchTerm, setSearchTerm] = useState('');
const [dateRange, setDateRange] = useState<{ start: Date | null; end: Date | null }>({ start: null, end: null });
const [status, setStatus] = useState('all');
// Dados fictícios para a lista
const items = [
{ id: 1, name: 'Projeto Alpha', client: 'Empresa A', date: '2023-10-01', status: 'Ativo', amount: 'R$ 1.500,00' },
{ id: 2, name: 'Serviço Beta', client: 'Empresa B', date: '2023-10-05', status: 'Pendente', amount: 'R$ 2.300,00' },
{ id: 3, name: 'Consultoria Gamma', client: 'Empresa C', date: '2023-10-10', status: 'Concluído', amount: 'R$ 800,00' },
{ id: 4, name: 'Design Delta', client: 'Empresa D', date: '2023-10-12', status: 'Ativo', amount: 'R$ 4.200,00' },
];
return (
<div className="p-6 max-w-[1600px] mx-auto space-y-6">
<PageHeader
title="Página de Teste"
description="Área de desenvolvimento e homologação de novos componentes do padrão Aggios."
primaryAction={{
label: "Novo Item",
icon: <PlusIcon className="w-4 h-4" />,
onClick: () => console.log('Novo Item')
}}
/>
{/* Stats Grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
<StatsCard
title="Total de Clientes"
value="1.240"
icon={<UsersIcon className="w-6 h-6" />}
trend={{ value: '12%', label: 'vs mês passado', type: 'up' }}
/>
<StatsCard
title="Receita Mensal"
value="R$ 45.200"
icon={<CurrencyDollarIcon className="w-6 h-6" />}
trend={{ value: '8.4%', label: 'vs mês passado', type: 'up' }}
/>
<StatsCard
title="Projetos Ativos"
value="42"
icon={<BriefcaseSolidIcon className="w-6 h-6" />}
trend={{ value: '2', label: 'novos esta semana', type: 'neutral' }}
/>
<StatsCard
title="Taxa de Conversão"
value="18.5%"
icon={<ArrowTrendingUpIcon className="w-6 h-6" />}
trend={{ value: '2.1%', label: 'vs mês passado', type: 'down' }}
/>
</div>
{/* Filters Area: Clean Visual (Solid contrast) */}
<div className="flex flex-col md:flex-row gap-4 items-center">
<div className="flex-1 w-full">
<Input
placeholder="Pesquisar registros..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
leftIcon={<MagnifyingGlassIcon className="w-5 h-5 text-zinc-400" />}
className="bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 focus:border-zinc-400 dark:focus:border-zinc-500"
/>
</div>
<div className="w-full md:w-80">
<DatePicker
value={dateRange}
onChange={setDateRange}
buttonClassName="bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 text-zinc-700 dark:text-zinc-300 hover:border-zinc-400"
/>
</div>
<div className="w-full md:w-56">
<CustomSelect
value={status}
onChange={setStatus}
options={[
{ label: 'Todos os Status', value: 'all' },
{ label: 'Ativo', value: 'active', color: 'bg-emerald-500' },
{ label: 'Pendente', value: 'pending', color: 'bg-amber-500' },
{ label: 'Concluído', value: 'done', color: 'bg-blue-500' },
]}
buttonClassName="bg-white dark:bg-zinc-900 border-zinc-200 dark:border-zinc-800 hover:border-zinc-400"
/>
</div>
</div>
{/* Content Tabs */}
<Tabs
items={[
{
label: 'Visão Geral',
icon: <TableCellsIcon />,
content: (
<Card noPadding title="Itens Recentes" description="Lista de últimos itens cadastrados no sistema.">
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr className="bg-zinc-50/50 dark:bg-zinc-800/50 border-b border-zinc-200 dark:border-zinc-800 text-left">
<th className="px-6 py-4 text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Item</th>
<th className="px-6 py-4 text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Cliente</th>
<th className="px-6 py-4 text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Data</th>
<th className="px-6 py-4 text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider">Valor</th>
<th className="px-6 py-4 text-xs font-semibold text-zinc-500 dark:text-zinc-400 uppercase tracking-wider text-right">Ações</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-100 dark:divide-zinc-800">
{items.map((item) => (
<tr key={item.id} className="hover:bg-zinc-50 dark:hover:bg-zinc-800/50 transition-colors group">
<td className="px-6 py-4">
<div className="font-medium text-zinc-900 dark:text-white">{item.name}</div>
<div className="text-xs text-zinc-500">ID: #{item.id}</div>
</td>
<td className="px-6 py-4 text-sm text-zinc-600 dark:text-zinc-300">{item.client}</td>
<td className="px-6 py-4 text-sm text-zinc-600 dark:text-zinc-300">
<div className="flex items-center gap-2">
<CalendarIcon className="w-4 h-4 text-zinc-400" />
{new Date(item.date).toLocaleDateString('pt-BR')}
</div>
</td>
<td className="px-6 py-4 text-sm font-semibold text-zinc-900 dark:text-white">{item.amount}</td>
<td className="px-6 py-4 text-right">
<button className="p-2 rounded-lg hover:bg-zinc-100 dark:hover:bg-zinc-800 text-zinc-400 hover:text-zinc-600 transition-colors">
<EllipsisVerticalIcon className="w-5 h-5" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="p-4 bg-zinc-50/30 dark:bg-zinc-900/30 border-t border-zinc-200 dark:border-zinc-800 flex items-center justify-between">
<span className="text-xs text-zinc-500 italic">Exibindo {items.length} resultados encontrados.</span>
<div className="flex gap-2">
<Button variant="outline" size="sm">Anterior</Button>
<Button variant="outline" size="sm">Próximo</Button>
</div>
</div>
</Card>
)
},
{
label: 'Relatórios',
icon: <ChartPieIcon />,
content: (
<Card title="Analytics" description="Visualize o desempenho dos seus itens em tempo real.">
<div className="flex items-center justify-center h-48 border-2 border-dashed border-zinc-200 dark:border-zinc-800 rounded-xl">
<p className="text-zinc-400 text-sm font-medium">Gráficos e métricas detalhadas serão exibidos aqui.</p>
</div>
</Card>
)
},
{
label: 'Configurações',
icon: <CogIcon />,
content: (
<Card title="Preferências" description="Ajuste as configurações deste módulo de teste.">
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-zinc-50 dark:bg-zinc-800/50 rounded-xl">
<div>
<p className="text-sm font-bold text-zinc-900 dark:text-white">Notificações por E-mail</p>
<p className="text-xs text-zinc-500">Receba alertas automáticos sobre novos itens.</p>
</div>
<div className="w-10 h-6 bg-brand-500 rounded-full relative">
<div className="absolute right-1 top-1 w-4 h-4 bg-white rounded-full"></div>
</div>
</div>
</div>
</Card>
)
}
]}
/>
</div>
);
}

View File

@@ -9,6 +9,7 @@ export async function GET(req: NextRequest, { params }: { params: Promise<{ path
try {
const response = await fetch(`http://backend:8080/api/${path}${req.nextUrl.search}`, {
method: "GET",
cache: 'no-store',
headers: {
"Authorization": token || "",
"Content-Type": "application/json",
@@ -77,4 +78,33 @@ export async function POST(req: NextRequest, { params }: { params: Promise<{ pat
console.error("API proxy error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}
export async function DELETE(req: NextRequest, { params }: { params: Promise<{ path: string[] }> }) {
const { path: pathArray } = await params;
const path = pathArray?.join("/") || "";
const token = req.headers.get("authorization");
const host = req.headers.get("host");
try {
const response = await fetch(`http://backend:8080/api/${path}${req.nextUrl.search}`, {
method: "DELETE",
headers: {
"Authorization": token || "",
"Content-Type": "application/json",
"X-Forwarded-Host": host || "",
"X-Original-Host": host || "",
},
});
if (response.status === 204) {
return new NextResponse(null, { status: 204 });
}
const data = await response.json().catch(() => ({}));
return NextResponse.json(data, { status: response.status });
} catch (error) {
console.error("API proxy error:", error);
return NextResponse.json({ error: "Internal Server Error" }, { status: 500 });
}
}

View File

@@ -19,6 +19,7 @@ export async function GET(
}
const response = await fetch(`${API_URL}/api/crm/customers/${id}`, {
cache: 'no-store',
headers: {
'Authorization': token,
'X-Tenant-Subdomain': subdomain,

View File

@@ -10,6 +10,7 @@ export async function GET(request: NextRequest) {
console.log('[API Route] GET /api/crm/customers - subdomain:', subdomain);
const response = await fetch(`${API_URL}/api/crm/customers`, {
cache: 'no-store',
headers: {
'Authorization': token,
'X-Tenant-Subdomain': subdomain,