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:
22
api/routes/financial.ts
Normal file
22
api/routes/financial.ts
Normal 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
|
||||||
@@ -13,6 +13,7 @@ import diagnosticRouter from './routes/diagnostic.js'
|
|||||||
import hetznerRouter from './routes/hetzner.js'
|
import hetznerRouter from './routes/hetzner.js'
|
||||||
import wpMonitorRouter from './routes/wp-monitor.js'
|
import wpMonitorRouter from './routes/wp-monitor.js'
|
||||||
import serverMetricsRouter from './routes/server-metrics.js'
|
import serverMetricsRouter from './routes/server-metrics.js'
|
||||||
|
import financialRouter from './routes/financial.js'
|
||||||
import { collectAllServerMetrics } from './services/server-metrics.js'
|
import { collectAllServerMetrics } from './services/server-metrics.js'
|
||||||
|
|
||||||
const __filename = fileURLToPath(import.meta.url)
|
const __filename = fileURLToPath(import.meta.url)
|
||||||
@@ -41,6 +42,7 @@ app.use('/api/diagnostic', diagnosticRouter)
|
|||||||
app.use('/api/hetzner', hetznerRouter)
|
app.use('/api/hetzner', hetznerRouter)
|
||||||
app.use('/api/wp-monitor', wpMonitorRouter)
|
app.use('/api/wp-monitor', wpMonitorRouter)
|
||||||
app.use('/api/server-metrics', serverMetricsRouter)
|
app.use('/api/server-metrics', serverMetricsRouter)
|
||||||
|
app.use('/api/financial', financialRouter)
|
||||||
|
|
||||||
// Serve static files in production
|
// Serve static files in production
|
||||||
if (isProduction) {
|
if (isProduction) {
|
||||||
|
|||||||
113
api/services/financial.ts
Normal file
113
api/services/financial.ts
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -501,6 +501,10 @@ function App() {
|
|||||||
<Activity className="w-4 h-4" />
|
<Activity className="w-4 h-4" />
|
||||||
Monitor
|
Monitor
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/financial" className="px-4 py-2 rounded-lg text-zinc-400 hover:text-white hover:bg-white/10 text-sm font-medium transition-all flex items-center gap-2">
|
||||||
|
<CreditCard className="w-4 h-4" />
|
||||||
|
Financeiro
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
@@ -545,6 +549,10 @@ function App() {
|
|||||||
<Activity className="w-4 h-4" />
|
<Activity className="w-4 h-4" />
|
||||||
Monitor
|
Monitor
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/financial" onClick={() => setMobileMenuOpen(false)} className="px-4 py-3 rounded-lg text-zinc-400 hover:text-white hover:bg-white/10 text-sm font-medium transition-all flex items-center gap-3">
|
||||||
|
<CreditCard className="w-4 h-4" />
|
||||||
|
Financeiro
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</motion.nav>
|
</motion.nav>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { AuthProvider } from 'react-oidc-context'
|
|||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.tsx'
|
import App from './App.tsx'
|
||||||
import Monitor from './pages/Monitor.tsx'
|
import Monitor from './pages/Monitor.tsx'
|
||||||
|
import Financial from './pages/Financial.tsx'
|
||||||
import { oidcConfig } from './auth/config.ts'
|
import { oidcConfig } from './auth/config.ts'
|
||||||
import { AuthWrapper } from './auth/AuthWrapper.tsx'
|
import { AuthWrapper } from './auth/AuthWrapper.tsx'
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<App />} />
|
<Route path="/" element={<App />} />
|
||||||
<Route path="/monitor" element={<Monitor />} />
|
<Route path="/monitor" element={<Monitor />} />
|
||||||
|
<Route path="/financial" element={<Financial />} />
|
||||||
<Route path="/callback" element={<App />} />
|
<Route path="/callback" element={<App />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</AuthWrapper>
|
</AuthWrapper>
|
||||||
|
|||||||
309
src/pages/Financial.tsx
Normal file
309
src/pages/Financial.tsx
Normal file
@@ -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
|
||||||
|
}) => (
|
||||||
|
<motion.div variants={itemVariants} className="glass-card p-6">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<span className="text-sm text-zinc-400">{label}</span>
|
||||||
|
<div className={`w-10 h-10 rounded-xl ${color} flex items-center justify-center`}>
|
||||||
|
<Icon className="w-5 h-5 text-white" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-3xl font-bold text-white">{value}</div>
|
||||||
|
{sub && <div className="text-xs text-zinc-500 mt-1">{sub}</div>}
|
||||||
|
</motion.div>
|
||||||
|
)
|
||||||
|
|
||||||
|
const CustomTooltip = ({ active, payload, label }: any) => {
|
||||||
|
if (!active || !payload?.length) return null
|
||||||
|
return (
|
||||||
|
<div className="bg-zinc-900 border border-white/10 rounded-xl px-4 py-3 shadow-xl">
|
||||||
|
<p className="text-sm font-medium text-white mb-1">{label}</p>
|
||||||
|
{payload.map((p: any, i: number) => (
|
||||||
|
<p key={i} className="text-xs" style={{ color: p.color }}>
|
||||||
|
{p.name}: {formatEUR(p.value)}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Financial() {
|
||||||
|
const [data, setData] = useState<FinancialData | null>(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 (
|
||||||
|
<div className="min-h-screen bg-mesh flex items-center justify-center">
|
||||||
|
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} className="text-center">
|
||||||
|
<DollarSign className="w-12 h-12 text-brand-400 mx-auto mb-4 animate-pulse" />
|
||||||
|
<p className="text-zinc-400">A carregar dados financeiros...</p>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="min-h-screen bg-mesh">
|
||||||
|
<div className="bg-grid min-h-screen">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="sticky top-0 z-50 border-b border-white/5 bg-[#0a0a0f]/90 backdrop-blur-2xl">
|
||||||
|
<div className="max-w-[1600px] mx-auto px-6 lg:px-8 py-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<motion.div
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
className="w-11 h-11 rounded-xl bg-gradient-to-br from-brand-500 to-violet-600 flex items-center justify-center shadow-lg shadow-brand-500/30"
|
||||||
|
>
|
||||||
|
<Zap className="w-6 h-6 text-white" />
|
||||||
|
</motion.div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold text-white tracking-tight">Financeiro</h1>
|
||||||
|
<p className="text-xs text-zinc-500">Vendas e Despesas {new Date().getFullYear()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className={`px-4 py-2 rounded-full text-sm font-semibold ${lucroColor}`}>
|
||||||
|
{lucroLabel}: {formatEUR(Math.abs(data.lucro_ano))}
|
||||||
|
</span>
|
||||||
|
<motion.button
|
||||||
|
whileHover={{ scale: 1.05 }}
|
||||||
|
whileTap={{ scale: 0.95 }}
|
||||||
|
onClick={fetchData}
|
||||||
|
disabled={refreshing}
|
||||||
|
className="p-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 transition-all"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-5 h-5 text-zinc-400 ${refreshing ? 'animate-spin' : ''}`} />
|
||||||
|
</motion.button>
|
||||||
|
<Link
|
||||||
|
to="/monitor"
|
||||||
|
className="hidden md:flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-zinc-400 hover:text-white transition-all text-sm"
|
||||||
|
>
|
||||||
|
<Activity className="w-4 h-4" />
|
||||||
|
Monitor
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
to="/"
|
||||||
|
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-zinc-400 hover:text-white transition-all text-sm"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="w-4 h-4" />
|
||||||
|
<span className="hidden md:inline">Dashboard</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="max-w-[1600px] mx-auto px-6 lg:px-8 py-8">
|
||||||
|
<motion.div variants={containerVariants} initial="hidden" animate="show">
|
||||||
|
{/* Summary Cards */}
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-5 mb-8">
|
||||||
|
<StatCard
|
||||||
|
label="Vendas Mes"
|
||||||
|
value={formatEUR(data.vendas_mes)}
|
||||||
|
icon={TrendingUp}
|
||||||
|
color="bg-emerald-500/20"
|
||||||
|
sub={`Acumulado: ${formatEUR(data.vendas_ano)}`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Vendas Ano"
|
||||||
|
value={formatEUR(data.vendas_ano)}
|
||||||
|
icon={DollarSign}
|
||||||
|
color="bg-emerald-600/20"
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Despesas Mes"
|
||||||
|
value={formatEUR(data.despesas_mes)}
|
||||||
|
icon={Receipt}
|
||||||
|
color="bg-red-500/20"
|
||||||
|
sub={`Acumulado: ${formatEUR(data.despesas_ano)}`}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Despesas Ano"
|
||||||
|
value={formatEUR(data.despesas_ano)}
|
||||||
|
icon={TrendingDown}
|
||||||
|
color="bg-red-600/20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Lucro cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-5 mb-8">
|
||||||
|
<motion.div variants={itemVariants} className="glass-card p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<PiggyBank className={`w-6 h-6 ${data.lucro_mes >= 0 ? 'text-emerald-400' : 'text-red-400'}`} />
|
||||||
|
<span className="text-sm text-zinc-400">Resultado Mensal</span>
|
||||||
|
</div>
|
||||||
|
<div className={`text-4xl font-bold ${data.lucro_mes >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||||
|
{data.lucro_mes >= 0 ? '+' : ''}{formatEUR(data.lucro_mes)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
<motion.div variants={itemVariants} className="glass-card p-6">
|
||||||
|
<div className="flex items-center gap-3 mb-2">
|
||||||
|
<PiggyBank className={`w-6 h-6 ${data.lucro_ano >= 0 ? 'text-emerald-400' : 'text-red-400'}`} />
|
||||||
|
<span className="text-sm text-zinc-400">Resultado Anual</span>
|
||||||
|
</div>
|
||||||
|
<div className={`text-4xl font-bold ${data.lucro_ano >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||||
|
{data.lucro_ano >= 0 ? '+' : ''}{formatEUR(data.lucro_ano)}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Charts */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
||||||
|
{/* Bar Chart - Monthly Evolution */}
|
||||||
|
<motion.div variants={itemVariants} className="glass-card p-6 lg:col-span-2">
|
||||||
|
<h3 className="text-sm font-semibold text-white mb-6 uppercase tracking-wide flex items-center gap-2">
|
||||||
|
<LayoutDashboard className="w-4 h-4 text-brand-400" />
|
||||||
|
Evolucao Mensal
|
||||||
|
</h3>
|
||||||
|
<div className="h-[340px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<BarChart data={chartData} margin={{ top: 5, right: 10, left: 0, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
|
||||||
|
<XAxis dataKey="mes" tick={{ fill: '#71717a', fontSize: 12 }} axisLine={{ stroke: 'rgba(255,255,255,0.1)' }} />
|
||||||
|
<YAxis tick={{ fill: '#71717a', fontSize: 12 }} axisLine={{ stroke: 'rgba(255,255,255,0.1)' }} tickFormatter={(v) => `${v}€`} />
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Legend wrapperStyle={{ fontSize: 12, color: '#a1a1aa' }} />
|
||||||
|
<Bar dataKey="Receita" fill="#10b981" radius={[4, 4, 0, 0]} />
|
||||||
|
<Bar dataKey="Despesa" fill="#ef4444" radius={[4, 4, 0, 0]} />
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
|
||||||
|
{/* Pie Chart - Expense Distribution */}
|
||||||
|
<motion.div variants={itemVariants} className="glass-card p-6">
|
||||||
|
<h3 className="text-sm font-semibold text-white mb-6 uppercase tracking-wide flex items-center gap-2">
|
||||||
|
<Receipt className="w-4 h-4 text-brand-400" />
|
||||||
|
Despesas por Categoria
|
||||||
|
</h3>
|
||||||
|
<div className="h-[240px]">
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<PieChart>
|
||||||
|
<Pie
|
||||||
|
data={data.categorias.slice(0, 10)}
|
||||||
|
cx="50%"
|
||||||
|
cy="50%"
|
||||||
|
innerRadius={50}
|
||||||
|
outerRadius={90}
|
||||||
|
dataKey="value"
|
||||||
|
stroke="none"
|
||||||
|
>
|
||||||
|
{data.categorias.slice(0, 10).map((_, i) => (
|
||||||
|
<Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />
|
||||||
|
))}
|
||||||
|
</Pie>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number | undefined) => formatEUR(value || 0)}
|
||||||
|
contentStyle={{ background: '#18181b', border: '1px solid rgba(255,255,255,0.1)', borderRadius: 12, fontSize: 12 }}
|
||||||
|
itemStyle={{ color: '#e4e4e7' }}
|
||||||
|
/>
|
||||||
|
</PieChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="space-y-1.5 mt-2 max-h-[160px] overflow-y-auto scrollbar-thin">
|
||||||
|
{data.categorias.slice(0, 10).map((cat, i) => (
|
||||||
|
<div key={i} className="flex items-center justify-between text-xs">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{ background: PIE_COLORS[i % PIE_COLORS.length] }} />
|
||||||
|
<span className="text-zinc-400 truncate">{cat.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-white font-medium ml-2">{formatEUR(cat.value)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</div>
|
||||||
|
</motion.div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import {
|
|||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Activity,
|
Activity,
|
||||||
Clock,
|
Clock,
|
||||||
|
TrendingUp,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
@@ -264,6 +265,13 @@ export default function Monitor() {
|
|||||||
>
|
>
|
||||||
<RefreshCw className={`w-5 h-5 text-zinc-400 ${refreshing ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-5 h-5 text-zinc-400 ${refreshing ? 'animate-spin' : ''}`} />
|
||||||
</motion.button>
|
</motion.button>
|
||||||
|
<Link
|
||||||
|
to="/financial"
|
||||||
|
className="hidden md:flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-zinc-400 hover:text-white transition-all text-sm"
|
||||||
|
>
|
||||||
|
<TrendingUp className="w-4 h-4" />
|
||||||
|
Financeiro
|
||||||
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to="/"
|
to="/"
|
||||||
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-zinc-400 hover:text-white transition-all text-sm"
|
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-white/5 hover:bg-white/10 text-zinc-400 hover:text-white transition-all text-sm"
|
||||||
@@ -301,12 +309,20 @@ export default function Monitor() {
|
|||||||
</CategoryCard>
|
</CategoryCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Services */}
|
{/* Services - Compact Grid */}
|
||||||
{groupedItems.service && (
|
{groupedItems.service && (
|
||||||
<CategoryCard title="Serviços Críticos" icon={Wifi} count={groupedItems.service.length}>
|
<CategoryCard title="Serviços Críticos" icon={Wifi} count={groupedItems.service.length}>
|
||||||
{groupedItems.service.map((item) => (
|
<div className="grid grid-cols-2 gap-x-3 gap-y-1.5">
|
||||||
<MonitorItemRow key={item.id} item={item} />
|
{groupedItems.service.map((item) => (
|
||||||
))}
|
<div key={item.id} className="flex items-center gap-2 px-2 py-1.5 rounded-lg hover:bg-white/[0.04] transition-colors">
|
||||||
|
<div className={`w-2 h-2 rounded-full flex-shrink-0 ${
|
||||||
|
item.status === 'ok' || item.status === 'up' ? 'bg-emerald-400' :
|
||||||
|
item.status === 'warning' ? 'bg-amber-400' : 'bg-red-400'
|
||||||
|
}`} />
|
||||||
|
<span className="text-sm text-zinc-300 truncate">{item.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</CategoryCard>
|
</CategoryCard>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -438,6 +454,7 @@ function getMockData(): MonitorData {
|
|||||||
{ id: 521, name: 'Google Docs', category: 'service', status: 'up', details: {}, last_check: '' },
|
{ 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: 549, name: 'MCP Hub', category: 'service', status: 'up', details: {}, last_check: '' },
|
||||||
{ id: 515, name: 'WhatSMS', 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: [
|
site: [
|
||||||
{ id: 15960, name: 'Descomplicar', category: 'site', status: 'up', details: { domain: 'descomplicar.pt', response_time: 0.093 }, last_check: '' },
|
{ 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',
|
overall: 'warning',
|
||||||
summary: [
|
summary: [
|
||||||
{ category: 'server', total: 6, ok: 6, warning: 0, critical: 0 },
|
{ 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: 'site', total: 7, ok: 6, warning: 0, critical: 1 },
|
||||||
{ category: 'container', total: 1, ok: 0, warning: 1, critical: 0 },
|
{ category: 'container', total: 1, ok: 0, warning: 1, critical: 0 },
|
||||||
{ category: 'backup', total: 4, ok: 2, warning: 1, critical: 1 },
|
{ category: 'backup', total: 4, ok: 2, warning: 1, critical: 1 },
|
||||||
|
|||||||
Reference in New Issue
Block a user