@consilioweb/payload-support 0.9.12 → 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.
- package/dist/components/TicketConversation/locales/en.json +8 -0
- package/dist/components/TicketConversation/locales/fr.json +9 -1
- package/dist/index.cjs +1 -0
- package/dist/index.d.cts +18 -1
- package/dist/index.d.ts +18 -1
- package/dist/index.js +1 -1
- package/dist/styles/TicketDetail.module.scss +270 -20
- package/dist/styles/TicketInbox.module.scss +112 -50
- package/dist/views/TicketDetailView/client.cjs +80 -13
- package/dist/views/TicketDetailView/client.js +80 -13
- package/dist/views/TicketInboxView/client.cjs +116 -58
- package/dist/views/TicketInboxView/client.js +116 -58
- package/dist/views/shared/StatusPill.cjs +36 -0
- package/dist/views/shared/StatusPill.js +30 -0
- package/dist/views/shared/StatusPill.module.scss +66 -0
- package/dist/views/shared/index.cjs +14 -0
- package/dist/views/shared/index.js +2 -0
- package/dist/views/shared/sla.cjs +37 -0
- package/dist/views/shared/sla.js +34 -0
- package/package.json +1 -1
- package/src/components/TicketConversation/locales/en.json +8 -0
- package/src/components/TicketConversation/locales/fr.json +9 -1
- package/src/index.ts +2 -0
- package/src/styles/TicketDetail.module.scss +270 -20
- package/src/styles/TicketInbox.module.scss +112 -50
- package/src/views/TicketDetailView/client.tsx +88 -27
- package/src/views/TicketInboxView/client.tsx +106 -50
- package/src/views/shared/StatusPill.module.scss +66 -0
- package/src/views/shared/StatusPill.tsx +44 -0
- package/src/views/shared/index.ts +4 -0
- package/src/views/shared/sla.ts +56 -0
|
@@ -4,6 +4,7 @@ import React, { 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'
|
|
7
|
+
import { StatusPill, computeSlaState, formatSlaRemaining } from '../shared'
|
|
7
8
|
import s from '../../styles/TicketInbox.module.scss'
|
|
8
9
|
|
|
9
10
|
interface Ticket {
|
|
@@ -18,35 +19,29 @@ interface Ticket {
|
|
|
18
19
|
createdAt: string
|
|
19
20
|
lastClientMessageAt?: string
|
|
20
21
|
lastAdminReadAt?: string
|
|
22
|
+
// SLA fields (already declared on the Tickets collection)
|
|
23
|
+
slaFirstResponseDue?: string | null
|
|
24
|
+
slaFirstResponseBreached?: boolean | null
|
|
25
|
+
slaResolutionDue?: string | null
|
|
26
|
+
slaResolutionBreached?: boolean | null
|
|
27
|
+
firstResponseAt?: string | null
|
|
21
28
|
}
|
|
22
29
|
|
|
23
|
-
type Tab = 'all' | 'open' | 'waiting_client' | 'resolved'
|
|
24
|
-
|
|
25
|
-
const STATUS_DOTS: Record<string, string> = {
|
|
26
|
-
open: '#22c55e',
|
|
27
|
-
waiting_client: '#eab308',
|
|
28
|
-
resolved: '#94a3b8',
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const STATUS_LABEL_KEYS: Record<string, string> = {
|
|
32
|
-
open: 'inbox.tabs.open',
|
|
33
|
-
waiting_client: 'inbox.tabs.waiting',
|
|
34
|
-
resolved: 'inbox.tabs.resolved',
|
|
35
|
-
}
|
|
30
|
+
type Tab = 'all' | 'open' | 'waiting_client' | 'resolved' | 'sla_breach'
|
|
36
31
|
|
|
37
32
|
const PRIORITY_COLORS: Record<string, string> = {
|
|
38
|
-
urgent: '
|
|
39
|
-
high: '
|
|
40
|
-
normal: '
|
|
41
|
-
low: '
|
|
33
|
+
urgent: 'var(--theme-error-500)',
|
|
34
|
+
high: 'var(--theme-warning-500)',
|
|
35
|
+
normal: 'var(--theme-info-500, #2a6bd8)',
|
|
36
|
+
low: 'var(--theme-elevation-400)',
|
|
42
37
|
}
|
|
43
38
|
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
39
|
+
const STATUS_VARIANT: Record<string, 'open' | 'pending' | 'resolved' | 'closed'> = {
|
|
40
|
+
open: 'open',
|
|
41
|
+
waiting_client: 'pending',
|
|
42
|
+
pending: 'pending',
|
|
43
|
+
resolved: 'resolved',
|
|
44
|
+
closed: 'closed',
|
|
50
45
|
}
|
|
51
46
|
|
|
52
47
|
function relativeTime(dateStr: string): string {
|
|
@@ -68,13 +63,13 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
68
63
|
const [loading, setLoading] = useState(true)
|
|
69
64
|
const [tab, setTab] = useState<Tab>(() => {
|
|
70
65
|
const urlTab = searchParams.get('tab')
|
|
71
|
-
if (urlTab && ['all', 'open', 'waiting_client', 'resolved'].includes(urlTab)) return urlTab as Tab
|
|
66
|
+
if (urlTab && ['all', 'open', 'waiting_client', 'resolved', 'sla_breach'].includes(urlTab)) return urlTab as Tab
|
|
72
67
|
return 'all'
|
|
73
68
|
})
|
|
74
69
|
// Sync tab with URL searchParams on navigation
|
|
75
70
|
useEffect(() => {
|
|
76
71
|
const urlTab = searchParams.get('tab')
|
|
77
|
-
if (urlTab && ['all', 'open', 'waiting_client', 'resolved'].includes(urlTab)) {
|
|
72
|
+
if (urlTab && ['all', 'open', 'waiting_client', 'resolved', 'sla_breach'].includes(urlTab)) {
|
|
78
73
|
setTab(urlTab as Tab)
|
|
79
74
|
} else if (!urlTab) {
|
|
80
75
|
setTab('all')
|
|
@@ -82,7 +77,7 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
82
77
|
}, [searchParams])
|
|
83
78
|
const [search, setSearch] = useState('')
|
|
84
79
|
const [sort, setSort] = useState('-updatedAt')
|
|
85
|
-
const [counts, setCounts] = useState({ all: 0, open: 0, waiting: 0, resolved: 0 })
|
|
80
|
+
const [counts, setCounts] = useState({ all: 0, open: 0, waiting: 0, resolved: 0, breach: 0 })
|
|
86
81
|
const [selectedIdx, setSelectedIdx] = useState(-1)
|
|
87
82
|
const [checkedIds, setCheckedIds] = useState<Set<number>>(new Set())
|
|
88
83
|
const [bulkAction, setBulkAction] = useState('')
|
|
@@ -121,8 +116,23 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
121
116
|
}
|
|
122
117
|
|
|
123
118
|
const fetchTickets = useCallback(async () => {
|
|
124
|
-
const params = [
|
|
125
|
-
|
|
119
|
+
const params = [
|
|
120
|
+
`limit=30`, `sort=${sort}`, `depth=1`,
|
|
121
|
+
`select[id]=true`, `select[ticketNumber]=true`, `select[subject]=true`,
|
|
122
|
+
`select[status]=true`, `select[priority]=true`, `select[category]=true`,
|
|
123
|
+
`select[client]=true`, `select[updatedAt]=true`,
|
|
124
|
+
`select[lastClientMessageAt]=true`, `select[lastAdminReadAt]=true`,
|
|
125
|
+
`select[slaFirstResponseDue]=true`, `select[slaFirstResponseBreached]=true`,
|
|
126
|
+
`select[slaResolutionDue]=true`, `select[slaResolutionBreached]=true`,
|
|
127
|
+
`select[firstResponseAt]=true`,
|
|
128
|
+
]
|
|
129
|
+
if (tab === 'sla_breach') {
|
|
130
|
+
// Either first-response breached or resolution breached
|
|
131
|
+
params.push(`where[or][0][slaFirstResponseBreached][equals]=true`)
|
|
132
|
+
params.push(`where[or][1][slaResolutionBreached][equals]=true`)
|
|
133
|
+
} else if (tab !== 'all') {
|
|
134
|
+
params.push(`where[status][equals]=${tab}`)
|
|
135
|
+
}
|
|
126
136
|
if (search.trim()) {
|
|
127
137
|
params.push(`where[or][0][subject][contains]=${encodeURIComponent(search)}`)
|
|
128
138
|
params.push(`where[or][1][ticketNumber][contains]=${encodeURIComponent(search)}`)
|
|
@@ -148,14 +158,21 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
148
158
|
useEffect(() => {
|
|
149
159
|
const fetchCounts = async () => {
|
|
150
160
|
try {
|
|
151
|
-
const [all, openRes, waiting, resolved] = await Promise.all([
|
|
161
|
+
const [all, openRes, waiting, resolved, breach] = await Promise.all([
|
|
152
162
|
fetch('/api/tickets?limit=0&depth=0', { credentials: 'include' }),
|
|
153
163
|
fetch('/api/tickets?limit=0&depth=0&where[status][equals]=open', { credentials: 'include' }),
|
|
154
164
|
fetch('/api/tickets?limit=0&depth=0&where[status][equals]=waiting_client', { credentials: 'include' }),
|
|
155
165
|
fetch('/api/tickets?limit=0&depth=0&where[status][equals]=resolved', { credentials: 'include' }),
|
|
166
|
+
fetch('/api/tickets?limit=0&depth=0&where[or][0][slaFirstResponseBreached][equals]=true&where[or][1][slaResolutionBreached][equals]=true', { credentials: 'include' }),
|
|
156
167
|
])
|
|
157
|
-
const [a, o, w, r] = await Promise.all([all.json(), openRes.json(), waiting.json(), resolved.json()])
|
|
158
|
-
setCounts({
|
|
168
|
+
const [a, o, w, r, b] = await Promise.all([all.json(), openRes.json(), waiting.json(), resolved.json(), breach.json()])
|
|
169
|
+
setCounts({
|
|
170
|
+
all: a.totalDocs || 0,
|
|
171
|
+
open: o.totalDocs || 0,
|
|
172
|
+
waiting: w.totalDocs || 0,
|
|
173
|
+
resolved: r.totalDocs || 0,
|
|
174
|
+
breach: b.totalDocs || 0,
|
|
175
|
+
})
|
|
159
176
|
} catch { /* silent */ }
|
|
160
177
|
}
|
|
161
178
|
fetchCounts()
|
|
@@ -181,10 +198,11 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
181
198
|
return () => window.removeEventListener('keydown', handleKey)
|
|
182
199
|
}, [tickets, selectedIdx])
|
|
183
200
|
|
|
184
|
-
const tabs: Array<{ key: Tab; label: string; count: number }> = [
|
|
201
|
+
const tabs: Array<{ key: Tab; label: string; count: number; alert?: boolean }> = [
|
|
185
202
|
{ key: 'all', label: t('inbox.tabs.all'), count: counts.all },
|
|
186
203
|
{ key: 'open', label: t('inbox.tabs.open'), count: counts.open },
|
|
187
204
|
{ key: 'waiting_client', label: t('inbox.tabs.waiting'), count: counts.waiting },
|
|
205
|
+
{ key: 'sla_breach', label: t('inbox.tabs.slaBreach'), count: counts.breach, alert: true },
|
|
188
206
|
{ key: 'resolved', label: t('inbox.tabs.resolved'), count: counts.resolved },
|
|
189
207
|
]
|
|
190
208
|
|
|
@@ -192,7 +210,17 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
192
210
|
<div className={s.page}>
|
|
193
211
|
{/* Header */}
|
|
194
212
|
<div className={s.header}>
|
|
195
|
-
<
|
|
213
|
+
<div>
|
|
214
|
+
<h1 className={s.title}>{t('inbox.title')}</h1>
|
|
215
|
+
<p className={s.subtitle}>
|
|
216
|
+
{tickets.length > 0 && `${tickets.length} sur ${counts.all} · `}
|
|
217
|
+
{counts.breach > 0 ? (
|
|
218
|
+
<span className={s.subtitleBreach}>{counts.breach} SLA dépassé{counts.breach > 1 ? 's' : ''}</span>
|
|
219
|
+
) : (
|
|
220
|
+
<span>Aucun SLA en alerte</span>
|
|
221
|
+
)}
|
|
222
|
+
</p>
|
|
223
|
+
</div>
|
|
196
224
|
<div className={s.headerRight}>
|
|
197
225
|
<div className={s.searchWrap}>
|
|
198
226
|
<svg className={s.searchIcon} width="14" height="14" viewBox="0 0 16 16" fill="none"><circle cx="7" cy="7" r="5" stroke="currentColor" strokeWidth="1.5"/><path d="M11 11l3.5 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
|
|
@@ -210,17 +238,22 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
210
238
|
</div>
|
|
211
239
|
|
|
212
240
|
{/* Tabs */}
|
|
213
|
-
<div className={s.tabs}>
|
|
214
|
-
{tabs.map((tk) =>
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
241
|
+
<div className={s.tabs} role="tablist">
|
|
242
|
+
{tabs.map((tk) => {
|
|
243
|
+
const isAlert = tk.alert && tk.count > 0
|
|
244
|
+
return (
|
|
245
|
+
<button
|
|
246
|
+
key={tk.key}
|
|
247
|
+
role="tab"
|
|
248
|
+
aria-selected={tab === tk.key}
|
|
249
|
+
className={`${s.tab} ${tab === tk.key ? s.tabActive : ''} ${isAlert ? s.tabAlert : ''}`}
|
|
250
|
+
onClick={() => { setTab(tk.key); setSelectedIdx(-1) }}
|
|
251
|
+
>
|
|
252
|
+
{tk.label}
|
|
253
|
+
<span className={`${s.tabCount} ${isAlert ? s.tabCountAlert : ''}`}>{tk.count}</span>
|
|
254
|
+
</button>
|
|
255
|
+
)
|
|
256
|
+
})}
|
|
224
257
|
</div>
|
|
225
258
|
|
|
226
259
|
{/* Sort + Bulk */}
|
|
@@ -264,12 +297,20 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
264
297
|
const displayClient = clientName ? `${clientName}${clientCompany ? `, ${clientCompany}` : ''}` : '—'
|
|
265
298
|
const isUnread = tk.lastClientMessageAt && (!tk.lastAdminReadAt || new Date(tk.lastClientMessageAt) > new Date(tk.lastAdminReadAt))
|
|
266
299
|
const priorityColor = PRIORITY_COLORS[tk.priority] || 'transparent'
|
|
300
|
+
const sla = computeSlaState(tk)
|
|
301
|
+
const isBreach = sla.state === 'breach'
|
|
302
|
+
const statusVariant: 'open' | 'pending' | 'resolved' | 'closed' = STATUS_VARIANT[tk.status] || 'open'
|
|
267
303
|
|
|
268
304
|
return (
|
|
269
305
|
<a
|
|
270
306
|
key={tk.id}
|
|
271
307
|
href={`/admin/support/ticket?id=${tk.id}`}
|
|
272
|
-
className={
|
|
308
|
+
className={[
|
|
309
|
+
s.row,
|
|
310
|
+
idx === selectedIdx ? s.rowSelected : '',
|
|
311
|
+
isUnread ? s.rowUnread : '',
|
|
312
|
+
isBreach ? s.rowBreach : '',
|
|
313
|
+
].filter(Boolean).join(' ')}
|
|
273
314
|
onClick={(e) => { e.preventDefault(); window.location.href = `/admin/support/ticket?id=${tk.id}` }}
|
|
274
315
|
>
|
|
275
316
|
<input
|
|
@@ -277,15 +318,30 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
277
318
|
checked={checkedIds.has(tk.id)}
|
|
278
319
|
onClick={(e) => e.stopPropagation()}
|
|
279
320
|
onChange={() => toggleCheck(tk.id)}
|
|
280
|
-
|
|
321
|
+
className={s.rowCheck}
|
|
322
|
+
aria-label={`Sélectionner ${tk.ticketNumber}`}
|
|
323
|
+
/>
|
|
324
|
+
<span
|
|
325
|
+
className={s.priorityDot}
|
|
326
|
+
style={{ background: priorityColor }}
|
|
327
|
+
aria-label={`Priorité ${tk.priority}`}
|
|
328
|
+
title={`Priorité ${tk.priority}`}
|
|
281
329
|
/>
|
|
282
|
-
<
|
|
283
|
-
<span className={s.statusText}>{t(STATUS_LABEL_KEYS[tk.status] || 'ticket.status.open')}</span>
|
|
330
|
+
<StatusPill status={isBreach ? 'sla-breach' : statusVariant} />
|
|
284
331
|
<span className={s.ticketNum}>{tk.ticketNumber}</span>
|
|
285
332
|
<span className={s.subject}>{tk.subject}</span>
|
|
286
333
|
<span className={s.client}>{displayClient}</span>
|
|
287
|
-
|
|
288
|
-
|
|
334
|
+
<span
|
|
335
|
+
className={[
|
|
336
|
+
s.slaCell,
|
|
337
|
+
sla.state === 'breach' ? s.slaBreach : '',
|
|
338
|
+
sla.state === 'warn' ? s.slaWarn : '',
|
|
339
|
+
sla.state === 'ok' ? s.slaOk : '',
|
|
340
|
+
].filter(Boolean).join(' ')}
|
|
341
|
+
title={sla.due ? new Date(sla.due).toLocaleString('fr-FR') : ''}
|
|
342
|
+
>
|
|
343
|
+
{formatSlaRemaining(sla.remainingMs)}
|
|
344
|
+
</span>
|
|
289
345
|
<span className={s.timeAgo}>{relativeTime(tk.updatedAt)}</span>
|
|
290
346
|
{isUnread ? <div className={s.unreadDot} /> : <span />}
|
|
291
347
|
</a>
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import s from './StatusPill.module.scss'
|
|
5
|
+
|
|
6
|
+
export type TicketStatusVariant =
|
|
7
|
+
| 'open'
|
|
8
|
+
| 'pending'
|
|
9
|
+
| 'waiting_client'
|
|
10
|
+
| 'resolved'
|
|
11
|
+
| 'closed'
|
|
12
|
+
| 'escalated'
|
|
13
|
+
| 'sla-breach'
|
|
14
|
+
|
|
15
|
+
export interface StatusPillProps {
|
|
16
|
+
status: TicketStatusVariant | string
|
|
17
|
+
label?: string
|
|
18
|
+
className?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const VARIANT_MAP: Record<string, { cls: string; defaultLabel: string }> = {
|
|
22
|
+
open: { cls: s.open, defaultLabel: 'Ouvert' },
|
|
23
|
+
pending: { cls: s.pending, defaultLabel: 'En attente' },
|
|
24
|
+
waiting_client: { cls: s.pending, defaultLabel: 'Attente client' },
|
|
25
|
+
resolved: { cls: s.resolved, defaultLabel: 'Résolu' },
|
|
26
|
+
closed: { cls: s.neutral, defaultLabel: 'Fermé' },
|
|
27
|
+
escalated: { cls: s.escalated, defaultLabel: 'Escaladé' },
|
|
28
|
+
'sla-breach': { cls: s.breach, defaultLabel: 'SLA dépassé' },
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const StatusPill: React.FC<StatusPillProps> = ({ status, label, className }) => {
|
|
32
|
+
const v = VARIANT_MAP[status] || VARIANT_MAP.open
|
|
33
|
+
const text = label ?? v.defaultLabel
|
|
34
|
+
return (
|
|
35
|
+
<span
|
|
36
|
+
className={[s.pill, v.cls, className].filter(Boolean).join(' ')}
|
|
37
|
+
role="status"
|
|
38
|
+
aria-label={`Statut : ${text}`}
|
|
39
|
+
>
|
|
40
|
+
<span className={s.dot} aria-hidden />
|
|
41
|
+
{text}
|
|
42
|
+
</span>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -4,3 +4,7 @@ export { AdminViewHeader } from './AdminViewHeader'
|
|
|
4
4
|
export { Skeleton, SkeletonText, SkeletonCard, SkeletonTable, SkeletonDashboard } from './Skeleton'
|
|
5
5
|
export { getFeatures, saveFeatures, DEFAULT_FEATURES } from './config'
|
|
6
6
|
export type { TicketingFeatures } from './config'
|
|
7
|
+
export { StatusPill } from './StatusPill'
|
|
8
|
+
export type { StatusPillProps, TicketStatusVariant } from './StatusPill'
|
|
9
|
+
export { computeSlaState, formatSlaRemaining } from './sla'
|
|
10
|
+
export type { SlaState } from './sla'
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// SLA helpers — derive remaining time + visual state from ticket's
|
|
2
|
+
// slaFirstResponseDue / slaFirstResponseBreached fields (already on Tickets).
|
|
3
|
+
|
|
4
|
+
export type SlaState = 'ok' | 'warn' | 'breach' | 'none' | 'met'
|
|
5
|
+
|
|
6
|
+
export interface SlaInput {
|
|
7
|
+
slaFirstResponseDue?: string | null
|
|
8
|
+
slaFirstResponseBreached?: boolean | null
|
|
9
|
+
firstResponseAt?: string | null
|
|
10
|
+
slaResolutionDue?: string | null
|
|
11
|
+
slaResolutionBreached?: boolean | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const HOUR_MS = 3_600_000
|
|
15
|
+
const MINUTE_MS = 60_000
|
|
16
|
+
|
|
17
|
+
export function computeSlaState(t: SlaInput, now: Date = new Date()): {
|
|
18
|
+
state: SlaState
|
|
19
|
+
remainingMs: number | null
|
|
20
|
+
due: string | null
|
|
21
|
+
} {
|
|
22
|
+
// Once first-response is met, fall back to resolution SLA.
|
|
23
|
+
const hasFirstResponse = !!t.firstResponseAt
|
|
24
|
+
const dueRaw = hasFirstResponse ? t.slaResolutionDue : t.slaFirstResponseDue
|
|
25
|
+
const breached = hasFirstResponse ? !!t.slaResolutionBreached : !!t.slaFirstResponseBreached
|
|
26
|
+
|
|
27
|
+
if (!dueRaw) return { state: 'none', remainingMs: null, due: null }
|
|
28
|
+
|
|
29
|
+
const dueDate = new Date(dueRaw)
|
|
30
|
+
if (Number.isNaN(dueDate.getTime())) return { state: 'none', remainingMs: null, due: null }
|
|
31
|
+
|
|
32
|
+
const remaining = dueDate.getTime() - now.getTime()
|
|
33
|
+
|
|
34
|
+
if (breached || remaining < 0) {
|
|
35
|
+
return { state: 'breach', remainingMs: remaining, due: dueRaw }
|
|
36
|
+
}
|
|
37
|
+
if (remaining < HOUR_MS) {
|
|
38
|
+
return { state: 'warn', remainingMs: remaining, due: dueRaw }
|
|
39
|
+
}
|
|
40
|
+
return { state: 'ok', remainingMs: remaining, due: dueRaw }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function formatSlaRemaining(remainingMs: number | null): string {
|
|
44
|
+
if (remainingMs == null) return '—'
|
|
45
|
+
const abs = Math.abs(remainingMs)
|
|
46
|
+
const sign = remainingMs < 0 ? '−' : ''
|
|
47
|
+
if (abs < HOUR_MS) {
|
|
48
|
+
const m = Math.floor(abs / MINUTE_MS)
|
|
49
|
+
return `${sign}${m}m`
|
|
50
|
+
}
|
|
51
|
+
const h = Math.floor(abs / HOUR_MS)
|
|
52
|
+
const m = Math.floor((abs % HOUR_MS) / MINUTE_MS)
|
|
53
|
+
if (h < 24) return `${sign}${h}h${m > 0 ? ` ${String(m).padStart(2, '0')}` : ''}`
|
|
54
|
+
const d = Math.floor(h / 24)
|
|
55
|
+
return `${sign}${d}j`
|
|
56
|
+
}
|