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:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user