@consilioweb/payload-support 0.9.13 → 0.10.0

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.
@@ -4,30 +4,21 @@ import { useState, useEffect, useCallback } from 'react';
4
4
  import { useSearchParams } from 'next/navigation';
5
5
  import Link from 'next/link';
6
6
  import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation.js';
7
+ import { computeSlaState, StatusPill, formatSlaRemaining } from '../shared.js';
7
8
  import s from '../../styles/TicketInbox.module.scss';
8
9
 
9
- const STATUS_DOTS = {
10
- open: "#22c55e",
11
- waiting_client: "#eab308",
12
- resolved: "#94a3b8"
13
- };
14
- const STATUS_LABEL_KEYS = {
15
- open: "inbox.tabs.open",
16
- waiting_client: "inbox.tabs.waiting",
17
- resolved: "inbox.tabs.resolved"
18
- };
19
10
  const PRIORITY_COLORS = {
20
- urgent: "#ef4444",
21
- high: "#f97316",
22
- normal: "transparent",
23
- low: "transparent"
11
+ urgent: "var(--theme-error-500)",
12
+ high: "var(--theme-warning-500)",
13
+ normal: "var(--theme-info-500, #2a6bd8)",
14
+ low: "var(--theme-elevation-400)"
24
15
  };
25
- const CATEGORY_LABEL_KEYS = {
26
- bug: "ticket.category.bug",
27
- content: "ticket.category.content",
28
- feature: "ticket.category.feature",
29
- question: "ticket.category.question",
30
- hosting: "ticket.category.hosting"
16
+ const STATUS_VARIANT = {
17
+ open: "open",
18
+ waiting_client: "pending",
19
+ pending: "pending",
20
+ resolved: "resolved",
21
+ closed: "closed"
31
22
  };
32
23
  function relativeTime(dateStr) {
33
24
  const diff = Date.now() - new Date(dateStr).getTime();
@@ -47,12 +38,12 @@ const TicketInboxClient = () => {
47
38
  const [loading, setLoading] = useState(true);
48
39
  const [tab, setTab] = useState(() => {
49
40
  const urlTab = searchParams.get("tab");
50
- if (urlTab && ["all", "open", "waiting_client", "resolved"].includes(urlTab)) return urlTab;
41
+ if (urlTab && ["all", "open", "waiting_client", "resolved", "sla_breach"].includes(urlTab)) return urlTab;
51
42
  return "all";
52
43
  });
53
44
  useEffect(() => {
54
45
  const urlTab = searchParams.get("tab");
55
- if (urlTab && ["all", "open", "waiting_client", "resolved"].includes(urlTab)) {
46
+ if (urlTab && ["all", "open", "waiting_client", "resolved", "sla_breach"].includes(urlTab)) {
56
47
  setTab(urlTab);
57
48
  } else if (!urlTab) {
58
49
  setTab("all");
@@ -60,7 +51,7 @@ const TicketInboxClient = () => {
60
51
  }, [searchParams]);
61
52
  const [search, setSearch] = useState("");
62
53
  const [sort, setSort] = useState("-updatedAt");
63
- const [counts, setCounts] = useState({ all: 0, open: 0, waiting: 0, resolved: 0 });
54
+ const [counts, setCounts] = useState({ all: 0, open: 0, waiting: 0, resolved: 0, breach: 0 });
64
55
  const [selectedIdx, setSelectedIdx] = useState(-1);
65
56
  const [checkedIds, setCheckedIds] = useState(/* @__PURE__ */ new Set());
66
57
  const [bulkAction, setBulkAction] = useState("");
@@ -93,8 +84,32 @@ const TicketInboxClient = () => {
93
84
  setBulkProcessing(false);
94
85
  };
95
86
  const fetchTickets = useCallback(async () => {
96
- const params = [`limit=30`, `sort=${sort}`, `depth=1`, `select[id]=true`, `select[ticketNumber]=true`, `select[subject]=true`, `select[status]=true`, `select[priority]=true`, `select[category]=true`, `select[client]=true`, `select[updatedAt]=true`, `select[lastClientMessageAt]=true`, `select[lastAdminReadAt]=true`];
97
- if (tab !== "all") params.push(`where[status][equals]=${tab}`);
87
+ const params = [
88
+ `limit=30`,
89
+ `sort=${sort}`,
90
+ `depth=1`,
91
+ `select[id]=true`,
92
+ `select[ticketNumber]=true`,
93
+ `select[subject]=true`,
94
+ `select[status]=true`,
95
+ `select[priority]=true`,
96
+ `select[category]=true`,
97
+ `select[client]=true`,
98
+ `select[updatedAt]=true`,
99
+ `select[lastClientMessageAt]=true`,
100
+ `select[lastAdminReadAt]=true`,
101
+ `select[slaFirstResponseDue]=true`,
102
+ `select[slaFirstResponseBreached]=true`,
103
+ `select[slaResolutionDue]=true`,
104
+ `select[slaResolutionBreached]=true`,
105
+ `select[firstResponseAt]=true`
106
+ ];
107
+ if (tab === "sla_breach") {
108
+ params.push(`where[or][0][slaFirstResponseBreached][equals]=true`);
109
+ params.push(`where[or][1][slaResolutionBreached][equals]=true`);
110
+ } else if (tab !== "all") {
111
+ params.push(`where[status][equals]=${tab}`);
112
+ }
98
113
  if (search.trim()) {
99
114
  params.push(`where[or][0][subject][contains]=${encodeURIComponent(search)}`);
100
115
  params.push(`where[or][1][ticketNumber][contains]=${encodeURIComponent(search)}`);
@@ -120,14 +135,21 @@ const TicketInboxClient = () => {
120
135
  useEffect(() => {
121
136
  const fetchCounts = async () => {
122
137
  try {
123
- const [all, openRes, waiting, resolved] = await Promise.all([
138
+ const [all, openRes, waiting, resolved, breach] = await Promise.all([
124
139
  fetch("/api/tickets?limit=0&depth=0", { credentials: "include" }),
125
140
  fetch("/api/tickets?limit=0&depth=0&where[status][equals]=open", { credentials: "include" }),
126
141
  fetch("/api/tickets?limit=0&depth=0&where[status][equals]=waiting_client", { credentials: "include" }),
127
- fetch("/api/tickets?limit=0&depth=0&where[status][equals]=resolved", { credentials: "include" })
142
+ fetch("/api/tickets?limit=0&depth=0&where[status][equals]=resolved", { credentials: "include" }),
143
+ fetch("/api/tickets?limit=0&depth=0&where[or][0][slaFirstResponseBreached][equals]=true&where[or][1][slaResolutionBreached][equals]=true", { credentials: "include" })
128
144
  ]);
129
- const [a, o, w, r] = await Promise.all([all.json(), openRes.json(), waiting.json(), resolved.json()]);
130
- setCounts({ all: a.totalDocs || 0, open: o.totalDocs || 0, waiting: w.totalDocs || 0, resolved: r.totalDocs || 0 });
145
+ const [a, o, w, r, b] = await Promise.all([all.json(), openRes.json(), waiting.json(), resolved.json(), breach.json()]);
146
+ setCounts({
147
+ all: a.totalDocs || 0,
148
+ open: o.totalDocs || 0,
149
+ waiting: w.totalDocs || 0,
150
+ resolved: r.totalDocs || 0,
151
+ breach: b.totalDocs || 0
152
+ });
131
153
  } catch {
132
154
  }
133
155
  };
@@ -159,11 +181,22 @@ const TicketInboxClient = () => {
159
181
  { key: "all", label: t("inbox.tabs.all"), count: counts.all },
160
182
  { key: "open", label: t("inbox.tabs.open"), count: counts.open },
161
183
  { key: "waiting_client", label: t("inbox.tabs.waiting"), count: counts.waiting },
184
+ { key: "sla_breach", label: t("inbox.tabs.slaBreach"), count: counts.breach, alert: true },
162
185
  { key: "resolved", label: t("inbox.tabs.resolved"), count: counts.resolved }
163
186
  ];
164
187
  return /* @__PURE__ */ jsxs("div", { className: s.page, children: [
165
188
  /* @__PURE__ */ jsxs("div", { className: s.header, children: [
166
- /* @__PURE__ */ jsx("h1", { className: s.title, children: t("inbox.title") }),
189
+ /* @__PURE__ */ jsxs("div", { children: [
190
+ /* @__PURE__ */ jsx("h1", { className: s.title, children: t("inbox.title") }),
191
+ /* @__PURE__ */ jsxs("p", { className: s.subtitle, children: [
192
+ tickets.length > 0 && `${tickets.length} sur ${counts.all} \xB7 `,
193
+ counts.breach > 0 ? /* @__PURE__ */ jsxs("span", { className: s.subtitleBreach, children: [
194
+ counts.breach,
195
+ " SLA d\xE9pass\xE9",
196
+ counts.breach > 1 ? "s" : ""
197
+ ] }) : /* @__PURE__ */ jsx("span", { children: "Aucun SLA en alerte" })
198
+ ] })
199
+ ] }),
167
200
  /* @__PURE__ */ jsxs("div", { className: s.headerRight, children: [
168
201
  /* @__PURE__ */ jsxs("div", { className: s.searchWrap, children: [
169
202
  /* @__PURE__ */ jsxs("svg", { className: s.searchIcon, width: "14", height: "14", viewBox: "0 0 16 16", fill: "none", children: [
@@ -185,25 +218,26 @@ const TicketInboxClient = () => {
185
218
  /* @__PURE__ */ jsx(Link, { href: "/admin/support/new-ticket", className: s.newTicketBtn, children: t("inbox.newTicketBtn") })
186
219
  ] })
187
220
  ] }),
188
- /* @__PURE__ */ jsx("div", { className: s.tabs, children: tabs.map((tk) => /* @__PURE__ */ jsxs(
189
- "button",
190
- {
191
- className: `${s.tab} ${tab === tk.key ? s.tabActive : ""}`,
192
- onClick: () => {
193
- setTab(tk.key);
194
- setSelectedIdx(-1);
221
+ /* @__PURE__ */ jsx("div", { className: s.tabs, role: "tablist", children: tabs.map((tk) => {
222
+ const isAlert = tk.alert && tk.count > 0;
223
+ return /* @__PURE__ */ jsxs(
224
+ "button",
225
+ {
226
+ role: "tab",
227
+ "aria-selected": tab === tk.key,
228
+ className: `${s.tab} ${tab === tk.key ? s.tabActive : ""} ${isAlert ? s.tabAlert : ""}`,
229
+ onClick: () => {
230
+ setTab(tk.key);
231
+ setSelectedIdx(-1);
232
+ },
233
+ children: [
234
+ tk.label,
235
+ /* @__PURE__ */ jsx("span", { className: `${s.tabCount} ${isAlert ? s.tabCountAlert : ""}`, children: tk.count })
236
+ ]
195
237
  },
196
- children: [
197
- tk.label,
198
- /* @__PURE__ */ jsxs("span", { className: s.tabCount, children: [
199
- "(",
200
- tk.count,
201
- ")"
202
- ] })
203
- ]
204
- },
205
- tk.key
206
- )) }),
238
+ tk.key
239
+ );
240
+ }) }),
207
241
  /* @__PURE__ */ jsx("div", { className: s.sortRow, children: checkedIds.size > 0 ? /* @__PURE__ */ jsxs("div", { style: { display: "flex", gap: 8, alignItems: "center", flex: 1 }, children: [
208
242
  /* @__PURE__ */ jsx("span", { style: { fontSize: 13, fontWeight: 600, color: "var(--theme-text)" }, children: checkedIds.size > 1 ? t("inbox.selectedPlural", { count: String(checkedIds.size) }) : t("inbox.selected", { count: String(checkedIds.size) }) }),
209
243
  /* @__PURE__ */ jsx("button", { className: s.sortSelect, onClick: () => handleBulkAction("close"), disabled: bulkProcessing, children: t("inbox.closeAction") }),
@@ -233,11 +267,19 @@ const TicketInboxClient = () => {
233
267
  const displayClient = clientName ? `${clientName}${clientCompany ? `, ${clientCompany}` : ""}` : "\u2014";
234
268
  const isUnread = tk.lastClientMessageAt && (!tk.lastAdminReadAt || new Date(tk.lastClientMessageAt) > new Date(tk.lastAdminReadAt));
235
269
  const priorityColor = PRIORITY_COLORS[tk.priority] || "transparent";
270
+ const sla = computeSlaState(tk);
271
+ const isBreach = sla.state === "breach";
272
+ const statusVariant = STATUS_VARIANT[tk.status] || "open";
236
273
  return /* @__PURE__ */ jsxs(
237
274
  "a",
238
275
  {
239
276
  href: `/admin/support/ticket?id=${tk.id}`,
240
- className: `${s.row} ${idx === selectedIdx ? s.rowSelected : ""}`,
277
+ className: [
278
+ s.row,
279
+ idx === selectedIdx ? s.rowSelected : "",
280
+ isUnread ? s.rowUnread : "",
281
+ isBreach ? s.rowBreach : ""
282
+ ].filter(Boolean).join(" "),
241
283
  onClick: (e) => {
242
284
  e.preventDefault();
243
285
  window.location.href = `/admin/support/ticket?id=${tk.id}`;
@@ -250,20 +292,36 @@ const TicketInboxClient = () => {
250
292
  checked: checkedIds.has(tk.id),
251
293
  onClick: (e) => e.stopPropagation(),
252
294
  onChange: () => toggleCheck(tk.id),
253
- style: { width: 14, height: 14, cursor: "pointer", accentColor: "#2563eb" }
295
+ className: s.rowCheck,
296
+ "aria-label": `S\xE9lectionner ${tk.ticketNumber}`
297
+ }
298
+ ),
299
+ /* @__PURE__ */ jsx(
300
+ "span",
301
+ {
302
+ className: s.priorityDot,
303
+ style: { background: priorityColor },
304
+ "aria-label": `Priorit\xE9 ${tk.priority}`,
305
+ title: `Priorit\xE9 ${tk.priority}`
254
306
  }
255
307
  ),
256
- /* @__PURE__ */ jsx("div", { className: s.statusDot, style: { backgroundColor: STATUS_DOTS[tk.status] || "#94a3b8" } }),
257
- /* @__PURE__ */ jsx("span", { className: s.statusText, children: t(STATUS_LABEL_KEYS[tk.status] || "ticket.status.open") }),
308
+ /* @__PURE__ */ jsx(StatusPill, { status: isBreach ? "sla-breach" : statusVariant }),
258
309
  /* @__PURE__ */ jsx("span", { className: s.ticketNum, children: tk.ticketNumber }),
259
310
  /* @__PURE__ */ jsx("span", { className: s.subject, children: tk.subject }),
260
311
  /* @__PURE__ */ jsx("span", { className: s.client, children: displayClient }),
261
- tk.category ? /* @__PURE__ */ jsxs("span", { className: s.categoryChip, children: [
262
- "[",
263
- t(CATEGORY_LABEL_KEYS[tk.category] || "ticket.category.bug"),
264
- "]"
265
- ] }) : /* @__PURE__ */ jsx("span", {}),
266
- /* @__PURE__ */ jsx("div", { className: s.priorityBar, style: { backgroundColor: priorityColor } }),
312
+ /* @__PURE__ */ jsx(
313
+ "span",
314
+ {
315
+ className: [
316
+ s.slaCell,
317
+ sla.state === "breach" ? s.slaBreach : "",
318
+ sla.state === "warn" ? s.slaWarn : "",
319
+ sla.state === "ok" ? s.slaOk : ""
320
+ ].filter(Boolean).join(" "),
321
+ title: sla.due ? new Date(sla.due).toLocaleString("fr-FR") : "",
322
+ children: formatSlaRemaining(sla.remainingMs)
323
+ }
324
+ ),
267
325
  /* @__PURE__ */ jsx("span", { className: s.timeAgo, children: relativeTime(tk.updatedAt) }),
268
326
  isUnread ? /* @__PURE__ */ jsx("div", { className: s.unreadDot }) : /* @__PURE__ */ jsx("span", {})
269
327
  ]
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ var jsxRuntime = require('react/jsx-runtime');
4
+ var s = require('./StatusPill.module.scss');
5
+
6
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
7
+
8
+ var s__default = /*#__PURE__*/_interopDefault(s);
9
+
10
+ const VARIANT_MAP = {
11
+ open: { cls: s__default.default.open, defaultLabel: "Ouvert" },
12
+ pending: { cls: s__default.default.pending, defaultLabel: "En attente" },
13
+ waiting_client: { cls: s__default.default.pending, defaultLabel: "Attente client" },
14
+ resolved: { cls: s__default.default.resolved, defaultLabel: "R\xE9solu" },
15
+ closed: { cls: s__default.default.neutral, defaultLabel: "Ferm\xE9" },
16
+ escalated: { cls: s__default.default.escalated, defaultLabel: "Escalad\xE9" },
17
+ "sla-breach": { cls: s__default.default.breach, defaultLabel: "SLA d\xE9pass\xE9" }
18
+ };
19
+ const StatusPill = ({ status, label, className }) => {
20
+ const v = VARIANT_MAP[status] || VARIANT_MAP.open;
21
+ const text = label ?? v.defaultLabel;
22
+ return /* @__PURE__ */ jsxRuntime.jsxs(
23
+ "span",
24
+ {
25
+ className: [s__default.default.pill, v.cls, className].filter(Boolean).join(" "),
26
+ role: "status",
27
+ "aria-label": `Statut : ${text}`,
28
+ children: [
29
+ /* @__PURE__ */ jsxRuntime.jsx("span", { className: s__default.default.dot, "aria-hidden": true }),
30
+ text
31
+ ]
32
+ }
33
+ );
34
+ };
35
+
36
+ exports.StatusPill = StatusPill;
@@ -0,0 +1,30 @@
1
+ import { jsxs, jsx } from 'react/jsx-runtime';
2
+ import s from './StatusPill.module.scss';
3
+
4
+ const VARIANT_MAP = {
5
+ open: { cls: s.open, defaultLabel: "Ouvert" },
6
+ pending: { cls: s.pending, defaultLabel: "En attente" },
7
+ waiting_client: { cls: s.pending, defaultLabel: "Attente client" },
8
+ resolved: { cls: s.resolved, defaultLabel: "R\xE9solu" },
9
+ closed: { cls: s.neutral, defaultLabel: "Ferm\xE9" },
10
+ escalated: { cls: s.escalated, defaultLabel: "Escalad\xE9" },
11
+ "sla-breach": { cls: s.breach, defaultLabel: "SLA d\xE9pass\xE9" }
12
+ };
13
+ const StatusPill = ({ status, label, className }) => {
14
+ const v = VARIANT_MAP[status] || VARIANT_MAP.open;
15
+ const text = label ?? v.defaultLabel;
16
+ return /* @__PURE__ */ jsxs(
17
+ "span",
18
+ {
19
+ className: [s.pill, v.cls, className].filter(Boolean).join(" "),
20
+ role: "status",
21
+ "aria-label": `Statut : ${text}`,
22
+ children: [
23
+ /* @__PURE__ */ jsx("span", { className: s.dot, "aria-hidden": true }),
24
+ text
25
+ ]
26
+ }
27
+ );
28
+ };
29
+
30
+ export { StatusPill };
@@ -0,0 +1,66 @@
1
+ // StatusPill — sémantique unique des statuts ticket
2
+ // Tous les fonds/couleurs sont mappés sur les --theme-* Payload natifs
3
+ // via color-mix() pour rester cohérent en light/dark.
4
+
5
+ .pill {
6
+ display: inline-flex;
7
+ align-items: center;
8
+ gap: 6px;
9
+ height: 22px;
10
+ padding: 0 8px;
11
+ border-radius: 999px;
12
+ font-size: 11px;
13
+ font-weight: 500;
14
+ letter-spacing: 0.01em;
15
+ white-space: nowrap;
16
+ border: 1px solid transparent;
17
+ line-height: 1;
18
+ }
19
+
20
+ .dot {
21
+ width: 6px;
22
+ height: 6px;
23
+ border-radius: 50%;
24
+ background: currentColor;
25
+ flex-shrink: 0;
26
+ }
27
+
28
+ // ---------- Variants ----------
29
+ // Each variant uses color-mix on Payload's theme tokens so dark/light just works.
30
+
31
+ .open {
32
+ background: color-mix(in srgb, var(--theme-success-500, #2dbf86) 12%, transparent);
33
+ color: var(--theme-success-500, #16a34a);
34
+ }
35
+
36
+ .pending {
37
+ background: color-mix(in srgb, var(--theme-warning-500, #e9a23a) 14%, transparent);
38
+ color: var(--theme-warning-500, #c97a0a);
39
+ }
40
+
41
+ .resolved {
42
+ background: color-mix(in srgb, var(--theme-elevation-500, #7a7a7a) 14%, transparent);
43
+ color: var(--theme-elevation-600, #5e5e5e);
44
+ }
45
+
46
+ .neutral {
47
+ background: var(--theme-elevation-100, #f3f3f3);
48
+ color: var(--theme-elevation-600, #5e5e5e);
49
+ }
50
+
51
+ .escalated {
52
+ background: color-mix(in srgb, var(--theme-error-500, #c4392c) 14%, transparent);
53
+ color: var(--theme-error-500, #c4392c);
54
+ }
55
+
56
+ .breach {
57
+ background: color-mix(in srgb, var(--theme-error-500, #c4392c) 14%, transparent);
58
+ color: var(--theme-error-500, #c4392c);
59
+ border-color: color-mix(in srgb, var(--theme-error-500, #c4392c) 30%, transparent);
60
+ }
61
+
62
+ [data-theme='dark'] {
63
+ .open { color: var(--theme-success-500, #2dbf86); }
64
+ .pending { color: var(--theme-warning-500, #e9a23a); }
65
+ .escalated, .breach { color: var(--theme-error-500, #ed5f51); }
66
+ }
@@ -5,6 +5,8 @@ var adminTokens = require('./adminTokens');
5
5
  var AdminViewHeader = require('./AdminViewHeader');
6
6
  var Skeleton = require('./Skeleton');
7
7
  var config = require('./config');
8
+ var StatusPill = require('./StatusPill');
9
+ var sla = require('./sla');
8
10
 
9
11
 
10
12
 
@@ -56,3 +58,15 @@ Object.defineProperty(exports, "saveFeatures", {
56
58
  enumerable: true,
57
59
  get: function () { return config.saveFeatures; }
58
60
  });
61
+ Object.defineProperty(exports, "StatusPill", {
62
+ enumerable: true,
63
+ get: function () { return StatusPill.StatusPill; }
64
+ });
65
+ Object.defineProperty(exports, "computeSlaState", {
66
+ enumerable: true,
67
+ get: function () { return sla.computeSlaState; }
68
+ });
69
+ Object.defineProperty(exports, "formatSlaRemaining", {
70
+ enumerable: true,
71
+ get: function () { return sla.formatSlaRemaining; }
72
+ });
@@ -3,3 +3,5 @@ export { V, btnStyle } from './adminTokens.js';
3
3
  export { AdminViewHeader } from './AdminViewHeader.js';
4
4
  export { Skeleton, SkeletonCard, SkeletonDashboard, SkeletonTable, SkeletonText } from './Skeleton.js';
5
5
  export { DEFAULT_FEATURES, getFeatures, saveFeatures } from './config.js';
6
+ export { StatusPill } from './StatusPill.js';
7
+ export { computeSlaState, formatSlaRemaining } from './sla.js';
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+
3
+ const HOUR_MS = 36e5;
4
+ const MINUTE_MS = 6e4;
5
+ function computeSlaState(t, now = /* @__PURE__ */ new Date()) {
6
+ const hasFirstResponse = !!t.firstResponseAt;
7
+ const dueRaw = hasFirstResponse ? t.slaResolutionDue : t.slaFirstResponseDue;
8
+ const breached = hasFirstResponse ? !!t.slaResolutionBreached : !!t.slaFirstResponseBreached;
9
+ if (!dueRaw) return { state: "none", remainingMs: null, due: null };
10
+ const dueDate = new Date(dueRaw);
11
+ if (Number.isNaN(dueDate.getTime())) return { state: "none", remainingMs: null, due: null };
12
+ const remaining = dueDate.getTime() - now.getTime();
13
+ if (breached || remaining < 0) {
14
+ return { state: "breach", remainingMs: remaining, due: dueRaw };
15
+ }
16
+ if (remaining < HOUR_MS) {
17
+ return { state: "warn", remainingMs: remaining, due: dueRaw };
18
+ }
19
+ return { state: "ok", remainingMs: remaining, due: dueRaw };
20
+ }
21
+ function formatSlaRemaining(remainingMs) {
22
+ if (remainingMs == null) return "\u2014";
23
+ const abs = Math.abs(remainingMs);
24
+ const sign = remainingMs < 0 ? "\u2212" : "";
25
+ if (abs < HOUR_MS) {
26
+ const m2 = Math.floor(abs / MINUTE_MS);
27
+ return `${sign}${m2}m`;
28
+ }
29
+ const h = Math.floor(abs / HOUR_MS);
30
+ const m = Math.floor(abs % HOUR_MS / MINUTE_MS);
31
+ if (h < 24) return `${sign}${h}h${m > 0 ? ` ${String(m).padStart(2, "0")}` : ""}`;
32
+ const d = Math.floor(h / 24);
33
+ return `${sign}${d}j`;
34
+ }
35
+
36
+ exports.computeSlaState = computeSlaState;
37
+ exports.formatSlaRemaining = formatSlaRemaining;
@@ -0,0 +1,34 @@
1
+ const HOUR_MS = 36e5;
2
+ const MINUTE_MS = 6e4;
3
+ function computeSlaState(t, now = /* @__PURE__ */ new Date()) {
4
+ const hasFirstResponse = !!t.firstResponseAt;
5
+ const dueRaw = hasFirstResponse ? t.slaResolutionDue : t.slaFirstResponseDue;
6
+ const breached = hasFirstResponse ? !!t.slaResolutionBreached : !!t.slaFirstResponseBreached;
7
+ if (!dueRaw) return { state: "none", remainingMs: null, due: null };
8
+ const dueDate = new Date(dueRaw);
9
+ if (Number.isNaN(dueDate.getTime())) return { state: "none", remainingMs: null, due: null };
10
+ const remaining = dueDate.getTime() - now.getTime();
11
+ if (breached || remaining < 0) {
12
+ return { state: "breach", remainingMs: remaining, due: dueRaw };
13
+ }
14
+ if (remaining < HOUR_MS) {
15
+ return { state: "warn", remainingMs: remaining, due: dueRaw };
16
+ }
17
+ return { state: "ok", remainingMs: remaining, due: dueRaw };
18
+ }
19
+ function formatSlaRemaining(remainingMs) {
20
+ if (remainingMs == null) return "\u2014";
21
+ const abs = Math.abs(remainingMs);
22
+ const sign = remainingMs < 0 ? "\u2212" : "";
23
+ if (abs < HOUR_MS) {
24
+ const m2 = Math.floor(abs / MINUTE_MS);
25
+ return `${sign}${m2}m`;
26
+ }
27
+ const h = Math.floor(abs / HOUR_MS);
28
+ const m = Math.floor(abs % HOUR_MS / MINUTE_MS);
29
+ if (h < 24) return `${sign}${h}h${m > 0 ? ` ${String(m).padStart(2, "0")}` : ""}`;
30
+ const d = Math.floor(h / 24);
31
+ return `${sign}${d}j`;
32
+ }
33
+
34
+ export { computeSlaState, formatSlaRemaining };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@consilioweb/payload-support",
3
- "version": "0.9.13",
3
+ "version": "0.10.0",
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",
@@ -108,8 +108,16 @@
108
108
  "all": "All",
109
109
  "open": "Open",
110
110
  "waiting": "Waiting",
111
+ "slaBreach": "SLA breached",
111
112
  "resolved": "Resolved"
112
113
  },
114
+ "slaColumn": "SLA",
115
+ "slaBanner": {
116
+ "breached": "SLA breached",
117
+ "responseAwaitedFor": "Response awaited for {{duration}}",
118
+ "target": "Target: {{target}}",
119
+ "escalate": "Escalate"
120
+ },
113
121
  "sort": {
114
122
  "newest": "Sort by: Newest",
115
123
  "oldest": "Sort by: Oldest",
@@ -108,7 +108,15 @@
108
108
  "all": "Tous",
109
109
  "open": "Ouverts",
110
110
  "waiting": "Attente",
111
- "resolved": "Resolus"
111
+ "slaBreach": "SLA dépassé",
112
+ "resolved": "Résolus"
113
+ },
114
+ "slaColumn": "SLA",
115
+ "slaBanner": {
116
+ "breached": "SLA dépassé",
117
+ "responseAwaitedFor": "Réponse attendue depuis {{duration}}",
118
+ "target": "Cible : {{target}}",
119
+ "escalate": "Escalader"
112
120
  },
113
121
  "sort": {
114
122
  "newest": "Tri : Recents",