316 lines
17 KiB
TypeScript
316 lines
17 KiB
TypeScript
'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">
|
|
<ERPDashboardContent />
|
|
</SolutionGuard>
|
|
);
|
|
}
|
|
|