Files
emaui/apps/backoffice/src/app/features/license-review/sla.ts
2026-08-02 22:44:08 +03:00

90 lines
2.8 KiB
TypeScript

import type { LicenseApplication } from '@ema-platform/api';
/** Amber once this much of the window has been consumed. */
const WARNING_RATIO = 0.7;
const HOUR_MS = 60 * 60 * 1000;
export interface SlaState {
state: 'ok' | 'warning' | 'breached' | 'untracked' | 'decided';
/** Mantine colour. Always paired with `label` — never colour alone. */
color: string;
/** Short text for the badge, e.g. "2d left" or "Overdue 6h". */
label: string;
/** The full explanation, including the target, for the tooltip. */
tooltip: string;
/** Fraction of the window used, clamped to 0..1. */
ratio: number;
}
function formatDuration(ms: number): string {
const hours = Math.floor(Math.abs(ms) / HOUR_MS);
if (hours < 1) return '<1h';
if (hours < 48) return `${hours}h`;
return `${Math.floor(hours / 24)}d`;
}
/**
* How an application is tracking against its licence type's SLA.
*
* Types with no `slaHours` are untracked rather than instantly overdue — the
* authority has not set a target for them, which is not the same as missing
* one. Decided applications stop the clock: an approval that took three weeks
* is history, not an outstanding breach.
*/
export function computeSla(
application: LicenseApplication,
now: number = Date.now(),
): SlaState {
const slaHours = application.licenseType?.slaHours;
const submittedAt = application.submittedAt;
if (!slaHours || !submittedAt) {
return {
state: 'untracked',
color: 'gray',
label: '—',
tooltip: 'No turnaround target is set for this licence type.',
ratio: 0,
};
}
const submitted = new Date(submittedAt).getTime();
const target = submitted + slaHours * HOUR_MS;
const elapsed = (application.decidedAt ? new Date(application.decidedAt).getTime() : now) - submitted;
const window = slaHours * HOUR_MS;
const ratio = Math.min(Math.max(elapsed / window, 0), 1);
const targetText = `Target ${slaHours}h from submission (${new Date(target).toLocaleString()})`;
if (application.decidedAt) {
const met = elapsed <= window;
return {
state: 'decided',
color: met ? 'teal' : 'gray',
label: met ? 'Met' : 'Missed',
tooltip: `Decided in ${formatDuration(elapsed)}. ${targetText}`,
ratio,
};
}
const remaining = target - now;
if (remaining < 0) {
return {
state: 'breached',
color: 'red',
label: `Overdue ${formatDuration(remaining)}`,
tooltip: `Overdue by ${formatDuration(remaining)}. ${targetText}`,
ratio: 1,
};
}
const used = elapsed / window;
return {
state: used >= WARNING_RATIO ? 'warning' : 'ok',
color: used >= WARNING_RATIO ? 'yellow' : 'teal',
label: `${formatDuration(remaining)} left`,
tooltip: `${formatDuration(remaining)} remaining. ${targetText}`,
ratio,
};
}