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

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