@consilioweb/payload-support 6.0.0 → 6.0.2
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/utils/db.js +20 -0
- package/dist/utils/readSettings.js +150 -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 +4 -3
- package/scripts/copy-subpath-types.mjs +103 -0
- package/scripts/verify-dist-imports.mjs +84 -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;
|
package/dist/utils/db.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
function dbFind(payload, slug, options = {}) {
|
|
2
|
+
return payload.find({ collection: slug, ...options });
|
|
3
|
+
}
|
|
4
|
+
function dbFindByID(payload, slug, options) {
|
|
5
|
+
return payload.findByID({ collection: slug, ...options });
|
|
6
|
+
}
|
|
7
|
+
function dbCreate(payload, slug, options) {
|
|
8
|
+
return payload.create({ collection: slug, ...options });
|
|
9
|
+
}
|
|
10
|
+
function dbUpdate(payload, slug, options) {
|
|
11
|
+
return payload.update({ collection: slug, ...options });
|
|
12
|
+
}
|
|
13
|
+
function dbCount(payload, slug, options = {}) {
|
|
14
|
+
return payload.count({ collection: slug, ...options });
|
|
15
|
+
}
|
|
16
|
+
function dbDelete(payload, slug, options) {
|
|
17
|
+
return payload.delete({ collection: slug, ...options });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export { dbCount, dbCreate, dbDelete, dbFind, dbFindByID, dbUpdate };
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { dbFind } from './db.js';
|
|
2
|
+
import { DEFAULT_TICKETING_FEATURES, projectAutoClose, normalizeFeatures } from './features.js';
|
|
3
|
+
|
|
4
|
+
const SUPPORT_SETTINGS_PREF_KEY = "support-settings";
|
|
5
|
+
const PREF_KEY = SUPPORT_SETTINGS_PREF_KEY;
|
|
6
|
+
const USER_PREFS_KEY_PREFIX = "support-user-prefs";
|
|
7
|
+
const LEGACY_ROUND_ROBIN_KEY = "support-round-robin";
|
|
8
|
+
const SUPPORT_STAFF_SLUG_CONFIG_KEY = "supportStaffCollection";
|
|
9
|
+
function resolveStaffPrefSlug(payload, staffSlug) {
|
|
10
|
+
if (staffSlug) return staffSlug;
|
|
11
|
+
const config = payload.config;
|
|
12
|
+
const registered = config?.custom?.[SUPPORT_STAFF_SLUG_CONFIG_KEY];
|
|
13
|
+
if (typeof registered === "string" && registered) return registered;
|
|
14
|
+
return config?.admin?.user || "users";
|
|
15
|
+
}
|
|
16
|
+
const DEFAULT_SETTINGS = {
|
|
17
|
+
email: { fromAddress: "", fromName: "Support", replyToAddress: "" },
|
|
18
|
+
ai: { provider: "anthropic", model: "claude-haiku-4-5-20251001", enableSentiment: true, enableSynthesis: true, enableSuggestion: true, enableRewrite: true },
|
|
19
|
+
sla: { firstResponseMinutes: 120, resolutionMinutes: 1440, businessHoursOnly: true, escalationEmail: "" },
|
|
20
|
+
autoClose: { enabled: true, daysBeforeClose: 7, reminderDaysBefore: 2 },
|
|
21
|
+
features: { ...DEFAULT_TICKETING_FEATURES }
|
|
22
|
+
};
|
|
23
|
+
const DEFAULT_USER_PREFS = {
|
|
24
|
+
locale: "fr",
|
|
25
|
+
signature: ""
|
|
26
|
+
};
|
|
27
|
+
const settingsCache = /* @__PURE__ */ new Map();
|
|
28
|
+
const SETTINGS_TTL_MS = 6e4;
|
|
29
|
+
const SETTINGS_CACHE_MAX = 8;
|
|
30
|
+
const warnedForeignSettingsRow = /* @__PURE__ */ new Set();
|
|
31
|
+
function invalidateSupportSettingsCache() {
|
|
32
|
+
settingsCache.clear();
|
|
33
|
+
warnedForeignSettingsRow.clear();
|
|
34
|
+
}
|
|
35
|
+
function mergeSupportSettings(stored, base = DEFAULT_SETTINGS) {
|
|
36
|
+
const autoClose = { ...base.autoClose, ...stored?.autoClose };
|
|
37
|
+
return {
|
|
38
|
+
email: { ...base.email, ...stored?.email },
|
|
39
|
+
ai: { ...base.ai, ...stored?.ai },
|
|
40
|
+
sla: { ...base.sla, ...stored?.sla },
|
|
41
|
+
autoClose,
|
|
42
|
+
// `autoClose` / `autoCloseDays` are projections of the block above, so they
|
|
43
|
+
// are recomputed here rather than trusted from whatever was persisted.
|
|
44
|
+
features: projectAutoClose(
|
|
45
|
+
normalizeFeatures({ ...base.features, ...stored?.features }),
|
|
46
|
+
autoClose
|
|
47
|
+
)
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
async function readSupportSettingsState(payload, staffSlug) {
|
|
51
|
+
const staff = resolveStaffPrefSlug(payload, staffSlug);
|
|
52
|
+
const cached = settingsCache.get(staff);
|
|
53
|
+
if (cached && Date.now() - cached.ts < SETTINGS_TTL_MS) {
|
|
54
|
+
return cached.value;
|
|
55
|
+
}
|
|
56
|
+
let value = {
|
|
57
|
+
settings: mergeSupportSettings(null),
|
|
58
|
+
featuresConfigured: false
|
|
59
|
+
};
|
|
60
|
+
try {
|
|
61
|
+
const prefs = await dbFind(payload, "payload-preferences", {
|
|
62
|
+
// Sibling keys are AND-ed by Payload. The `user.relationTo` clause is the
|
|
63
|
+
// security boundary: without it any authenticated principal can plant a
|
|
64
|
+
// `support-settings` row and own the plugin's server settings.
|
|
65
|
+
where: { key: { equals: PREF_KEY }, "user.relationTo": { equals: staff } },
|
|
66
|
+
// The upsert is scoped per admin user, so several rows can share the key.
|
|
67
|
+
// Sorting makes "last write wins" deterministic instead of arbitrary.
|
|
68
|
+
sort: "-updatedAt",
|
|
69
|
+
limit: 1,
|
|
70
|
+
depth: 0,
|
|
71
|
+
overrideAccess: true
|
|
72
|
+
});
|
|
73
|
+
if (prefs.docs.length > 0) {
|
|
74
|
+
const stored = prefs.docs[0].value;
|
|
75
|
+
const featuresConfigured = !!stored.features && typeof stored.features === "object";
|
|
76
|
+
const settings = mergeSupportSettings(stored);
|
|
77
|
+
if (!featuresConfigured) {
|
|
78
|
+
settings.features.roundRobin = await readLegacyRoundRobin(payload, staff);
|
|
79
|
+
}
|
|
80
|
+
value = { settings, featuresConfigured };
|
|
81
|
+
} else {
|
|
82
|
+
await warnOnForeignSettingsRow(payload, staff);
|
|
83
|
+
}
|
|
84
|
+
} catch {
|
|
85
|
+
}
|
|
86
|
+
if (settingsCache.size >= SETTINGS_CACHE_MAX && !settingsCache.has(staff)) settingsCache.clear();
|
|
87
|
+
settingsCache.set(staff, { value, ts: Date.now() });
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
async function warnOnForeignSettingsRow(payload, staff) {
|
|
91
|
+
if (warnedForeignSettingsRow.has(staff)) return;
|
|
92
|
+
try {
|
|
93
|
+
const any = await dbFind(payload, "payload-preferences", {
|
|
94
|
+
where: { key: { equals: PREF_KEY } },
|
|
95
|
+
limit: 1,
|
|
96
|
+
depth: 0,
|
|
97
|
+
overrideAccess: true
|
|
98
|
+
});
|
|
99
|
+
if (any.docs.length === 0) return;
|
|
100
|
+
if (warnedForeignSettingsRow.size >= SETTINGS_CACHE_MAX) warnedForeignSettingsRow.clear();
|
|
101
|
+
warnedForeignSettingsRow.add(staff);
|
|
102
|
+
console.warn(
|
|
103
|
+
`[support] A "${PREF_KEY}" preference row exists but none is owned by the "${staff}" collection: the plugin is running on its DEFAULT settings. Either the staff auth collection differs from \`admin.user\`, or the row was written by a principal that is not staff \u2014 in which case it is ignored on purpose.`
|
|
104
|
+
);
|
|
105
|
+
} catch {
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async function readSupportSettings(payload, staffSlug) {
|
|
109
|
+
return (await readSupportSettingsState(payload, staffSlug)).settings;
|
|
110
|
+
}
|
|
111
|
+
async function readLegacyRoundRobin(payload, staff) {
|
|
112
|
+
try {
|
|
113
|
+
const prefs = await dbFind(payload, "payload-preferences", {
|
|
114
|
+
where: { key: { equals: LEGACY_ROUND_ROBIN_KEY }, "user.relationTo": { equals: staff } },
|
|
115
|
+
limit: 1,
|
|
116
|
+
depth: 0,
|
|
117
|
+
overrideAccess: true
|
|
118
|
+
});
|
|
119
|
+
if (prefs.docs.length > 0) {
|
|
120
|
+
return prefs.docs[0].value?.enabled === true;
|
|
121
|
+
}
|
|
122
|
+
} catch {
|
|
123
|
+
}
|
|
124
|
+
return DEFAULT_TICKETING_FEATURES.roundRobin;
|
|
125
|
+
}
|
|
126
|
+
async function readUserPrefs(payload, userId, staffSlug) {
|
|
127
|
+
try {
|
|
128
|
+
const key = `${USER_PREFS_KEY_PREFIX}-${userId}`;
|
|
129
|
+
const prefs = await dbFind(payload, "payload-preferences", {
|
|
130
|
+
where: {
|
|
131
|
+
key: { equals: key },
|
|
132
|
+
"user.relationTo": { equals: resolveStaffPrefSlug(payload, staffSlug) }
|
|
133
|
+
},
|
|
134
|
+
limit: 1,
|
|
135
|
+
depth: 0,
|
|
136
|
+
overrideAccess: true
|
|
137
|
+
});
|
|
138
|
+
if (prefs.docs.length > 0) {
|
|
139
|
+
const stored = prefs.docs[0].value;
|
|
140
|
+
return {
|
|
141
|
+
locale: stored.locale || DEFAULT_USER_PREFS.locale,
|
|
142
|
+
signature: stored.signature ?? DEFAULT_USER_PREFS.signature
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
}
|
|
147
|
+
return { ...DEFAULT_USER_PREFS };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export { DEFAULT_SETTINGS, DEFAULT_USER_PREFS, SUPPORT_SETTINGS_PREF_KEY, SUPPORT_STAFF_SLUG_CONFIG_KEY, invalidateSupportSettingsCache, mergeSupportSettings, readSupportSettings, readSupportSettingsState, readUserPrefs, resolveStaffPrefSlug };
|
|
@@ -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.2",
|
|
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",
|
|
@@ -52,7 +52,8 @@
|
|
|
52
52
|
"scripts/uninstall.mjs",
|
|
53
53
|
"scripts/uninstall-data.mjs",
|
|
54
54
|
"README.md",
|
|
55
|
-
"LICENSE"
|
|
55
|
+
"LICENSE",
|
|
56
|
+
"scripts"
|
|
56
57
|
],
|
|
57
58
|
"keywords": [
|
|
58
59
|
"payload",
|
|
@@ -137,7 +138,7 @@
|
|
|
137
138
|
}
|
|
138
139
|
},
|
|
139
140
|
"scripts": {
|
|
140
|
-
"build": "tsup && tsc -p tsconfig.types.json && node scripts/copy-subpath-types.mjs",
|
|
141
|
+
"build": "tsup && tsc -p tsconfig.types.json && node scripts/copy-subpath-types.mjs && node scripts/verify-dist-imports.mjs",
|
|
141
142
|
"typecheck": "tsc --noEmit",
|
|
142
143
|
"test": "vitest run",
|
|
143
144
|
"test:watch": "vitest",
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copies the declarations emitted by `tsc -p tsconfig.types.json` into dist/,
|
|
3
|
+
* then rewrites their relative import specifiers so they carry an explicit
|
|
4
|
+
* `.js` extension.
|
|
5
|
+
*
|
|
6
|
+
* Why a separate pass: the `bundle: false` tsup entry that emits dist/views/**
|
|
7
|
+
* and dist/components/** cannot generate declarations — turning `dts: true` on
|
|
8
|
+
* for ~100 entries makes rollup-plugin-dts run for well over ten minutes without
|
|
9
|
+
* finishing. Without them, `dist/views.d.ts` re-exports 13 views from paths that
|
|
10
|
+
* carry no declaration (TS7016 under noImplicitAny) and the publicly documented
|
|
11
|
+
* `./components/TicketConversation` subpath has no types at all.
|
|
12
|
+
*
|
|
13
|
+
* Why the extension rewrite: the sources are compiled with
|
|
14
|
+
* `moduleResolution: bundler`, so both tsc and tsup emit extensionless relative
|
|
15
|
+
* specifiers (`from './client'`, `from '../../utils/features'`). A consumer on
|
|
16
|
+
* `moduleResolution: node16 | nodenext` — the recommended setting for an ESM
|
|
17
|
+
* package — then gets `TS2835: Relative import paths need explicit file
|
|
18
|
+
* extensions`, and with the default `skipLibCheck: true` that error is swallowed
|
|
19
|
+
* and every export silently degrades to `any`. tsup's `onSuccess` already does
|
|
20
|
+
* this for the emitted `.js`; this does the same for the `.d.ts` (the copied ones
|
|
21
|
+
* *and* the `dist/views.d.ts` barrel produced by tsup's dts pass).
|
|
22
|
+
*
|
|
23
|
+
* `utils` is copied too: the emitted view declarations reference
|
|
24
|
+
* `../../utils/features`, whose JS is already emitted by the same tsup pass.
|
|
25
|
+
*/
|
|
26
|
+
import { cpSync, existsSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
27
|
+
import { dirname, join, resolve } from 'node:path'
|
|
28
|
+
|
|
29
|
+
const OUT = '.types-out'
|
|
30
|
+
const SUBPATHS = ['views', 'components', 'utils']
|
|
31
|
+
|
|
32
|
+
if (!existsSync(OUT)) {
|
|
33
|
+
console.error(`[build] "${OUT}" is missing — run \`tsc -p tsconfig.types.json\` before this script.`)
|
|
34
|
+
process.exit(1)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const dir of SUBPATHS) {
|
|
38
|
+
const from = `${OUT}/${dir}`
|
|
39
|
+
if (existsSync(from)) cpSync(from, `dist/${dir}`, { recursive: true })
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
rmSync(OUT, { recursive: true, force: true })
|
|
43
|
+
console.log('✓ Copied subpath declarations into dist/')
|
|
44
|
+
|
|
45
|
+
// ─── Explicit .js extensions on relative specifiers ──────────────────────────
|
|
46
|
+
|
|
47
|
+
const HAS_EXTENSION = /\.(js|jsx|mjs|cjs|css|scss|json)$/
|
|
48
|
+
const RELATIVE_SPECIFIER = /((?:from|import)\s*['"])(\.\.?\/[^'"]+?)(['"])/g
|
|
49
|
+
|
|
50
|
+
const errors = []
|
|
51
|
+
|
|
52
|
+
/** Resolve an extensionless specifier against the emitted declarations. */
|
|
53
|
+
function withExtension(fromFile, specifier) {
|
|
54
|
+
const base = resolve(dirname(fromFile), specifier)
|
|
55
|
+
if (existsSync(`${base}.d.ts`)) return `${specifier}.js`
|
|
56
|
+
if (existsSync(join(base, 'index.d.ts'))) return `${specifier}/index.js`
|
|
57
|
+
return null
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function rewrite(file) {
|
|
61
|
+
const content = readFileSync(file, 'utf-8')
|
|
62
|
+
const fixed = content.replace(RELATIVE_SPECIFIER, (match, prefix, specifier, suffix) => {
|
|
63
|
+
if (HAS_EXTENSION.test(specifier)) return match
|
|
64
|
+
const resolved = withExtension(file, specifier)
|
|
65
|
+
if (!resolved) {
|
|
66
|
+
errors.push(`${file}: cannot resolve "${specifier}" to an emitted declaration`)
|
|
67
|
+
return match
|
|
68
|
+
}
|
|
69
|
+
return `${prefix}${resolved}${suffix}`
|
|
70
|
+
})
|
|
71
|
+
if (fixed !== content) writeFileSync(file, fixed)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function walkDts(dir) {
|
|
75
|
+
if (!existsSync(dir)) return
|
|
76
|
+
for (const entry of readdirSync(dir)) {
|
|
77
|
+
const path = join(dir, entry)
|
|
78
|
+
if (statSync(path).isDirectory()) {
|
|
79
|
+
walkDts(path)
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
if (path.endsWith('.d.ts')) rewrite(path)
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// The `./views` barrel is emitted by tsup's dts pass, not by the tsc pass above,
|
|
87
|
+
// but it has the exact same extensionless specifiers — and it is the entry point
|
|
88
|
+
// consumers actually import, so it matters most.
|
|
89
|
+
const VIEWS_BARREL = 'dist/views.d.ts'
|
|
90
|
+
if (!existsSync(VIEWS_BARREL)) {
|
|
91
|
+
console.error(`[build] "${VIEWS_BARREL}" is missing — did the tsup views barrel entry run?`)
|
|
92
|
+
process.exit(1)
|
|
93
|
+
}
|
|
94
|
+
rewrite(VIEWS_BARREL)
|
|
95
|
+
for (const dir of SUBPATHS) walkDts(`dist/${dir}`)
|
|
96
|
+
|
|
97
|
+
if (errors.length > 0) {
|
|
98
|
+
console.error('[build] unresolved relative specifiers in emitted declarations:')
|
|
99
|
+
for (const error of errors) console.error(` - ${error}`)
|
|
100
|
+
process.exit(1)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
console.log('✓ Added explicit .js extensions to relative specifiers in dist/**/*.d.ts')
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Every relative import in `dist/` must resolve to a file that is actually there.
|
|
4
|
+
*
|
|
5
|
+
* This catches one specific, silent failure mode of a multi-pass tsup build.
|
|
6
|
+
* The `bundle: false` pass preserves the source tree and rewrites nothing, so a
|
|
7
|
+
* relative import survives into the emitted file. If the target module is not
|
|
8
|
+
* itself in that pass's `entry` list, it is never emitted as a standalone file —
|
|
9
|
+
* it only exists inlined inside the bundled `dist/index.js`. The import then
|
|
10
|
+
* points at nothing.
|
|
11
|
+
*
|
|
12
|
+
* Nothing in the normal toolchain sees it. `tsc` type-checks the SOURCE tree,
|
|
13
|
+
* where the module exists. Vitest imports from `src/`, same thing. The build
|
|
14
|
+
* itself succeeds — tsup has no reason to object. The failure surfaces only when
|
|
15
|
+
* the HOST application's bundler resolves the published package, which is to say
|
|
16
|
+
* after publication, in someone else's build.
|
|
17
|
+
*
|
|
18
|
+
* `import type` is invisible here on purpose: TypeScript erases it, so it leaves
|
|
19
|
+
* no trace in the emitted file and cannot break anything. Only value imports do.
|
|
20
|
+
* That is why the defect hid for two releases in this package — every other
|
|
21
|
+
* importer of the same module used `import type`.
|
|
22
|
+
*/
|
|
23
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
24
|
+
import { dirname, join, resolve } from 'node:path'
|
|
25
|
+
|
|
26
|
+
const DIST = resolve(process.cwd(), 'dist')
|
|
27
|
+
|
|
28
|
+
if (!existsSync(DIST)) {
|
|
29
|
+
console.error('verify-dist-imports: dist/ is missing — run the build first.')
|
|
30
|
+
process.exit(1)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Every emitted JavaScript file, at any depth. */
|
|
34
|
+
function walk(dir) {
|
|
35
|
+
return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
36
|
+
const path = join(dir, entry.name)
|
|
37
|
+
if (entry.isDirectory()) return walk(path)
|
|
38
|
+
return /\.(js|cjs|mjs)$/.test(entry.name) ? [path] : []
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Relative specifiers only. Bare specifiers are the consumer's dependency
|
|
44
|
+
* problem, not ours, and `node:` builtins always resolve.
|
|
45
|
+
*/
|
|
46
|
+
const SPECIFIER =
|
|
47
|
+
/(?:^|[\s;{(])(?:import|export)\s+(?:[^'"]*?\sfrom\s+)?['"](\.[^'"]+)['"]|require\(\s*['"](\.[^'"]+)['"]\s*\)|import\(\s*['"](\.[^'"]+)['"]\s*\)/g
|
|
48
|
+
|
|
49
|
+
/** Resolve the way Node and a bundler would: as written, then the usual suffixes. */
|
|
50
|
+
function resolves(fromFile, specifier) {
|
|
51
|
+
const base = resolve(dirname(fromFile), specifier)
|
|
52
|
+
const candidates = [
|
|
53
|
+
base,
|
|
54
|
+
`${base}.js`, `${base}.cjs`, `${base}.mjs`,
|
|
55
|
+
join(base, 'index.js'), join(base, 'index.cjs'), join(base, 'index.mjs'),
|
|
56
|
+
]
|
|
57
|
+
return candidates.some((c) => existsSync(c) && statSync(c).isFile())
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const broken = []
|
|
61
|
+
for (const file of walk(DIST)) {
|
|
62
|
+
const source = readFileSync(file, 'utf8')
|
|
63
|
+
for (const match of source.matchAll(SPECIFIER)) {
|
|
64
|
+
const specifier = match[1] || match[2] || match[3]
|
|
65
|
+
if (!specifier || resolves(file, specifier)) continue
|
|
66
|
+
broken.push({ file: file.replace(`${process.cwd()}/`, ''), specifier })
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (broken.length === 0) {
|
|
71
|
+
console.log('verify-dist-imports: every relative import in dist/ resolves.')
|
|
72
|
+
process.exit(0)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
console.error(`verify-dist-imports: ${broken.length} unresolved relative import(s) in dist/.\n`)
|
|
76
|
+
for (const { file, specifier } of broken) {
|
|
77
|
+
console.error(` ${file}\n imports '${specifier}' — not emitted`)
|
|
78
|
+
}
|
|
79
|
+
console.error(
|
|
80
|
+
'\nThe target is almost certainly missing from the `entry` list of the\n' +
|
|
81
|
+
'`bundle: false` pass in tsup.config.ts. Add it there, or move the value\n' +
|
|
82
|
+
'being imported into a module that pass already emits.',
|
|
83
|
+
)
|
|
84
|
+
process.exit(1)
|
|
@@ -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 }
|