1<template>
2 <div v-if="cards.length" class="space-y-0.5">
3 <div
4 v-for="card in cards"
5 :key="card.title"
6 class="flex min-h-7 items-center gap-2 leading-5"
7 >
8 <FieldLabel :label="card.title" />
9 <!-- 9px = field controls' 8px padding + 1px transparent border, so the
10 value lines up with the other sidebar field values -->
11 <div class="flex min-w-0 flex-1 items-center gap-1.5 ps-[9px]">
12 <span
13 class="min-w-0 truncate text-base"
14 :class="slaTextColor(card.metric)"
15 >
16 {{ cardValue(card) }}
17 </span>
18 <!-- HoverCard, not Popover: this opens on hover, and reka's popover
19 trigger would also toggle it on click -->
20 <HoverCard side="bottom" align="center">
21 <template #trigger>
22 <LucideInfo
23 class="size-3.5 shrink-0 cursor-pointer text-ink-gray-5"
24 />
25 </template>
26 <template #default>
27 <div class="flex min-w-[170px] flex-col gap-2.5 p-4 text-sm">
28 <div
29 v-for="row in cardDetails(card)"
30 :key="row.label"
31 class="flex items-baseline justify-between gap-8"
32 >
33 <span class="text-ink-gray-5">{{ __(row.label) }}</span>
34 <span
35 class="tabular-nums"
36 :class="row.danger ? 'text-ink-red-6' : 'text-ink-gray-8'"
37 >
38 {{ row.value }}
39 </span>
40 </div>
41 </div>
42 </template>
43 </HoverCard>
44 </div>
45 </div>
46 </div>
47</template>
48
49<script setup lang="ts">
50import FieldLabel from "@/components/FieldLabel.vue";
51import { slaTextColor, useSLA, type SLAMetric } from "@/composables/useSLA";
52import { __ } from "@/translation";
53import { TicketSymbol } from "@/types";
54import { dateFormat } from "@/utils";
55import { HoverCard } from "frappe-ui";
56import { computed, inject } from "vue";
57import LucideInfo from "~icons/lucide/info";
58
59interface SLACard {
60 title: string;
61 metric: SLAMetric;
62 fulfilledLabel: string;
63 actualLabel: string;
64}
65
66const ticket = inject(TicketSymbol)!;
67const { firstResponse, resolution } = useSLA(ticket);
68
69const cards = computed<SLACard[]>(() =>
70 [
71 {
72 title: "First Response",
73 metric: firstResponse.value,
74 fulfilledLabel: "Fulfilled",
75 actualLabel: "Responded on",
76 },
77 {
78 title: "Resolution",
79 metric: resolution.value,
80 fulfilledLabel: "Fulfilled",
81 actualLabel: "Resolved on",
82 },
83 ].filter((card): card is SLACard => Boolean(card.metric))
84);
85
86function cardValue(card: SLACard): string {
87 if (card.metric.state !== "fulfilled") return __(card.metric.value);
88 if (!card.metric.fulfilledIn) return __(card.fulfilledLabel);
89 return `${__(card.fulfilledLabel)} ${__("in")} ${card.metric.fulfilledIn}`;
90}
91
92function cardDetails(card: SLACard) {
93 const metric = card.metric;
94 const rows = [];
95 if (metric.dueBy) {
96 rows.push({ label: "Due by", value: fmt(metric.dueBy), danger: false });
97 }
98 if (metric.state === "hold") {
99 rows.push({
100 label: "On hold since",
101 value: fmt(ticket.value.doc.on_hold_since as string),
102 danger: false,
103 });
104 }
105 if (metric.actual) {
106 // The timestamp is neutral fact; only the Delay rows carry the failure
107 // signal, so the responded/resolved date stays in the default ink.
108 rows.push({
109 label: card.actualLabel,
110 value: fmt(metric.actual),
111 danger: false,
112 });
113 }
114 if (metric.delay) {
115 rows.push({
116 label: metric.delayInWorkingHours ? "Delay (working hours)" : "Delay",
117 value: metric.delay,
118 danger: true,
119 });
120 if (metric.calendarDelay) {
121 rows.push({
122 label: "Delay (total)",
123 value: `+${metric.calendarDelay}`,
124 danger: true,
125 });
126 }
127 }
128 if (metric.fulfilledIn) {
129 rows.push({
130 label: "Fulfilled in",
131 value: metric.fulfilledIn,
132 danger: false,
133 });
134 }
135 return rows;
136}
137
138function fmt(date: string): string {
139 // Year included: SLA breaches span months/years, so a bare "MMM D" makes the
140 // due/actual dates read as contradictory (e.g. resolved "before" the due date).
141 return dateFormat(date, "MMM D, YYYY, h:mm A");
142}
143</script>
144