@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 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 deadline = new Date(t.slaResolutionDue);
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 deadline = new Date(doc.slaResolutionDue);
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 deadline = new Date(t.slaResolutionDue);
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 deadline = new Date(doc.slaResolutionDue);
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;
@@ -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": { "label": "Quick replies", "description": "Pre-registered response templates with dynamic variables" },
699
- "scheduledReplies": { "label": "Scheduled replies", "description": "Send a reply at a future date/time" },
700
- "activityLog": { "label": "Activity log", "description": "Timeline of actions on each ticket (status changes, assignment...)" },
701
- "emailTracking": { "label": "Email tracking", "description": "Send and open tracking for email notifications" },
702
- "chat": { "label": "Live Chat", "description": "Real-time chat with ticket conversion" },
703
- "externalMessages": { "label": "External messages", "description": "Manually add messages received by email, SMS, WhatsApp..." },
704
- "ai": { "label": "Artificial Intelligence", "description": "Sentiment analysis, summary, reply suggestion, rewriting" },
705
- "timeTracking": { "label": "Time tracking", "description": "Timer, manual entries, billing" },
706
- "satisfaction": { "label": "Satisfaction surveys", "description": "CSAT score after ticket resolution" },
707
- "merge": { "label": "Ticket merge", "description": "Combine two tickets into one" },
708
- "splitTicket": { "label": "Message extraction", "description": "Extract a message into a new linked ticket" },
709
- "snooze": { "label": "Snooze", "description": "Temporarily hide a ticket with automatic reminder" },
710
- "clientHistory": { "label": "Client history", "description": "Past tickets, projects and internal notes" }
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": { "label": "Réponses rapides", "description": "Templates de réponses pré-enregistrées avec variables dynamiques" },
699
- "scheduledReplies": { "label": "Réponses programmées", "description": "Envoyer une réponse à une date/heure future" },
700
- "activityLog": { "label": "Journal d'activité", "description": "Timeline des actions sur chaque ticket (changements de statut, assignation...)" },
701
- "emailTracking": { "label": "Suivi des emails", "description": "Tracking d'envoi et d'ouverture des notifications email" },
702
- "chat": { "label": "Live Chat", "description": "Chat en temps réel avec conversion en ticket" },
703
- "externalMessages": { "label": "Messages externes", "description": "Ajouter manuellement des messages reçus par email, SMS, WhatsApp..." },
704
- "ai": { "label": "Intelligence Artificielle", "description": "Analyse de sentiment, synthèse, suggestion de réponse, reformulation" },
705
- "timeTracking": { "label": "Suivi du temps", "description": "Timer, entrées manuelles, facturation" },
706
- "satisfaction": { "label": "Enquêtes satisfaction", "description": "Score CSAT après résolution du ticket" },
707
- "merge": { "label": "Fusion de tickets", "description": "Combiner deux tickets en un seul" },
708
- "splitTicket": { "label": "Extraction de message", "description": "Extraire un message en nouveau ticket lié" },
709
- "snooze": { "label": "Snooze", "description": "Masquer temporairement un ticket et rappel automatique" },
710
- "clientHistory": { "label": "Historique client", "description": "Tickets passés, projets et notes internes du client" }
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;
@@ -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 remaining = dueDate.getTime() - now.getTime();
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.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 deadline = new Date(t.slaResolutionDue)
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 {
@@ -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
- const deadline = new Date(doc.slaResolutionDue as string)
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={sla.due ? new Date(sla.due).toLocaleString(DATE_LOCALE) : ''}
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": { "label": "Quick replies", "description": "Pre-registered response templates with dynamic variables" },
699
- "scheduledReplies": { "label": "Scheduled replies", "description": "Send a reply at a future date/time" },
700
- "activityLog": { "label": "Activity log", "description": "Timeline of actions on each ticket (status changes, assignment...)" },
701
- "emailTracking": { "label": "Email tracking", "description": "Send and open tracking for email notifications" },
702
- "chat": { "label": "Live Chat", "description": "Real-time chat with ticket conversion" },
703
- "externalMessages": { "label": "External messages", "description": "Manually add messages received by email, SMS, WhatsApp..." },
704
- "ai": { "label": "Artificial Intelligence", "description": "Sentiment analysis, summary, reply suggestion, rewriting" },
705
- "timeTracking": { "label": "Time tracking", "description": "Timer, manual entries, billing" },
706
- "satisfaction": { "label": "Satisfaction surveys", "description": "CSAT score after ticket resolution" },
707
- "merge": { "label": "Ticket merge", "description": "Combine two tickets into one" },
708
- "splitTicket": { "label": "Message extraction", "description": "Extract a message into a new linked ticket" },
709
- "snooze": { "label": "Snooze", "description": "Temporarily hide a ticket with automatic reminder" },
710
- "clientHistory": { "label": "Client history", "description": "Past tickets, projects and internal notes" }
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": { "label": "Réponses rapides", "description": "Templates de réponses pré-enregistrées avec variables dynamiques" },
699
- "scheduledReplies": { "label": "Réponses programmées", "description": "Envoyer une réponse à une date/heure future" },
700
- "activityLog": { "label": "Journal d'activité", "description": "Timeline des actions sur chaque ticket (changements de statut, assignation...)" },
701
- "emailTracking": { "label": "Suivi des emails", "description": "Tracking d'envoi et d'ouverture des notifications email" },
702
- "chat": { "label": "Live Chat", "description": "Chat en temps réel avec conversion en ticket" },
703
- "externalMessages": { "label": "Messages externes", "description": "Ajouter manuellement des messages reçus par email, SMS, WhatsApp..." },
704
- "ai": { "label": "Intelligence Artificielle", "description": "Analyse de sentiment, synthèse, suggestion de réponse, reformulation" },
705
- "timeTracking": { "label": "Suivi du temps", "description": "Timer, entrées manuelles, facturation" },
706
- "satisfaction": { "label": "Enquêtes satisfaction", "description": "Score CSAT après résolution du ticket" },
707
- "merge": { "label": "Fusion de tickets", "description": "Combiner deux tickets en un seul" },
708
- "splitTicket": { "label": "Extraction de message", "description": "Extraire un message en nouveau ticket lié" },
709
- "snooze": { "label": "Snooze", "description": "Masquer temporairement un ticket et rappel automatique" },
710
- "clientHistory": { "label": "Historique client", "description": "Tickets passés, projets et notes internes du client" }
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,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
- const remaining = dueDate.getTime() - now.getTime()
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 }