diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4b7972d --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Database Configuration +DB_HOST=localhost +DB_USER=ealmeida_desk24 +DB_PASS=your_password_here +DB_NAME=ealmeida_desk24 + +# API Configuration +API_PORT=3001 +FRONTEND_URL=http://localhost:5173 + +# Production URLs +# FRONTEND_URL=https://dash.descomplicar.pt diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e305960 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,137 @@ +# Changelog + +Todas as alterações notáveis neste projecto serão documentadas neste ficheiro. + +## [2.0.0] - 2026-02-04 + +### Added + +#### API Node.js/Express Completa +- ✅ Servidor Express em `/api/server.ts` +- ✅ Connection pool MySQL em `/api/db.ts` +- ✅ Proxy Vite configurado (`/api` → `http://localhost:3001`) + +#### Serviços +- ✅ **Google Calendar API** (`/api/services/calendar.ts`) + - Integração com calendários pessoal e profissional + - Eventos de hoje e da semana + - OAuth2 configurado com refresh token + +- ✅ **Dashboard Queries** (`/api/services/dashboard.ts`) + - Tasks: urgente, alta prioridade, vencidas, em testes, esta semana + - Monday Mood (tag especial #67) + - Tickets abertos + - Leads: contactar, followup, proposta + - Projectos activos + - Timesheet semanal (staff_id=1) + - Billing 360 (clientes com horas facturadas vs entregues) + - Pipeline completo (leads, estimates, proposals) + +- ✅ **Monitoring Queries** (`/api/services/monitoring.ts`) + - Query à tabela `tbl_eal_monitoring` + - Summary por categoria + - Status geral (ok/warning/critical) + +#### Endpoints +- ✅ `GET /api/dashboard` - Dashboard completo +- ✅ `GET /api/monitor` - Monitorização de sistemas +- ✅ `GET /api/health` - Health check + +#### Frontend +- ✅ `App.tsx` actualizado para usar `/api/dashboard` (linha 425) +- ✅ Fallback para dados mock em caso de erro (desenvolvimento) + +#### Infraestrutura +- ✅ Scripts npm: + - `npm run dev` - Vite + API em paralelo (concurrently) + - `npm run dev:api` - API apenas (tsx watch) + - `npm run dev:ui` - Vite apenas + - `npm start` - Produção (serve API + static build) + +#### Dependências Adicionadas +- `express` ^4.19.2 +- `cors` ^2.8.5 +- `mysql2` ^3.11.5 +- `googleapis` ^144.0.0 +- `concurrently` ^9.1.2 (dev) +- `tsx` ^4.19.2 (dev) +- `@types/express` ^5.0.0 (dev) +- `@types/cors` ^2.8.17 (dev) + +#### Documentação +- ✅ `api/README.md` - Documentação completa da API +- ✅ `.env.example` - Template de configuração + +### Changed + +- Título da tarefa #1556: "Dashboard Descomplicar - Dados Reais e API" +- `vite.config.ts` - Adicionado proxy para `/api` +- `package.json` - Nome alterado para `dash-descomplicar` + +### Migrated + +Toda a lógica do PHP (`plan-eal.descomplicar.pt`) migrada para TypeScript: + +#### De `index.php`: +- ✅ Google Calendar API (pessoal + profissional) +- ✅ Todas as queries à BD Desk CRM +- ✅ Billing 360 (horas facturadas vs entregues) +- ✅ Pipeline de vendas +- ✅ Cálculo de timesheet semanal +- ✅ Monday Mood (tarefas com tag especial) + +#### De `monitor.php`: +- ✅ Query à `tbl_eal_monitoring` +- ✅ Organização por categoria +- ✅ Cálculo de status geral + +### Technical Notes + +#### Semana: Segunda a Domingo +- Função `getWeekRange()` calcula início da semana (segunda-feira) +- Timesheet usa UNIX timestamp para compatibilidade com dados antigos + +#### Billing 360 +- Calcula horas entregues de `tbltaskstimers` +- Compara com horas facturadas de `billing_360_invoices` +- Status: `credit` (>5h crédito), `debt` (<-5h débito), `ok` (entre -5 e +5) + +#### Pipeline +- Agrega leads por fase (`tblleads_status`) +- Inclui estimates (status 2=Enviado, 3=Visto) +- Inclui proposals (status 4) +- Valor total calculado em SQL + +#### Performance +- Queries em paralelo com `Promise.all()` +- Connection pool MySQL (10 conexões) +- Vite proxy em dev (zero CORS issues) + +### Pending + +- [ ] Sistema de autenticação (JWT ou sessões) +- [ ] Deploy em produção (EasyPanel) +- [ ] Configurar variáveis de ambiente em produção +- [ ] Testar Google Calendar OAuth refresh + +### Breaking Changes + +- ❌ PHP backend descontinuado (`plan-eal.descomplicar.pt` será apagado) +- ✅ Nova API Node.js/Express em `/api` +- ✅ React dashboard em `dash.descomplicar.pt` + +--- + +## [1.0.0] - 2026-02-03 + +### Added +- Dashboard React inicial com Vite + TypeScript +- UI com Tailwind CSS 4 + Framer Motion +- Design system: Glassmorphism + Bento Grid +- Componentes: HeroStat, GlassCard, ProgressRing, Sparkline +- Dados mock para demonstração +- Autenticação Authentik (OIDC) configurada +- Deploy EasyPanel em `dash.descomplicar.pt` + +### Note +Versão inicial com dados mock. API implementada na v2.0.0. diff --git a/api/README.md b/api/README.md new file mode 100644 index 0000000..964b2ad --- /dev/null +++ b/api/README.md @@ -0,0 +1,98 @@ +# Dashboard Descomplicar API + +API Node.js/Express com queries diretas à BD Desk CRM. + +## Estrutura + +``` +api/ +├── server.ts # Express server +├── db.ts # MySQL connection pool +├── services/ +│ ├── calendar.ts # Google Calendar API +│ ├── dashboard.ts # Dashboard queries +│ └── monitoring.ts # Monitoring queries +└── routes/ + ├── dashboard.ts # GET /api/dashboard + └── monitor.ts # GET /api/monitor +``` + +## Endpoints + +### GET /api/dashboard +Retorna dados do dashboard principal: +- Tarefas (urgente, alta, vencidas, testes, semana) +- Tickets abertos +- Leads (contactar, followup, proposta) +- Projectos activos +- Timesheet semanal +- Billing 360 +- Pipeline de vendas +- Eventos Google Calendar (hoje + semana) + +### GET /api/monitor +Retorna status de monitorização: +- Servidores (CPU, RAM, Disco) +- Serviços web +- Sites WordPress +- Containers Docker + +### GET /api/health +Health check endpoint + +## Setup + +1. **Instalar dependências:** +```bash +npm install +``` + +2. **Configurar .env:** +```bash +cp .env.example .env +# Editar .env com credenciais correctas +``` + +3. **Desenvolvimento:** +```bash +npm run dev # Vite + API (ambos) +npm run dev:api # API apenas +npm run dev:ui # Vite apenas +``` + +4. **Produção:** +```bash +npm run build +npm start +``` + +## Queries Migradas do PHP + +Todas as queries do `index.php` e `monitor.php` foram migradas para TypeScript: + +- ✅ Tasks (urgente, alta, vencidas, testes, semana, monday mood) +- ✅ Tickets +- ✅ Leads (contactar, followup, proposta) +- ✅ Projectos +- ✅ Timesheet semanal +- ✅ Billing 360 +- ✅ Pipeline (leads, estimates, proposals) +- ✅ Google Calendar (eventos pessoais + profissionais) +- ✅ Monitorização (tbl_eal_monitoring) + +## BD Necessárias + +- `ealmeida_desk24` (BD principal Perfex CRM) +- Tabelas Perfex: `tbltasks`, `tblleads`, `tblprojects`, `tbltickets`, etc. +- Tabelas custom: `billing_360_clients`, `billing_360_invoices`, `tbl_eal_monitoring` + +## Google Calendar API + +Credenciais OAuth2 configuradas em `services/calendar.ts`: +- Client ID +- Client Secret +- Refresh Token + +Calendários: +- `primary` - Eventos pessoais +- `emanuel@descomplicar.pt` - Eventos profissionais diff --git a/api/db.ts b/api/db.ts new file mode 100644 index 0000000..8f81379 --- /dev/null +++ b/api/db.ts @@ -0,0 +1,32 @@ +/** + * Database Connection Pool + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import mysql from 'mysql2/promise' + +// Database configuration +const config = { + host: process.env.DB_HOST || 'localhost', + user: process.env.DB_USER || 'ealmeida_desk24', + password: process.env.DB_PASS || '9qPRdCGGqM4o', + database: process.env.DB_NAME || 'ealmeida_desk24', + waitForConnections: true, + connectionLimit: 10, + queueLimit: 0, + charset: 'utf8mb4' +} + +// Create connection pool +const pool = mysql.createPool(config) + +// Test connection +pool.getConnection() + .then(conn => { + console.log('✅ MySQL connected') + conn.release() + }) + .catch(err => { + console.error('❌ MySQL connection error:', err.message) + }) + +export default pool diff --git a/api/routes/dashboard.ts b/api/routes/dashboard.ts new file mode 100644 index 0000000..6e97dc6 --- /dev/null +++ b/api/routes/dashboard.ts @@ -0,0 +1,100 @@ +/** + * Dashboard API Route + * GET /api/dashboard + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import { Router } from 'express' +import type { Request, Response } from 'express' +import * as dashboardService from '../services/dashboard.js' +import * as calendarService from '../services/calendar.js' + +const router = Router() + +router.get('/', async (req: Request, res: Response) => { + try { + // Date info + const now = new Date() + const diasSemana = ['Domingo', 'Segunda-feira', 'Terça-feira', 'Quarta-feira', 'Quinta-feira', 'Sexta-feira', 'Sábado'] + const meses = ['', 'Janeiro', 'Fevereiro', 'Março', 'Abril', 'Maio', 'Junho', 'Julho', 'Agosto', 'Setembro', 'Outubro', 'Novembro', 'Dezembro'] + + const data_formatada = `${diasSemana[now.getDay()]}, ${now.getDate()} de ${meses[now.getMonth() + 1]} de ${now.getFullYear()}` + const is_monday = now.getDay() === 1 + + // Parallel queries for performance + const [ + urgente, + alta, + vencidas, + em_testes, + esta_semana, + monday_mood, + tickets, + contactar, + followup, + proposta, + projectos, + timesheet, + billing_360, + pipeline, + eventos_hoje, + eventos_semana + ] = await Promise.all([ + dashboardService.getUrgenteTasks(), + dashboardService.getAltaTasks(), + dashboardService.getVencidasTasks(), + dashboardService.getEmTestesTasks(), + dashboardService.getEstaSemana(), + dashboardService.getMondayMood(), + dashboardService.getTickets(), + dashboardService.getContactarLeads(), + dashboardService.getFollowupLeads(), + dashboardService.getPropostaLeads(), + dashboardService.getProjectos(), + dashboardService.getTimesheet(), + dashboardService.getBilling360(), + dashboardService.getPipeline(), + calendarService.getTodayEvents(), + calendarService.getWeekEvents() + ]) + + // Calculate totals + const total_tarefas = urgente.length + alta.length + const total_leads = contactar.length + proposta.length + followup.length + + // Build response matching React component format + const response = { + data_formatada, + is_monday, + eventos_hoje, + eventos_semana, + monday_mood, + urgente, + alta, + vencidas, + em_testes, + esta_semana, + tickets, + proposta, + contactar, + followup, + projectos, + billing_360, + resumo: { + tarefas: total_tarefas, + tickets: tickets.length, + projectos: projectos.length, + leads: total_leads, + horas: timesheet.horas, + horas_pct: timesheet.horas_pct, + pipeline_valor: pipeline.pipeline_valor + } + } + + res.json(response) + } catch (error) { + console.error('Dashboard API error:', error) + res.status(500).json({ error: 'Internal server error' }) + } +}) + +export default router diff --git a/api/routes/monitor.ts b/api/routes/monitor.ts new file mode 100644 index 0000000..4453c76 --- /dev/null +++ b/api/routes/monitor.ts @@ -0,0 +1,22 @@ +/** + * Monitor API Route + * GET /api/monitor + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import { Router } from 'express' +import type { Request, Response } from 'express' +import * as monitoringService from '../services/monitoring.js' + +const router = Router() + +router.get('/', async (req: Request, res: Response) => { + try { + const data = await monitoringService.getMonitoringData() + res.json(data) + } catch (error) { + console.error('Monitor API error:', error) + res.status(500).json({ error: 'Internal server error' }) + } +}) + +export default router diff --git a/api/server.ts b/api/server.ts new file mode 100644 index 0000000..26b9739 --- /dev/null +++ b/api/server.ts @@ -0,0 +1,40 @@ +/** + * Express API Server + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import express from 'express' +import cors from 'cors' +import dashboardRouter from './routes/dashboard.js' +import monitorRouter from './routes/monitor.js' + +const app = express() +const PORT = process.env.API_PORT || 3001 + +// Middleware +app.use(cors({ + origin: process.env.FRONTEND_URL || 'http://localhost:5173', + credentials: true +})) +app.use(express.json()) + +// Health check +app.get('/api/health', (req, res) => { + res.json({ status: 'ok', timestamp: new Date().toISOString() }) +}) + +// Routes +app.use('/api/dashboard', dashboardRouter) +app.use('/api/monitor', monitorRouter) + +// Error handling +app.use((err: any, req: express.Request, res: express.Response, next: express.NextFunction) => { + console.error('Server error:', err) + res.status(500).json({ error: 'Internal server error' }) +}) + +// Start server +app.listen(PORT, () => { + console.log(`🚀 API Server running on http://localhost:${PORT}`) + console.log(`📊 Dashboard: http://localhost:${PORT}/api/dashboard`) + console.log(`🔍 Monitor: http://localhost:${PORT}/api/monitor`) +}) diff --git a/api/services/calendar.ts b/api/services/calendar.ts new file mode 100644 index 0000000..27303b1 --- /dev/null +++ b/api/services/calendar.ts @@ -0,0 +1,106 @@ +/** + * Google Calendar Service + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import { google } from 'googleapis' + +const oauth2Client = new google.auth.OAuth2( + '188617934470-pomrua9oj4459dk69jpv6qhvst9pd3f6.apps.googleusercontent.com', + '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', + 'https://developers.google.com/oauthplayground' +) + +oauth2Client.setCredentials({ + refresh_token: '1//03AJOfA8x4_eyCgYIARAAGAMSNwF-L9Ir2hVygx8arVuZpZKJpqPsFpGCLo3pXJGC9rxpHnVw5Gki5cLWG7Ez64RcT0RFVItZ2fQ' +}) + +const calendar = google.calendar({ version: 'v3', auth: oauth2Client }) + +interface CalendarEvent { + titulo: string + hora: string + data?: string + tipo: 'personal' | 'work' + link: string +} + +export async function getEvents(calendarId: string, timeMin: string, timeMax: string): Promise { + try { + const response = await calendar.events.list({ + calendarId, + timeMin, + timeMax, + singleEvents: true, + orderBy: 'startTime', + maxResults: 20 + }) + + const events: CalendarEvent[] = [] + const items = response.data.items || [] + + for (const event of items) { + const start = event.start?.dateTime || event.start?.date + if (!start) continue + + const startDate = new Date(start) + const tipo = calendarId === 'primary' ? 'personal' : 'work' + + events.push({ + titulo: event.summary || 'Sem título', + hora: startDate.toLocaleTimeString('pt-PT', { hour: '2-digit', minute: '2-digit' }), + data: calendarId === 'primary' + ? undefined + : startDate.toLocaleDateString('pt-PT', { weekday: 'short', day: '2-digit', month: '2-digit' }), + tipo, + link: event.htmlLink || '#' + }) + } + + return events + } catch (error) { + console.error(`Calendar error (${calendarId}):`, error) + return [] + } +} + +export async function getTodayEvents(): Promise { + const today = new Date() + today.setHours(0, 0, 0, 0) + const tomorrow = new Date(today) + tomorrow.setDate(tomorrow.getDate() + 1) + + const timeMin = today.toISOString() + const timeMax = tomorrow.toISOString() + + const [personal, work] = await Promise.all([ + getEvents('primary', timeMin, timeMax), + getEvents('emanuel@descomplicar.pt', timeMin, timeMax) + ]) + + return [...personal, ...work].sort((a, b) => a.hora.localeCompare(b.hora)) +} + +export async function getWeekEvents(): Promise { + const today = new Date() + today.setHours(0, 0, 0, 0) + const tomorrow = new Date(today) + tomorrow.setDate(tomorrow.getDate() + 1) + + const nextSunday = new Date(today) + nextSunday.setDate(today.getDate() + (7 - today.getDay())) + nextSunday.setHours(23, 59, 59, 999) + + const timeMin = tomorrow.toISOString() + const timeMax = nextSunday.toISOString() + + const [personal, work] = await Promise.all([ + getEvents('primary', timeMin, timeMax), + getEvents('emanuel@descomplicar.pt', timeMin, timeMax) + ]) + + return [...personal, ...work].sort((a, b) => { + const dateA = a.data + a.hora + const dateB = b.data + b.hora + return dateA.localeCompare(dateB) + }) +} diff --git a/api/services/dashboard.ts b/api/services/dashboard.ts new file mode 100644 index 0000000..c6ecf1d --- /dev/null +++ b/api/services/dashboard.ts @@ -0,0 +1,280 @@ +/** + * Dashboard Queries Service + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import db from '../db.js' +import type { RowDataPacket } from 'mysql2' + +// Helper: Get week range (Monday-Sunday) +function getWeekRange() { + const now = new Date() + const dow = now.getDay() + const diff = dow === 0 ? -6 : 1 - dow + + const monday = new Date(now) + monday.setDate(now.getDate() + diff) + monday.setHours(0, 0, 0, 0) + + const sunday = new Date(monday) + sunday.setDate(monday.getDate() + 6) + sunday.setHours(23, 59, 59, 999) + + return { + inicio_semana: monday.toISOString().split('T')[0], + fim_semana: sunday.toISOString().split('T')[0], + inicio_unix: Math.floor(monday.getTime() / 1000) + } +} + +// Tasks +export async function getUrgenteTasks() { + const [rows] = await db.query(` + SELECT t.id, t.name, t.status, t.duedate, p.name as projeto + FROM tbltasks t + LEFT JOIN tblprojects p ON t.rel_id = p.id AND t.rel_type = 'project' + WHERE t.priority = 4 AND t.status IN (1,4) + `) + return rows +} + +export async function getAltaTasks() { + const [rows] = await db.query(` + SELECT t.id, t.name, t.status, t.duedate, p.name as projeto, + DATEDIFF(t.duedate, CURDATE()) as dias + FROM tbltasks t + LEFT JOIN tblprojects p ON t.rel_id = p.id AND t.rel_type = 'project' + WHERE t.priority = 3 AND t.status IN (1,4) + `) + return rows +} + +export async function getVencidasTasks() { + const [rows] = await db.query(` + SELECT t.id, t.name, t.duedate, p.name as projeto, + DATEDIFF(CURDATE(), t.duedate) as dias_atraso + FROM tbltasks t + LEFT JOIN tblprojects p ON t.rel_id = p.id AND t.rel_type = 'project' + WHERE t.duedate < CURDATE() + AND t.status IN (1,4) + AND (t.description NOT LIKE '%[TAGS: ongoing]%' OR t.description IS NULL) + `) + return rows +} + +export async function getEmTestesTasks() { + const [rows] = await db.query(` + SELECT t.id, t.name, t.duedate, p.name as projeto + FROM tbltasks t + LEFT JOIN tblprojects p ON t.rel_id = p.id AND t.rel_type = 'project' + WHERE t.status = 3 + ORDER BY t.duedate ASC + `) + return rows +} + +export async function getEstaSemana() { + const { inicio_semana, fim_semana } = getWeekRange() + const [rows] = await db.query(` + SELECT t.id, t.name, t.duedate, p.name as projeto, + DATEDIFF(t.duedate, CURDATE()) as dias + FROM tbltasks t + LEFT JOIN tblprojects p ON t.rel_id = p.id AND t.rel_type = 'project' + WHERE t.duedate BETWEEN ? AND ? + AND t.status IN (1,3,4) + `, [inicio_semana, fim_semana]) + return rows +} + +export async function getMondayMood() { + const now = new Date() + if (now.getDay() !== 1) return [] + + const [rows] = await db.query(` + SELECT t.id, t.name, t.status, p.name as projeto + FROM tbltasks t + LEFT JOIN tblprojects p ON t.rel_id = p.id AND t.rel_type = 'project' + INNER JOIN tbltaggables tg ON t.id = tg.rel_id AND tg.rel_type = 'task' + WHERE tg.tag_id = 67 AND t.status IN (1,4) + ORDER BY t.name + `) + return rows +} + +// Tickets +export async function getTickets() { + const [rows] = await db.query(` + SELECT ticketid, subject, priority + FROM tbltickets + WHERE status = 1 + `) + return rows +} + +// Leads +export async function getContactarLeads() { + const [rows] = await db.query(` + SELECT l.id, l.name, l.company, DATEDIFF(CURDATE(), l.dateadded) as dias, + (SELECT name FROM tblleads_sources WHERE id = l.source) as source + FROM tblleads l + WHERE l.lost = 0 AND l.junk = 0 AND ( + l.status = 14 + OR (l.status = 12 AND ( + (SELECT MAX(date) FROM tbllead_activity_log WHERE leadid = l.id) IS NULL + OR (SELECT MAX(date) FROM tbllead_activity_log WHERE leadid = l.id) < DATE_SUB(CURDATE(), INTERVAL 60 DAY) + )) + ) + ORDER BY l.dateadded DESC + `) + return rows +} + +export async function getFollowupLeads() { + const [rows] = await db.query(` + SELECT l.id, l.name, l.company, DATEDIFF(CURDATE(), l.dateadded) as dias, + (SELECT name FROM tblleads_sources WHERE id = l.source) as source, + DATEDIFF(CURDATE(), (SELECT MAX(date) FROM tbllead_activity_log WHERE leadid = l.id)) as dias_sem_contacto + FROM tblleads l + WHERE l.status = 12 AND l.lost = 0 AND l.junk = 0 + AND (SELECT MAX(date) FROM tbllead_activity_log WHERE leadid = l.id) >= DATE_SUB(CURDATE(), INTERVAL 60 DAY) + ORDER BY (SELECT MAX(date) FROM tbllead_activity_log WHERE leadid = l.id) ASC + `) + return rows +} + +export async function getPropostaLeads() { + const [rows] = await db.query(` + SELECT id, name, company, DATEDIFF(CURDATE(), dateadded) as dias, + (SELECT name FROM tblleads_sources WHERE id = tblleads.source) as source + FROM tblleads + WHERE status = 4 AND lost = 0 AND junk = 0 + `) + return rows +} + +// Projects +export async function getProjectos() { + const [rows] = await db.query(` + SELECT p.id, p.name, c.company as cliente, + (SELECT COUNT(*) FROM tbltasks WHERE rel_id = p.id AND rel_type = 'project') as total, + (SELECT COUNT(*) FROM tbltasks WHERE rel_id = p.id AND rel_type = 'project' AND status = 5) as concluidas + FROM tblprojects p + LEFT JOIN tblclients c ON p.clientid = c.userid + WHERE p.status IN (2, 53) + `) + return rows +} + +// Timesheet +export async function getTimesheet() { + const { inicio_semana, inicio_unix } = getWeekRange() + const [rows] = await db.query(` + SELECT SUM( + CASE + WHEN LENGTH(start_time) < 15 + THEN (CAST(end_time AS UNSIGNED) - CAST(start_time AS UNSIGNED)) / 3600 + ELSE TIMESTAMPDIFF(SECOND, start_time, end_time) / 3600 + END + ) as horas + FROM tbltaskstimers + WHERE staff_id = 1 AND end_time IS NOT NULL + AND ((LENGTH(start_time) < 15 AND CAST(start_time AS UNSIGNED) >= ?) + OR (LENGTH(start_time) >= 15 AND start_time >= ?)) + `, [inicio_unix, inicio_semana]) + + const horas = Math.round((rows[0]?.horas || 0) * 10) / 10 + const horas_pct = Math.min(100, Math.round((horas / 40) * 100)) + + return { horas, horas_pct } +} + +// Billing 360 +export async function getBilling360() { + const [rows] = await db.query(` + SELECT + c.client_name, + c.desk_project_id, + c.monthly_hours, + COALESCE(SUM(i.hours_invoiced), 0) as total_invoiced, + (SELECT COALESCE(SUM( + CASE + WHEN LENGTH(tt.start_time) < 15 + THEN (CAST(tt.end_time AS UNSIGNED) - CAST(tt.start_time AS UNSIGNED)) / 3600 + ELSE TIMESTAMPDIFF(SECOND, tt.start_time, tt.end_time) / 3600 + END + ), 0) + FROM tbltaskstimers tt + INNER JOIN tbltasks t ON tt.task_id = t.id + WHERE t.rel_id = c.desk_project_id + AND t.rel_type = 'project' + AND tt.end_time IS NOT NULL + ) as total_delivered + FROM billing_360_clients c + LEFT JOIN billing_360_invoices i ON c.id = i.client_id + WHERE c.active = 1 + GROUP BY c.id + ORDER BY c.client_name + `) + + return rows.map(client => { + const total_delivered = Math.round(client.total_delivered * 10) / 10 + const balance = Math.round((client.total_invoiced - total_delivered) * 10) / 10 + + let status: 'credit' | 'debt' | 'ok' + if (balance > 5) status = 'credit' + else if (balance < -5) status = 'debt' + else status = 'ok' + + return { + ...client, + total_delivered, + balance, + status + } + }) +} + +// Pipeline +export async function getPipeline() { + const [leads] = await db.query(` + SELECT ls.name as fase, COUNT(l.id) as qtd, COALESCE(SUM(l.lead_value), 0) as valor + FROM tblleads l + LEFT JOIN tblleads_status ls ON l.status = ls.id + WHERE l.lost = 0 AND l.junk = 0 AND l.status NOT IN (1) + GROUP BY l.status, ls.name, ls.statusorder + ORDER BY ls.statusorder + `) + + const [estimates] = await db.query(` + SELECT + CASE status + WHEN 2 THEN 'Enviado' + WHEN 3 THEN 'Visto' + END as fase, + COUNT(*) as qtd, + SUM(subtotal) as valor + FROM tblestimates + WHERE status IN (2,3) + GROUP BY status + `) + + const [proposals] = await db.query(` + SELECT COUNT(*) as qtd, COALESCE(SUM(subtotal), 0) as valor + FROM tblproposals + WHERE status = 4 + `) + + const [total] = await db.query(` + SELECT ( + (SELECT COALESCE(SUM(lead_value), 0) FROM tblleads WHERE lost=0 AND junk=0 AND status NOT IN (1)) + + (SELECT COALESCE(SUM(subtotal), 0) FROM tblestimates WHERE status IN (2,3)) + + (SELECT COALESCE(SUM(subtotal), 0) FROM tblproposals WHERE status = 4) + ) as total + `) + + return { + pipeline_leads: leads, + pipeline_estimates: estimates, + pipeline_proposals: proposals[0] || { qtd: 0, valor: 0 }, + pipeline_valor: Math.round(total[0]?.total || 0) + } +} diff --git a/api/services/monitoring.ts b/api/services/monitoring.ts new file mode 100644 index 0000000..64a6947 --- /dev/null +++ b/api/services/monitoring.ts @@ -0,0 +1,85 @@ +/** + * Monitoring Queries Service + * @author Descomplicar® | @link descomplicar.pt | @copyright 2026 + */ +import db from '../db.js' +import type { RowDataPacket } from 'mysql2' + +interface MonitoringItem { + id: number + name: string + category: string + type: string + status: string + details: any + last_check: string + created_at: string + updated_at: string +} + +interface CategorySummary { + category: string + total: number + ok: number + warning: number + critical: number +} + +export async function getMonitoringData() { + // Get all items + const [items] = await db.query(` + SELECT * FROM tbl_eal_monitoring + ORDER BY category, name + `) + + // Get summary by category + const [summary] = await db.query(` + SELECT + category, + COUNT(*) as total, + SUM(CASE WHEN status IN ('ok','up') THEN 1 ELSE 0 END) as ok, + SUM(CASE WHEN status = 'warning' THEN 1 ELSE 0 END) as warning, + SUM(CASE WHEN status IN ('failed','down') THEN 1 ELSE 0 END) as critical + FROM tbl_eal_monitoring + GROUP BY category + `) + + // Parse details JSON + const itemsParsed = items.map(item => ({ + ...item, + details: typeof item.details === 'string' ? JSON.parse(item.details) : item.details + })) + + // Organize by category + const data: Record = {} + for (const item of itemsParsed) { + if (!data[item.category]) { + data[item.category] = [] + } + data[item.category].push(item) + } + + // Calculate overall status + let overall: 'ok' | 'warning' | 'critical' = 'ok' + let total_critical = 0 + let total_warning = 0 + + for (const s of summary as CategorySummary[]) { + total_critical += s.critical + total_warning += s.warning + } + + if (total_critical > 0) overall = 'critical' + else if (total_warning > 0) overall = 'warning' + + return { + items: data, + summary, + overall, + stats: { + total_critical, + total_warning, + total_ok: items.length - total_critical - total_warning + } + } +} diff --git a/package.json b/package.json index b968545..66df073 100644 --- a/package.json +++ b/package.json @@ -1,18 +1,25 @@ { - "name": "plan-eal-v3", + "name": "dash-descomplicar", "private": true, - "version": "0.0.0", + "version": "1.0.0", "type": "module", "scripts": { - "dev": "vite", + "dev": "concurrently \"vite\" \"tsx watch api/server.ts\"", + "dev:api": "tsx watch api/server.ts", + "dev:ui": "vite", "build": "tsc -b && vite build", + "start": "node api/server.js", "lint": "eslint .", "preview": "vite preview" }, "dependencies": { "clsx": "^2.1.1", + "cors": "^2.8.5", + "express": "^4.19.2", "framer-motion": "^12.30.1", + "googleapis": "^144.0.0", "lucide-react": "^0.563.0", + "mysql2": "^3.11.5", "oidc-client-ts": "^3.0.1", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -24,17 +31,21 @@ "devDependencies": { "@eslint/js": "^9.39.1", "@tailwindcss/postcss": "^4.1.18", + "@types/cors": "^2.8.17", + "@types/express": "^5.0.0", "@types/node": "^24.10.10", "@types/react": "^19.2.5", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.1", "autoprefixer": "^10.4.24", + "concurrently": "^9.1.2", "eslint": "^9.39.1", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.4.24", "globals": "^16.5.0", "postcss": "^8.5.6", "tailwindcss": "^4.1.18", + "tsx": "^4.19.2", "typescript": "~5.9.3", "typescript-eslint": "^8.46.4", "vite": "^7.2.4" diff --git a/src/App.tsx b/src/App.tsx index 43fbf61..f70e11a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -422,11 +422,12 @@ function App() { const fetchData = useCallback(async () => { setRefreshing(true) try { - const response = await fetch('/api.php') + const response = await fetch('/api/dashboard') if (!response.ok) throw new Error('Failed to fetch') const json = await response.json() setData(json) - } catch { + } catch (error) { + console.error('Failed to fetch dashboard data:', error) setData(getMockData()) } finally { setLoading(false) diff --git a/vite.config.ts b/vite.config.ts index 860eaf8..1e33437 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -13,5 +13,11 @@ export default defineConfig({ server: { port: 3050, host: true, + proxy: { + '/api': { + target: 'http://localhost:3001', + changeOrigin: true, + }, + }, }, })