@consilioweb/payload-support 6.0.1 → 6.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.cjs +5 -2
- package/dist/index.js +5 -2
- package/dist/styles/TicketInbox.module.scss +4 -0
- package/dist/views/TicketInboxView/client.js +8 -4
- package/dist/views/shared/locales/en.json +55 -14
- package/dist/views/shared/locales/fr.json +55 -14
- package/dist/views/shared/sla.d.ts +20 -1
- package/dist/views/shared/sla.js +13 -2
- package/package.json +3 -2
- package/scripts/audit-sla-breaches.mjs +175 -0
- package/src/endpoints/sla-check.ts +13 -2
- package/src/hooks/checkSLA.ts +14 -1
- package/src/styles/TicketInbox.module.scss +4 -0
- package/src/views/TicketInboxView/client.tsx +20 -5
- package/src/views/shared/locales/en.json +55 -14
- package/src/views/shared/locales/fr.json +55 -14
- package/src/views/shared/sla.ts +38 -2
package/dist/index.cjs
CHANGED
|
@@ -2102,6 +2102,7 @@ function createSlaCheckEndpoint(slugs) {
|
|
|
2102
2102
|
slaResolutionDue: true,
|
|
2103
2103
|
slaFirstResponseBreached: true,
|
|
2104
2104
|
slaResolutionBreached: true,
|
|
2105
|
+
slaPausedAt: true,
|
|
2105
2106
|
firstResponseAt: true,
|
|
2106
2107
|
createdAt: true
|
|
2107
2108
|
}
|
|
@@ -2136,7 +2137,8 @@ function createSlaCheckEndpoint(slugs) {
|
|
|
2136
2137
|
}
|
|
2137
2138
|
}
|
|
2138
2139
|
if (t.slaResolutionDue) {
|
|
2139
|
-
const
|
|
2140
|
+
const pausedMs = t.slaPausedAt ? Math.max(0, now.getTime() - new Date(t.slaPausedAt).getTime()) : 0;
|
|
2141
|
+
const deadline = new Date(new Date(t.slaResolutionDue).getTime() + pausedMs);
|
|
2140
2142
|
if (now > deadline) {
|
|
2141
2143
|
ticketData.breachTypes.push("resolution");
|
|
2142
2144
|
} else {
|
|
@@ -8057,7 +8059,8 @@ function createCheckSlaOnResolve(slugs, notificationSlug = "admin-notifications"
|
|
|
8057
8059
|
try {
|
|
8058
8060
|
const { payload } = req;
|
|
8059
8061
|
const now = /* @__PURE__ */ new Date();
|
|
8060
|
-
const
|
|
8062
|
+
const pausedMs = doc.slaPausedAt ? Math.max(0, now.getTime() - new Date(doc.slaPausedAt).getTime()) : 0;
|
|
8063
|
+
const deadline = new Date(new Date(doc.slaResolutionDue).getTime() + pausedMs);
|
|
8061
8064
|
const breached = now > deadline;
|
|
8062
8065
|
await dbUpdate(payload, slugs.tickets, {
|
|
8063
8066
|
id: doc.id,
|
package/dist/index.js
CHANGED
|
@@ -2093,6 +2093,7 @@ function createSlaCheckEndpoint(slugs) {
|
|
|
2093
2093
|
slaResolutionDue: true,
|
|
2094
2094
|
slaFirstResponseBreached: true,
|
|
2095
2095
|
slaResolutionBreached: true,
|
|
2096
|
+
slaPausedAt: true,
|
|
2096
2097
|
firstResponseAt: true,
|
|
2097
2098
|
createdAt: true
|
|
2098
2099
|
}
|
|
@@ -2127,7 +2128,8 @@ function createSlaCheckEndpoint(slugs) {
|
|
|
2127
2128
|
}
|
|
2128
2129
|
}
|
|
2129
2130
|
if (t.slaResolutionDue) {
|
|
2130
|
-
const
|
|
2131
|
+
const pausedMs = t.slaPausedAt ? Math.max(0, now.getTime() - new Date(t.slaPausedAt).getTime()) : 0;
|
|
2132
|
+
const deadline = new Date(new Date(t.slaResolutionDue).getTime() + pausedMs);
|
|
2131
2133
|
if (now > deadline) {
|
|
2132
2134
|
ticketData.breachTypes.push("resolution");
|
|
2133
2135
|
} else {
|
|
@@ -8048,7 +8050,8 @@ function createCheckSlaOnResolve(slugs, notificationSlug = "admin-notifications"
|
|
|
8048
8050
|
try {
|
|
8049
8051
|
const { payload } = req;
|
|
8050
8052
|
const now = /* @__PURE__ */ new Date();
|
|
8051
|
-
const
|
|
8053
|
+
const pausedMs = doc.slaPausedAt ? Math.max(0, now.getTime() - new Date(doc.slaPausedAt).getTime()) : 0;
|
|
8054
|
+
const deadline = new Date(new Date(doc.slaResolutionDue).getTime() + pausedMs);
|
|
8052
8055
|
const breached = now > deadline;
|
|
8053
8056
|
await dbUpdate(payload, slugs.tickets, {
|
|
8054
8057
|
id: doc.id,
|
|
@@ -286,6 +286,10 @@
|
|
|
286
286
|
.slaOk { color: var(--theme-elevation-400); }
|
|
287
287
|
.slaWarn { color: var(--theme-warning-500, #c97a0a); font-weight: 600; }
|
|
288
288
|
.slaBreach { color: var(--theme-error-500, #c4392c); font-weight: 700; }
|
|
289
|
+
// Paused: deliberately neutral and italic. The clock is stopped because the
|
|
290
|
+
// team is waiting on the client, so this is not a state anyone should act on —
|
|
291
|
+
// it must not compete with the warn and breach colours next to it.
|
|
292
|
+
.slaPaused { color: var(--theme-elevation-650); font-style: italic; }
|
|
289
293
|
|
|
290
294
|
.timeAgo {
|
|
291
295
|
font-size: 12px;
|
|
@@ -105,11 +105,13 @@ const TicketInboxClient = () => {
|
|
|
105
105
|
`select[slaFirstResponseBreached]=true`,
|
|
106
106
|
`select[slaResolutionDue]=true`,
|
|
107
107
|
`select[slaResolutionBreached]=true`,
|
|
108
|
+
`select[slaPausedAt]=true`,
|
|
108
109
|
`select[firstResponseAt]=true`
|
|
109
110
|
];
|
|
110
111
|
if (tab === "sla_breach") {
|
|
111
112
|
params.push(`where[or][0][slaFirstResponseBreached][equals]=true`);
|
|
112
113
|
params.push(`where[or][1][slaResolutionBreached][equals]=true`);
|
|
114
|
+
params.push(`where[slaPausedAt][exists]=false`);
|
|
113
115
|
} else if (tab !== "all") {
|
|
114
116
|
params.push(`where[status][equals]=${tab}`);
|
|
115
117
|
}
|
|
@@ -146,7 +148,7 @@ const TicketInboxClient = () => {
|
|
|
146
148
|
fetch(`/api/tickets/count?where[status][equals]=open&${sn}`, { credentials: "include" }),
|
|
147
149
|
fetch(`/api/tickets/count?where[status][equals]=waiting_client&${sn}`, { credentials: "include" }),
|
|
148
150
|
fetch(`/api/tickets/count?where[status][equals]=resolved&${sn}`, { credentials: "include" }),
|
|
149
|
-
fetch(`/api/tickets/count?where[or][0][slaFirstResponseBreached][equals]=true&where[or][1][slaResolutionBreached][equals]=true&${sn}`, { credentials: "include" })
|
|
151
|
+
fetch(`/api/tickets/count?where[or][0][slaFirstResponseBreached][equals]=true&where[or][1][slaResolutionBreached][equals]=true&where[slaPausedAt][exists]=false&${sn}`, { credentials: "include" })
|
|
150
152
|
]);
|
|
151
153
|
const [a, o, w, r, b] = await Promise.all([all.json(), openRes.json(), waiting.json(), resolved.json(), breach.json()]);
|
|
152
154
|
setCounts({
|
|
@@ -300,6 +302,7 @@ const TicketInboxClient = () => {
|
|
|
300
302
|
const priorityColor = PRIORITY_COLORS[tk.priority] || "transparent";
|
|
301
303
|
const sla = computeSlaState(tk);
|
|
302
304
|
const isBreach = sla.state === "breach";
|
|
305
|
+
const isPaused = sla.state === "paused";
|
|
303
306
|
const statusVariant = STATUS_VARIANT[tk.status] || "open";
|
|
304
307
|
return /* @__PURE__ */ jsxs(
|
|
305
308
|
"a",
|
|
@@ -347,10 +350,11 @@ const TicketInboxClient = () => {
|
|
|
347
350
|
s.slaCell,
|
|
348
351
|
sla.state === "breach" ? s.slaBreach : "",
|
|
349
352
|
sla.state === "warn" ? s.slaWarn : "",
|
|
350
|
-
sla.state === "ok" ? s.slaOk : ""
|
|
353
|
+
sla.state === "ok" ? s.slaOk : "",
|
|
354
|
+
isPaused ? s.slaPaused : ""
|
|
351
355
|
].filter(Boolean).join(" "),
|
|
352
|
-
title: sla.due ? new Date(sla.due).toLocaleString(DATE_LOCALE) : "",
|
|
353
|
-
children: formatSlaRemaining(sla.remainingMs)
|
|
356
|
+
title: isPaused ? t("inbox.slaPausedTitle", { since: formatSlaRemaining(sla.pausedMs ?? 0) }) : sla.due ? new Date(sla.due).toLocaleString(DATE_LOCALE) : "",
|
|
357
|
+
children: isPaused ? t("inbox.slaPaused") : formatSlaRemaining(sla.remainingMs)
|
|
354
358
|
}
|
|
355
359
|
),
|
|
356
360
|
/* @__PURE__ */ jsx("span", { className: s.timeAgo, children: relativeTime(tk.updatedAt, t) }),
|
|
@@ -348,7 +348,9 @@
|
|
|
348
348
|
"responseAwaitedFor": "Awaiting for {{duration}}",
|
|
349
349
|
"target": "Due: {{target}}",
|
|
350
350
|
"escalate": "Escalate"
|
|
351
|
-
}
|
|
351
|
+
},
|
|
352
|
+
"slaPaused": "paused",
|
|
353
|
+
"slaPausedTitle": "SLA paused for {since} — the clock resumes when the client replies"
|
|
352
354
|
},
|
|
353
355
|
"emailTracking": {
|
|
354
356
|
"title": "Email tracking",
|
|
@@ -695,19 +697,58 @@
|
|
|
695
697
|
"saved2": "✓ Saved",
|
|
696
698
|
"saveChanges": "Save changes",
|
|
697
699
|
"features": {
|
|
698
|
-
"canned": {
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
"
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
"
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
"
|
|
700
|
+
"canned": {
|
|
701
|
+
"label": "Quick replies",
|
|
702
|
+
"description": "Pre-registered response templates with dynamic variables"
|
|
703
|
+
},
|
|
704
|
+
"scheduledReplies": {
|
|
705
|
+
"label": "Scheduled replies",
|
|
706
|
+
"description": "Send a reply at a future date/time"
|
|
707
|
+
},
|
|
708
|
+
"activityLog": {
|
|
709
|
+
"label": "Activity log",
|
|
710
|
+
"description": "Timeline of actions on each ticket (status changes, assignment...)"
|
|
711
|
+
},
|
|
712
|
+
"emailTracking": {
|
|
713
|
+
"label": "Email tracking",
|
|
714
|
+
"description": "Send and open tracking for email notifications"
|
|
715
|
+
},
|
|
716
|
+
"chat": {
|
|
717
|
+
"label": "Live Chat",
|
|
718
|
+
"description": "Real-time chat with ticket conversion"
|
|
719
|
+
},
|
|
720
|
+
"externalMessages": {
|
|
721
|
+
"label": "External messages",
|
|
722
|
+
"description": "Manually add messages received by email, SMS, WhatsApp..."
|
|
723
|
+
},
|
|
724
|
+
"ai": {
|
|
725
|
+
"label": "Artificial Intelligence",
|
|
726
|
+
"description": "Sentiment analysis, summary, reply suggestion, rewriting"
|
|
727
|
+
},
|
|
728
|
+
"timeTracking": {
|
|
729
|
+
"label": "Time tracking",
|
|
730
|
+
"description": "Timer, manual entries, billing"
|
|
731
|
+
},
|
|
732
|
+
"satisfaction": {
|
|
733
|
+
"label": "Satisfaction surveys",
|
|
734
|
+
"description": "CSAT score after ticket resolution"
|
|
735
|
+
},
|
|
736
|
+
"merge": {
|
|
737
|
+
"label": "Ticket merge",
|
|
738
|
+
"description": "Combine two tickets into one"
|
|
739
|
+
},
|
|
740
|
+
"splitTicket": {
|
|
741
|
+
"label": "Message extraction",
|
|
742
|
+
"description": "Extract a message into a new linked ticket"
|
|
743
|
+
},
|
|
744
|
+
"snooze": {
|
|
745
|
+
"label": "Snooze",
|
|
746
|
+
"description": "Temporarily hide a ticket with automatic reminder"
|
|
747
|
+
},
|
|
748
|
+
"clientHistory": {
|
|
749
|
+
"label": "Client history",
|
|
750
|
+
"description": "Past tickets, projects and internal notes"
|
|
751
|
+
}
|
|
711
752
|
},
|
|
712
753
|
"categories": {
|
|
713
754
|
"core": "Core features",
|
|
@@ -348,7 +348,9 @@
|
|
|
348
348
|
"responseAwaitedFor": "En attente depuis {{duration}}",
|
|
349
349
|
"target": "Échéance : {{target}}",
|
|
350
350
|
"escalate": "Escalader"
|
|
351
|
-
}
|
|
351
|
+
},
|
|
352
|
+
"slaPaused": "en pause",
|
|
353
|
+
"slaPausedTitle": "SLA en pause depuis {since} — le compteur repart à la réponse du client"
|
|
352
354
|
},
|
|
353
355
|
"emailTracking": {
|
|
354
356
|
"title": "Suivi des emails",
|
|
@@ -695,19 +697,58 @@
|
|
|
695
697
|
"saved2": "✓ Sauvegardé",
|
|
696
698
|
"saveChanges": "Sauvegarder les modifications",
|
|
697
699
|
"features": {
|
|
698
|
-
"canned": {
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
"
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
"
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
"
|
|
700
|
+
"canned": {
|
|
701
|
+
"label": "Réponses rapides",
|
|
702
|
+
"description": "Templates de réponses pré-enregistrées avec variables dynamiques"
|
|
703
|
+
},
|
|
704
|
+
"scheduledReplies": {
|
|
705
|
+
"label": "Réponses programmées",
|
|
706
|
+
"description": "Envoyer une réponse à une date/heure future"
|
|
707
|
+
},
|
|
708
|
+
"activityLog": {
|
|
709
|
+
"label": "Journal d'activité",
|
|
710
|
+
"description": "Timeline des actions sur chaque ticket (changements de statut, assignation...)"
|
|
711
|
+
},
|
|
712
|
+
"emailTracking": {
|
|
713
|
+
"label": "Suivi des emails",
|
|
714
|
+
"description": "Tracking d'envoi et d'ouverture des notifications email"
|
|
715
|
+
},
|
|
716
|
+
"chat": {
|
|
717
|
+
"label": "Live Chat",
|
|
718
|
+
"description": "Chat en temps réel avec conversion en ticket"
|
|
719
|
+
},
|
|
720
|
+
"externalMessages": {
|
|
721
|
+
"label": "Messages externes",
|
|
722
|
+
"description": "Ajouter manuellement des messages reçus par email, SMS, WhatsApp..."
|
|
723
|
+
},
|
|
724
|
+
"ai": {
|
|
725
|
+
"label": "Intelligence Artificielle",
|
|
726
|
+
"description": "Analyse de sentiment, synthèse, suggestion de réponse, reformulation"
|
|
727
|
+
},
|
|
728
|
+
"timeTracking": {
|
|
729
|
+
"label": "Suivi du temps",
|
|
730
|
+
"description": "Timer, entrées manuelles, facturation"
|
|
731
|
+
},
|
|
732
|
+
"satisfaction": {
|
|
733
|
+
"label": "Enquêtes satisfaction",
|
|
734
|
+
"description": "Score CSAT après résolution du ticket"
|
|
735
|
+
},
|
|
736
|
+
"merge": {
|
|
737
|
+
"label": "Fusion de tickets",
|
|
738
|
+
"description": "Combiner deux tickets en un seul"
|
|
739
|
+
},
|
|
740
|
+
"splitTicket": {
|
|
741
|
+
"label": "Extraction de message",
|
|
742
|
+
"description": "Extraire un message en nouveau ticket lié"
|
|
743
|
+
},
|
|
744
|
+
"snooze": {
|
|
745
|
+
"label": "Snooze",
|
|
746
|
+
"description": "Masquer temporairement un ticket et rappel automatique"
|
|
747
|
+
},
|
|
748
|
+
"clientHistory": {
|
|
749
|
+
"label": "Historique client",
|
|
750
|
+
"description": "Tickets passés, projets et notes internes du client"
|
|
751
|
+
}
|
|
711
752
|
},
|
|
712
753
|
"categories": {
|
|
713
754
|
"core": "Fonctionnalités de base",
|
|
@@ -1,14 +1,33 @@
|
|
|
1
|
-
export type SlaState = 'ok' | 'warn' | 'breach' | 'none' | 'met';
|
|
1
|
+
export type SlaState = 'ok' | 'warn' | 'breach' | 'none' | 'met' | 'paused';
|
|
2
2
|
export interface SlaInput {
|
|
3
3
|
slaFirstResponseDue?: string | null;
|
|
4
4
|
slaFirstResponseBreached?: boolean | null;
|
|
5
5
|
firstResponseAt?: string | null;
|
|
6
6
|
slaResolutionDue?: string | null;
|
|
7
7
|
slaResolutionBreached?: boolean | null;
|
|
8
|
+
/** Set while the ticket sits in `waiting_client`; the resolution clock is stopped. */
|
|
9
|
+
slaPausedAt?: string | null;
|
|
8
10
|
}
|
|
11
|
+
/**
|
|
12
|
+
* How long the resolution clock has been stopped, in milliseconds, or 0.
|
|
13
|
+
*
|
|
14
|
+
* `createPauseSlaOnHold` only stamps `slaPausedAt` when the ticket enters
|
|
15
|
+
* `waiting_client`; it pushes `slaResolutionDue` forward *on the way out*, once
|
|
16
|
+
* it knows how long the pause lasted. So for the whole duration of the pause the
|
|
17
|
+
* stored deadline is stale by exactly this much, and anything comparing it to
|
|
18
|
+
* `now` — this function's callers included — reads a breach that the server does
|
|
19
|
+
* not consider one.
|
|
20
|
+
*
|
|
21
|
+
* Rather than wait for the resume hook, we add the elapsed pause back here. The
|
|
22
|
+
* arithmetic is the same the hook performs later, so the badge during the pause
|
|
23
|
+
* and the stored deadline after it agree.
|
|
24
|
+
*/
|
|
25
|
+
export declare function pausedForMs(t: SlaInput, now?: Date): number;
|
|
9
26
|
export declare function computeSlaState(t: SlaInput, now?: Date): {
|
|
10
27
|
state: SlaState;
|
|
11
28
|
remainingMs: number | null;
|
|
12
29
|
due: string | null;
|
|
30
|
+
/** Only set while paused: how long the clock has been stopped. */
|
|
31
|
+
pausedMs?: number;
|
|
13
32
|
};
|
|
14
33
|
export declare function formatSlaRemaining(remainingMs: number | null): string;
|
package/dist/views/shared/sla.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
const HOUR_MS = 36e5;
|
|
2
2
|
const MINUTE_MS = 6e4;
|
|
3
|
+
function pausedForMs(t, now = /* @__PURE__ */ new Date()) {
|
|
4
|
+
if (!t.slaPausedAt) return 0;
|
|
5
|
+
const pausedAt = new Date(t.slaPausedAt);
|
|
6
|
+
if (Number.isNaN(pausedAt.getTime())) return 0;
|
|
7
|
+
return Math.max(0, now.getTime() - pausedAt.getTime());
|
|
8
|
+
}
|
|
3
9
|
function computeSlaState(t, now = /* @__PURE__ */ new Date()) {
|
|
4
10
|
const hasFirstResponse = !!t.firstResponseAt;
|
|
5
11
|
const dueRaw = hasFirstResponse ? t.slaResolutionDue : t.slaFirstResponseDue;
|
|
@@ -7,7 +13,12 @@ function computeSlaState(t, now = /* @__PURE__ */ new Date()) {
|
|
|
7
13
|
if (!dueRaw) return { state: "none", remainingMs: null, due: null };
|
|
8
14
|
const dueDate = new Date(dueRaw);
|
|
9
15
|
if (Number.isNaN(dueDate.getTime())) return { state: "none", remainingMs: null, due: null };
|
|
10
|
-
const
|
|
16
|
+
const pausedMs = hasFirstResponse ? pausedForMs(t, now) : 0;
|
|
17
|
+
const remaining = dueDate.getTime() + pausedMs - now.getTime();
|
|
18
|
+
if (pausedMs > 0) {
|
|
19
|
+
if (breached) return { state: "breach", remainingMs: remaining, due: dueRaw, pausedMs };
|
|
20
|
+
return { state: "paused", remainingMs: remaining, due: dueRaw, pausedMs };
|
|
21
|
+
}
|
|
11
22
|
if (breached || remaining < 0) {
|
|
12
23
|
return { state: "breach", remainingMs: remaining, due: dueRaw };
|
|
13
24
|
}
|
|
@@ -31,4 +42,4 @@ function formatSlaRemaining(remainingMs) {
|
|
|
31
42
|
return `${sign}${d}j`;
|
|
32
43
|
}
|
|
33
44
|
|
|
34
|
-
export { computeSlaState, formatSlaRemaining };
|
|
45
|
+
export { computeSlaState, formatSlaRemaining, pausedForMs };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@consilioweb/payload-support",
|
|
3
|
-
"version": "6.0.
|
|
3
|
+
"version": "6.0.3",
|
|
4
4
|
"description": "Payload CMS plugin — professional support & ticketing system with AI, SLA, time tracking, live chat, and more",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.cjs",
|
|
@@ -41,7 +41,8 @@
|
|
|
41
41
|
}
|
|
42
42
|
},
|
|
43
43
|
"bin": {
|
|
44
|
-
"support-uninstall": "./scripts/uninstall.mjs"
|
|
44
|
+
"support-uninstall": "./scripts/uninstall.mjs",
|
|
45
|
+
"support-audit-sla": "./scripts/audit-sla-breaches.mjs"
|
|
45
46
|
},
|
|
46
47
|
"files": [
|
|
47
48
|
"dist",
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Data remediation for `slaResolutionBreached` flags written in error.
|
|
5
|
+
*
|
|
6
|
+
* NOT meant to be run directly: it is spawned through `payload run`, which puts
|
|
7
|
+
* the TypeScript loader and the environment in place so the host's
|
|
8
|
+
* `payload.config.ts` can be imported. See the usage banner at the bottom.
|
|
9
|
+
*
|
|
10
|
+
* WHY THIS EXISTS
|
|
11
|
+
* ---------------
|
|
12
|
+
* Until 6.0.2, resolving a ticket straight out of `waiting_client` could store a
|
|
13
|
+
* breach that never happened.
|
|
14
|
+
*
|
|
15
|
+
* `createPauseSlaOnHold` and `createCheckSlaOnResolve` are both `afterChange`
|
|
16
|
+
* hooks on tickets, registered in that order. When a paused ticket is resolved,
|
|
17
|
+
* the first pushes `slaResolutionDue` forward by the paused span and writes it
|
|
18
|
+
* to the database — but the `doc` handed to the second was captured before that
|
|
19
|
+
* write. It therefore compared `now` against the UN-extended deadline and wrote
|
|
20
|
+
* `slaResolutionBreached: true`.
|
|
21
|
+
*
|
|
22
|
+
* 6.0.2 closes the cause. It does not sweep what came through, and nothing else
|
|
23
|
+
* does either: the flag is `admin: { readOnly: true }`, so it cannot be cleared
|
|
24
|
+
* from the admin panel.
|
|
25
|
+
*
|
|
26
|
+
* HOW A FALSE FLAG IS IDENTIFIED — and why this is provable rather than guessed
|
|
27
|
+
* ---------------------------------------------------------------------------
|
|
28
|
+
* The pause hook DID write the extended deadline; only the sibling hook read a
|
|
29
|
+
* stale copy. So the row ends up carrying a CORRECT `slaResolutionDue` next to
|
|
30
|
+
* an INCORRECT `slaResolutionBreached`, and re-comparing the two settles it:
|
|
31
|
+
*
|
|
32
|
+
* resolvedAt <= slaResolutionDue → the ticket met its SLA, flag is false
|
|
33
|
+
* resolvedAt > slaResolutionDue → it really did breach, flag is right
|
|
34
|
+
*
|
|
35
|
+
* A ticket that was never paused has an unmodified deadline, so the same
|
|
36
|
+
* comparison confirms its flag instead of clearing it. There is no window in
|
|
37
|
+
* which this test clears a genuine breach.
|
|
38
|
+
*
|
|
39
|
+
* WHAT IT CANNOT SEE
|
|
40
|
+
* ------------------
|
|
41
|
+
* If the resume hook itself failed — its body is wrapped in a `try`/`catch` that
|
|
42
|
+
* logs to `console.error` and swallows — the deadline was never extended, and
|
|
43
|
+
* the row looks exactly like an honest breach. Those are indistinguishable after
|
|
44
|
+
* the fact and are left alone. Check your server logs for
|
|
45
|
+
* `[sla] Failed to pause/resume SLA on hold` over the affected period.
|
|
46
|
+
*
|
|
47
|
+
* `slaFirstResponseBreached` is never touched: the pause stops the resolution
|
|
48
|
+
* clock only, so first-response flags were always computed correctly.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { getPayload } from 'payload'
|
|
52
|
+
import { findConfig } from 'payload/node'
|
|
53
|
+
import { pathToFileURL } from 'node:url'
|
|
54
|
+
|
|
55
|
+
const args = process.argv.slice(2)
|
|
56
|
+
const APPLY = args.includes('--fix')
|
|
57
|
+
const slugArg = args.find((a) => a.startsWith('--tickets='))
|
|
58
|
+
const TICKETS_SLUG = slugArg ? slugArg.slice('--tickets='.length) : 'tickets'
|
|
59
|
+
|
|
60
|
+
const fmt = (d) => (d ? new Date(d).toISOString().replace('T', ' ').slice(0, 16) : '—')
|
|
61
|
+
|
|
62
|
+
/** Minutes between two dates, signed, for a human-readable margin. */
|
|
63
|
+
const minutesBetween = (a, b) => Math.round((new Date(a).getTime() - new Date(b).getTime()) / 60000)
|
|
64
|
+
|
|
65
|
+
async function main() {
|
|
66
|
+
const config = await import(pathToFileURL(findConfig()).href).then((m) => m.default)
|
|
67
|
+
const payload = await getPayload({ config })
|
|
68
|
+
|
|
69
|
+
let rows
|
|
70
|
+
try {
|
|
71
|
+
const res = await payload.find({
|
|
72
|
+
collection: TICKETS_SLUG,
|
|
73
|
+
where: { slaResolutionBreached: { equals: true } },
|
|
74
|
+
limit: 0,
|
|
75
|
+
depth: 0,
|
|
76
|
+
overrideAccess: true,
|
|
77
|
+
select: {
|
|
78
|
+
ticketNumber: true,
|
|
79
|
+
status: true,
|
|
80
|
+
resolvedAt: true,
|
|
81
|
+
slaResolutionDue: true,
|
|
82
|
+
slaResolutionBreached: true,
|
|
83
|
+
slaPausedAt: true,
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
rows = res.docs
|
|
87
|
+
} catch (err) {
|
|
88
|
+
console.error(`\n Could not read the '${TICKETS_SLUG}' collection: ${err.message}`)
|
|
89
|
+
console.error(' If your install renames it, pass --tickets=<slug>.\n')
|
|
90
|
+
process.exit(1)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
console.log(`\n ${rows.length} ticket(s) carry slaResolutionBreached = true.\n`)
|
|
94
|
+
|
|
95
|
+
const falseFlags = []
|
|
96
|
+
const confirmed = []
|
|
97
|
+
const undecidable = []
|
|
98
|
+
|
|
99
|
+
for (const t of rows) {
|
|
100
|
+
if (!t.slaResolutionDue) {
|
|
101
|
+
// No deadline to compare against — the flag predates the SLA policy or the
|
|
102
|
+
// field was cleared. Not ours to judge.
|
|
103
|
+
undecidable.push({ t, why: 'no slaResolutionDue to compare against' })
|
|
104
|
+
continue
|
|
105
|
+
}
|
|
106
|
+
if (!t.resolvedAt) {
|
|
107
|
+
// Still open. The resolve race cannot have produced this flag, so it came
|
|
108
|
+
// from somewhere we are not modelling — report, never touch.
|
|
109
|
+
undecidable.push({ t, why: `flagged but not resolved (status: ${t.status})` })
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
const margin = minutesBetween(t.slaResolutionDue, t.resolvedAt)
|
|
113
|
+
if (margin >= 0) falseFlags.push({ t, margin })
|
|
114
|
+
else confirmed.push({ t, margin })
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
if (confirmed.length) {
|
|
118
|
+
console.log(` ${confirmed.length} confirmed breach(es) — left untouched:`)
|
|
119
|
+
for (const { t, margin } of confirmed) {
|
|
120
|
+
console.log(` ${t.ticketNumber} resolved ${fmt(t.resolvedAt)}, ${-margin} min past its deadline`)
|
|
121
|
+
}
|
|
122
|
+
console.log('')
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (undecidable.length) {
|
|
126
|
+
console.log(` ${undecidable.length} row(s) this script will not judge:`)
|
|
127
|
+
for (const { t, why } of undecidable) console.log(` ${t.ticketNumber} ${why}`)
|
|
128
|
+
console.log('')
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!falseFlags.length) {
|
|
132
|
+
console.log(' No false breach found. Nothing to repair.\n')
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
console.log(` ${falseFlags.length} FALSE breach(es) — resolved within the deadline:`)
|
|
137
|
+
for (const { t, margin } of falseFlags) {
|
|
138
|
+
console.log(
|
|
139
|
+
` ${t.ticketNumber} resolved ${fmt(t.resolvedAt)}, ` +
|
|
140
|
+
`deadline ${fmt(t.slaResolutionDue)} — ${margin} min to spare`,
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
console.log('')
|
|
144
|
+
|
|
145
|
+
if (!APPLY) {
|
|
146
|
+
console.log(' Read-only run. Re-run with --fix to clear these flags.\n')
|
|
147
|
+
return
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
let repaired = 0
|
|
151
|
+
for (const { t } of falseFlags) {
|
|
152
|
+
try {
|
|
153
|
+
await payload.update({
|
|
154
|
+
collection: TICKETS_SLUG,
|
|
155
|
+
id: t.id,
|
|
156
|
+
data: { slaResolutionBreached: false },
|
|
157
|
+
overrideAccess: true,
|
|
158
|
+
// The hooks on this collection react to a status change; this write
|
|
159
|
+
// changes none, but skipping them keeps the repair inert either way.
|
|
160
|
+
context: { skipSlaHooks: true },
|
|
161
|
+
})
|
|
162
|
+
repaired += 1
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.error(` ${t.ticketNumber}: update failed — ${err.message}`)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
console.log(`\n Cleared ${repaired} of ${falseFlags.length} false flag(s).\n`)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
main()
|
|
171
|
+
.then(() => process.exit(0))
|
|
172
|
+
.catch((err) => {
|
|
173
|
+
console.error('\n audit-sla-breaches failed:', err)
|
|
174
|
+
process.exit(1)
|
|
175
|
+
})
|
|
@@ -47,6 +47,7 @@ export function createSlaCheckEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
47
47
|
slaResolutionDue: true,
|
|
48
48
|
slaFirstResponseBreached: true,
|
|
49
49
|
slaResolutionBreached: true,
|
|
50
|
+
slaPausedAt: true,
|
|
50
51
|
firstResponseAt: true,
|
|
51
52
|
createdAt: true,
|
|
52
53
|
},
|
|
@@ -85,9 +86,19 @@ export function createSlaCheckEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
85
86
|
}
|
|
86
87
|
}
|
|
87
88
|
|
|
88
|
-
// Check resolution SLA
|
|
89
|
+
// Check resolution SLA.
|
|
90
|
+
//
|
|
91
|
+
// A ticket sitting in `waiting_client` has its resolution clock
|
|
92
|
+
// stopped, but `createPauseSlaOnHold` only pushes `slaResolutionDue`
|
|
93
|
+
// forward when the ticket LEAVES that status. During the pause the
|
|
94
|
+
// stored deadline is stale by exactly the elapsed pause, so comparing
|
|
95
|
+
// it to `now` reports a breach the server does not consider one — and
|
|
96
|
+
// this endpoint is what feeds the escalation mail.
|
|
89
97
|
if (t.slaResolutionDue) {
|
|
90
|
-
const
|
|
98
|
+
const pausedMs = t.slaPausedAt
|
|
99
|
+
? Math.max(0, now.getTime() - new Date(t.slaPausedAt).getTime())
|
|
100
|
+
: 0
|
|
101
|
+
const deadline = new Date(new Date(t.slaResolutionDue).getTime() + pausedMs)
|
|
91
102
|
if (now > deadline) {
|
|
92
103
|
ticketData.breachTypes.push('resolution')
|
|
93
104
|
} else {
|
package/src/hooks/checkSLA.ts
CHANGED
|
@@ -353,7 +353,20 @@ export function createCheckSlaOnResolve(slugs: CollectionSlugs, notificationSlug
|
|
|
353
353
|
try {
|
|
354
354
|
const { payload } = req
|
|
355
355
|
const now = new Date()
|
|
356
|
-
|
|
356
|
+
|
|
357
|
+
// `createPauseSlaOnHold` is registered just before this hook and, when the
|
|
358
|
+
// ticket is resolved straight out of `waiting_client`, it pushes
|
|
359
|
+
// `slaResolutionDue` forward by the paused span. That write lands in the
|
|
360
|
+
// database, but the `doc` handed to THIS hook was captured before it — so
|
|
361
|
+
// reading `doc.slaResolutionDue` here compares against the un-extended
|
|
362
|
+
// deadline and persists a breach that never happened.
|
|
363
|
+
//
|
|
364
|
+
// Re-adding the pause locally gives the same deadline the sibling hook
|
|
365
|
+
// just wrote, without depending on hook ordering or on re-reading the row.
|
|
366
|
+
const pausedMs = doc.slaPausedAt
|
|
367
|
+
? Math.max(0, now.getTime() - new Date(doc.slaPausedAt as string).getTime())
|
|
368
|
+
: 0
|
|
369
|
+
const deadline = new Date(new Date(doc.slaResolutionDue as string).getTime() + pausedMs)
|
|
357
370
|
const breached = now > deadline
|
|
358
371
|
|
|
359
372
|
await dbUpdate(payload, slugs.tickets, {
|
|
@@ -286,6 +286,10 @@
|
|
|
286
286
|
.slaOk { color: var(--theme-elevation-400); }
|
|
287
287
|
.slaWarn { color: var(--theme-warning-500, #c97a0a); font-weight: 600; }
|
|
288
288
|
.slaBreach { color: var(--theme-error-500, #c4392c); font-weight: 700; }
|
|
289
|
+
// Paused: deliberately neutral and italic. The clock is stopped because the
|
|
290
|
+
// team is waiting on the client, so this is not a state anyone should act on —
|
|
291
|
+
// it must not compete with the warn and breach colours next to it.
|
|
292
|
+
.slaPaused { color: var(--theme-elevation-650); font-style: italic; }
|
|
289
293
|
|
|
290
294
|
.timeAgo {
|
|
291
295
|
font-size: 12px;
|
|
@@ -26,6 +26,7 @@ interface Ticket {
|
|
|
26
26
|
slaFirstResponseBreached?: boolean | null
|
|
27
27
|
slaResolutionDue?: string | null
|
|
28
28
|
slaResolutionBreached?: boolean | null
|
|
29
|
+
slaPausedAt?: string | null
|
|
29
30
|
firstResponseAt?: string | null
|
|
30
31
|
}
|
|
31
32
|
|
|
@@ -128,13 +129,21 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
128
129
|
`select[client]=true`, `select[updatedAt]=true`,
|
|
129
130
|
`select[lastClientMessageAt]=true`, `select[lastAdminReadAt]=true`,
|
|
130
131
|
`select[slaFirstResponseDue]=true`, `select[slaFirstResponseBreached]=true`,
|
|
131
|
-
`select[slaResolutionDue]=true`, `select[slaResolutionBreached]=true`,
|
|
132
|
+
`select[slaResolutionDue]=true`, `select[slaResolutionBreached]=true`, `select[slaPausedAt]=true`,
|
|
132
133
|
`select[firstResponseAt]=true`,
|
|
133
134
|
]
|
|
134
135
|
if (tab === 'sla_breach') {
|
|
135
|
-
// Either first-response breached or resolution breached
|
|
136
|
+
// Either first-response breached or resolution breached.
|
|
137
|
+
//
|
|
138
|
+
// A paused ticket is excluded. `createPauseSlaOnHold` stamps `slaPausedAt`
|
|
139
|
+
// on the way into `waiting_client` and only pushes `slaResolutionDue`
|
|
140
|
+
// forward on the way out, so during the pause the stored deadline is stale
|
|
141
|
+
// and this list would show a breach the server does not consider one.
|
|
142
|
+
// `computeSlaState` applies the same rule when it renders the row, so the
|
|
143
|
+
// tab and the badge cannot disagree.
|
|
136
144
|
params.push(`where[or][0][slaFirstResponseBreached][equals]=true`)
|
|
137
145
|
params.push(`where[or][1][slaResolutionBreached][equals]=true`)
|
|
146
|
+
params.push(`where[slaPausedAt][exists]=false`)
|
|
138
147
|
} else if (tab !== 'all') {
|
|
139
148
|
params.push(`where[status][equals]=${tab}`)
|
|
140
149
|
}
|
|
@@ -178,7 +187,7 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
178
187
|
fetch(`/api/tickets/count?where[status][equals]=open&${sn}`, { credentials: 'include' }),
|
|
179
188
|
fetch(`/api/tickets/count?where[status][equals]=waiting_client&${sn}`, { credentials: 'include' }),
|
|
180
189
|
fetch(`/api/tickets/count?where[status][equals]=resolved&${sn}`, { credentials: 'include' }),
|
|
181
|
-
fetch(`/api/tickets/count?where[or][0][slaFirstResponseBreached][equals]=true&where[or][1][slaResolutionBreached][equals]=true&${sn}`, { credentials: 'include' }),
|
|
190
|
+
fetch(`/api/tickets/count?where[or][0][slaFirstResponseBreached][equals]=true&where[or][1][slaResolutionBreached][equals]=true&where[slaPausedAt][exists]=false&${sn}`, { credentials: 'include' }),
|
|
182
191
|
])
|
|
183
192
|
const [a, o, w, r, b] = await Promise.all([all.json(), openRes.json(), waiting.json(), resolved.json(), breach.json()])
|
|
184
193
|
setCounts({
|
|
@@ -350,6 +359,7 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
350
359
|
const priorityColor = PRIORITY_COLORS[tk.priority] || 'transparent'
|
|
351
360
|
const sla = computeSlaState(tk)
|
|
352
361
|
const isBreach = sla.state === 'breach'
|
|
362
|
+
const isPaused = sla.state === 'paused'
|
|
353
363
|
const statusVariant: 'open' | 'pending' | 'resolved' | 'closed' = STATUS_VARIANT[tk.status] || 'open'
|
|
354
364
|
|
|
355
365
|
return (
|
|
@@ -388,10 +398,15 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
388
398
|
sla.state === 'breach' ? s.slaBreach : '',
|
|
389
399
|
sla.state === 'warn' ? s.slaWarn : '',
|
|
390
400
|
sla.state === 'ok' ? s.slaOk : '',
|
|
401
|
+
isPaused ? s.slaPaused : '',
|
|
391
402
|
].filter(Boolean).join(' ')}
|
|
392
|
-
title={
|
|
403
|
+
title={
|
|
404
|
+
isPaused
|
|
405
|
+
? t('inbox.slaPausedTitle', { since: formatSlaRemaining(sla.pausedMs ?? 0) })
|
|
406
|
+
: sla.due ? new Date(sla.due).toLocaleString(DATE_LOCALE) : ''
|
|
407
|
+
}
|
|
393
408
|
>
|
|
394
|
-
{formatSlaRemaining(sla.remainingMs)}
|
|
409
|
+
{isPaused ? t('inbox.slaPaused') : formatSlaRemaining(sla.remainingMs)}
|
|
395
410
|
</span>
|
|
396
411
|
<span className={s.timeAgo}>{relativeTime(tk.updatedAt, t)}</span>
|
|
397
412
|
{isUnread ? <div className={s.unreadDot} /> : <span />}
|
|
@@ -348,7 +348,9 @@
|
|
|
348
348
|
"responseAwaitedFor": "Awaiting for {{duration}}",
|
|
349
349
|
"target": "Due: {{target}}",
|
|
350
350
|
"escalate": "Escalate"
|
|
351
|
-
}
|
|
351
|
+
},
|
|
352
|
+
"slaPaused": "paused",
|
|
353
|
+
"slaPausedTitle": "SLA paused for {since} — the clock resumes when the client replies"
|
|
352
354
|
},
|
|
353
355
|
"emailTracking": {
|
|
354
356
|
"title": "Email tracking",
|
|
@@ -695,19 +697,58 @@
|
|
|
695
697
|
"saved2": "✓ Saved",
|
|
696
698
|
"saveChanges": "Save changes",
|
|
697
699
|
"features": {
|
|
698
|
-
"canned": {
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
"
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
"
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
"
|
|
700
|
+
"canned": {
|
|
701
|
+
"label": "Quick replies",
|
|
702
|
+
"description": "Pre-registered response templates with dynamic variables"
|
|
703
|
+
},
|
|
704
|
+
"scheduledReplies": {
|
|
705
|
+
"label": "Scheduled replies",
|
|
706
|
+
"description": "Send a reply at a future date/time"
|
|
707
|
+
},
|
|
708
|
+
"activityLog": {
|
|
709
|
+
"label": "Activity log",
|
|
710
|
+
"description": "Timeline of actions on each ticket (status changes, assignment...)"
|
|
711
|
+
},
|
|
712
|
+
"emailTracking": {
|
|
713
|
+
"label": "Email tracking",
|
|
714
|
+
"description": "Send and open tracking for email notifications"
|
|
715
|
+
},
|
|
716
|
+
"chat": {
|
|
717
|
+
"label": "Live Chat",
|
|
718
|
+
"description": "Real-time chat with ticket conversion"
|
|
719
|
+
},
|
|
720
|
+
"externalMessages": {
|
|
721
|
+
"label": "External messages",
|
|
722
|
+
"description": "Manually add messages received by email, SMS, WhatsApp..."
|
|
723
|
+
},
|
|
724
|
+
"ai": {
|
|
725
|
+
"label": "Artificial Intelligence",
|
|
726
|
+
"description": "Sentiment analysis, summary, reply suggestion, rewriting"
|
|
727
|
+
},
|
|
728
|
+
"timeTracking": {
|
|
729
|
+
"label": "Time tracking",
|
|
730
|
+
"description": "Timer, manual entries, billing"
|
|
731
|
+
},
|
|
732
|
+
"satisfaction": {
|
|
733
|
+
"label": "Satisfaction surveys",
|
|
734
|
+
"description": "CSAT score after ticket resolution"
|
|
735
|
+
},
|
|
736
|
+
"merge": {
|
|
737
|
+
"label": "Ticket merge",
|
|
738
|
+
"description": "Combine two tickets into one"
|
|
739
|
+
},
|
|
740
|
+
"splitTicket": {
|
|
741
|
+
"label": "Message extraction",
|
|
742
|
+
"description": "Extract a message into a new linked ticket"
|
|
743
|
+
},
|
|
744
|
+
"snooze": {
|
|
745
|
+
"label": "Snooze",
|
|
746
|
+
"description": "Temporarily hide a ticket with automatic reminder"
|
|
747
|
+
},
|
|
748
|
+
"clientHistory": {
|
|
749
|
+
"label": "Client history",
|
|
750
|
+
"description": "Past tickets, projects and internal notes"
|
|
751
|
+
}
|
|
711
752
|
},
|
|
712
753
|
"categories": {
|
|
713
754
|
"core": "Core features",
|
|
@@ -348,7 +348,9 @@
|
|
|
348
348
|
"responseAwaitedFor": "En attente depuis {{duration}}",
|
|
349
349
|
"target": "Échéance : {{target}}",
|
|
350
350
|
"escalate": "Escalader"
|
|
351
|
-
}
|
|
351
|
+
},
|
|
352
|
+
"slaPaused": "en pause",
|
|
353
|
+
"slaPausedTitle": "SLA en pause depuis {since} — le compteur repart à la réponse du client"
|
|
352
354
|
},
|
|
353
355
|
"emailTracking": {
|
|
354
356
|
"title": "Suivi des emails",
|
|
@@ -695,19 +697,58 @@
|
|
|
695
697
|
"saved2": "✓ Sauvegardé",
|
|
696
698
|
"saveChanges": "Sauvegarder les modifications",
|
|
697
699
|
"features": {
|
|
698
|
-
"canned": {
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
"
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
"
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
"
|
|
700
|
+
"canned": {
|
|
701
|
+
"label": "Réponses rapides",
|
|
702
|
+
"description": "Templates de réponses pré-enregistrées avec variables dynamiques"
|
|
703
|
+
},
|
|
704
|
+
"scheduledReplies": {
|
|
705
|
+
"label": "Réponses programmées",
|
|
706
|
+
"description": "Envoyer une réponse à une date/heure future"
|
|
707
|
+
},
|
|
708
|
+
"activityLog": {
|
|
709
|
+
"label": "Journal d'activité",
|
|
710
|
+
"description": "Timeline des actions sur chaque ticket (changements de statut, assignation...)"
|
|
711
|
+
},
|
|
712
|
+
"emailTracking": {
|
|
713
|
+
"label": "Suivi des emails",
|
|
714
|
+
"description": "Tracking d'envoi et d'ouverture des notifications email"
|
|
715
|
+
},
|
|
716
|
+
"chat": {
|
|
717
|
+
"label": "Live Chat",
|
|
718
|
+
"description": "Chat en temps réel avec conversion en ticket"
|
|
719
|
+
},
|
|
720
|
+
"externalMessages": {
|
|
721
|
+
"label": "Messages externes",
|
|
722
|
+
"description": "Ajouter manuellement des messages reçus par email, SMS, WhatsApp..."
|
|
723
|
+
},
|
|
724
|
+
"ai": {
|
|
725
|
+
"label": "Intelligence Artificielle",
|
|
726
|
+
"description": "Analyse de sentiment, synthèse, suggestion de réponse, reformulation"
|
|
727
|
+
},
|
|
728
|
+
"timeTracking": {
|
|
729
|
+
"label": "Suivi du temps",
|
|
730
|
+
"description": "Timer, entrées manuelles, facturation"
|
|
731
|
+
},
|
|
732
|
+
"satisfaction": {
|
|
733
|
+
"label": "Enquêtes satisfaction",
|
|
734
|
+
"description": "Score CSAT après résolution du ticket"
|
|
735
|
+
},
|
|
736
|
+
"merge": {
|
|
737
|
+
"label": "Fusion de tickets",
|
|
738
|
+
"description": "Combiner deux tickets en un seul"
|
|
739
|
+
},
|
|
740
|
+
"splitTicket": {
|
|
741
|
+
"label": "Extraction de message",
|
|
742
|
+
"description": "Extraire un message en nouveau ticket lié"
|
|
743
|
+
},
|
|
744
|
+
"snooze": {
|
|
745
|
+
"label": "Snooze",
|
|
746
|
+
"description": "Masquer temporairement un ticket et rappel automatique"
|
|
747
|
+
},
|
|
748
|
+
"clientHistory": {
|
|
749
|
+
"label": "Historique client",
|
|
750
|
+
"description": "Tickets passés, projets et notes internes du client"
|
|
751
|
+
}
|
|
711
752
|
},
|
|
712
753
|
"categories": {
|
|
713
754
|
"core": "Fonctionnalités de base",
|
package/src/views/shared/sla.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// SLA helpers — derive remaining time + visual state from ticket's
|
|
2
2
|
// slaFirstResponseDue / slaFirstResponseBreached fields (already on Tickets).
|
|
3
3
|
|
|
4
|
-
export type SlaState = 'ok' | 'warn' | 'breach' | 'none' | 'met'
|
|
4
|
+
export type SlaState = 'ok' | 'warn' | 'breach' | 'none' | 'met' | 'paused'
|
|
5
5
|
|
|
6
6
|
export interface SlaInput {
|
|
7
7
|
slaFirstResponseDue?: string | null
|
|
@@ -9,15 +9,40 @@ export interface SlaInput {
|
|
|
9
9
|
firstResponseAt?: string | null
|
|
10
10
|
slaResolutionDue?: string | null
|
|
11
11
|
slaResolutionBreached?: boolean | null
|
|
12
|
+
/** Set while the ticket sits in `waiting_client`; the resolution clock is stopped. */
|
|
13
|
+
slaPausedAt?: string | null
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
const HOUR_MS = 3_600_000
|
|
15
17
|
const MINUTE_MS = 60_000
|
|
16
18
|
|
|
19
|
+
/**
|
|
20
|
+
* How long the resolution clock has been stopped, in milliseconds, or 0.
|
|
21
|
+
*
|
|
22
|
+
* `createPauseSlaOnHold` only stamps `slaPausedAt` when the ticket enters
|
|
23
|
+
* `waiting_client`; it pushes `slaResolutionDue` forward *on the way out*, once
|
|
24
|
+
* it knows how long the pause lasted. So for the whole duration of the pause the
|
|
25
|
+
* stored deadline is stale by exactly this much, and anything comparing it to
|
|
26
|
+
* `now` — this function's callers included — reads a breach that the server does
|
|
27
|
+
* not consider one.
|
|
28
|
+
*
|
|
29
|
+
* Rather than wait for the resume hook, we add the elapsed pause back here. The
|
|
30
|
+
* arithmetic is the same the hook performs later, so the badge during the pause
|
|
31
|
+
* and the stored deadline after it agree.
|
|
32
|
+
*/
|
|
33
|
+
export function pausedForMs(t: SlaInput, now: Date = new Date()): number {
|
|
34
|
+
if (!t.slaPausedAt) return 0
|
|
35
|
+
const pausedAt = new Date(t.slaPausedAt)
|
|
36
|
+
if (Number.isNaN(pausedAt.getTime())) return 0
|
|
37
|
+
return Math.max(0, now.getTime() - pausedAt.getTime())
|
|
38
|
+
}
|
|
39
|
+
|
|
17
40
|
export function computeSlaState(t: SlaInput, now: Date = new Date()): {
|
|
18
41
|
state: SlaState
|
|
19
42
|
remainingMs: number | null
|
|
20
43
|
due: string | null
|
|
44
|
+
/** Only set while paused: how long the clock has been stopped. */
|
|
45
|
+
pausedMs?: number
|
|
21
46
|
} {
|
|
22
47
|
// Once first-response is met, fall back to resolution SLA.
|
|
23
48
|
const hasFirstResponse = !!t.firstResponseAt
|
|
@@ -29,7 +54,18 @@ export function computeSlaState(t: SlaInput, now: Date = new Date()): {
|
|
|
29
54
|
const dueDate = new Date(dueRaw)
|
|
30
55
|
if (Number.isNaN(dueDate.getTime())) return { state: 'none', remainingMs: null, due: null }
|
|
31
56
|
|
|
32
|
-
|
|
57
|
+
// The pause only stops the RESOLUTION clock. A first-response SLA keeps
|
|
58
|
+
// running while the client is being waited on — the agent has still not
|
|
59
|
+
// answered, which is precisely what that target measures.
|
|
60
|
+
const pausedMs = hasFirstResponse ? pausedForMs(t, now) : 0
|
|
61
|
+
const remaining = dueDate.getTime() + pausedMs - now.getTime()
|
|
62
|
+
|
|
63
|
+
if (pausedMs > 0) {
|
|
64
|
+
// A stored breach flag is not overridden: if the clock had already run out
|
|
65
|
+
// before the client was asked, the ticket really did breach.
|
|
66
|
+
if (breached) return { state: 'breach', remainingMs: remaining, due: dueRaw, pausedMs }
|
|
67
|
+
return { state: 'paused', remainingMs: remaining, due: dueRaw, pausedMs }
|
|
68
|
+
}
|
|
33
69
|
|
|
34
70
|
if (breached || remaining < 0) {
|
|
35
71
|
return { state: 'breach', remainingMs: remaining, due: dueRaw }
|