feat: add Node.js/Express API with real data from Desk CRM
- ✅ API completa em /api com TypeScript - ✅ Google Calendar integration (pessoal + profissional) - ✅ Queries diretas à BD: tasks, leads, projectos, billing, pipeline - ✅ Endpoints: /api/dashboard, /api/monitor, /api/health - ✅ Vite proxy configurado (/api → localhost:3001) - ✅ App.tsx usa /api/dashboard (não mais dados mock) - ✅ Migração completa do PHP (index.php + monitor.php) - ✅ CHANGELOG.md criado para tracking - ✅ Scripts npm: dev (paralelo), dev:api, dev:ui, start Dependencies: - express, cors, mysql2, googleapis - concurrently, tsx (dev) Breaking: PHP backend será descontinuado See: CHANGELOG.md, api/README.md Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
12
.env.example
Normal file
12
.env.example
Normal file
@@ -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
|
||||
137
CHANGELOG.md
Normal file
137
CHANGELOG.md
Normal file
@@ -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.
|
||||
98
api/README.md
Normal file
98
api/README.md
Normal file
@@ -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
|
||||
32
api/db.ts
Normal file
32
api/db.ts
Normal file
@@ -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
|
||||
100
api/routes/dashboard.ts
Normal file
100
api/routes/dashboard.ts
Normal file
@@ -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
|
||||
22
api/routes/monitor.ts
Normal file
22
api/routes/monitor.ts
Normal file
@@ -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
|
||||
40
api/server.ts
Normal file
40
api/server.ts
Normal file
@@ -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`)
|
||||
})
|
||||
106
api/services/calendar.ts
Normal file
106
api/services/calendar.ts
Normal file
@@ -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<CalendarEvent[]> {
|
||||
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<CalendarEvent[]> {
|
||||
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<CalendarEvent[]> {
|
||||
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)
|
||||
})
|
||||
}
|
||||
280
api/services/dashboard.ts
Normal file
280
api/services/dashboard.ts
Normal file
@@ -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<RowDataPacket[]>(`
|
||||
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<RowDataPacket[]>(`
|
||||
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<RowDataPacket[]>(`
|
||||
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<RowDataPacket[]>(`
|
||||
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<RowDataPacket[]>(`
|
||||
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<RowDataPacket[]>(`
|
||||
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<RowDataPacket[]>(`
|
||||
SELECT ticketid, subject, priority
|
||||
FROM tbltickets
|
||||
WHERE status = 1
|
||||
`)
|
||||
return rows
|
||||
}
|
||||
|
||||
// Leads
|
||||
export async function getContactarLeads() {
|
||||
const [rows] = await db.query<RowDataPacket[]>(`
|
||||
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<RowDataPacket[]>(`
|
||||
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<RowDataPacket[]>(`
|
||||
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<RowDataPacket[]>(`
|
||||
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<RowDataPacket[]>(`
|
||||
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<RowDataPacket[]>(`
|
||||
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<RowDataPacket[]>(`
|
||||
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<RowDataPacket[]>(`
|
||||
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<RowDataPacket[]>(`
|
||||
SELECT COUNT(*) as qtd, COALESCE(SUM(subtotal), 0) as valor
|
||||
FROM tblproposals
|
||||
WHERE status = 4
|
||||
`)
|
||||
|
||||
const [total] = await db.query<RowDataPacket[]>(`
|
||||
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)
|
||||
}
|
||||
}
|
||||
85
api/services/monitoring.ts
Normal file
85
api/services/monitoring.ts
Normal file
@@ -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<RowDataPacket[]>(`
|
||||
SELECT * FROM tbl_eal_monitoring
|
||||
ORDER BY category, name
|
||||
`)
|
||||
|
||||
// Get summary by category
|
||||
const [summary] = await db.query<RowDataPacket[]>(`
|
||||
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<string, MonitoringItem[]> = {}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
17
package.json
17
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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -13,5 +13,11 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 3050,
|
||||
host: true,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3001',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user