@consilioweb/payload-support 5.0.0 → 6.0.1

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 (120) 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/utils/db.js +20 -0
  34. package/dist/utils/readSettings.js +150 -0
  35. package/dist/views/BillingView/client.js +15 -8
  36. package/dist/views/ChatView/client.js +2 -0
  37. package/dist/views/CrmView/client.js +2 -0
  38. package/dist/views/EmailTrackingView/client.js +23 -11
  39. package/dist/views/ImportConversationView/client.js +1 -0
  40. package/dist/views/LogsView/client.js +1 -1
  41. package/dist/views/NewTicketView/client.js +20 -13
  42. package/dist/views/PendingEmailsView/client.js +9 -3
  43. package/dist/views/SupportDashboardView/client.js +17 -19
  44. package/dist/views/TicketDetailView/client.js +23 -11
  45. package/dist/views/TicketInboxView/client.js +52 -26
  46. package/dist/views/TicketingSettingsView/TicketingSettings.module.scss +7 -7
  47. package/dist/views/TicketingSettingsView/client.js +44 -26
  48. package/dist/views/TimeDashboardView/client.js +10 -7
  49. package/dist/views/shared/ErrorBoundary.d.ts +36 -1
  50. package/dist/views/shared/ErrorBoundary.js +59 -28
  51. package/dist/views/shared/adminTokens.d.ts +15 -5
  52. package/dist/views/shared/adminTokens.js +23 -7
  53. package/dist/views/shared/icons.d.ts +49 -0
  54. package/dist/views/shared/icons.js +92 -0
  55. package/dist/views/shared/locales/en.json +15 -1
  56. package/dist/views/shared/locales/fr.json +15 -1
  57. package/package.json +9 -4
  58. package/scripts/copy-subpath-types.mjs +103 -0
  59. package/scripts/uninstall-data.mjs +178 -0
  60. package/scripts/uninstall.mjs +184 -0
  61. package/scripts/verify-dist-imports.mjs +84 -0
  62. package/src/collections/Tickets.ts +7 -0
  63. package/src/collections/TimeEntries.ts +11 -3
  64. package/src/components/RichTextEditor/index.tsx +1 -1
  65. package/src/components/TicketConversation/RewriteDropdown.tsx +1 -1
  66. package/src/components/TicketConversation/components/AISummaryPanel.tsx +3 -0
  67. package/src/components/TicketConversation/components/ActionPanels.tsx +13 -8
  68. package/src/components/TicketConversation/components/ClientHistory.tsx +1 -0
  69. package/src/components/TicketConversation/components/CodeBlock.tsx +1 -0
  70. package/src/components/TicketConversation/components/CodeBlockInserter.tsx +1 -1
  71. package/src/components/TicketConversation/components/QuickActions.tsx +10 -8
  72. package/src/components/TicketConversation/components/TicketHeader.tsx +2 -0
  73. package/src/components/TicketConversation/components/TimeTrackingPanel.tsx +17 -9
  74. package/src/components/TicketConversation/index.tsx +41 -7
  75. package/src/endpoints/delete-account.ts +191 -47
  76. package/src/endpoints/export-data.ts +104 -4
  77. package/src/endpoints/purge-logs.ts +6 -15
  78. package/src/index.ts +2 -0
  79. package/src/plugin.ts +51 -1
  80. package/src/portal/LiveChat.tsx +1 -1
  81. package/src/portal/auth/profile/page.tsx +3 -2
  82. package/src/portal/icons.tsx +91 -0
  83. package/src/styles/BillingView.module.scss +11 -11
  84. package/src/styles/ChatView.module.scss +7 -7
  85. package/src/styles/CommandPalette.module.scss +1 -1
  86. package/src/styles/CrmView.module.scss +9 -9
  87. package/src/styles/EmailTracking.module.scss +6 -6
  88. package/src/styles/ImportConversation.module.scss +5 -5
  89. package/src/styles/Logs.module.scss +5 -5
  90. package/src/styles/NewTicket.module.scss +2 -2
  91. package/src/styles/PendingEmails.module.scss +13 -13
  92. package/src/styles/SupportDashboard.module.scss +24 -8
  93. package/src/styles/TicketDetail.module.scss +20 -20
  94. package/src/styles/TicketInbox.module.scss +6 -6
  95. package/src/styles/TicketingSettings.module.scss +10 -10
  96. package/src/styles/TimeDashboard.module.scss +6 -6
  97. package/src/styles/_tokens.scss +3 -1
  98. package/src/types.ts +20 -0
  99. package/src/utils/retention.ts +111 -0
  100. package/src/utils/slugs.ts +15 -0
  101. package/src/views/BillingView/client.tsx +17 -8
  102. package/src/views/ChatView/client.tsx +2 -0
  103. package/src/views/CrmView/client.tsx +2 -0
  104. package/src/views/EmailTrackingView/client.tsx +18 -7
  105. package/src/views/ImportConversationView/client.tsx +1 -0
  106. package/src/views/LogsView/client.tsx +1 -1
  107. package/src/views/NewTicketView/client.tsx +22 -13
  108. package/src/views/PendingEmailsView/client.tsx +9 -3
  109. package/src/views/SupportDashboardView/client.tsx +11 -5
  110. package/src/views/TicketDetailView/client.tsx +25 -11
  111. package/src/views/TicketInboxView/client.tsx +47 -7
  112. package/src/views/TicketingSettingsView/TicketingSettings.module.scss +7 -7
  113. package/src/views/TicketingSettingsView/client.tsx +201 -144
  114. package/src/views/TimeDashboardView/client.tsx +10 -7
  115. package/src/views/shared/ErrorBoundary.tsx +95 -31
  116. package/src/views/shared/adminTokens.ts +28 -11
  117. package/src/views/shared/icons.tsx +146 -0
  118. package/src/views/shared/locales/en.json +15 -1
  119. package/src/views/shared/locales/fr.json +15 -1
  120. package/src/types/lucide-react.d.ts +0 -42
@@ -0,0 +1,111 @@
1
+ import type { Payload, PayloadRequest } from 'payload'
2
+ import type { CollectionSlugs } from './slugs'
3
+ import { dbDelete } from './db'
4
+
5
+ /**
6
+ * How long the two journals are kept, in days.
7
+ *
8
+ * Defaults follow the CNIL guidance the README documents: six months would be
9
+ * the floor for connection logs, a year is the ceiling usually accepted, and
10
+ * these two values sit inside that range while staying long enough to
11
+ * investigate an intrusion. Set a value to 0 to disable the automatic purge of
12
+ * that journal — never to mean "delete everything", which is what `days=0`
13
+ * means on the manual endpoint.
14
+ */
15
+ export interface RetentionConfig {
16
+ /** Retention of `auth-logs` in days. `0` disables the scheduled purge. */
17
+ authLogsDays?: number
18
+ /** Retention of `email-logs` in days. `0` disables the scheduled purge. */
19
+ emailLogsDays?: number
20
+ /**
21
+ * Cron for the scheduled purge. Payload's cron takes an optional leading
22
+ * seconds field. Default: every day at 03:30.
23
+ */
24
+ cron?: string
25
+ /** Queue the scheduled job is pushed to. Default: `default`. */
26
+ queue?: string
27
+ }
28
+
29
+ export const DEFAULT_RETENTION = {
30
+ authLogsDays: 180,
31
+ emailLogsDays: 365,
32
+ cron: '0 30 3 * * *',
33
+ queue: 'default',
34
+ } as const
35
+
36
+ export const PURGE_LOGS_TASK_SLUG = 'support-purge-logs'
37
+
38
+ /** Journals the purge is allowed to touch, keyed by their public name. */
39
+ export function purgeableCollections(slugs: CollectionSlugs): Record<string, string> {
40
+ return {
41
+ 'email-logs': slugs.emailLogs,
42
+ 'auth-logs': slugs.authLogs,
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Delete rows of `slug` created more than `days` days ago.
48
+ *
49
+ * `days <= 0` means "everything" and is only reachable from the manual
50
+ * admin-only endpoint. The scheduled task refuses it: a misconfigured cron
51
+ * that wipes the whole security journal every night is a far worse outcome
52
+ * than a journal that grows.
53
+ */
54
+ export async function purgeOlderThan(
55
+ payload: Payload,
56
+ slug: string,
57
+ days: number,
58
+ req?: PayloadRequest,
59
+ ): Promise<number> {
60
+ const cutoff = days > 0 ? new Date(Date.now() - days * 86400000).toISOString() : null
61
+
62
+ const result = await dbDelete(payload, slug, {
63
+ where: cutoff
64
+ ? { createdAt: { less_than: cutoff } }
65
+ : { id: { exists: true } },
66
+ overrideAccess: true,
67
+ ...(req ? { req } : {}),
68
+ })
69
+
70
+ return Array.isArray(result?.docs) ? result.docs.length : 0
71
+ }
72
+
73
+ export interface ScheduledPurgeResult {
74
+ purged: Record<string, number>
75
+ skipped: string[]
76
+ }
77
+
78
+ /**
79
+ * The scheduled half of the purge. Never accepts `days <= 0`, and silently
80
+ * skips a journal whose collection is not registered (its feature flag is off).
81
+ */
82
+ export async function runScheduledPurge(
83
+ payload: Payload,
84
+ slugs: CollectionSlugs,
85
+ retention: RetentionConfig | false | undefined,
86
+ req?: PayloadRequest,
87
+ ): Promise<ScheduledPurgeResult> {
88
+ if (retention === false) return { purged: {}, skipped: ['auth-logs', 'email-logs'] }
89
+
90
+ const plan: Array<[string, string, number]> = [
91
+ ['auth-logs', slugs.authLogs, retention?.authLogsDays ?? DEFAULT_RETENTION.authLogsDays],
92
+ ['email-logs', slugs.emailLogs, retention?.emailLogsDays ?? DEFAULT_RETENTION.emailLogsDays],
93
+ ]
94
+
95
+ const purged: Record<string, number> = {}
96
+ const skipped: string[] = []
97
+
98
+ for (const [name, slug, days] of plan) {
99
+ if (!Number.isFinite(days) || days <= 0) {
100
+ skipped.push(name)
101
+ continue
102
+ }
103
+ if (!(payload.collections as Record<string, unknown> | undefined)?.[slug]) {
104
+ skipped.push(name)
105
+ continue
106
+ }
107
+ purged[name] = await purgeOlderThan(payload, slug, days, req)
108
+ }
109
+
110
+ return { purged, skipped }
111
+ }
@@ -58,6 +58,21 @@ export const DEFAULT_SLUGS: CollectionSlugs = {
58
58
  media: 'media',
59
59
  }
60
60
 
61
+ /**
62
+ * Collections the plugin registers under a FIXED slug.
63
+ *
64
+ * Unlike everything in `CollectionSlugs`, these three hardcode their `slug`
65
+ * (ClientSummaries.ts, TicketCollaborators.ts, TicketFeedback.ts), so any code
66
+ * that reads or deletes their rows must use these literals rather than a
67
+ * configurable slug — otherwise an app that overrides `collectionSlugs` would
68
+ * address a collection that does not exist.
69
+ */
70
+ export const FIXED_SLUGS = {
71
+ clientSummaries: 'client-summaries',
72
+ ticketCollaborators: 'ticket-collaborators',
73
+ ticketFeedback: 'ticket-feedback',
74
+ } as const
75
+
61
76
  /**
62
77
  * Resolve collection slugs merging user overrides with defaults.
63
78
  */
@@ -1,6 +1,6 @@
1
1
  'use client'
2
2
 
3
- import React, { useState, useCallback } from 'react'
3
+ import React, { useState, useCallback, useId } from 'react'
4
4
  import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
5
5
  import { DATE_LOCALE } from '../shared/dateLocale'
6
6
  import styles from '../../styles/BillingView.module.scss'
@@ -86,6 +86,12 @@ function getQuarterRange(offset: number): { from: string; to: string } {
86
86
 
87
87
  export const BillingClient: React.FC = () => {
88
88
  const { t } = useTranslation()
89
+ // The `<label>`s in the filter row already carry the translated text; they
90
+ // just were not associated with their control.
91
+ const fromId = useId()
92
+ const toId = useId()
93
+ const projectFilterId = useId()
94
+ const rateId = useId()
89
95
  const [from, setFrom] = useState(() => getMonthRange(0).from)
90
96
  const [to, setTo] = useState(() => getMonthRange(0).to)
91
97
  const [projectId, setProjectId] = useState('')
@@ -287,16 +293,16 @@ export const BillingClient: React.FC = () => {
287
293
 
288
294
  <div className={styles.filterRow}>
289
295
  <div className={styles.fieldGroup}>
290
- <label className={styles.label}>{t('billing.filters.from')}</label>
291
- <input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className={styles.input} />
296
+ <label className={styles.label} htmlFor={fromId}>{t('billing.filters.from')}</label>
297
+ <input id={fromId} type="date" value={from} onChange={(e) => setFrom(e.target.value)} className={styles.input} />
292
298
  </div>
293
299
  <div className={styles.fieldGroup}>
294
- <label className={styles.label}>{t('billing.filters.to')}</label>
295
- <input type="date" value={to} onChange={(e) => setTo(e.target.value)} className={styles.input} />
300
+ <label className={styles.label} htmlFor={toId}>{t('billing.filters.to')}</label>
301
+ <input id={toId} type="date" value={to} onChange={(e) => setTo(e.target.value)} className={styles.input} />
296
302
  </div>
297
303
  <div className={styles.fieldGroup}>
298
- <label className={styles.label}>{t('billing.filters.project')}</label>
299
- <select value={projectId} onChange={(e) => setProjectId(e.target.value)} className={styles.select}>
304
+ <label className={styles.label} htmlFor={projectFilterId}>{t('billing.filters.project')}</label>
305
+ <select id={projectFilterId} value={projectId} onChange={(e) => setProjectId(e.target.value)} className={styles.select}>
300
306
  <option value="">{t('ticket.allProjects')}</option>
301
307
  {projects.map((p) => (
302
308
  <option key={p.id} value={p.id}>{p.name}</option>
@@ -304,9 +310,10 @@ export const BillingClient: React.FC = () => {
304
310
  </select>
305
311
  </div>
306
312
  <div className={styles.fieldGroup}>
307
- <label className={styles.label}>{t('billing.filters.hourlyRate')}</label>
313
+ <label className={styles.label} htmlFor={rateId}>{t('billing.filters.hourlyRate')}</label>
308
314
  <div className={styles.rateRow}>
309
315
  <input
316
+ id={rateId}
310
317
  type="number"
311
318
  value={rate}
312
319
  onChange={(e) => setRate(Number(e.target.value))}
@@ -370,6 +377,7 @@ export const BillingClient: React.FC = () => {
370
377
  <th className={styles.thCheckbox}>
371
378
  <input
372
379
  type="checkbox"
380
+ aria-label={t('billing.tableCheckboxTitle')}
373
381
  checked={group.tickets.every((t) => billedTickets.has(t.id))}
374
382
  onChange={() => {
375
383
  const ids = group.tickets.map((t) => t.id)
@@ -405,6 +413,7 @@ export const BillingClient: React.FC = () => {
405
413
  <td className={styles.td} rowSpan={rowSpan} style={{ verticalAlign: 'middle' }}>
406
414
  <input
407
415
  type="checkbox"
416
+ aria-label={isBilled ? t('billing.markUnbilledTitle') : t('billing.markBilledTitle')}
408
417
  checked={isBilled}
409
418
  onChange={() => toggleBilled(ticket.id)}
410
419
  className={styles.checkbox}
@@ -343,6 +343,7 @@ export const ChatViewClient: React.FC = () => {
343
343
  <form onSubmit={sendMessage} className={styles.composer}>
344
344
  {cannedResponses.length > 0 && (
345
345
  <select
346
+ aria-label={t('chat.quickReply')}
346
347
  onChange={(e) => {
347
348
  const cr = cannedResponses.find((c) => String(c.id) === e.target.value)
348
349
  if (cr) setInput(cr.body)
@@ -359,6 +360,7 @@ export const ChatViewClient: React.FC = () => {
359
360
  <div className={styles.composerRow}>
360
361
  <input
361
362
  type="text"
363
+ aria-label={t('chat.inputPlaceholder')}
362
364
  value={input}
363
365
  onChange={(e) => setInput(e.target.value)}
364
366
  placeholder={t('chat.inputPlaceholder')}
@@ -275,6 +275,7 @@ export const CrmClient: React.FC = () => {
275
275
  <div className={styles.sidebarSearch}>
276
276
  <input
277
277
  type="text"
278
+ aria-label={t('crm.searchLabel')}
278
279
  placeholder={t('crm.searchPlaceholder')}
279
280
  value={search}
280
281
  onChange={(e) => setSearch(e.target.value)}
@@ -379,6 +380,7 @@ export const CrmClient: React.FC = () => {
379
380
  </p>
380
381
  <input
381
382
  type="text"
383
+ aria-label={t('crm.mergeTitle')}
382
384
  value={mergeSearch}
383
385
  onChange={(e) => setMergeSearch(e.target.value)}
384
386
  placeholder={t('crm.mergeSearchPlaceholder')}
@@ -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>