From 12e1552d02578812ac0ebc4fa320916c9a55fe24 Mon Sep 17 00:00:00 2001 From: Emanuel Almeida Date: Sat, 7 Feb 2026 22:50:12 +0000 Subject: [PATCH] feat: add financial panel, compact services list, add Syncthing - New /financial page with sales/expenses cards, monthly bar chart and expense distribution pie chart (Recharts) - New API endpoint GET /api/financial with queries on tblinvoices and tblexpenses - Compact services grid (2-col dots layout) in Monitor page - Add Syncthing to critical services monitoring - Add Financeiro nav link to Dashboard, Monitor and Financial headers Co-Authored-By: Claude Opus 4.6 --- api/routes/financial.ts | 22 +++ api/server.ts | 2 + api/services/financial.ts | 113 ++++++++++++++ src/App.tsx | 8 + src/main.tsx | 2 + src/pages/Financial.tsx | 309 ++++++++++++++++++++++++++++++++++++++ src/pages/Monitor.tsx | 27 +++- 7 files changed, 478 insertions(+), 5 deletions(-) create mode 100644 api/routes/financial.ts create mode 100644 api/services/financial.ts create mode 100644 src/pages/Financial.tsx diff --git a/api/routes/financial.ts b/api/routes/financial.ts new file mode 100644 index 0000000..6932fd3 --- /dev/null +++ b/api/routes/financial.ts @@ -0,0 +1,22 @@ +/** + * Financial API Route + * GET /api/financial + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import { Router } from 'express' +import type { Request, Response } from 'express' +import { getFinancialData } from '../services/financial.js' + +const router = Router() + +router.get('/', async (_req: Request, res: Response) => { + try { + const data = await getFinancialData() + res.json(data) + } catch (error) { + console.error('Financial API error:', error) + res.status(500).json({ error: 'Internal server error' }) + } +}) + +export default router diff --git a/api/server.ts b/api/server.ts index 3b73c06..ee48fb2 100644 --- a/api/server.ts +++ b/api/server.ts @@ -13,6 +13,7 @@ import diagnosticRouter from './routes/diagnostic.js' import hetznerRouter from './routes/hetzner.js' import wpMonitorRouter from './routes/wp-monitor.js' import serverMetricsRouter from './routes/server-metrics.js' +import financialRouter from './routes/financial.js' import { collectAllServerMetrics } from './services/server-metrics.js' const __filename = fileURLToPath(import.meta.url) @@ -41,6 +42,7 @@ app.use('/api/diagnostic', diagnosticRouter) app.use('/api/hetzner', hetznerRouter) app.use('/api/wp-monitor', wpMonitorRouter) app.use('/api/server-metrics', serverMetricsRouter) +app.use('/api/financial', financialRouter) // Serve static files in production if (isProduction) { diff --git a/api/services/financial.ts b/api/services/financial.ts new file mode 100644 index 0000000..36a437a --- /dev/null +++ b/api/services/financial.ts @@ -0,0 +1,113 @@ +/** + * Financial Queries Service + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import db from '../db.js' +import type { RowDataPacket } from 'mysql2' + +export async function getFinancialData() { + const [ + [vendasMes], + [vendasAno], + [despesasMes], + [despesasAno], + categorias, + evolucaoReceitas, + evolucaoDespesas, + ] = await Promise.all([ + // Vendas mes actual (facturas nao-draft, nao-canceladas) + db.query(` + SELECT COALESCE(SUM(total), 0) as valor + FROM tblinvoices + WHERE YEAR(date) = YEAR(CURDATE()) AND MONTH(date) = MONTH(CURDATE()) + AND status NOT IN (1, 5) + `), + + // Vendas acumuladas ano + db.query(` + SELECT COALESCE(SUM(total), 0) as valor + FROM tblinvoices + WHERE YEAR(date) = YEAR(CURDATE()) AND status NOT IN (1, 5) + `), + + // Despesas mes actual + db.query(` + SELECT COALESCE(SUM(amount), 0) as valor + FROM tblexpenses + WHERE YEAR(date) = YEAR(CURDATE()) AND MONTH(date) = MONTH(CURDATE()) + `), + + // Despesas acumuladas ano + db.query(` + SELECT COALESCE(SUM(amount), 0) as valor + FROM tblexpenses + WHERE YEAR(date) = YEAR(CURDATE()) + `), + + // Distribuicao por categoria + db.query(` + SELECT ec.name as categoria, ROUND(SUM(e.amount), 2) as total + FROM tblexpenses e + LEFT JOIN tblexpenses_categories ec ON e.category = ec.id + WHERE YEAR(e.date) = YEAR(CURDATE()) + GROUP BY e.category, ec.name + ORDER BY total DESC + `), + + // Evolucao mensal receitas (ultimos 12 meses) + db.query(` + SELECT DATE_FORMAT(date, '%Y-%m') as mes, ROUND(SUM(total), 2) as valor + FROM tblinvoices + WHERE status NOT IN (1, 5) AND date >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH) + GROUP BY mes ORDER BY mes + `), + + // Evolucao mensal despesas (ultimos 12 meses) + db.query(` + SELECT DATE_FORMAT(date, '%Y-%m') as mes, ROUND(SUM(amount), 2) as valor + FROM tblexpenses + WHERE date >= DATE_SUB(CURDATE(), INTERVAL 12 MONTH) + GROUP BY mes ORDER BY mes + `), + ]) + + const vendas_mes = Math.round((vendasMes[0]?.valor || 0) * 100) / 100 + const vendas_ano = Math.round((vendasAno[0]?.valor || 0) * 100) / 100 + const despesas_mes = Math.round((despesasMes[0]?.valor || 0) * 100) / 100 + const despesas_ano = Math.round((despesasAno[0]?.valor || 0) * 100) / 100 + const lucro_mes = Math.round((vendas_mes - despesas_mes) * 100) / 100 + const lucro_ano = Math.round((vendas_ano - despesas_ano) * 100) / 100 + + // Build monthly evolution map + const mesesMap = new Map() + for (const r of evolucaoReceitas[0] as RowDataPacket[]) { + mesesMap.set(r.mes, { receita: r.valor, despesa: 0 }) + } + for (const d of evolucaoDespesas[0] as RowDataPacket[]) { + const existing = mesesMap.get(d.mes) || { receita: 0, despesa: 0 } + existing.despesa = d.valor + mesesMap.set(d.mes, existing) + } + + const evolucao = Array.from(mesesMap.entries()) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([mes, vals]) => ({ + mes, + receita: vals.receita, + despesa: vals.despesa, + })) + + return { + vendas_mes, + vendas_ano, + despesas_mes, + despesas_ano, + lucro_mes, + lucro_ano, + categorias: (categorias[0] as RowDataPacket[]).map(c => ({ + name: c.categoria || 'Sem categoria', + value: c.total, + })), + evolucao, + } +} diff --git a/src/App.tsx b/src/App.tsx index 9b751ef..b59d436 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -501,6 +501,10 @@ function App() { Monitor + + + Financeiro + )} diff --git a/src/main.tsx b/src/main.tsx index 0408f1b..5e47dfb 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,6 +5,7 @@ import { AuthProvider } from 'react-oidc-context' import './index.css' import App from './App.tsx' import Monitor from './pages/Monitor.tsx' +import Financial from './pages/Financial.tsx' import { oidcConfig } from './auth/config.ts' import { AuthWrapper } from './auth/AuthWrapper.tsx' @@ -16,6 +17,7 @@ createRoot(document.getElementById('root')!).render( } /> } /> + } /> } /> diff --git a/src/pages/Financial.tsx b/src/pages/Financial.tsx new file mode 100644 index 0000000..fdd846f --- /dev/null +++ b/src/pages/Financial.tsx @@ -0,0 +1,309 @@ +import { useState, useEffect, useCallback } from 'react' +import { motion } from 'framer-motion' +import { Link } from 'react-router-dom' +import { + BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend, + PieChart, Pie, Cell, +} from 'recharts' +import { + Zap, + RefreshCw, + ArrowLeft, + TrendingUp, + TrendingDown, + DollarSign, + Receipt, + PiggyBank, + Activity, + LayoutDashboard, +} from 'lucide-react' + +interface FinancialData { + vendas_mes: number + vendas_ano: number + despesas_mes: number + despesas_ano: number + lucro_mes: number + lucro_ano: number + categorias: { name: string; value: number }[] + evolucao: { mes: string; receita: number; despesa: number }[] +} + +const containerVariants = { + hidden: { opacity: 0 }, + show: { opacity: 1, transition: { staggerChildren: 0.05 } } +} + +const itemVariants = { + hidden: { opacity: 0, y: 20 }, + show: { opacity: 1, y: 0 } +} + +const PIE_COLORS = [ + '#10b981', '#8b5cf6', '#f59e0b', '#3b82f6', '#ef4444', + '#06b6d4', '#ec4899', '#84cc16', '#f97316', '#6366f1', + '#14b8a6', '#e879f9', '#a3e635', '#fb923c', '#818cf8', +] + +const formatEUR = (v: number) => `${v.toLocaleString('pt-PT', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}€` + +const formatMesLabel = (mes: string) => { + const [, m] = mes.split('-') + const nomes = ['', 'Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'] + return nomes[parseInt(m)] || mes +} + +const StatCard = ({ label, value, icon: Icon, color, sub }: { + label: string; value: string; icon: React.ElementType; color: string; sub?: string +}) => ( + +
+ {label} +
+ +
+
+
{value}
+ {sub &&
{sub}
} +
+) + +const CustomTooltip = ({ active, payload, label }: any) => { + if (!active || !payload?.length) return null + return ( +
+

{label}

+ {payload.map((p: any, i: number) => ( +

+ {p.name}: {formatEUR(p.value)} +

+ ))} +
+ ) +} + +export default function Financial() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + + const fetchData = useCallback(async () => { + setRefreshing(true) + try { + const response = await fetch('/api/financial') + if (!response.ok) throw new Error('Failed') + const json = await response.json() + setData(json) + } catch { + console.error('Failed to fetch financial data') + } finally { + setLoading(false) + setRefreshing(false) + } + }, []) + + useEffect(() => { fetchData() }, [fetchData]) + + if (loading) { + return ( +
+ + +

A carregar dados financeiros...

+
+
+ ) + } + + if (!data) return null + + const lucroColor = data.lucro_ano >= 0 ? 'text-emerald-400 bg-emerald-500/20' : 'text-red-400 bg-red-500/20' + const lucroLabel = data.lucro_ano >= 0 ? 'Lucro' : 'Prejuizo' + + // Format evolution data for chart + const chartData = data.evolucao.map(e => ({ + mes: formatMesLabel(e.mes), + Receita: e.receita, + Despesa: e.despesa, + })) + + return ( +
+
+ {/* Header */} +
+
+
+
+ + + +
+

Financeiro

+

Vendas e Despesas {new Date().getFullYear()}

+
+
+ +
+ + {lucroLabel}: {formatEUR(Math.abs(data.lucro_ano))} + + + + + + + Monitor + + + + Dashboard + +
+
+
+
+ + {/* Main Content */} +
+ + {/* Summary Cards */} +
+ + + + +
+ + {/* Lucro cards */} +
+ +
+ = 0 ? 'text-emerald-400' : 'text-red-400'}`} /> + Resultado Mensal +
+
= 0 ? 'text-emerald-400' : 'text-red-400'}`}> + {data.lucro_mes >= 0 ? '+' : ''}{formatEUR(data.lucro_mes)} +
+
+ +
+ = 0 ? 'text-emerald-400' : 'text-red-400'}`} /> + Resultado Anual +
+
= 0 ? 'text-emerald-400' : 'text-red-400'}`}> + {data.lucro_ano >= 0 ? '+' : ''}{formatEUR(data.lucro_ano)} +
+
+
+ + {/* Charts */} +
+ {/* Bar Chart - Monthly Evolution */} + +

+ + Evolucao Mensal +

+
+ + + + + `${v}€`} /> + } /> + + + + + +
+
+ + {/* Pie Chart - Expense Distribution */} + +

+ + Despesas por Categoria +

+
+ + + + {data.categorias.slice(0, 10).map((_, i) => ( + + ))} + + formatEUR(value || 0)} + contentStyle={{ background: '#18181b', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 12, fontSize: 12 }} + itemStyle={{ color: '#e4e4e7' }} + /> + + +
+ {/* Legend */} +
+ {data.categorias.slice(0, 10).map((cat, i) => ( +
+
+
+ {cat.name} +
+ {formatEUR(cat.value)} +
+ ))} +
+ +
+
+
+
+
+ ) +} diff --git a/src/pages/Monitor.tsx b/src/pages/Monitor.tsx index fb5d93f..a9b4bf8 100644 --- a/src/pages/Monitor.tsx +++ b/src/pages/Monitor.tsx @@ -16,6 +16,7 @@ import { ArrowLeft, Activity, Clock, + TrendingUp, } from 'lucide-react' // Types @@ -264,6 +265,13 @@ export default function Monitor() { > + + + Financeiro + )} - {/* Services */} + {/* Services - Compact Grid */} {groupedItems.service && ( - {groupedItems.service.map((item) => ( - - ))} +
+ {groupedItems.service.map((item) => ( +
+
+ {item.name} +
+ ))} +
)} @@ -438,6 +454,7 @@ function getMockData(): MonitorData { { id: 521, name: 'Google Docs', category: 'service', status: 'up', details: {}, last_check: '' }, { id: 549, name: 'MCP Hub', category: 'service', status: 'up', details: {}, last_check: '' }, { id: 515, name: 'WhatSMS', category: 'service', status: 'up', details: {}, last_check: '' }, + { id: 41594, name: 'Syncthing', category: 'service', status: 'up', details: {}, last_check: '' }, ], site: [ { id: 15960, name: 'Descomplicar', category: 'site', status: 'up', details: { domain: 'descomplicar.pt', response_time: 0.093 }, last_check: '' }, @@ -470,7 +487,7 @@ function getMockData(): MonitorData { overall: 'warning', summary: [ { category: 'server', total: 6, ok: 6, warning: 0, critical: 0 }, - { category: 'service', total: 10, ok: 10, warning: 0, critical: 0 }, + { category: 'service', total: 11, ok: 11, warning: 0, critical: 0 }, { category: 'site', total: 7, ok: 6, warning: 0, critical: 1 }, { category: 'container', total: 1, ok: 0, warning: 1, critical: 0 }, { category: 'backup', total: 4, ok: 2, warning: 1, critical: 1 },