@consilioweb/payload-support 5.0.0 → 6.0.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.
Files changed (116) hide show
  1. package/README.md +249 -22
  2. package/dist/components/RichTextEditor/index.js +1 -1
  3. package/dist/components/TicketConversation/RewriteDropdown.js +1 -1
  4. package/dist/components/TicketConversation/components/AISummaryPanel.js +3 -0
  5. package/dist/components/TicketConversation/components/ActionPanels.js +13 -8
  6. package/dist/components/TicketConversation/components/ClientHistory.js +1 -0
  7. package/dist/components/TicketConversation/components/CodeBlock.js +1 -0
  8. package/dist/components/TicketConversation/components/CodeBlockInserter.js +1 -1
  9. package/dist/components/TicketConversation/components/QuickActions.js +10 -8
  10. package/dist/components/TicketConversation/components/TicketHeader.js +2 -0
  11. package/dist/components/TicketConversation/components/TimeTrackingPanel.js +15 -9
  12. package/dist/components/TicketConversation/index.d.ts +10 -0
  13. package/dist/components/TicketConversation/index.js +25 -7
  14. package/dist/index.cjs +306 -60
  15. package/dist/index.d.cts +107 -38
  16. package/dist/index.d.ts +107 -38
  17. package/dist/index.js +300 -59
  18. package/dist/styles/BillingView.module.scss +11 -11
  19. package/dist/styles/ChatView.module.scss +7 -7
  20. package/dist/styles/CommandPalette.module.scss +1 -1
  21. package/dist/styles/CrmView.module.scss +9 -9
  22. package/dist/styles/EmailTracking.module.scss +6 -6
  23. package/dist/styles/ImportConversation.module.scss +5 -5
  24. package/dist/styles/Logs.module.scss +5 -5
  25. package/dist/styles/NewTicket.module.scss +2 -2
  26. package/dist/styles/PendingEmails.module.scss +13 -13
  27. package/dist/styles/SupportDashboard.module.scss +24 -8
  28. package/dist/styles/TicketDetail.module.scss +20 -20
  29. package/dist/styles/TicketInbox.module.scss +6 -6
  30. package/dist/styles/TicketingSettings.module.scss +10 -10
  31. package/dist/styles/TimeDashboard.module.scss +6 -6
  32. package/dist/styles/_tokens.scss +3 -1
  33. package/dist/views/BillingView/client.js +15 -8
  34. package/dist/views/ChatView/client.js +2 -0
  35. package/dist/views/CrmView/client.js +2 -0
  36. package/dist/views/EmailTrackingView/client.js +23 -11
  37. package/dist/views/ImportConversationView/client.js +1 -0
  38. package/dist/views/LogsView/client.js +1 -1
  39. package/dist/views/NewTicketView/client.js +20 -13
  40. package/dist/views/PendingEmailsView/client.js +9 -3
  41. package/dist/views/SupportDashboardView/client.js +17 -19
  42. package/dist/views/TicketDetailView/client.js +23 -11
  43. package/dist/views/TicketInboxView/client.js +52 -26
  44. package/dist/views/TicketingSettingsView/TicketingSettings.module.scss +7 -7
  45. package/dist/views/TicketingSettingsView/client.js +44 -26
  46. package/dist/views/TimeDashboardView/client.js +10 -7
  47. package/dist/views/shared/ErrorBoundary.d.ts +36 -1
  48. package/dist/views/shared/ErrorBoundary.js +59 -28
  49. package/dist/views/shared/adminTokens.d.ts +15 -5
  50. package/dist/views/shared/adminTokens.js +23 -7
  51. package/dist/views/shared/icons.d.ts +49 -0
  52. package/dist/views/shared/icons.js +92 -0
  53. package/dist/views/shared/locales/en.json +15 -1
  54. package/dist/views/shared/locales/fr.json +15 -1
  55. package/package.json +6 -2
  56. package/scripts/uninstall-data.mjs +178 -0
  57. package/scripts/uninstall.mjs +184 -0
  58. package/src/collections/Tickets.ts +7 -0
  59. package/src/collections/TimeEntries.ts +11 -3
  60. package/src/components/RichTextEditor/index.tsx +1 -1
  61. package/src/components/TicketConversation/RewriteDropdown.tsx +1 -1
  62. package/src/components/TicketConversation/components/AISummaryPanel.tsx +3 -0
  63. package/src/components/TicketConversation/components/ActionPanels.tsx +13 -8
  64. package/src/components/TicketConversation/components/ClientHistory.tsx +1 -0
  65. package/src/components/TicketConversation/components/CodeBlock.tsx +1 -0
  66. package/src/components/TicketConversation/components/CodeBlockInserter.tsx +1 -1
  67. package/src/components/TicketConversation/components/QuickActions.tsx +10 -8
  68. package/src/components/TicketConversation/components/TicketHeader.tsx +2 -0
  69. package/src/components/TicketConversation/components/TimeTrackingPanel.tsx +17 -9
  70. package/src/components/TicketConversation/index.tsx +41 -7
  71. package/src/endpoints/delete-account.ts +191 -47
  72. package/src/endpoints/export-data.ts +104 -4
  73. package/src/endpoints/purge-logs.ts +6 -15
  74. package/src/index.ts +2 -0
  75. package/src/plugin.ts +51 -1
  76. package/src/portal/LiveChat.tsx +1 -1
  77. package/src/portal/auth/profile/page.tsx +3 -2
  78. package/src/portal/icons.tsx +91 -0
  79. package/src/styles/BillingView.module.scss +11 -11
  80. package/src/styles/ChatView.module.scss +7 -7
  81. package/src/styles/CommandPalette.module.scss +1 -1
  82. package/src/styles/CrmView.module.scss +9 -9
  83. package/src/styles/EmailTracking.module.scss +6 -6
  84. package/src/styles/ImportConversation.module.scss +5 -5
  85. package/src/styles/Logs.module.scss +5 -5
  86. package/src/styles/NewTicket.module.scss +2 -2
  87. package/src/styles/PendingEmails.module.scss +13 -13
  88. package/src/styles/SupportDashboard.module.scss +24 -8
  89. package/src/styles/TicketDetail.module.scss +20 -20
  90. package/src/styles/TicketInbox.module.scss +6 -6
  91. package/src/styles/TicketingSettings.module.scss +10 -10
  92. package/src/styles/TimeDashboard.module.scss +6 -6
  93. package/src/styles/_tokens.scss +3 -1
  94. package/src/types.ts +20 -0
  95. package/src/utils/retention.ts +111 -0
  96. package/src/utils/slugs.ts +15 -0
  97. package/src/views/BillingView/client.tsx +17 -8
  98. package/src/views/ChatView/client.tsx +2 -0
  99. package/src/views/CrmView/client.tsx +2 -0
  100. package/src/views/EmailTrackingView/client.tsx +18 -7
  101. package/src/views/ImportConversationView/client.tsx +1 -0
  102. package/src/views/LogsView/client.tsx +1 -1
  103. package/src/views/NewTicketView/client.tsx +22 -13
  104. package/src/views/PendingEmailsView/client.tsx +9 -3
  105. package/src/views/SupportDashboardView/client.tsx +11 -5
  106. package/src/views/TicketDetailView/client.tsx +25 -11
  107. package/src/views/TicketInboxView/client.tsx +47 -7
  108. package/src/views/TicketingSettingsView/TicketingSettings.module.scss +7 -7
  109. package/src/views/TicketingSettingsView/client.tsx +201 -144
  110. package/src/views/TimeDashboardView/client.tsx +10 -7
  111. package/src/views/shared/ErrorBoundary.tsx +95 -31
  112. package/src/views/shared/adminTokens.ts +28 -11
  113. package/src/views/shared/icons.tsx +146 -0
  114. package/src/views/shared/locales/en.json +15 -1
  115. package/src/views/shared/locales/fr.json +15 -1
  116. package/src/types/lucide-react.d.ts +0 -42
@@ -2,6 +2,7 @@
2
2
 
3
3
  import React, { useState, useEffect, useCallback } from 'react'
4
4
  import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
5
+ import { V } from '../shared/adminTokens'
5
6
  import { DATE_LOCALE } from '../shared/dateLocale'
6
7
  import s from '../../styles/EmailTracking.module.scss'
7
8
 
@@ -111,7 +112,7 @@ export const EmailTrackingClient: React.FC = () => {
111
112
  <button key={v} className={`${s.dateRangeBtn} ${dateRange === v ? s.dateRangeActive : ''}`} onClick={() => setDateRange(v)}>{v}j</button>
112
113
  ))}
113
114
  </div>
114
- <input type="text" className={s.searchInput} placeholder={t('emailTracking.searchPlaceholder')} value={search} onChange={(e) => setSearch(e.target.value)} />
115
+ <input type="text" className={s.searchInput} aria-label={t('emailTracking.searchLabel')} placeholder={t('emailTracking.searchPlaceholder')} value={search} onChange={(e) => setSearch(e.target.value)} />
115
116
  </div>
116
117
 
117
118
  {/* Table */}
@@ -125,7 +126,7 @@ export const EmailTrackingClient: React.FC = () => {
125
126
  <tbody>
126
127
  {logs.map((log) => (
127
128
  <React.Fragment key={log.id}>
128
- <tr onClick={() => log.status === 'error' && log.errorMessage ? setExpandedRow(expandedRow === log.id ? null : log.id) : null} style={{ cursor: log.status === 'error' && log.errorMessage ? 'pointer' : 'default' }}>
129
+ <tr>
129
130
  <td style={{ fontSize: 12, whiteSpace: 'nowrap' }}>{fmtDate(log.createdAt)}</td>
130
131
  <td style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={log.recipientEmail}>{log.recipientEmail || '—'}</td>
131
132
  <td style={{ maxWidth: 250, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={log.subject}>{log.subject || '—'}</td>
@@ -137,14 +138,24 @@ export const EmailTrackingClient: React.FC = () => {
137
138
  <span className={`${s.processingTime} ${log.processingTimeMs > 2000 ? s.timeSlow : log.processingTimeMs > 500 ? s.timeMedium : s.timeFast}`}>{log.processingTimeMs}ms</span>
138
139
  ) : '—'}
139
140
  {log.status === 'error' && log.errorMessage && (
140
- <button className={s.expandBtn} onClick={(e) => { e.stopPropagation(); setExpandedRow(expandedRow === log.id ? null : log.id) }}>
141
+ /* The row itself carries no handler: a `<tr onClick>` is
142
+ * unreachable by keyboard and role="button" on a row would
143
+ * destroy the table semantics. This button is the control. */
144
+ <button
145
+ type="button"
146
+ className={s.expandBtn}
147
+ aria-expanded={expandedRow === log.id}
148
+ aria-controls={`email-error-${log.id}`}
149
+ aria-label={t('emailTracking.toggleError')}
150
+ onClick={() => setExpandedRow(expandedRow === log.id ? null : log.id)}
151
+ >
141
152
  {expandedRow === log.id ? '▴' : '▾'}
142
153
  </button>
143
154
  )}
144
155
  </td>
145
156
  </tr>
146
157
  {expandedRow === log.id && log.errorMessage && (
147
- <tr><td colSpan={7}><div className={s.errorDetail}>Erreur{log.httpStatus ? ` (HTTP ${log.httpStatus})` : ''}: {log.errorMessage}</div></td></tr>
158
+ <tr><td colSpan={7}><div className={s.errorDetail} id={`email-error-${log.id}`}>{t('emailTracking.errorLabel')}{log.httpStatus ? ` (HTTP ${log.httpStatus})` : ''}: {log.errorMessage}</div></td></tr>
148
159
  )}
149
160
  </React.Fragment>
150
161
  ))}
@@ -155,9 +166,9 @@ export const EmailTrackingClient: React.FC = () => {
155
166
  {/* Pagination */}
156
167
  {totalDocs > 0 && (
157
168
  <div className={s.pagination}>
158
- <button className={s.pageBtn} onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>{t('common.previous')}</button>
159
- <span style={{ fontSize: 12, color: 'var(--theme-elevation-500)', alignSelf: 'center' }}>{t('common.page')} {page} — {totalDocs} {t('common.results')}</span>
160
- <button className={s.pageBtn} onClick={() => setPage((p) => p + 1)} disabled={!hasMore}>{t('common.next')}</button>
169
+ <button type="button" className={s.pageBtn} onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>{t('common.previous')}</button>
170
+ <span style={{ fontSize: 12, color: V.textSecondary, alignSelf: 'center' }}>{t('common.page')} {page} — {totalDocs} {t('common.results')}</span>
171
+ <button type="button" className={s.pageBtn} onClick={() => setPage((p) => p + 1)} disabled={!hasMore}>{t('common.next')}</button>
161
172
  </div>
162
173
  )}
163
174
  </div>
@@ -192,6 +192,7 @@ export function ImportConversationClient() {
192
192
  ref={fileRef}
193
193
  type="file"
194
194
  accept=".md,.txt"
195
+ aria-label={t('import.acceptedFormats')}
195
196
  onChange={onFileChange}
196
197
  style={{ display: 'none' }}
197
198
  />
@@ -131,7 +131,7 @@ export const LogsClient: React.FC = () => {
131
131
  </td>
132
132
  <td className={s.truncate} title={log.recipientEmail}>{log.recipientEmail || '—'}</td>
133
133
  <td className={s.truncate} title={log.subject}>{log.subject || '—'}</td>
134
- <td style={{ fontSize: 12, color: 'var(--theme-elevation-500)' }}>{log.action || '—'}</td>
134
+ <td style={{ fontSize: 12, color: 'var(--theme-elevation-650)' }}>{log.action || '—'}</td>
135
135
  <td style={{ textAlign: 'right' }}>
136
136
  {log.processingTimeMs != null ? (
137
137
  <span className={s.mono} style={{ color: log.processingTimeMs > 2000 ? '#dc2626' : log.processingTimeMs > 500 ? '#d97706' : '#16a34a' }}>
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import React, { useState, useEffect } from 'react'
3
+ import React, { useState, useEffect, useId } from 'react'
4
4
  import Link from 'next/link'
5
5
  import { useRouter } from 'next/navigation'
6
6
  import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
@@ -27,6 +27,14 @@ const PRIORITY_KEYS = [
27
27
 
28
28
  export const NewTicketClient: React.FC = () => {
29
29
  const { t } = useTranslation()
30
+ // Each `<label>` below already carries the right, already-translated text;
31
+ // it just was not associated with anything. useId, never a literal.
32
+ const clientSearchId = useId()
33
+ const subjectId = useId()
34
+ const categoryId = useId()
35
+ const priorityId = useId()
36
+ const projectId_ = useId()
37
+ const descriptionId = useId()
30
38
  const router = useRouter()
31
39
  const [subject, setSubject] = useState('')
32
40
  const [description, setDescription] = useState('')
@@ -130,18 +138,19 @@ export const NewTicketClient: React.FC = () => {
130
138
  <form className={s.form} onSubmit={handleSubmit}>
131
139
  {/* Client search */}
132
140
  <div className={s.fieldGroup}>
133
- <label className={s.label}>{t('newTicket.clientLabel')} <span className={s.required}>*</span></label>
141
+ <label className={s.label} htmlFor={clientSearchId}>{t('newTicket.clientLabel')} <span className={s.required}>*</span></label>
134
142
  {selectedClient ? (
135
143
  <div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 14px', borderRadius: 10, border: '1px solid var(--theme-elevation-200)', background: 'var(--theme-elevation-50)' }}>
136
144
  <span style={{ fontWeight: 600, fontSize: 13, color: 'var(--theme-text)' }}>
137
145
  {selectedClient.firstName} {selectedClient.lastName} — {selectedClient.company}
138
146
  </span>
139
- <span style={{ fontSize: 12, color: 'var(--theme-elevation-500)' }}>{selectedClient.email}</span>
147
+ <span style={{ fontSize: 12, color: 'var(--theme-elevation-650)' }}>{selectedClient.email}</span>
140
148
  <button type="button" onClick={() => { setSelectedClient(null); setClientId(null); setClientSearch('') }} style={{ marginLeft: 'auto', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--theme-elevation-400)', fontSize: 16 }}>&times;</button>
141
149
  </div>
142
150
  ) : (
143
151
  <div className={s.searchWrap}>
144
152
  <input
153
+ id={clientSearchId}
145
154
  type="text"
146
155
  className={s.input}
147
156
  placeholder={t('newTicket.clientSearchPlaceholder')}
@@ -169,27 +178,27 @@ export const NewTicketClient: React.FC = () => {
169
178
 
170
179
  {/* Subject */}
171
180
  <div className={s.fieldGroup}>
172
- <label className={s.label}>{t('newTicket.subjectLabel')} <span className={s.required}>*</span></label>
173
- <input type="text" className={s.input} placeholder={t('newTicket.subjectPlaceholder')} value={subject} onChange={(e) => setSubject(e.target.value)} />
181
+ <label className={s.label} htmlFor={subjectId}>{t('newTicket.subjectLabel')} <span className={s.required}>*</span></label>
182
+ <input id={subjectId} type="text" className={s.input} placeholder={t('newTicket.subjectPlaceholder')} value={subject} onChange={(e) => setSubject(e.target.value)} />
174
183
  </div>
175
184
 
176
185
  {/* Category + Priority + Project */}
177
186
  <div className={s.row3}>
178
187
  <div className={s.fieldGroup}>
179
- <label className={s.label}>{t('newTicket.categoryLabel')}</label>
180
- <select className={s.select} value={category} onChange={(e) => setCategory(e.target.value)}>
188
+ <label className={s.label} htmlFor={categoryId}>{t('newTicket.categoryLabel')}</label>
189
+ <select id={categoryId} className={s.select} value={category} onChange={(e) => setCategory(e.target.value)}>
181
190
  {CATEGORY_KEYS.map((c) => <option key={c.value} value={c.value}>{t(c.key)}</option>)}
182
191
  </select>
183
192
  </div>
184
193
  <div className={s.fieldGroup}>
185
- <label className={s.label}>{t('newTicket.priorityLabel')}</label>
186
- <select className={s.select} value={priority} onChange={(e) => setPriority(e.target.value)}>
194
+ <label className={s.label} htmlFor={priorityId}>{t('newTicket.priorityLabel')}</label>
195
+ <select id={priorityId} className={s.select} value={priority} onChange={(e) => setPriority(e.target.value)}>
187
196
  {PRIORITY_KEYS.map((p) => <option key={p.value} value={p.value}>{t(p.key)}</option>)}
188
197
  </select>
189
198
  </div>
190
199
  <div className={s.fieldGroup}>
191
- <label className={s.label}>{t('newTicket.projectLabel')}</label>
192
- <select className={s.select} value={projectId || ''} onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : null)}>
200
+ <label className={s.label} htmlFor={projectId_}>{t('newTicket.projectLabel')}</label>
201
+ <select id={projectId_} className={s.select} value={projectId || ''} onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : null)}>
193
202
  <option value="">{t('newTicket.noProject')}</option>
194
203
  {projects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
195
204
  </select>
@@ -198,8 +207,8 @@ export const NewTicketClient: React.FC = () => {
198
207
 
199
208
  {/* Description */}
200
209
  <div className={s.fieldGroup}>
201
- <label className={s.label}>{t('newTicket.descriptionLabel')}</label>
202
- <textarea className={s.textarea} placeholder={t('newTicket.descriptionPlaceholder')} value={description} onChange={(e) => setDescription(e.target.value)} />
210
+ <label className={s.label} htmlFor={descriptionId}>{t('newTicket.descriptionLabel')}</label>
211
+ <textarea id={descriptionId} className={s.textarea} placeholder={t('newTicket.descriptionPlaceholder')} value={description} onChange={(e) => setDescription(e.target.value)} />
203
212
  </div>
204
213
 
205
214
  <button type="submit" className={s.submitBtn} disabled={submitting}>
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import React, { useState, useEffect, useCallback } from 'react'
4
- import { Inbox, Plus, Link2, X, Search, ChevronDown, ChevronUp, Paperclip } from 'lucide-react'
4
+ import { Inbox, Plus, Link2, X, Search, ChevronDown, ChevronUp, Paperclip } from '../shared/icons'
5
5
  import { SkeletonDashboard } from '../shared/Skeleton'
6
6
  import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
7
7
  import styles from '../../styles/PendingEmails.module.scss'
@@ -86,7 +86,7 @@ function TicketSearchModal({
86
86
  <div className={styles.modal} onClick={(e) => e.stopPropagation()}>
87
87
  <div className={styles.modalHeader}>
88
88
  <h3 className={styles.modalTitle}>Rattacher a un ticket</h3>
89
- <button onClick={onClose} className={styles.modalClose}>
89
+ <button type="button" aria-label="Fermer" onClick={onClose} className={styles.modalClose}>
90
90
  <X size={20} />
91
91
  </button>
92
92
  </div>
@@ -112,6 +112,7 @@ function TicketSearchModal({
112
112
  <Search size={16} className={styles.searchIcon} />
113
113
  <input
114
114
  type="text"
115
+ aria-label="Rechercher un ticket"
115
116
  placeholder="Rechercher un ticket (TK-0042, sujet...)..."
116
117
  value={search}
117
118
  onChange={(e) => setSearch(e.target.value)}
@@ -225,7 +226,7 @@ function ClientPickerModal({
225
226
  <div className={styles.modal} onClick={(e) => e.stopPropagation()}>
226
227
  <div className={styles.modalHeader}>
227
228
  <h3 className={styles.modalTitle}>Choisir un client</h3>
228
- <button onClick={onClose} className={styles.modalClose}>
229
+ <button type="button" aria-label="Fermer" onClick={onClose} className={styles.modalClose}>
229
230
  <X size={20} />
230
231
  </button>
231
232
  </div>
@@ -251,6 +252,7 @@ function ClientPickerModal({
251
252
  <Search size={16} className={styles.searchIcon} />
252
253
  <input
253
254
  type="text"
255
+ aria-label="Rechercher un client"
254
256
  placeholder="Rechercher un client (nom, email, entreprise)..."
255
257
  value={search}
256
258
  onChange={(e) => setSearch(e.target.value)}
@@ -287,6 +289,7 @@ function ClientPickerModal({
287
289
  <div className={styles.createFormRow}>
288
290
  <input
289
291
  type="text"
292
+ aria-label="Prenom"
290
293
  placeholder="Prenom *"
291
294
  value={newClient.firstName}
292
295
  onChange={(e) => setNewClient((p) => ({ ...p, firstName: e.target.value }))}
@@ -294,6 +297,7 @@ function ClientPickerModal({
294
297
  />
295
298
  <input
296
299
  type="text"
300
+ aria-label="Nom"
297
301
  placeholder="Nom"
298
302
  value={newClient.lastName}
299
303
  onChange={(e) => setNewClient((p) => ({ ...p, lastName: e.target.value }))}
@@ -302,6 +306,7 @@ function ClientPickerModal({
302
306
  </div>
303
307
  <input
304
308
  type="email"
309
+ aria-label="Email"
305
310
  placeholder="Email *"
306
311
  value={newClient.email}
307
312
  onChange={(e) => setNewClient((p) => ({ ...p, email: e.target.value }))}
@@ -309,6 +314,7 @@ function ClientPickerModal({
309
314
  />
310
315
  <input
311
316
  type="text"
317
+ aria-label="Entreprise"
312
318
  placeholder="Entreprise *"
313
319
  value={newClient.company}
314
320
  onChange={(e) => setNewClient((p) => ({ ...p, company: e.target.value }))}
@@ -511,15 +511,21 @@ export const SupportDashboardClient: React.FC = () => {
511
511
  </thead>
512
512
  <tbody>
513
513
  {tickets.map((tk) => (
514
- <tr
515
- key={tk.id}
516
- onClick={() => { window.location.href = `/admin/support/ticket?id=${tk.id}` }}
517
- >
514
+ /*
515
+ * The row is not the control: a `<tr onClick>` is invisible to
516
+ * the keyboard, and turning it into role="button" would cost
517
+ * the table semantics. The link lives in the subject cell.
518
+ */
519
+ <tr key={tk.id}>
518
520
  <td>
519
521
  <span className={`${styles.statusDot} ${getStatusDotClass(tk.status)}`} />
520
522
  </td>
521
523
  <td className={styles.ticketNum}>#{tk.ticketNumber}</td>
522
- <td className={styles.ticketSubject}>{tk.subject}</td>
524
+ <td className={styles.ticketSubject}>
525
+ <a className={styles.ticketLink} href={`/admin/support/ticket?id=${tk.id}`}>
526
+ {tk.subject}
527
+ </a>
528
+ </td>
523
529
  <td className={styles.ticketClient}>{getClientName(tk)}</td>
524
530
  <td className={styles.ticketTime}>{timeAgo(tk.updatedAt)}</td>
525
531
  <td className={styles.ticketArrow}>&rarr;</td>
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import React, { useState, useEffect, useCallback, useRef } from 'react'
3
+ import React, { useState, useEffect, useCallback, useId, useRef } from 'react'
4
4
  import { useSearchParams } from 'next/navigation'
5
5
  import Link from 'next/link'
6
6
  import { RichTextEditor, type RichTextEditorHandle } from '../../components/RichTextEditor/index'
@@ -26,6 +26,13 @@ export const TicketDetailClient: React.FC = () => {
26
26
  const threadEndRef = useRef<HTMLDivElement>(null)
27
27
  const dropdownRef = useRef<HTMLDivElement>(null)
28
28
  const fileInputRef = useRef<HTMLInputElement>(null)
29
+ // Generated ids so every control below carries the name of the text next to
30
+ // it. useId, never a literal: this view can be mounted more than once.
31
+ const manualTimeId = useId()
32
+ const billingTypeId = useId()
33
+ const flatAmountId = useId()
34
+ const billedAmountId = useId()
35
+ const paymentStatusId = useId()
29
36
 
30
37
  const [ticket, setTicket] = useState<Record<string, unknown> | null>(null)
31
38
  const [messages, setMessages] = useState<Message[]>([])
@@ -721,7 +728,7 @@ const [clientTyping, setClientTyping] = useState(false)
721
728
  const file = typeof att.file === 'object' ? att.file : null
722
729
  if (!file) return null
723
730
  return (file.mimeType || '').startsWith('image/')
724
- ? <a key={i} href={file.url || '#'} target="_blank" rel="noopener noreferrer"><img src={file.url || ''} alt="" className={s.attachmentImg} /></a>
731
+ ? <a key={i} href={file.url || '#'} target="_blank" rel="noopener noreferrer"><img src={file.url || ''} alt={file.filename || t('detail.file')} className={s.attachmentImg} /></a>
725
732
  : <a key={i} href={file.url || '#'} target="_blank" rel="noopener noreferrer" className={s.attachmentFile}>PJ {file.filename || 'Fichier'}</a>
726
733
  })}
727
734
  </div>
@@ -810,7 +817,7 @@ const [clientTyping, setClientTyping] = useState(false)
810
817
  />
811
818
  {/* #5 — File upload button */}
812
819
  <button className={s.toolbarBtn} data-tooltip={t('detail.file')} aria-label={t('detail.file')} onClick={() => fileInputRef.current?.click()} style={{ fontSize: 11, fontWeight: 700, padding: '4px 10px', width: 'auto' }}>📎 {t('detail.file')}</button>
813
- <input ref={fileInputRef} type="file" multiple className={s.hiddenFileInput} onChange={(e) => handleFileSelect(e.target.files)} />
820
+ <input ref={fileInputRef} type="file" multiple aria-label={t('detail.file')} className={s.hiddenFileInput} onChange={(e) => handleFileSelect(e.target.files)} />
814
821
  {macros.length > 0 && (
815
822
  <select
816
823
  className={s.cannedSelect}
@@ -871,6 +878,7 @@ const [clientTyping, setClientTyping] = useState(false)
871
878
  <div className={s.composerFooter}>
872
879
  <div className={s.composerOptions}>
873
880
  <select
881
+ aria-label={t('detail.sendAs')}
874
882
  value={sendAsClient ? 'client' : 'admin'}
875
883
  onChange={(e) => {
876
884
  const asClient = e.target.value === 'client'
@@ -1089,9 +1097,9 @@ const [clientTyping, setClientTyping] = useState(false)
1089
1097
  </div>
1090
1098
  {/* Manual time entry */}
1091
1099
  <div style={{ display: 'flex', gap: 6, marginTop: 8, alignItems: 'center' }}>
1092
- <input type="number" min="1" placeholder="min" style={{ width: 60, padding: '4px 8px', borderRadius: 6, border: '1px solid var(--theme-elevation-200)', fontSize: 12, color: 'var(--theme-text)', background: 'var(--theme-elevation-0)' }} id="manual-time-input" />
1100
+ <input type="number" min="1" placeholder="min" aria-label={t('detail.manualMinutes')} style={{ width: 60, padding: '4px 8px', borderRadius: 6, border: '1px solid var(--theme-elevation-200)', fontSize: 12, color: 'var(--theme-text)', background: 'var(--theme-elevation-0)' }} id={manualTimeId} />
1093
1101
  <button className={`${s.timerBtn} ${s.timerBtnGhost}`} onClick={async () => {
1094
- const input = document.getElementById('manual-time-input') as HTMLInputElement
1102
+ const input = document.getElementById(manualTimeId) as HTMLInputElement
1095
1103
  const mins = Number(input?.value)
1096
1104
  if (!mins || mins < 1 || !ticketId) return
1097
1105
  try {
@@ -1102,7 +1110,7 @@ const [clientTyping, setClientTyping] = useState(false)
1102
1110
  }} style={{ fontSize: 11 }}>{t('detail.addTime')}</button>
1103
1111
  </div>
1104
1112
  {/* Billing info */}
1105
- <div style={{ marginTop: 8, fontSize: 11, color: 'var(--theme-elevation-500)' }}>
1113
+ <div style={{ marginTop: 8, fontSize: 11, color: 'var(--theme-elevation-650)' }}>
1106
1114
  <div style={{ display: 'flex', justifyContent: 'space-between', padding: '2px 0', alignItems: 'center' }}>
1107
1115
  <span>{t('detail.billing.billable')}</span>
1108
1116
  <button
@@ -1126,7 +1134,7 @@ const [clientTyping, setClientTyping] = useState(false)
1126
1134
  {timeEntries.length > 0 && (
1127
1135
  <div style={{ marginTop: 8, fontSize: 11 }}>
1128
1136
  {timeEntries.slice(0, 6).map((e) => (
1129
- <div key={e.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0', color: 'var(--theme-elevation-500)' }}>
1137
+ <div key={e.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0', color: 'var(--theme-elevation-650)' }}>
1130
1138
  <span>{new Date(e.date).toLocaleDateString(DATE_LOCALE, { day: 'numeric', month: 'short' })}</span>
1131
1139
  <span title={e.description} style={{ fontWeight: 600, cursor: e.description ? 'help' : 'default' }}>{e.duration}min</span>
1132
1140
  </div>
@@ -1149,6 +1157,7 @@ const [clientTyping, setClientTyping] = useState(false)
1149
1157
  {addingTag ? (
1150
1158
  <input
1151
1159
  className={s.tagInput}
1160
+ aria-label={t('detail.addTag')}
1152
1161
  value={newTagValue}
1153
1162
  onChange={(e) => setNewTagValue(e.target.value)}
1154
1163
  onKeyDown={(e) => { if (e.key === 'Enter') handleAddTag(); if (e.key === 'Escape') { setAddingTag(false); setNewTagValue('') } }}
@@ -1167,8 +1176,9 @@ const [clientTyping, setClientTyping] = useState(false)
1167
1176
  <div className={s.sideSectionTitle}>{t('detail.billing.title')}</div>
1168
1177
  <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
1169
1178
  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
1170
- <span style={{ fontSize: 12 }}>{t('detail.billing.type')}</span>
1179
+ <label style={{ fontSize: 12 }} htmlFor={billingTypeId}>{t('detail.billing.type')}</label>
1171
1180
  <select
1181
+ id={billingTypeId}
1172
1182
  value={(ticket as any)?.billingType || 'hourly'}
1173
1183
  onChange={async (e) => {
1174
1184
  try {
@@ -1188,9 +1198,10 @@ const [clientTyping, setClientTyping] = useState(false)
1188
1198
  </div>
1189
1199
  {(ticket as any)?.billingType === 'flat' && (
1190
1200
  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
1191
- <span style={{ fontSize: 12 }}>{t('detail.billing.flatAmount')}</span>
1201
+ <label style={{ fontSize: 12 }} htmlFor={flatAmountId}>{t('detail.billing.flatAmount')}</label>
1192
1202
  <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
1193
1203
  <input
1204
+ id={flatAmountId}
1194
1205
  type="number"
1195
1206
  defaultValue={(ticket as any)?.flatRateAmount ?? ''}
1196
1207
  placeholder="0"
@@ -1212,9 +1223,10 @@ const [clientTyping, setClientTyping] = useState(false)
1212
1223
  </div>
1213
1224
  )}
1214
1225
  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
1215
- <span style={{ fontSize: 12 }}>{t('detail.billing.billedAmount')}</span>
1226
+ <label style={{ fontSize: 12 }} htmlFor={billedAmountId}>{t('detail.billing.billedAmount')}</label>
1216
1227
  <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
1217
1228
  <input
1229
+ id={billedAmountId}
1218
1230
  type="number"
1219
1231
  defaultValue={(ticket as any)?.billedAmount ?? ''}
1220
1232
  placeholder="0"
@@ -1235,8 +1247,9 @@ const [clientTyping, setClientTyping] = useState(false)
1235
1247
  </div>
1236
1248
  </div>
1237
1249
  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
1238
- <span style={{ fontSize: 12 }}>{t('detail.billing.payment')}</span>
1250
+ <label style={{ fontSize: 12 }} htmlFor={paymentStatusId}>{t('detail.billing.payment')}</label>
1239
1251
  <select
1252
+ id={paymentStatusId}
1240
1253
  value={(ticket as any)?.paymentStatus || 'unpaid'}
1241
1254
  onChange={async (e) => {
1242
1255
  try {
@@ -1358,6 +1371,7 @@ const [clientTyping, setClientTyping] = useState(false)
1358
1371
  <div className={s.splitPreview}>{splitModal.preview}</div>
1359
1372
  <input
1360
1373
  className={s.splitInput}
1374
+ aria-label={t('detail.split.subjectLabel')}
1361
1375
  value={splitSubject}
1362
1376
  onChange={(e) => setSplitSubject(e.target.value)}
1363
1377
  onKeyDown={(e) => { if (e.key === 'Enter') handleSplitConfirm() }}
@@ -31,6 +31,9 @@ interface Ticket {
31
31
 
32
32
  type Tab = 'all' | 'open' | 'waiting_client' | 'resolved' | 'sla_breach'
33
33
 
34
+ // Single id: this view is a full admin route, mounted once.
35
+ const INBOX_TABPANEL_ID = 'inbox-tabpanel'
36
+
34
37
  const PRIORITY_COLORS: Record<string, string> = {
35
38
  urgent: 'var(--theme-error-500)',
36
39
  high: 'var(--theme-warning-500)',
@@ -239,6 +242,7 @@ export const TicketInboxClient: React.FC = () => {
239
242
  <input
240
243
  type="text"
241
244
  className={s.searchInput}
245
+ aria-label={t('inbox.searchLabel')}
242
246
  placeholder={t('inbox.searchPlaceholder')}
243
247
  value={search}
244
248
  onChange={(e) => setSearch(e.target.value)}
@@ -250,14 +254,48 @@ export const TicketInboxClient: React.FC = () => {
250
254
  </div>
251
255
 
252
256
  {/* Tabs */}
253
- <div className={s.tabs} role="tablist">
257
+ {/*
258
+ A tablist is ONE tab stop: the inactive tabs carry `tabIndex={-1}` and
259
+ the arrow keys move between them. That is the ARIA pattern, but the
260
+ roving tabIndex is only half of it — without this handler the four
261
+ inactive tabs are simply unreachable from the keyboard, which is worse
262
+ than the plain tab order it replaced.
263
+ */}
264
+ <div
265
+ className={s.tabs}
266
+ role="tablist"
267
+ aria-label={t('inbox.tabsLabel')}
268
+ onKeyDown={(e) => {
269
+ const keys = ['ArrowLeft', 'ArrowRight', 'Home', 'End']
270
+ if (!keys.includes(e.key)) return
271
+ e.preventDefault()
272
+ const i = tabs.findIndex((tk) => tk.key === tab)
273
+ const next =
274
+ e.key === 'Home' ? 0
275
+ : e.key === 'End' ? tabs.length - 1
276
+ : e.key === 'ArrowLeft' ? (i - 1 + tabs.length) % tabs.length
277
+ : (i + 1) % tabs.length
278
+ const target = tabs[next]
279
+ if (!target) return
280
+ setTab(target.key)
281
+ setSelectedIdx(-1)
282
+ // Focus follows selection, as the pattern expects for an
283
+ // automatically-activated tablist.
284
+ document.getElementById(`inbox-tab-${target.key}`)?.focus()
285
+ }}
286
+ >
254
287
  {tabs.map((tk) => {
255
288
  const isAlert = tk.alert && tk.count > 0
256
289
  return (
257
290
  <button
258
291
  key={tk.key}
292
+ type="button"
259
293
  role="tab"
294
+ id={`inbox-tab-${tk.key}`}
260
295
  aria-selected={tab === tk.key}
296
+ aria-controls={INBOX_TABPANEL_ID}
297
+ // Roving tabindex: a tablist is one tab stop, arrows move inside.
298
+ tabIndex={tab === tk.key ? 0 : -1}
261
299
  className={`${s.tab} ${tab === tk.key ? s.tabActive : ''} ${isAlert ? s.tabAlert : ''}`}
262
300
  onClick={() => { setTab(tk.key); setSelectedIdx(-1) }}
263
301
  >
@@ -273,17 +311,17 @@ export const TicketInboxClient: React.FC = () => {
273
311
  {checkedIds.size > 0 ? (
274
312
  <div style={{ display: 'flex', gap: 8, alignItems: 'center', flex: 1 }}>
275
313
  <span style={{ fontSize: 13, fontWeight: 600, color: 'var(--theme-text)' }}>{checkedIds.size > 1 ? t('inbox.selectedPlural', { count: String(checkedIds.size) }) : t('inbox.selected', { count: String(checkedIds.size) })}</span>
276
- <button className={s.sortSelect} onClick={() => handleBulkAction('close')} disabled={bulkProcessing}>{t('inbox.closeAction')}</button>
277
- <button className={s.sortSelect} onClick={() => handleBulkAction('reopen')} disabled={bulkProcessing}>{t('inbox.reopenAction')}</button>
278
- <select className={s.sortSelect} value={bulkAction} onChange={(e) => { if (e.target.value) handleBulkAction(e.target.value); setBulkAction('') }}>
314
+ <button type="button" className={s.sortSelect} onClick={() => handleBulkAction('close')} disabled={bulkProcessing}>{t('inbox.closeAction')}</button>
315
+ <button type="button" className={s.sortSelect} onClick={() => handleBulkAction('reopen')} disabled={bulkProcessing}>{t('inbox.reopenAction')}</button>
316
+ <select className={s.sortSelect} aria-label={t('inbox.moreActions')} value={bulkAction} onChange={(e) => { if (e.target.value) handleBulkAction(e.target.value); setBulkAction('') }}>
279
317
  <option value="">{t('inbox.moreActions')}</option>
280
318
  <option value="set_priority">{t('inbox.changePriority')}</option>
281
319
  <option value="delete">{t('inbox.deleteAction')}</option>
282
320
  </select>
283
- <button className={s.sortSelect} onClick={() => setCheckedIds(new Set())} style={{ marginLeft: 'auto' }}>{t('inbox.deselect')}</button>
321
+ <button type="button" className={s.sortSelect} onClick={() => setCheckedIds(new Set())} style={{ marginLeft: 'auto' }}>{t('inbox.deselect')}</button>
284
322
  </div>
285
323
  ) : (
286
- <select className={s.sortSelect} value={sort} onChange={(e) => setSort(e.target.value)}>
324
+ <select className={s.sortSelect} aria-label={t('inbox.sortLabel')} value={sort} onChange={(e) => setSort(e.target.value)}>
287
325
  <option value="-updatedAt">{t('inbox.sort.newest')}</option>
288
326
  <option value="updatedAt">{t('inbox.sort.oldest')}</option>
289
327
  <option value="-createdAt">{t('inbox.sort.created')}</option>
@@ -292,7 +330,8 @@ export const TicketInboxClient: React.FC = () => {
292
330
  )}
293
331
  </div>
294
332
 
295
- {/* List */}
333
+ {/* List — the panel the tablist above controls */}
334
+ <div role="tabpanel" id={INBOX_TABPANEL_ID} aria-labelledby={`inbox-tab-${tab}`}>
296
335
  {loading ? (
297
336
  <div className={s.loading}>{t('common.loading')}</div>
298
337
  ) : tickets.length === 0 ? (
@@ -361,6 +400,7 @@ export const TicketInboxClient: React.FC = () => {
361
400
  })}
362
401
  </div>
363
402
  )}
403
+ </div>
364
404
 
365
405
  {/* Keyboard hints */}
366
406
  <div className={s.keyboardHints}>
@@ -70,7 +70,7 @@
70
70
 
71
71
  .sectionDescription {
72
72
  font-size: 0.8125rem;
73
- color: var(--theme-elevation-500);
73
+ color: var(--theme-elevation-650);
74
74
  margin: 0 0 1rem;
75
75
  line-height: 1.5;
76
76
  }
@@ -143,7 +143,7 @@
143
143
 
144
144
  .featureDesc {
145
145
  font-size: 0.75rem;
146
- color: var(--theme-elevation-500);
146
+ color: var(--theme-elevation-650);
147
147
  margin-top: 0.125rem;
148
148
  }
149
149
 
@@ -166,7 +166,7 @@
166
166
 
167
167
  .fieldDescription {
168
168
  font-size: 0.75rem;
169
- color: var(--theme-elevation-500);
169
+ color: var(--theme-elevation-650);
170
170
  font-weight: 400;
171
171
  margin-top: 0.125rem;
172
172
  }
@@ -194,7 +194,7 @@
194
194
  .inputReadonly {
195
195
  composes: input;
196
196
  background: var(--theme-elevation-50);
197
- color: var(--theme-elevation-500);
197
+ color: var(--theme-elevation-650);
198
198
  cursor: not-allowed;
199
199
  }
200
200
 
@@ -230,7 +230,7 @@
230
230
 
231
231
  .inlineDesc {
232
232
  font-size: 0.75rem;
233
- color: var(--theme-elevation-500);
233
+ color: var(--theme-elevation-650);
234
234
  margin-top: 0.125rem;
235
235
  }
236
236
 
@@ -244,7 +244,7 @@
244
244
 
245
245
  .slaHint {
246
246
  font-size: 0.75rem;
247
- color: var(--theme-elevation-500);
247
+ color: var(--theme-elevation-650);
248
248
  }
249
249
 
250
250
  // ── API Key ───────────────────────────────────
@@ -290,7 +290,7 @@
290
290
  .aiToggleDesc {
291
291
  display: block;
292
292
  font-size: 0.6875rem;
293
- color: var(--theme-elevation-500);
293
+ color: var(--theme-elevation-650);
294
294
  }
295
295
 
296
296
  // ── Signature ─────────────────────────────────