@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
@@ -287,7 +287,6 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, Props>(function R
287
287
  fontSize: '14px',
288
288
  lineHeight: 1.6,
289
289
  color: '#1f2937',
290
- outline: 'none',
291
290
  overflowY: 'auto',
292
291
  }}
293
292
  />
@@ -300,6 +299,7 @@ export const RichTextEditor = forwardRef<RichTextEditorHandle, Props>(function R
300
299
  type="file"
301
300
  accept="image/*"
302
301
  multiple
302
+ aria-label="Inserer une image"
303
303
  onChange={handleFileChange}
304
304
  style={{ display: 'none' }}
305
305
  />
@@ -76,7 +76,7 @@ export const RewriteDropdown: React.FC<{
76
76
  onMouseLeave={(e) => { (e.target as HTMLElement).style.background = 'transparent' }}
77
77
  >
78
78
  <span style={{ fontSize: 12, fontWeight: 600, color: 'var(--theme-text, #111)' }}>{style.label}</span>
79
- <span style={{ fontSize: 10, color: 'var(--theme-elevation-500, #6b7280)' }}>{style.desc}</span>
79
+ <span style={{ fontSize: 10, color: 'var(--theme-elevation-650, #6b7280)' }}>{style.desc}</span>
80
80
  </button>
81
81
  ))}
82
82
  </div>
@@ -39,6 +39,7 @@ export function AISummaryPanel({
39
39
  return (
40
40
  <div style={{ marginBottom: '14px' }}>
41
41
  <button
42
+ type="button"
42
43
  onClick={() => { setShowAiSummary(!showAiSummary); if (!showAiSummary && !aiSummary) handleAiGenerate() }}
43
44
  style={{
44
45
  ...s.ghostBtn('#17807c', false),
@@ -61,6 +62,7 @@ export function AISummaryPanel({
61
62
  </h4>
62
63
  <div style={{ display: 'flex', gap: '6px' }}>
63
64
  <button
65
+ type="button"
64
66
  onClick={handleAiGenerate}
65
67
  disabled={aiGenerating}
66
68
  style={{ ...s.outlineBtn('#17807c', aiGenerating), fontSize: '11px', padding: '4px 10px' }}
@@ -69,6 +71,7 @@ export function AISummaryPanel({
69
71
  </button>
70
72
  {aiSummary && !aiGenerating && (
71
73
  <button
74
+ type="button"
72
75
  onClick={handleAiSave}
73
76
  disabled={aiSaving || aiSaved}
74
77
  style={{ ...s.btn(aiSaved ? '#16a34a' : '#2563eb', aiSaving), fontSize: '11px', padding: '4px 10px' }}
@@ -26,12 +26,13 @@ export function MergePanel({
26
26
  <div style={{ display: 'flex', gap: '8px', alignItems: 'center', marginBottom: '8px' }}>
27
27
  <input
28
28
  type="text"
29
+ aria-label="Numero du ticket cible"
29
30
  value={mergeTarget}
30
31
  onChange={(e) => { setMergeTarget(e.target.value); setMergeTargetInfo(null); setMergeError('') }}
31
32
  placeholder="TK-0001"
32
33
  style={{ ...s.input, width: '130px' }}
33
34
  />
34
- <button onClick={handleMergeLookup} style={{ ...s.outlineBtn('#ec4899'), fontSize: '12px', padding: '6px 14px' }}>
35
+ <button type="button" onClick={handleMergeLookup} style={{ ...s.outlineBtn('#ec4899'), fontSize: '12px', padding: '6px 14px' }}>
35
36
  Rechercher
36
37
  </button>
37
38
  {mergeError && <span style={{ fontSize: '12px', color: '#be185d', fontWeight: 600 }}>{mergeError}</span>}
@@ -41,7 +42,7 @@ export function MergePanel({
41
42
  <span style={{ fontSize: '13px', fontWeight: 600 }}>
42
43
  {mergeTargetInfo.ticketNumber} — {mergeTargetInfo.subject}
43
44
  </span>
44
- <button onClick={handleMerge} disabled={merging} style={{ ...s.btn('#ec4899', merging), color: '#fff', fontSize: '12px', padding: '6px 14px' }}>
45
+ <button type="button" onClick={handleMerge} disabled={merging} style={{ ...s.btn('#ec4899', merging), color: '#fff', fontSize: '12px', padding: '6px 14px' }}>
45
46
  {merging ? 'Fusion...' : 'Confirmer la fusion'}
46
47
  </button>
47
48
  </div>
@@ -76,6 +77,7 @@ export function ExtMessagePanel({
76
77
  <div style={{ padding: '14px 18px', borderRadius: '8px', backgroundColor: '#eef2ff', border: '1px solid #c7d2fe', marginBottom: '14px' }}>
77
78
  <h4 style={{ fontSize: '13px', fontWeight: 600, marginBottom: '10px', color: '#312e81' }}>Ajouter un message reçu (email, SMS, WhatsApp...)</h4>
78
79
  <textarea
80
+ aria-label="Contenu du message recu"
79
81
  value={extMsgBody}
80
82
  onChange={(e) => setExtMsgBody(e.target.value)}
81
83
  placeholder="Coller le contenu du message reçu..."
@@ -83,17 +85,18 @@ export function ExtMessagePanel({
83
85
  style={{ ...s.input, width: '100%', resize: 'vertical', marginBottom: '10px' }}
84
86
  />
85
87
  <div style={{ display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap', marginBottom: '10px' }}>
86
- <select value={extMsgAuthor} onChange={(e) => setExtMsgAuthor(e.target.value as 'client' | 'admin')} style={{ ...s.input, fontSize: '12px' }}>
88
+ <select aria-label="Auteur du message recu" value={extMsgAuthor} onChange={(e) => setExtMsgAuthor(e.target.value as 'client' | 'admin')} style={{ ...s.input, fontSize: '12px' }}>
87
89
  <option value="client">Envoyé par le client</option>
88
90
  <option value="admin">Envoyé par le support</option>
89
91
  </select>
90
92
  <input
91
93
  type="datetime-local"
94
+ aria-label="Date du message recu"
92
95
  value={extMsgDate}
93
96
  onChange={(e) => setExtMsgDate(e.target.value)}
94
97
  style={{ ...s.input, fontSize: '12px' }}
95
98
  />
96
- <input ref={extFileInputRef} type="file" multiple onChange={handleExtFileChange} style={{ display: 'none' }} accept="image/*,.pdf,.doc,.docx,.txt,.zip" />
99
+ <input ref={extFileInputRef} type="file" multiple aria-label="Joindre des fichiers au message recu" onChange={handleExtFileChange} style={{ display: 'none' }} accept="image/*,.pdf,.doc,.docx,.txt,.zip" />
97
100
  <button type="button" onClick={() => extFileInputRef.current?.click()} style={{ ...s.ghostBtn('#6b7280'), fontSize: '12px', padding: '6px 12px' }}>
98
101
  + PJ
99
102
  </button>
@@ -108,7 +111,7 @@ export function ExtMessagePanel({
108
111
  ))}
109
112
  </div>
110
113
  )}
111
- <button onClick={handleSendExtMsg} disabled={sendingExtMsg || !extMsgBody.trim()} style={s.btn('#1d2b4d', sendingExtMsg || !extMsgBody.trim())}>
114
+ <button type="button" onClick={handleSendExtMsg} disabled={sendingExtMsg || !extMsgBody.trim()} style={s.btn('#1d2b4d', sendingExtMsg || !extMsgBody.trim())}>
112
115
  {sendingExtMsg ? 'Ajout...' : 'Ajouter (sans notification)'}
113
116
  </button>
114
117
  </div>
@@ -186,6 +189,7 @@ export function ReminderPanel({
186
189
  )}
187
190
 
188
191
  <button
192
+ type="button"
189
193
  onClick={handleSendReminder}
190
194
  disabled={reminderSending || !clientEmail}
191
195
  style={{ ...s.btn(C.orange, reminderSending || !clientEmail), color: '#fff', fontSize: '13px', padding: '8px 18px' }}
@@ -207,11 +211,12 @@ export function SnoozePanel({ snoozeSaving, handleSnooze }: SnoozePanelProps) {
207
211
  <div style={{ padding: '14px 18px', borderRadius: '8px', backgroundColor: '#eef8f7', border: '1px solid #b8dcda', marginBottom: '14px' }}>
208
212
  <h4 style={{ fontSize: '13px', fontWeight: 600, marginBottom: '10px', color: '#5b21b6' }}>Snooze — masquer temporairement</h4>
209
213
  <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', alignItems: 'center' }}>
210
- <button onClick={() => handleSnooze(1)} disabled={snoozeSaving} style={{ ...s.outlineBtn('#17807c', snoozeSaving), fontSize: '12px' }}>1 jour</button>
211
- <button onClick={() => handleSnooze(3)} disabled={snoozeSaving} style={{ ...s.outlineBtn('#17807c', snoozeSaving), fontSize: '12px' }}>3 jours</button>
212
- <button onClick={() => handleSnooze(7)} disabled={snoozeSaving} style={{ ...s.outlineBtn('#17807c', snoozeSaving), fontSize: '12px' }}>1 semaine</button>
214
+ <button type="button" onClick={() => handleSnooze(1)} disabled={snoozeSaving} style={{ ...s.outlineBtn('#17807c', snoozeSaving), fontSize: '12px' }}>1 jour</button>
215
+ <button type="button" onClick={() => handleSnooze(3)} disabled={snoozeSaving} style={{ ...s.outlineBtn('#17807c', snoozeSaving), fontSize: '12px' }}>3 jours</button>
216
+ <button type="button" onClick={() => handleSnooze(7)} disabled={snoozeSaving} style={{ ...s.outlineBtn('#17807c', snoozeSaving), fontSize: '12px' }}>1 semaine</button>
213
217
  <input
214
218
  type="datetime-local"
219
+ aria-label="Snoozer jusqu'a une date precise"
215
220
  onChange={(e) => { if (e.target.value) handleSnooze(null, e.target.value) }}
216
221
  style={{ ...s.input, fontSize: '12px' }}
217
222
  />
@@ -96,6 +96,7 @@ export function ClientHistory({
96
96
  {/* Notes */}
97
97
  <h5 style={{ fontSize: '12px', fontWeight: 600, color: C.textSecondary, marginBottom: '8px' }}>Notes internes</h5>
98
98
  <textarea
99
+ aria-label="Notes internes sur le client"
99
100
  value={clientNotes}
100
101
  onChange={(e) => onNotesChange(e.target.value)}
101
102
  rows={3}
@@ -90,6 +90,7 @@ function SingleCodeBlock({ lang, code }: { lang: string; code: string }) {
90
90
  </span>
91
91
  </div>
92
92
  <button
93
+ type="button"
93
94
  onClick={handleCopy}
94
95
  style={{
95
96
  background: 'none',
@@ -109,6 +109,7 @@ export function CodeBlockInserter({ onInsert, className, style }: CodeBlockInser
109
109
  <input
110
110
  ref={inputRef}
111
111
  type="text"
112
+ aria-label="Rechercher un langage"
112
113
  value={filter}
113
114
  onChange={(e) => setFilter(e.target.value)}
114
115
  onKeyDown={(e) => {
@@ -124,7 +125,6 @@ export function CodeBlockInserter({ onInsert, className, style }: CodeBlockInser
124
125
  border: '1px solid #334155',
125
126
  borderRadius: 6,
126
127
  color: '#e2e8f0',
127
- outline: 'none',
128
128
  boxSizing: 'border-box',
129
129
  }}
130
130
  />
@@ -48,6 +48,7 @@ export function QuickActions({
48
48
  <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginBottom: '16px', alignItems: 'center' }}>
49
49
  {statusTransitions.map((a) => (
50
50
  <button
51
+ type="button"
51
52
  key={a.status}
52
53
  onClick={() => onStatusChange(a.status)}
53
54
  disabled={statusUpdating}
@@ -57,22 +58,22 @@ export function QuickActions({
57
58
  </button>
58
59
  ))}
59
60
  {snoozeUntil && new Date(snoozeUntil) > new Date() && (
60
- <button onClick={onCancelSnooze} disabled={snoozeSaving} style={{ ...s.ghostBtn('#17807c', snoozeSaving), fontSize: '12px', padding: '5px 10px' }}>
61
+ <button type="button" onClick={onCancelSnooze} disabled={snoozeSaving} style={{ ...s.ghostBtn('#17807c', snoozeSaving), fontSize: '12px', padding: '5px 10px' }}>
61
62
  Annuler snooze
62
63
  </button>
63
64
  )}
64
65
  <span style={{ borderLeft: `1px solid ${C.border}`, height: '20px', margin: '0 4px' }} />
65
- <button onClick={onToggleMerge} style={s.ghostBtn('#be185d')}>Fusionner</button>
66
- <button onClick={onToggleExtMsg} style={s.ghostBtn('#4f46e5')}>+ Message reçu</button>
67
- <button onClick={onToggleSnooze} style={s.ghostBtn('#17807c')}>Snooze</button>
66
+ <button type="button" onClick={onToggleMerge} style={s.ghostBtn('#be185d')}>Fusionner</button>
67
+ <button type="button" onClick={onToggleExtMsg} style={s.ghostBtn('#4f46e5')}>+ Message reçu</button>
68
+ <button type="button" onClick={onToggleSnooze} style={s.ghostBtn('#17807c')}>Snooze</button>
68
69
  {showReminderButton && (
69
- <button onClick={onToggleReminder} style={s.ghostBtn('#ea580c')}>{'⏰'} Relancer</button>
70
+ <button type="button" onClick={onToggleReminder} style={s.ghostBtn('#ea580c')}>{'⏰'} Relancer</button>
70
71
  )}
71
- <button onClick={onNextTicket} style={s.ghostBtn('#16a34a')}>Ticket suivant</button>
72
+ <button type="button" onClick={onNextTicket} style={s.ghostBtn('#16a34a')}>Ticket suivant</button>
72
73
  {scheduledClose && (
73
74
  <span style={{ display: 'inline-flex', alignItems: 'center', gap: '8px', padding: '5px 10px', borderRadius: '6px', backgroundColor: '#fff7ed', border: '1px solid #fed7aa', fontSize: '12px', color: '#9a3412', fontWeight: 600 }}>
74
75
  {'⏰'} Fermeture auto le {scheduledClose}
75
- <button onClick={onCancelScheduledClose} disabled={cancelingClose} style={{ border: 'none', background: 'none', color: '#b91c1c', cursor: cancelingClose ? 'not-allowed' : 'pointer', fontWeight: 700, fontSize: '12px', textDecoration: 'underline', padding: 0, opacity: cancelingClose ? 0.5 : 1 }}>
76
+ <button type="button" onClick={onCancelScheduledClose} disabled={cancelingClose} style={{ border: 'none', background: 'none', color: '#b91c1c', cursor: cancelingClose ? 'not-allowed' : 'pointer', fontWeight: 700, fontSize: '12px', textDecoration: 'underline', padding: 0, opacity: cancelingClose ? 0.5 : 1 }}>
76
77
  {cancelingClose ? '...' : 'Annuler'}
77
78
  </button>
78
79
  </span>
@@ -90,6 +91,7 @@ export function QuickActions({
90
91
  Ticket suivant : <strong>{nextTicketInfo}</strong>
91
92
  </span>
92
93
  <button
94
+ type="button"
93
95
  onClick={() => { window.location.href = `/admin/support/ticket?id=${nextTicketId}` }}
94
96
  style={{ ...s.btn(C.statusResolved), color: C.white, fontSize: '12px', padding: '5px 14px' }}
95
97
  >
@@ -99,7 +101,7 @@ export function QuickActions({
99
101
  ) : (
100
102
  <span style={{ fontSize: '13px', color: '#166534', fontWeight: 700 }}>{nextTicketInfo}</span>
101
103
  )}
102
- <button onClick={onCloseNextTicket} style={{ border: 'none', background: 'none', color: C.textMuted, cursor: 'pointer', fontSize: '16px', fontWeight: 700, marginLeft: '8px' }}>&times;</button>
104
+ <button type="button" onClick={onCloseNextTicket} style={{ border: 'none', background: 'none', color: C.textMuted, cursor: 'pointer', fontSize: '16px', fontWeight: 700, marginLeft: '8px' }}>&times;</button>
103
105
  </div>
104
106
  )}
105
107
  </>
@@ -71,6 +71,7 @@ export function TicketHeader({
71
71
  )}
72
72
  <span style={{ marginLeft: 'auto', display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
73
73
  <button
74
+ type="button"
74
75
  onClick={() => onCopyLink('admin')}
75
76
  title="Copier le lien admin"
76
77
  style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '13px', opacity: copiedLink === 'admin' ? 1 : 0.5, padding: '2px 4px' }}
@@ -78,6 +79,7 @@ export function TicketHeader({
78
79
  {copiedLink === 'admin' ? '\u2705' : '\uD83D\uDCCB'}
79
80
  </button>
80
81
  <button
82
+ type="button"
81
83
  onClick={() => onCopyLink('client')}
82
84
  title="Copier le lien client"
83
85
  style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '13px', opacity: copiedLink === 'client' ? 1 : 0.5, padding: '2px 4px' }}
@@ -34,6 +34,11 @@ export function TimeTrackingPanel({
34
34
  duration, setDuration, timeDescription, setTimeDescription,
35
35
  handleAddTime, addingTime, timeSuccess,
36
36
  }: TimeTrackingPanelProps) {
37
+ // The two `<label>`s of the manual entry row exist but were not associated
38
+ // with anything. useId, never a literal: the panel is one per ticket screen
39
+ // but the pattern has to hold if it is ever mounted twice.
40
+ const minutesId = React.useId()
41
+ const workDescriptionId = React.useId()
37
42
  const totalH = Math.floor(totalMinutes / 60)
38
43
  const totalM = totalMinutes % 60
39
44
 
@@ -63,6 +68,7 @@ export function TimeTrackingPanel({
63
68
  <div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
64
69
  {[-5, -1, 1, 5, 15, 30].map((m) => (
65
70
  <button
71
+ type="button"
66
72
  key={m}
67
73
  onClick={() => setTimerSeconds((p) => Math.max(0, p + m * 60))}
68
74
  style={{ width: Math.abs(m) >= 15 ? '32px' : '28px', height: '28px', borderRadius: '6px', border: '1px solid #e2e8f0', background: 'white', cursor: 'pointer', fontSize: Math.abs(m) >= 15 ? '12px' : '14px', fontWeight: 700, color: '#64748b' }}
@@ -75,28 +81,30 @@ export function TimeTrackingPanel({
75
81
  )}
76
82
 
77
83
  {!timerRunning && timerSeconds === 0 && (
78
- <button onClick={() => handleTimerStart(true)} style={{ ...s.btn('#dc2626', false), fontSize: '12px', padding: '6px 16px', display: 'flex', alignItems: 'center', gap: '6px' }}>
84
+ <button type="button" onClick={() => handleTimerStart(true)} style={{ ...s.btn('#dc2626', false), fontSize: '12px', padding: '6px 16px', display: 'flex', alignItems: 'center', gap: '6px' }}>
79
85
  &#9654; Démarrer
80
86
  </button>
81
87
  )}
82
88
  {timerRunning && (
83
- <button onClick={handleTimerStop} style={{ ...s.btn('#374151', false), fontSize: '12px', padding: '6px 16px', display: 'flex', alignItems: 'center', gap: '6px' }}>
89
+ <button type="button" onClick={handleTimerStop} style={{ ...s.btn('#374151', false), fontSize: '12px', padding: '6px 16px', display: 'flex', alignItems: 'center', gap: '6px' }}>
84
90
  &#9208; Pause
85
91
  </button>
86
92
  )}
87
93
  {!timerRunning && timerSeconds > 0 && (
88
94
  <>
89
- <button onClick={() => handleTimerStart(false)} style={{ ...s.btn('#dc2626', false), fontSize: '12px', padding: '6px 14px' }}>
95
+ <button type="button" onClick={() => handleTimerStart(false)} style={{ ...s.btn('#dc2626', false), fontSize: '12px', padding: '6px 14px' }}>
90
96
  &#9654; Reprendre
91
97
  </button>
92
98
  <input
93
99
  type="text"
100
+ aria-label="Description du temps en cours"
94
101
  value={timerDescription}
95
102
  onChange={(e) => setTimerDescription(e.target.value)}
96
103
  placeholder="Description..."
97
104
  style={{ ...s.input, fontSize: '12px', flex: 1, minWidth: '120px' }}
98
105
  />
99
106
  <button
107
+ type="button"
100
108
  onClick={handleTimerSave}
101
109
  disabled={addingTime || timerSeconds < 60}
102
110
  style={{ ...s.btn('#16a34a', addingTime || timerSeconds < 60), fontSize: '12px', padding: '6px 14px' }}
@@ -104,7 +112,7 @@ export function TimeTrackingPanel({
104
112
  >
105
113
  &#128190; {Math.round(timerSeconds / 60)} min
106
114
  </button>
107
- <button onClick={handleTimerDiscard} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '14px', color: '#94a3b8', padding: '4px' }} title="Annuler">
115
+ <button type="button" onClick={handleTimerDiscard} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '14px', color: '#94a3b8', padding: '4px' }} title="Annuler">
108
116
  &#10005;
109
117
  </button>
110
118
  </>
@@ -119,14 +127,14 @@ export function TimeTrackingPanel({
119
127
  {/* Manual time entry */}
120
128
  <div style={{ display: 'flex', gap: '8px', alignItems: 'flex-end', flexWrap: 'wrap', marginBottom: '14px' }}>
121
129
  <div>
122
- <label style={{ display: 'block', fontSize: '11px', color: C.textSecondary, marginBottom: '3px', fontWeight: 600 }}>Min</label>
123
- <input type="number" min="1" value={duration} onChange={(e) => setDuration(e.target.value)} placeholder="30" style={{ ...s.input, width: '80px' }} />
130
+ <label htmlFor={minutesId} style={{ display: 'block', fontSize: '11px', color: C.textSecondary, marginBottom: '3px', fontWeight: 600 }}>Min</label>
131
+ <input id={minutesId} type="number" min="1" value={duration} onChange={(e) => setDuration(e.target.value)} placeholder="30" style={{ ...s.input, width: '80px' }} />
124
132
  </div>
125
133
  <div style={{ flex: 1 }}>
126
- <label style={{ display: 'block', fontSize: '11px', color: C.textSecondary, marginBottom: '3px', fontWeight: 600 }}>Description</label>
127
- <input type="text" value={timeDescription} onChange={(e) => setTimeDescription(e.target.value)} placeholder="Travail effectué..." style={{ ...s.input, width: '100%' }} />
134
+ <label htmlFor={workDescriptionId} style={{ display: 'block', fontSize: '11px', color: C.textSecondary, marginBottom: '3px', fontWeight: 600 }}>Description</label>
135
+ <input id={workDescriptionId} type="text" value={timeDescription} onChange={(e) => setTimeDescription(e.target.value)} placeholder="Travail effectué..." style={{ ...s.input, width: '100%' }} />
128
136
  </div>
129
- <button onClick={handleAddTime} disabled={addingTime || !duration} style={s.btn(C.amber, addingTime || !duration)}>
137
+ <button type="button" onClick={handleAddTime} disabled={addingTime || !duration} style={s.btn(C.amber, addingTime || !duration)}>
130
138
  {addingTime ? '...' : '+ Temps'}
131
139
  </button>
132
140
  {timeSuccess && <span style={{ fontSize: '12px', color: '#16a34a', fontWeight: 600 }}>{timeSuccess}</span>}
@@ -24,9 +24,10 @@ import { useDocumentIdFromUrl } from './hooks/useDocumentIdFromUrl'
24
24
  import { useFeatures } from './hooks/useFeatures'
25
25
  import { SkeletonText } from './SkeletonText'
26
26
  import { RewriteDropdown } from './RewriteDropdown'
27
+ import { AdminErrorBoundary } from '../../views/shared/ErrorBoundary'
27
28
  import '../../styles/theme.css'
28
29
 
29
- const TicketConversation: React.FC = () => {
30
+ const TicketConversationInner: React.FC = () => {
30
31
  const { id } = useDocumentIdFromUrl()
31
32
  const { features } = useFeatures()
32
33
  const [messages, setMessages] = useState<Message[]>([])
@@ -416,6 +417,7 @@ const TicketConversation: React.FC = () => {
416
417
  <div style={{ flex: 1 }}>
417
418
  <input
418
419
  type="text"
420
+ aria-label="Rechercher dans la conversation"
419
421
  value={searchQuery}
420
422
  onChange={(e) => setSearchQuery(e.target.value)}
421
423
  placeholder="Rechercher..."
@@ -442,6 +444,7 @@ const TicketConversation: React.FC = () => {
442
444
  <>
443
445
  {showCollapse && hiddenCount > 0 && (
444
446
  <button
447
+ type="button"
445
448
  onClick={() => setMessagesCollapsed(false)}
446
449
  style={{
447
450
  background: 'none', border: `1px dashed ${C.border}`, borderRadius: '6px',
@@ -454,6 +457,7 @@ const TicketConversation: React.FC = () => {
454
457
  )}
455
458
  {!messagesCollapsed && filtered.length > 1 && !isSearching && (
456
459
  <button
460
+ type="button"
457
461
  onClick={() => setMessagesCollapsed(true)}
458
462
  style={{
459
463
  background: 'none', border: `1px dashed ${C.border}`, borderRadius: '6px',
@@ -501,6 +505,7 @@ const TicketConversation: React.FC = () => {
501
505
  <span style={s.badge(C.emailBg, C.orange)}>Email</span>
502
506
  ) : (
503
507
  <select
508
+ aria-label="Auteur du message"
504
509
  value={msg.authorType}
505
510
  onChange={(e) => handleToggleAuthor(msg.id, e.target.value === 'admin' ? 'client' : 'admin')}
506
511
  disabled={togglingAuthor === msg.id}
@@ -537,6 +542,7 @@ const TicketConversation: React.FC = () => {
537
542
  {/* Split button */}
538
543
  {!msg.isInternal && !(msg as unknown as { deletedAt?: string }).deletedAt && (
539
544
  <button
545
+ type="button"
540
546
  onClick={() => handleSplitMessage(msg.id, ticketSubject)}
541
547
  style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '10px', color: '#6b7280', padding: 0 }}
542
548
  title="Extraire en nouveau ticket"
@@ -579,16 +585,17 @@ const TicketConversation: React.FC = () => {
579
585
  {editingMsg === msg.id ? (
580
586
  <div style={{ marginTop: '6px' }}>
581
587
  <textarea
588
+ aria-label="Editer le message"
582
589
  value={editBody}
583
590
  onChange={(e) => { setEditBody(e.target.value); setEditHtml(e.target.value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br />')) }}
584
591
  rows={4}
585
592
  style={{ ...s.input, width: '100%', resize: 'vertical', fontSize: '13px' }}
586
593
  />
587
594
  <div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
588
- <button onClick={() => handleEditSave(msg.id)} disabled={savingEdit || (!editBody.trim() && !editHtml)} style={{ ...s.btn(C.blue, savingEdit), fontSize: '11px', padding: '5px 12px' }}>
595
+ <button type="button" onClick={() => handleEditSave(msg.id)} disabled={savingEdit || (!editBody.trim() && !editHtml)} style={{ ...s.btn(C.blue, savingEdit), fontSize: '11px', padding: '5px 12px' }}>
589
596
  {savingEdit ? '...' : 'Enregistrer'}
590
597
  </button>
591
- <button onClick={handleEditCancel} style={{ ...s.ghostBtn('#6b7280'), fontSize: '11px', padding: '5px 12px' }}>
598
+ <button type="button" onClick={handleEditCancel} style={{ ...s.ghostBtn('#6b7280'), fontSize: '11px', padding: '5px 12px' }}>
592
599
  Annuler
593
600
  </button>
594
601
  </div>
@@ -775,6 +782,7 @@ const TicketConversation: React.FC = () => {
775
782
  ))}
776
783
  {features.ai && (
777
784
  <button
785
+ type="button"
778
786
  onClick={handleAiSuggestReply}
779
787
  disabled={aiReplying || messages.length === 0}
780
788
  style={{ ...s.outlineBtn('#17807c', aiReplying || messages.length === 0), fontSize: '11px', padding: '3px 10px', borderRadius: '14px' }}
@@ -800,7 +808,7 @@ const TicketConversation: React.FC = () => {
800
808
  />
801
809
  {features.canned && cannedResponses.length > 0 && (
802
810
  <>
803
- <select onChange={handleCannedSelect} style={{ ...s.input, fontSize: '11px', padding: '3px 8px', fontWeight: 600 }}>
811
+ <select aria-label="Reponse rapide" onChange={handleCannedSelect} style={{ ...s.input, fontSize: '11px', padding: '3px 8px', fontWeight: 600 }}>
804
812
  <option value="">Réponse rapide...</option>
805
813
  {cannedResponses.map((cr) => (
806
814
  <option key={cr.id} value={String(cr.id)}>{cr.title}</option>
@@ -818,6 +826,7 @@ const TicketConversation: React.FC = () => {
818
826
  {/* Reply textarea (replacing RichTextEditor) */}
819
827
  <div style={{ border: `1px solid ${C.border}`, borderRadius: '8px', overflow: 'hidden' }}>
820
828
  <textarea
829
+ aria-label="Reponse au client"
821
830
  value={replyBody}
822
831
  onChange={(e) => {
823
832
  const text = e.target.value
@@ -831,7 +840,6 @@ const TicketConversation: React.FC = () => {
831
840
  minHeight: '120px',
832
841
  padding: '12px',
833
842
  border: 'none',
834
- outline: 'none',
835
843
  fontSize: '14px',
836
844
  lineHeight: 1.5,
837
845
  resize: 'vertical',
@@ -848,6 +856,7 @@ const TicketConversation: React.FC = () => {
848
856
  ref={fileInputRef}
849
857
  type="file"
850
858
  multiple
859
+ aria-label="Joindre des fichiers"
851
860
  onChange={handleReplyFileChange}
852
861
  style={{ display: 'none' }}
853
862
  accept="image/*,.pdf,.doc,.docx,.txt,.zip"
@@ -866,6 +875,7 @@ const TicketConversation: React.FC = () => {
866
875
  {'\uD83D\uDCCE'} {file.name}
867
876
  <button
868
877
  type="button"
878
+ aria-label={`Retirer la piece jointe ${file.name}`}
869
879
  onClick={() => setReplyFiles((prev) => prev.filter((_, idx) => idx !== i))}
870
880
  style={{ border: 'none', background: 'none', color: '#ef4444', fontWeight: 700, cursor: 'pointer', fontSize: '14px', lineHeight: 1 }}
871
881
  >
@@ -879,6 +889,7 @@ const TicketConversation: React.FC = () => {
879
889
  {/* Send row */}
880
890
  <div style={{ display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap', marginTop: '8px' }}>
881
891
  <select
892
+ aria-label="Envoyer en tant que"
882
893
  value={sendAsClient ? 'client' : 'admin'}
883
894
  onChange={(e) => setSendAsClient(e.target.value === 'client')}
884
895
  style={{ ...s.input, fontSize: '12px', padding: '6px 8px', fontWeight: 600 }}
@@ -896,11 +907,12 @@ const TicketConversation: React.FC = () => {
896
907
  Envoyer au client
897
908
  </label>
898
909
  )}
899
- <button data-action="send-reply" onClick={handleSendReply} disabled={sending || (!replyBody.trim() && !replyHtml)} style={{ ...s.btn(isInternal ? C.amber : notifyClient ? '#16a34a' : C.blue, sending || (!replyBody.trim() && !replyHtml)), fontSize: '13px', padding: '8px 20px', marginLeft: 'auto' }}>
910
+ <button type="button" data-action="send-reply" onClick={handleSendReply} disabled={sending || (!replyBody.trim() && !replyHtml)} style={{ ...s.btn(isInternal ? C.amber : notifyClient ? '#16a34a' : C.blue, sending || (!replyBody.trim() && !replyHtml)), fontSize: '13px', padding: '8px 20px', marginLeft: 'auto' }}>
900
911
  {sending ? 'Envoi...' : isInternal ? 'Ajouter note' : notifyClient ? 'Envoyer + Notifier' : 'Sauvegarder'}
901
912
  </button>
902
913
  {!isInternal && (
903
914
  <button
915
+ type="button"
904
916
  onClick={() => setShowSchedule(!showSchedule)}
905
917
  disabled={!replyBody.trim() && !replyHtml}
906
918
  style={{ ...s.outlineBtn('#17807c', !replyBody.trim() && !replyHtml), fontSize: '12px', padding: '8px 12px' }}
@@ -915,19 +927,21 @@ const TicketConversation: React.FC = () => {
915
927
  <span style={{ fontSize: '12px', fontWeight: 600, color: '#17807c' }}>Programmer pour :</span>
916
928
  <input
917
929
  type="datetime-local"
930
+ aria-label="Date et heure d'envoi programme"
918
931
  value={scheduleDate}
919
932
  onChange={(e) => setScheduleDate(e.target.value)}
920
933
  min={new Date().toISOString().slice(0, 16)}
921
934
  style={{ ...s.input, fontSize: '12px', width: 'auto' }}
922
935
  />
923
936
  <button
937
+ type="button"
924
938
  onClick={handleScheduleReply}
925
939
  disabled={sending || !scheduleDate || (!replyBody.trim() && !replyHtml)}
926
940
  style={{ ...s.btn('#17807c', sending || !scheduleDate || (!replyBody.trim() && !replyHtml)), fontSize: '12px', padding: '6px 14px' }}
927
941
  >
928
942
  {sending ? '...' : '\u23F0 Programmer'}
929
943
  </button>
930
- <button onClick={() => setShowSchedule(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#94a3b8', fontSize: '14px' }}>{'\u2715'}</button>
944
+ <button type="button" aria-label="Annuler la programmation" onClick={() => setShowSchedule(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#94a3b8', fontSize: '14px' }}>{'\u2715'}</button>
931
945
  </div>
932
946
  )}
933
947
  <div style={{ fontSize: '11px', color: C.textMuted, marginTop: '4px', textAlign: 'right' }}>
@@ -1088,4 +1102,24 @@ const TicketConversation: React.FC = () => {
1088
1102
 
1089
1103
 
1090
1104
 
1105
+ /**
1106
+ * Payload mounts this component straight from the import map as the `ui` field
1107
+ * of the Tickets edit view, so the plugin never gets to render an ancestor for
1108
+ * it: the boundary has to live inside the module itself. Without it a single
1109
+ * render error in any of the twelve sub-components blanks the whole ticket
1110
+ * screen.
1111
+ *
1112
+ * The document id is used as a reset key so that navigating from a ticket that
1113
+ * broke to another one clears the error on its own.
1114
+ */
1115
+ const TicketConversation: React.FC = () => {
1116
+ const { id } = useDocumentIdFromUrl()
1117
+
1118
+ return (
1119
+ <AdminErrorBoundary viewName="TicketConversation" resetKeys={[id]}>
1120
+ <TicketConversationInner />
1121
+ </AdminErrorBoundary>
1122
+ )
1123
+ }
1124
+
1091
1125
  export default TicketConversation