144 lines
4.8 KiB
TypeScript
144 lines
4.8 KiB
TypeScript
|
|
/**
|
|||
|
|
* GET /api/dashboard/trends
|
|||
|
|
* Operational trend data backing /dashboard's chart row + queue posture.
|
|||
|
|
*
|
|||
|
|
* volumeByDay — last 30 days, ticket creation count per day
|
|||
|
|
* resolutionByDay — last 30 days, mean resolution hours per day completed
|
|||
|
|
* queueHeatmap — open tickets grouped by (queue, priority)
|
|||
|
|
* activeEngineers — top engineers today by hours logged
|
|||
|
|
*
|
|||
|
|
* All queries run in parallel. ~50 ms total against a warm DB.
|
|||
|
|
*/
|
|||
|
|
|
|||
|
|
import { NextResponse } from 'next/server';
|
|||
|
|
import { requireAuth } from '@/lib/auth-utils';
|
|||
|
|
import postgresClient from '@/lib/services/postgres-client';
|
|||
|
|
|
|||
|
|
const TREND_DAYS = 30;
|
|||
|
|
const TOP_QUEUES = 10;
|
|||
|
|
const TOP_ENGINEERS = 8;
|
|||
|
|
|
|||
|
|
export async function GET() {
|
|||
|
|
const { error } = await requireAuth();
|
|||
|
|
if (error) return error;
|
|||
|
|
|
|||
|
|
const [volumeRes, resolutionRes, heatmapRes, engineersRes] = await Promise.all([
|
|||
|
|
postgresClient.query<{ d: string; count: string }>(
|
|||
|
|
`WITH days AS (
|
|||
|
|
SELECT generate_series(
|
|||
|
|
CURRENT_DATE - INTERVAL '${TREND_DAYS - 1} days',
|
|||
|
|
CURRENT_DATE,
|
|||
|
|
INTERVAL '1 day'
|
|||
|
|
)::date AS d
|
|||
|
|
)
|
|||
|
|
SELECT d::text AS d,
|
|||
|
|
COALESCE(COUNT(t.id), 0)::text AS count
|
|||
|
|
FROM days
|
|||
|
|
LEFT JOIN tickets t
|
|||
|
|
ON t.create_date::date = days.d
|
|||
|
|
AND (t.is_deleted = false OR t.is_deleted IS NULL)
|
|||
|
|
GROUP BY d
|
|||
|
|
ORDER BY d`,
|
|||
|
|
),
|
|||
|
|
postgresClient.query<{ d: string; avg_hours: string | null }>(
|
|||
|
|
`WITH days AS (
|
|||
|
|
SELECT generate_series(
|
|||
|
|
CURRENT_DATE - INTERVAL '${TREND_DAYS - 1} days',
|
|||
|
|
CURRENT_DATE,
|
|||
|
|
INTERVAL '1 day'
|
|||
|
|
)::date AS d
|
|||
|
|
)
|
|||
|
|
SELECT d::text AS d,
|
|||
|
|
AVG(EXTRACT(EPOCH FROM (t.completed_date - t.create_date)) / 3600.0)::text AS avg_hours
|
|||
|
|
FROM days
|
|||
|
|
LEFT JOIN tickets t
|
|||
|
|
ON t.completed_date::date = days.d
|
|||
|
|
AND t.create_date IS NOT NULL
|
|||
|
|
AND (t.is_deleted = false OR t.is_deleted IS NULL)
|
|||
|
|
GROUP BY d
|
|||
|
|
ORDER BY d`,
|
|||
|
|
),
|
|||
|
|
postgresClient.query<{
|
|||
|
|
queue_id: number | null;
|
|||
|
|
queue_label: string | null;
|
|||
|
|
priority: number | null;
|
|||
|
|
count: string;
|
|||
|
|
}>(
|
|||
|
|
`SELECT t.queue_id,
|
|||
|
|
q.label AS queue_label,
|
|||
|
|
t.priority,
|
|||
|
|
COUNT(*)::text AS count
|
|||
|
|
FROM tickets t
|
|||
|
|
LEFT JOIN queues q ON q.value = t.queue_id
|
|||
|
|
WHERE t.completed_date IS NULL
|
|||
|
|
AND (t.is_deleted = false OR t.is_deleted IS NULL)
|
|||
|
|
GROUP BY t.queue_id, q.label, t.priority
|
|||
|
|
ORDER BY COUNT(*) DESC`,
|
|||
|
|
),
|
|||
|
|
postgresClient.query<{
|
|||
|
|
resource_id: string;
|
|||
|
|
resource_name: string;
|
|||
|
|
hours: string;
|
|||
|
|
tickets_touched: string;
|
|||
|
|
}>(
|
|||
|
|
`SELECT te.resource_id::text,
|
|||
|
|
COALESCE(NULLIF(TRIM(r.first_name || ' ' || COALESCE(r.last_name, '')), ''),
|
|||
|
|
r.email,
|
|||
|
|
'Resource ' || te.resource_id) AS resource_name,
|
|||
|
|
SUM(te.hours_worked)::text AS hours,
|
|||
|
|
COUNT(DISTINCT te.ticket_id)::text AS tickets_touched
|
|||
|
|
FROM time_entries te
|
|||
|
|
LEFT JOIN resources r ON r.id = te.resource_id
|
|||
|
|
WHERE te.entry_date::date = CURRENT_DATE
|
|||
|
|
AND te.hours_worked > 0
|
|||
|
|
GROUP BY te.resource_id, r.first_name, r.last_name, r.email
|
|||
|
|
ORDER BY SUM(te.hours_worked) DESC
|
|||
|
|
LIMIT ${TOP_ENGINEERS}`,
|
|||
|
|
),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
// Heatmap: top N queues by open volume × priority columns
|
|||
|
|
const heatmapRows = heatmapRes.rows;
|
|||
|
|
const queueTotals = new Map<number, { id: number; label: string; total: number }>();
|
|||
|
|
for (const row of heatmapRows) {
|
|||
|
|
if (row.queue_id == null) continue;
|
|||
|
|
const t = queueTotals.get(row.queue_id) ?? {
|
|||
|
|
id: row.queue_id,
|
|||
|
|
label: row.queue_label ?? `Queue ${row.queue_id}`,
|
|||
|
|
total: 0,
|
|||
|
|
};
|
|||
|
|
t.total += parseInt(row.count, 10);
|
|||
|
|
queueTotals.set(row.queue_id, t);
|
|||
|
|
}
|
|||
|
|
const topQueues = [...queueTotals.values()]
|
|||
|
|
.sort((a, b) => b.total - a.total)
|
|||
|
|
.slice(0, TOP_QUEUES);
|
|||
|
|
|
|||
|
|
const heatmap = topQueues.map((q) => {
|
|||
|
|
const cells: Record<number, number> = {};
|
|||
|
|
for (const row of heatmapRows) {
|
|||
|
|
if (row.queue_id !== q.id || row.priority == null) continue;
|
|||
|
|
cells[row.priority] = (cells[row.priority] ?? 0) + parseInt(row.count, 10);
|
|||
|
|
}
|
|||
|
|
return { queueId: q.id, queueLabel: q.label, total: q.total, byPriority: cells };
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return NextResponse.json({
|
|||
|
|
volumeByDay: volumeRes.rows.map((r) => ({
|
|||
|
|
date: r.d,
|
|||
|
|
count: parseInt(r.count, 10),
|
|||
|
|
})),
|
|||
|
|
resolutionByDay: resolutionRes.rows.map((r) => ({
|
|||
|
|
date: r.d,
|
|||
|
|
avgHours: r.avg_hours == null ? null : Math.round(parseFloat(r.avg_hours) * 10) / 10,
|
|||
|
|
})),
|
|||
|
|
queueHeatmap: heatmap,
|
|||
|
|
activeEngineers: engineersRes.rows.map((r) => ({
|
|||
|
|
resourceId: r.resource_id,
|
|||
|
|
name: r.resource_name,
|
|||
|
|
hours: Math.round(parseFloat(r.hours) * 10) / 10,
|
|||
|
|
ticketsTouched: parseInt(r.tickets_touched, 10),
|
|||
|
|
})),
|
|||
|
|
});
|
|||
|
|
}
|