fix(erp): enable erp pages and menu items
This commit is contained in:
@@ -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));
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
211
front-end-agency/app/(agency)/erp/ERPSettingsPage.tsx
Normal file
211
front-end-agency/app/(agency)/erp/ERPSettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
309
front-end-agency/app/(agency)/erp/OrdersPage.tsx
Normal file
309
front-end-agency/app/(agency)/erp/OrdersPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
503
front-end-agency/app/(agency)/erp/ProductsPage.tsx
Normal file
503
front-end-agency/app/(agency)/erp/ProductsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
front-end-agency/app/(agency)/erp/caixa/page.tsx
Normal file
12
front-end-agency/app/(agency)/erp/caixa/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
23
front-end-agency/app/(agency)/erp/configuracoes/page.tsx
Normal file
23
front-end-agency/app/(agency)/erp/configuracoes/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
358
front-end-agency/app/(agency)/erp/entidades/page.tsx
Normal file
358
front-end-agency/app/(agency)/erp/entidades/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
front-end-agency/app/(agency)/erp/estoque/page.tsx
Normal file
14
front-end-agency/app/(agency)/erp/estoque/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
front-end-agency/app/(agency)/erp/pagar/page.tsx
Normal file
12
front-end-agency/app/(agency)/erp/pagar/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 há 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 há 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
14
front-end-agency/app/(agency)/erp/pedidos/page.tsx
Normal file
14
front-end-agency/app/(agency)/erp/pedidos/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
12
front-end-agency/app/(agency)/erp/receber/page.tsx
Normal file
12
front-end-agency/app/(agency)/erp/receber/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
198
front-end-agency/app/(agency)/teste/page.tsx
Normal file
198
front-end-agency/app/(agency)/teste/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user