@consilioweb/payload-support 6.0.1 → 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;
@@ -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.1",
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",
@@ -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 }