@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.
@@ -7,6 +7,7 @@ import { RichTextEditor, type RichTextEditorHandle } from '../../components/Rich
7
7
  import { hasCodeBlocks, MessageWithCodeBlocks, CodeBlockRendererHtml } from '../../components/TicketConversation/components/CodeBlock'
8
8
  import { CodeBlockInserter } from '../../components/TicketConversation/components/CodeBlockInserter'
9
9
  import { getFeatures } from '../shared/config'
10
+ import { computeSlaState, formatSlaRemaining } from '../shared/sla'
10
11
  import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
11
12
  import s from '../../styles/TicketDetail.module.scss'
12
13
 
@@ -604,6 +605,41 @@ const [clientTyping, setClientTyping] = useState(false)
604
605
  </div>
605
606
  </div>
606
607
 
608
+ {/* SLA BREACH BANNER */}
609
+ {(() => {
610
+ const slaInfo = computeSlaState({
611
+ slaFirstResponseDue: ticket.slaFirstResponseDue as string | null,
612
+ slaFirstResponseBreached: ticket.slaFirstResponseBreached as boolean | null,
613
+ slaResolutionDue: ticket.slaResolutionDue as string | null,
614
+ slaResolutionBreached: ticket.slaResolutionBreached as boolean | null,
615
+ firstResponseAt: ticket.firstResponseAt as string | null,
616
+ })
617
+ if (slaInfo.state !== 'breach') return null
618
+ const duration = formatSlaRemaining(slaInfo.remainingMs).replace(/^−/, '')
619
+ const target = slaInfo.due ? new Date(slaInfo.due).toLocaleString('fr-FR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }) : ''
620
+ return (
621
+ <div className={s.slaBanner} role="alert">
622
+ <span className={s.slaBannerIcon} aria-hidden>
623
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /></svg>
624
+ </span>
625
+ <div className={s.slaBannerBody}>
626
+ <strong>{t('inbox.slaBanner.breached')}</strong>
627
+ <span className={s.slaBannerSep}>—</span>
628
+ <span>{t('inbox.slaBanner.responseAwaitedFor', { duration })}</span>
629
+ {target && <span className={s.slaBannerTarget}>· {t('inbox.slaBanner.target', { target })}</span>}
630
+ </div>
631
+ <button
632
+ type="button"
633
+ className={s.slaBannerCta}
634
+ onClick={() => handleStatusChange('escalated' as any)}
635
+ aria-label={t('inbox.slaBanner.escalate')}
636
+ >
637
+ ↑ {t('inbox.slaBanner.escalate')}
638
+ </button>
639
+ </div>
640
+ )
641
+ })()}
642
+
607
643
  {/* PRESENCE BANNER */}
608
644
  {otherViewers.length > 0 && (
609
645
  <div style={{
@@ -632,9 +668,18 @@ const [clientTyping, setClientTyping] = useState(false)
632
668
  return (
633
669
  <React.Fragment key={msg.id}>
634
670
  {showDate && <div className={s.dateSeparator}><span className={s.dateSeparatorText}>{dateLabel(msg.createdAt)}</span></div>}
635
- <div className={`${s.message} ${msg.isInternal ? s.messageInternal : ''}`} style={isPendingDelete ? { opacity: 0.3, pointerEvents: 'none' } : undefined}>
671
+ <div className={`${s.message} ${isAdmin ? s.messageAdmin : s.messageClient} ${msg.isInternal ? s.messageInternal : ''}`} style={isPendingDelete ? { opacity: 0.3, pointerEvents: 'none' } : undefined}>
636
672
  {/* #10 — Client avatar purple */}
637
- <div className={s.avatar} style={{ backgroundColor: isAdmin ? '#2563eb' : msg.authorType === 'email' ? '#ea580c' : '#7c3aed' }}>
673
+ <div
674
+ className={s.avatar}
675
+ style={{
676
+ backgroundColor: isAdmin
677
+ ? 'var(--theme-info-500, #2a6bd8)'
678
+ : msg.authorType === 'email'
679
+ ? 'var(--theme-warning-500, #c97a0a)'
680
+ : 'var(--theme-accent-500, #6b46e8)',
681
+ }}
682
+ >
638
683
  {isAdmin ? 'CW' : initials}
639
684
  </div>
640
685
  <div className={s.messageContent}>
@@ -741,7 +786,15 @@ const [clientTyping, setClientTyping] = useState(false)
741
786
  <div className={s.composerToolbar}>
742
787
  {features.ai && (
743
788
  <>
744
- <button className={s.toolbarBtn} data-tooltip={t('detail.iaSuggestion')} aria-label={t('detail.iaSuggestion')} onClick={handleAiSuggest} disabled={aiReplying || messages.length === 0} style={{ fontSize: 11, fontWeight: 700, padding: '4px 10px', width: 'auto' }}>{aiReplying ? '...' : `✨ ${t('detail.iaSuggestion')}`}</button>
789
+ <button
790
+ className={`${s.toolbarBtn} ${s.toolbarBtnAi}`}
791
+ data-tooltip={t('detail.iaSuggestion')}
792
+ aria-label={t('detail.iaSuggestion')}
793
+ onClick={handleAiSuggest}
794
+ disabled={aiReplying || messages.length === 0}
795
+ >
796
+ {aiReplying ? '…' : `✨ ${t('detail.iaSuggestion')}`}
797
+ </button>
745
798
  <RewriteDropdown disabled={aiRewriting || !replyBody.trim()} loading={aiRewriting} onSelect={(style) => handleAiRewrite(style)} toolbarBtnClass={s.toolbarBtn} />
746
799
  </>
747
800
  )}
@@ -832,6 +885,9 @@ const [clientTyping, setClientTyping] = useState(false)
832
885
  </>
833
886
  )}
834
887
  </div>
888
+ <span className={s.sendShortcut} aria-hidden>
889
+ <kbd>⌘</kbd><kbd>↵</kbd>
890
+ </span>
835
891
  <button className={`${s.sendBtn} ${isInternal ? s.sendBtnInternal : ''}`} onClick={handleSend} disabled={sending || !replyBody.trim()} data-action="send-reply">
836
892
  {sending ? t('detail.sending') : sendAsClient ? 'Ajouter message client' : isInternal ? t('detail.sendNote') : t('detail.sendReply')}
837
893
  </button>
@@ -858,6 +914,34 @@ const [clientTyping, setClientTyping] = useState(false)
858
914
  </div>
859
915
  )}
860
916
 
917
+ {/* AI client synthesis — promoted: highest-value briefing for the agent */}
918
+ {clientSummary && clientSummary.summary && (
919
+ <div className={`${s.sideSection} ${s.aiCard}`}>
920
+ <div className={s.aiCardTitle}>
921
+ <span className={s.aiCardIcon} aria-hidden>✨</span>
922
+ Synthèse client
923
+ </div>
924
+ <p className={s.aiCardLead}>{clientSummary.summary}</p>
925
+ {clientSummary.recurringTopics && clientSummary.recurringTopics.length > 0 && (
926
+ <div className={s.aiCardChips}>
927
+ {clientSummary.recurringTopics.slice(0, 5).map((tp, i) => (
928
+ <span key={`tp-${i}`} className={s.aiChip}>{tp.topic}</span>
929
+ ))}
930
+ </div>
931
+ )}
932
+ {clientSummary.keyFacts && clientSummary.keyFacts.length > 0 && (
933
+ <ul className={s.aiCardFacts}>
934
+ {clientSummary.keyFacts.slice(0, 4).map((f, i) => (
935
+ <li key={`kf-${i}`} className={s.aiCardFact}>
936
+ <span className={s.aiCardDot} aria-hidden />
937
+ {f}
938
+ </li>
939
+ ))}
940
+ </ul>
941
+ )}
942
+ </div>
943
+ )}
944
+
861
945
  {/* #1 — Editable sidebar fields */}
862
946
  <div className={s.sideSection}>
863
947
  <div className={s.sideSectionTitle}>{t('detail.details')}</div>
@@ -1092,32 +1176,9 @@ const [clientTyping, setClientTyping] = useState(false)
1092
1176
  </div>
1093
1177
  )}
1094
1178
 
1095
- {/* Client Intelligence (compact) */}
1096
- {clientSummary && clientSummary.summary && (
1097
- <div className={s.sideSection} style={{ background: 'linear-gradient(135deg, rgba(37,99,235,0.03) 0%, rgba(139,92,246,0.03) 100%)' }}>
1098
- <div className={s.sideSectionTitle} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
1099
- <span style={{ fontSize: 13 }}>🧠</span> Résumé client
1100
- </div>
1101
- <p style={{ margin: '0 0 8px', fontSize: 11, lineHeight: 1.6, color: '#374151' }}>{clientSummary.summary}</p>
1102
- {clientSummary.recurringTopics && clientSummary.recurringTopics.length > 0 && (
1103
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 6 }}>
1104
- {clientSummary.recurringTopics.slice(0, 3).map((tp, i) => (
1105
- <span key={i} style={{ padding: '2px 8px', borderRadius: 10, background: 'rgba(37,99,235,0.08)', color: '#2563eb', fontSize: 10, fontWeight: 600 }}>{tp.topic}</span>
1106
- ))}
1107
- </div>
1108
- )}
1109
- {clientSummary.keyFacts && clientSummary.keyFacts.length > 0 && (
1110
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
1111
- {clientSummary.keyFacts.slice(0, 3).map((f, i) => (
1112
- <span key={i} style={{ padding: '2px 8px', borderRadius: 10, background: 'rgba(22,163,74,0.08)', color: '#16a34a', fontSize: 10, fontWeight: 600 }}>{f}</span>
1113
- ))}
1114
- </div>
1115
- )}
1116
- </div>
1117
- )}
1118
1179
  {summaryLoading && (
1119
1180
  <div className={s.sideSection}>
1120
- <div style={{ fontSize: 11, color: '#94a3b8', textAlign: 'center', padding: 8 }}>🧠 Chargement résumé...</div>
1181
+ <div style={{ fontSize: 11, color: 'var(--theme-elevation-400)', textAlign: 'center', padding: 8 }}>✨ Chargement de la synthèse…</div>
1121
1182
  </div>
1122
1183
  )}
1123
1184
  </div>
@@ -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: '#ef4444',
39
- high: '#f97316',
40
- normal: 'transparent',
41
- low: 'transparent',
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 CATEGORY_LABEL_KEYS: Record<string, string> = {
45
- bug: 'ticket.category.bug',
46
- content: 'ticket.category.content',
47
- feature: 'ticket.category.feature',
48
- question: 'ticket.category.question',
49
- hosting: 'ticket.category.hosting',
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 = [`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`]
125
- if (tab !== 'all') params.push(`where[status][equals]=${tab}`)
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({ all: a.totalDocs || 0, open: o.totalDocs || 0, waiting: w.totalDocs || 0, resolved: r.totalDocs || 0 })
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
- <h1 className={s.title}>{t('inbox.title')}</h1>
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
- <button
216
- key={tk.key}
217
- className={`${s.tab} ${tab === tk.key ? s.tabActive : ''}`}
218
- onClick={() => { setTab(tk.key); setSelectedIdx(-1) }}
219
- >
220
- {tk.label}
221
- <span className={s.tabCount}>({tk.count})</span>
222
- </button>
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={`${s.row} ${idx === selectedIdx ? s.rowSelected : ''}`}
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
- style={{ width: 14, height: 14, cursor: 'pointer', accentColor: '#2563eb' }}
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
- <div className={s.statusDot} style={{ backgroundColor: STATUS_DOTS[tk.status] || '#94a3b8' }} />
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
- {tk.category ? <span className={s.categoryChip}>[{t(CATEGORY_LABEL_KEYS[tk.category] || 'ticket.category.bug')}]</span> : <span />}
288
- <div className={s.priorityBar} style={{ backgroundColor: priorityColor }} />
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
+ }