Initial import of Forge app
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
import { pool, rowToTicket, loadConfig } from './db';
|
||||
import type { Ticket } from './types';
|
||||
|
||||
// PM Insights: the problem/alert KPIs over the ACTIVE backlog, mirroring the
|
||||
// initial app's insights-button.js. Each group carries its full list plus the
|
||||
// "problematic" subset (what drives the alert counts and revenue-at-risk).
|
||||
|
||||
const DAY = 86_400_000;
|
||||
function daysSince(v: string | null): number | null {
|
||||
if (!v) return null;
|
||||
const iso = v.includes('T') ? v : v.replace(' ', 'T');
|
||||
const d = new Date(iso);
|
||||
return Number.isNaN(d.getTime()) ? null : (Date.now() - d.getTime()) / DAY;
|
||||
}
|
||||
function gbp(t: Ticket, fx: Record<string, number>): number {
|
||||
if (t.finalCost == null || !(t.finalCost > 0)) return 0;
|
||||
const rate = fx[(t.currencyCode || 'GBP').toUpperCase()] ?? 1;
|
||||
return t.finalCost / rate;
|
||||
}
|
||||
|
||||
function isColleague(name: string | null, colleagues: Set<string>): boolean {
|
||||
return !!name && colleagues.has(name.trim().toLowerCase());
|
||||
}
|
||||
function isAwaitingAgency(t: Ticket, colleagues: Set<string>): boolean {
|
||||
if (!/progress/i.test(t.state)) return false;
|
||||
if (colleagues.size > 0) return !isColleague(t.lastActivityBy, colleagues);
|
||||
return !!t.lastActivityBy && t.lastActivityBy === t.requestedFor;
|
||||
}
|
||||
|
||||
// Jira breached: linked, non-terminal Jira whose status has aged past its threshold.
|
||||
function jiraBreached(t: Ticket, thr: Record<string, number>): boolean {
|
||||
const status = t.jira?.status;
|
||||
if (!status) return false;
|
||||
if (/closed|resolved|done|cancel|released|live/i.test(status)) return false;
|
||||
const age = daysSince(t.jira?.statusChangedAt ?? null);
|
||||
if (age == null) return false;
|
||||
const limit = /waiting.?po/i.test(status) ? (thr.waitingPo1 ?? 7)
|
||||
: /uat/i.test(status) ? (thr.jiraUAT ?? 7)
|
||||
: (thr.jiraStuck ?? 7);
|
||||
return age >= limit;
|
||||
}
|
||||
function clientOwed(t: Ticket, colleagues: Set<string>): boolean {
|
||||
if (isColleague(t.lastActivityBy, colleagues)) return false;
|
||||
const age = daysSince(t.lastActivityAt);
|
||||
return age != null && age >= 1;
|
||||
}
|
||||
|
||||
export interface InsightTicket {
|
||||
number: string; shortDesc: string; assignedTo: string | null;
|
||||
brand: string | null; market: string | null; state: string;
|
||||
days: number | null; link: string | null; jiraStatus: string | null; costGbp: number;
|
||||
}
|
||||
export interface AlertGroup { key: string; label: string; count: number; problematic: number; tickets: InsightTicket[]; }
|
||||
export interface PmRow { pm: string; tickets: number; revenue: number; alerts: number; revAtRisk: number; }
|
||||
export interface InsightsResponse {
|
||||
totalAlerts: number;
|
||||
revAtRisk: number;
|
||||
groups: AlertGroup[];
|
||||
waitingPo: { count: number; revenue: number; levels: { l1: number; l2: number; l3: number } };
|
||||
pms: PmRow[];
|
||||
generatedAt: string;
|
||||
}
|
||||
|
||||
async function fetchActive(): Promise<Ticket[]> {
|
||||
const { rows } = await pool.query('SELECT * FROM tickets WHERE status = $1', ['active']);
|
||||
return rows.map(rowToTicket);
|
||||
}
|
||||
|
||||
function toInsightTicket(t: Ticket, days: number | null, fx: Record<string, number>): InsightTicket {
|
||||
return {
|
||||
number: t.number, shortDesc: t.shortDesc, assignedTo: t.assignedTo,
|
||||
brand: t.brand, market: t.market, state: t.state,
|
||||
days: days != null ? Math.floor(days) : null, link: t.link,
|
||||
jiraStatus: t.jira?.status ?? null, costGbp: Math.round(gbp(t, fx)),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getInsights(): Promise<InsightsResponse> {
|
||||
const tickets = await fetchActive();
|
||||
const cfg = loadConfig();
|
||||
const thr = cfg.insightsThresholds ?? {};
|
||||
const fx = cfg.fxRates ?? { GBP: 1 };
|
||||
const colleagues = new Set((cfg.colleagues ?? []).map(c => c.trim().toLowerCase()));
|
||||
|
||||
const problematic = new Set<string>(); // ticket numbers flagged by any SN-alert group
|
||||
const groups: AlertGroup[] = [];
|
||||
|
||||
const push = (key: string, label: string, members: Ticket[], isProb: (t: Ticket) => boolean, daysOf: (t: Ticket) => number | null, countProbTowardTotal = true) => {
|
||||
const list = members.map(t => ({ t, prob: isProb(t), days: daysOf(t) }));
|
||||
const probList = list.filter(x => x.prob);
|
||||
if (countProbTowardTotal) probList.forEach(x => problematic.add(x.t.number));
|
||||
groups.push({
|
||||
key, label, count: members.length, problematic: probList.length,
|
||||
tickets: list.sort((a, b) => (b.days ?? 0) - (a.days ?? 0)).map(x => toInsightTicket(x.t, x.days, fx)),
|
||||
});
|
||||
};
|
||||
|
||||
const active = tickets;
|
||||
const lifetimeOf = (t: Ticket) => daysSince(t.openedAt);
|
||||
const inStateOf = (t: Ticket) => daysSince(t.stateChangedAt);
|
||||
const sinceTouch = (t: Ticket) => daysSince(t.lastActivityAt);
|
||||
|
||||
push('unassigned', 'Unassigned',
|
||||
active.filter(t => !t.assignedTo),
|
||||
t => (lifetimeOf(t) ?? 0) >= (thr.unassigned ?? 1), lifetimeOf);
|
||||
|
||||
push('assigned', 'Open / Assigned',
|
||||
active.filter(t => t.assignedTo && /open|new|assigned/i.test(t.state) && !/progress|hold|awaiting|closed/i.test(t.state)),
|
||||
t => (inStateOf(t) ?? 0) >= (thr.assigned ?? 2), inStateOf);
|
||||
|
||||
push('hold', 'On Hold',
|
||||
active.filter(t => /on.?hold/i.test(t.state)),
|
||||
t => (inStateOf(t) ?? 0) >= (thr.hold ?? 5) || jiraBreached(t, thr) || clientOwed(t, colleagues), inStateOf);
|
||||
|
||||
const wip = active.filter(t => /progress/i.test(t.state) && !isAwaitingAgency(t, colleagues));
|
||||
push('wipStalled', 'WIP — Jira stalled',
|
||||
wip.filter(t => t.jira?.status),
|
||||
t => jiraBreached(t, thr), t => daysSince(t.jira?.statusChangedAt ?? null));
|
||||
push('wipNoJira', 'WIP — no Jira link',
|
||||
wip.filter(t => !t.jira?.status),
|
||||
() => true, inStateOf);
|
||||
|
||||
push('replied', 'Customer replied',
|
||||
active.filter(t => isAwaitingAgency(t, colleagues)),
|
||||
t => (sinceTouch(t) ?? 0) >= (thr.customerReplied ?? 1) || jiraBreached(t, thr), sinceTouch);
|
||||
|
||||
push('awaiting', 'Awaiting customer info',
|
||||
active.filter(t => /awaiting/i.test(t.state)),
|
||||
t => (inStateOf(t) ?? 0) >= (thr.awaiting ?? 5) || jiraBreached(t, thr), inStateOf);
|
||||
|
||||
// Informational groups (not counted toward Total Alerts / rev-at-risk)
|
||||
push('lifetime', 'Lifetime monsters (≥90d)',
|
||||
active.filter(t => (lifetimeOf(t) ?? 0) >= (thr.lifetime ?? 90)),
|
||||
() => true, lifetimeOf, false);
|
||||
push('inactive', 'Inactive requester',
|
||||
active.filter(t => /\(inactive\)/i.test(t.requestedFor ?? '')),
|
||||
() => true, lifetimeOf, false);
|
||||
|
||||
// Waiting PO: Jira "Waiting PO" or a finance row with a blank PO (still delivered).
|
||||
const waiting = active.filter(t => /waiting.?po/i.test(t.jira?.status ?? '') || t.poNumber === '');
|
||||
const poAge = (t: Ticket) => daysSince(t.stateChangedAt) ?? 0;
|
||||
const levels = { l1: 0, l2: 0, l3: 0 };
|
||||
for (const t of waiting) {
|
||||
const a = poAge(t);
|
||||
if (a >= (thr.waitingPo3 ?? 18)) levels.l3++;
|
||||
else if (a >= (thr.waitingPo2 ?? 14)) levels.l2++;
|
||||
else if (a >= (thr.waitingPo1 ?? 7)) levels.l1++;
|
||||
}
|
||||
const waitingRevenue = Math.round(waiting.reduce((s, t) => s + gbp(t, fx), 0));
|
||||
|
||||
// Per-PM roll-up
|
||||
const pmMap = new Map<string, PmRow>();
|
||||
for (const t of active) {
|
||||
const pm = t.assignedTo ?? 'Unassigned';
|
||||
const row = pmMap.get(pm) ?? { pm, tickets: 0, revenue: 0, alerts: 0, revAtRisk: 0 };
|
||||
row.tickets++;
|
||||
row.revenue += gbp(t, fx);
|
||||
if (problematic.has(t.number)) { row.alerts++; row.revAtRisk += gbp(t, fx); }
|
||||
pmMap.set(pm, row);
|
||||
}
|
||||
const pms = [...pmMap.values()]
|
||||
.map(r => ({ ...r, revenue: Math.round(r.revenue), revAtRisk: Math.round(r.revAtRisk) }))
|
||||
.sort((a, b) => b.alerts - a.alerts || b.revAtRisk - a.revAtRisk);
|
||||
|
||||
const revAtRisk = Math.round([...problematic].reduce((s, num) => s + gbp(active.find(t => t.number === num)!, fx), 0));
|
||||
|
||||
return {
|
||||
totalAlerts: problematic.size,
|
||||
revAtRisk,
|
||||
groups,
|
||||
waitingPo: { count: waiting.length, revenue: waitingRevenue, levels },
|
||||
pms,
|
||||
generatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user