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 <noreply@anthropic.com>
This commit is contained in:
2026-02-07 22:50:12 +00:00
parent 86ad4a64be
commit 12e1552d02
7 changed files with 478 additions and 5 deletions

22
api/routes/financial.ts Normal file
View File

@@ -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

View File

@@ -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) {

113
api/services/financial.ts Normal file
View File

@@ -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<RowDataPacket[]>(`
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<RowDataPacket[]>(`
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<RowDataPacket[]>(`
SELECT COALESCE(SUM(amount), 0) as valor
FROM tblexpenses
WHERE YEAR(date) = YEAR(CURDATE()) AND MONTH(date) = MONTH(CURDATE())
`),
// Despesas acumuladas ano
db.query<RowDataPacket[]>(`
SELECT COALESCE(SUM(amount), 0) as valor
FROM tblexpenses
WHERE YEAR(date) = YEAR(CURDATE())
`),
// Distribuicao por categoria
db.query<RowDataPacket[]>(`
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<RowDataPacket[]>(`
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<RowDataPacket[]>(`
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<string, { receita: number; despesa: number }>()
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,
}
}