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:
2026-02-04 03:26:24 +00:00
parent 6d7280566e
commit a766f3a765
13 changed files with 935 additions and 5 deletions

106
api/services/calendar.ts Normal file
View 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
View 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)
}
}

View 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
}
}
}