1import { isSlaMissed, twoUnitDuration } from "@opendesk/shared/utils";
2import { dayjsLocal } from "frappe-ui";
3import { computed, type ComputedRef, type Ref } from "vue";
4
5export type SLAState = "due" | "fulfilled" | "overdue" | "failed" | "hold";
6
7export interface SLAMetric {
8 state: SLAState;
9 /** Short display value, e.g. "2d 4h", "Overdue by 59m", "On Hold" */
10 value: string;
11 color: "orange" | "green" | "red" | "blue" | "violet";
12 /** Empty when the SLA target was never set or was cleared on pause */
13 dueBy: string;
14 /** When the SLA was actually met: first_responded_on / resolution_date */
15 actual: string;
16 /** How late, e.g. "+59m" — set for overdue and failed states */
17 delay: string;
18 /** True when delay comes from the SLA engine and counts working hours only */
19 delayInWorkingHours?: boolean;
20 /** Wall-clock delay, shown alongside the working-hours delay */
21 calendarDelay?: string;
22 /** How long the metric took when fulfilled, e.g. "3h 20m" */
23 fulfilledIn?: string;
24}
25
26interface TicketLike {
27 doc?: Record<string, any>;
28}
29
30const inkClasses: Record<SLAMetric["color"], string> = {
31 orange: "text-ink-amber-8",
32 green: "text-ink-green-8",
33 red: "text-ink-red-7",
34 blue: "text-ink-blue-8",
35 violet: "text-ink-violet-8",
36};
37
38// "Due" is the resting state of every open ticket, so it stays gray; colour
39// only appears when something changed (fulfilled, overdue, failed, hold).
40export function slaTextColor(metric: SLAMetric): string {
41 return metric.state === "due" ? "text-ink-gray-7" : inkClasses[metric.color];
42}
43
44export function useSLA(ticket: Ref<TicketLike | null | undefined>): {
45 firstResponse: ComputedRef<SLAMetric | null>;
46 resolution: ComputedRef<SLAMetric | null>;
47} {
48 const doc = computed(() => ticket.value?.doc ?? {});
49
50 // Cases:
51 // - responded before due (or with no recorded due date) -> fulfilled
52 // - responded after due -> failed
53 // - not responded, due in future -> due
54 // - not responded, due in past -> overdue
55 // - no policy -> null (card hidden), whether it never had one or was detached
56 const firstResponse = computed<SLAMetric | null>(() => {
57 const d = doc.value;
58 if (!d.sla) return null; // nothing promised, so nothing to report against
59
60 if (d.first_responded_on) {
61 if (!isSlaMissed(d.first_responded_on, d.response_by)) {
62 return {
63 ...metric("fulfilled", "", "green", {
64 dueBy: d.response_by,
65 actual: d.first_responded_on,
66 }),
67 fulfilledIn: d.first_response_time
68 ? twoUnitDuration(d.first_response_time * 1000)
69 : shortDuration(d.first_responded_on, d.creation),
70 };
71 }
72 const failed = d.first_response_failed_by
73 ? twoUnitDuration(d.first_response_failed_by * 1000)
74 : shortDuration(d.first_responded_on, d.response_by);
75 return {
76 ...metric("failed", `Failed by ${failed}`, "red", {
77 dueBy: d.response_by,
78 actual: d.first_responded_on,
79 delay: `+${failed}`,
80 }),
81 delayInWorkingHours: Boolean(d.first_response_failed_by),
82 calendarDelay: d.first_response_failed_by
83 ? shortDuration(d.first_responded_on, d.response_by)
84 : undefined,
85 };
86 }
87
88 if (!d.response_by) return null;
89
90 if (dayjsLocal().isBefore(dayjsLocal(d.response_by))) {
91 return metric(
92 "due",
93 `Due in ${coarseDuration(d.response_by)}`,
94 "orange",
95 {
96 dueBy: d.response_by,
97 }
98 );
99 }
100 const overdue = coarseDuration(d.response_by);
101 return metric("overdue", `Overdue by ${overdue}`, "red", {
102 dueBy: d.response_by,
103 delay: `+${overdue}`,
104 });
105 });
106
107 // Same cases as first response, plus:
108 // - paused before the deadline -> hold (pausing clears resolution_by, so an
109 // empty target while paused still means hold, not "no SLA")
110 const resolution = computed<SLAMetric | null>(() => {
111 const d = doc.value;
112 if (!d.sla) return null;
113
114 const pausedBeforeBreach =
115 !d.resolution_by ||
116 dayjsLocal(d.resolution_by).isAfter(dayjsLocal(d.on_hold_since));
117 if (
118 d.status_category === "Paused" &&
119 d.on_hold_since &&
120 pausedBeforeBreach
121 ) {
122 return metric("hold", "On Hold", "blue", { dueBy: d.resolution_by });
123 }
124
125 if (d.resolution_date) {
126 if (!isSlaMissed(d.resolution_date, d.resolution_by)) {
127 return {
128 ...metric("fulfilled", "", "green", {
129 dueBy: d.resolution_by,
130 actual: d.resolution_date,
131 }),
132 fulfilledIn: d.resolution_time
133 ? twoUnitDuration(d.resolution_time * 1000)
134 : shortDuration(d.resolution_date, d.creation),
135 };
136 }
137 const failed = d.resolution_failed_by
138 ? twoUnitDuration(d.resolution_failed_by * 1000)
139 : shortDuration(d.resolution_date, d.resolution_by);
140 return {
141 ...metric("failed", `Failed by ${failed}`, "red", {
142 dueBy: d.resolution_by,
143 actual: d.resolution_date,
144 delay: `+${failed}`,
145 }),
146 delayInWorkingHours: Boolean(d.resolution_failed_by),
147 calendarDelay: d.resolution_failed_by
148 ? shortDuration(d.resolution_date, d.resolution_by)
149 : undefined,
150 };
151 }
152
153 if (!d.resolution_by) return null;
154
155 if (dayjsLocal().isBefore(dayjsLocal(d.resolution_by))) {
156 return metric(
157 "due",
158 `Due in ${coarseDuration(d.resolution_by)}`,
159 "violet",
160 { dueBy: d.resolution_by }
161 );
162 }
163 const overdue = coarseDuration(d.resolution_by);
164 return metric("overdue", `Overdue by ${overdue}`, "red", {
165 dueBy: d.resolution_by,
166 delay: `+${overdue}`,
167 });
168 });
169
170 return { firstResponse, resolution };
171}
172
173function metric(
174 state: SLAState,
175 value: string,
176 color: SLAMetric["color"],
177 extra: Partial<Pick<SLAMetric, "dueBy" | "actual" | "delay">>
178): SLAMetric {
179 return {
180 state,
181 value,
182 color,
183 dueBy: extra.dueBy || "",
184 actual: extra.actual || "",
185 delay: extra.delay || "",
186 };
187}
188
189function shortDuration(date: string, end?: string): string {
190 return twoUnitDuration(
191 dayjsLocal(date).diff(end ? dayjsLocal(end) : dayjsLocal())
192 );
193}
194
195/** Distance from now in whole minutes. Countdown values ("Due in",
196 * "Overdue by") don't tick, so seconds would read as a frozen timer. */
197function coarseDuration(date: string): string {
198 const diff = dayjsLocal(date)
199 .startOf("minute")
200 .diff(dayjsLocal().startOf("minute"));
201 return twoUnitDuration(Math.abs(diff));
202}
203