@consilioweb/payload-support 0.8.1 → 0.9.4

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 (51) hide show
  1. package/dist/components/RichTextEditor/index.cjs +233 -0
  2. package/dist/components/RichTextEditor/index.js +232 -0
  3. package/dist/components/TicketConversation/components/CodeBlock.cjs +24 -7
  4. package/dist/components/TicketConversation/components/CodeBlock.js +23 -9
  5. package/dist/index.cjs +19 -2
  6. package/dist/index.js +19 -2
  7. package/dist/views/BillingView/client.cjs +260 -103
  8. package/dist/views/BillingView/client.js +259 -103
  9. package/dist/views/ChatView/client.cjs +184 -137
  10. package/dist/views/ChatView/client.js +180 -137
  11. package/dist/views/CrmView/client.cjs +270 -122
  12. package/dist/views/CrmView/client.js +266 -122
  13. package/dist/views/EmailTrackingView/client.cjs +80 -69
  14. package/dist/views/EmailTrackingView/client.js +80 -70
  15. package/dist/views/ImportConversationView/client.cjs +127 -94
  16. package/dist/views/ImportConversationView/client.js +123 -94
  17. package/dist/views/LogsView/client.cjs +56 -58
  18. package/dist/views/LogsView/client.js +52 -58
  19. package/dist/views/NewTicketView/client.cjs +39 -55
  20. package/dist/views/NewTicketView/client.js +38 -55
  21. package/dist/views/PendingEmailsView/client.cjs +399 -102
  22. package/dist/views/PendingEmailsView/client.js +396 -103
  23. package/dist/views/SupportDashboardView/client.cjs +276 -137
  24. package/dist/views/SupportDashboardView/client.js +275 -137
  25. package/dist/views/TicketDetailView/client.cjs +487 -204
  26. package/dist/views/TicketDetailView/client.js +486 -204
  27. package/dist/views/TicketInboxView/client.cjs +62 -65
  28. package/dist/views/TicketInboxView/client.js +62 -66
  29. package/dist/views/TicketingSettingsView/client.cjs +10 -8
  30. package/dist/views/TicketingSettingsView/client.js +10 -8
  31. package/dist/views/TimeDashboardView/client.cjs +70 -59
  32. package/dist/views/TimeDashboardView/client.js +69 -59
  33. package/package.json +6 -2
  34. package/src/collections/ClientSummaries.ts +0 -1
  35. package/src/components/RichTextEditor/index.tsx +261 -0
  36. package/src/components/TicketConversation/components/CodeBlock.tsx +53 -14
  37. package/src/plugin.ts +2 -0
  38. package/src/utils/emailTemplate.ts +37 -0
  39. package/src/views/BillingView/client.tsx +362 -69
  40. package/src/views/ChatView/client.tsx +225 -140
  41. package/src/views/CrmView/client.tsx +447 -189
  42. package/src/views/EmailTrackingView/client.tsx +111 -71
  43. package/src/views/ImportConversationView/client.tsx +255 -70
  44. package/src/views/LogsView/client.tsx +85 -50
  45. package/src/views/NewTicketView/client.tsx +37 -53
  46. package/src/views/PendingEmailsView/client.tsx +512 -92
  47. package/src/views/SupportDashboardView/client.tsx +294 -134
  48. package/src/views/TicketDetailView/client.tsx +486 -213
  49. package/src/views/TicketInboxView/client.tsx +52 -61
  50. package/src/views/TicketingSettingsView/client.tsx +10 -9
  51. package/src/views/TimeDashboardView/client.tsx +184 -69
@@ -3,6 +3,9 @@
3
3
  import React, { useState, useEffect, useCallback, useRef } from 'react'
4
4
  import { useSearchParams } from 'next/navigation'
5
5
  import Link from 'next/link'
6
+ import { RichTextEditor, type RichTextEditorHandle } from '../../components/RichTextEditor/index'
7
+ import { hasCodeBlocks, MessageWithCodeBlocks, CodeBlockRendererHtml } from '../../components/TicketConversation/components/CodeBlock'
8
+ import { CodeBlockInserter } from '../../components/TicketConversation/components/CodeBlockInserter'
6
9
  import { getFeatures } from '../shared/config'
7
10
  import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
8
11
  import s from '../../styles/TicketDetail.module.scss'
@@ -16,10 +19,10 @@ interface ClientInfo { id: number; company: string; firstName: string; lastName:
16
19
  interface TimeEntry { id: string | number; duration: number; description?: string; date: string }
17
20
  interface ActivityEntry { id: string | number; action: string; detail?: string; actorType?: string; createdAt: string }
18
21
 
19
- const STATUS: Record<string, { label: string; bg: string; color: string }> = {
20
- open: { label: 'Ouvert', bg: '#dbeafe', color: '#1e40af' },
21
- waiting_client: { label: 'En attente', bg: '#fef3c7', color: '#92400e' },
22
- resolved: { label: 'Resolu', bg: '#dcfce7', color: '#166534' },
22
+ const STATUS_STYLE: Record<string, { bg: string; color: string }> = {
23
+ open: { bg: '#dbeafe', color: '#1e40af' },
24
+ waiting_client: { bg: '#fef3c7', color: '#92400e' },
25
+ resolved: { bg: '#dcfce7', color: '#166534' },
23
26
  }
24
27
 
25
28
  function timeAgo(d: string): string {
@@ -38,11 +41,81 @@ function dateLabel(d: string): string {
38
41
  return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' })
39
42
  }
40
43
 
44
+ // ── Rewrite style dropdown ──────────────────────────────────────────────
45
+ const REWRITE_STYLES = [
46
+ { id: 'auto', label: '✏️ Auto', desc: 'Garde le ton actuel' },
47
+ { id: 'tutoyer', label: '👋 Tutoyer', desc: 'Passe en tu' },
48
+ { id: 'vouvoyer', label: '🎩 Vouvoyer', desc: 'Passe en vous' },
49
+ { id: 'formel', label: '💼 Formel', desc: 'Ton professionnel' },
50
+ { id: 'amical', label: '😊 Amical', desc: 'Ton chaleureux' },
51
+ { id: 'court', label: '⚡ Court', desc: 'Version concise' },
52
+ ]
53
+
54
+ const RewriteDropdown: React.FC<{
55
+ disabled: boolean
56
+ loading: boolean
57
+ onSelect: (style: string) => void
58
+ toolbarBtnClass?: string
59
+ }> = ({ disabled, loading, onSelect, toolbarBtnClass }) => {
60
+ const [open, setOpen] = useState(false)
61
+ const ref = useRef<HTMLDivElement>(null)
62
+
63
+ useEffect(() => {
64
+ if (!open) return
65
+ const close = (e: MouseEvent) => {
66
+ if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
67
+ }
68
+ document.addEventListener('mousedown', close)
69
+ return () => document.removeEventListener('mousedown', close)
70
+ }, [open])
71
+
72
+ return (
73
+ <div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
74
+ <button
75
+ type="button"
76
+ className={toolbarBtnClass}
77
+ onClick={() => setOpen(!open)}
78
+ disabled={disabled}
79
+ style={{ fontSize: 11, fontWeight: 700, padding: '4px 10px', width: 'auto', display: 'flex', alignItems: 'center', gap: 4 }}
80
+ >
81
+ {loading ? '...' : '✏️ Reformuler'}
82
+ {!loading && <span style={{ fontSize: 9, opacity: 0.6 }}>▼</span>}
83
+ </button>
84
+ {open && !disabled && !loading && (
85
+ <div style={{
86
+ position: 'absolute', bottom: '100%', left: 0, marginBottom: 4,
87
+ background: '#fff', border: '1px solid #e5e7eb', borderRadius: 8,
88
+ boxShadow: '0 4px 16px rgba(0,0,0,0.12)', zIndex: 100, minWidth: 180, overflow: 'hidden',
89
+ }}>
90
+ {REWRITE_STYLES.map((style) => (
91
+ <button
92
+ key={style.id}
93
+ type="button"
94
+ onClick={() => { setOpen(false); onSelect(style.id) }}
95
+ style={{
96
+ display: 'flex', flexDirection: 'column', width: '100%', padding: '8px 12px',
97
+ border: 'none', background: 'transparent', cursor: 'pointer', textAlign: 'left',
98
+ borderBottom: '1px solid #f3f4f6',
99
+ }}
100
+ onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = '#f9fafb' }}
101
+ onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = 'transparent' }}
102
+ >
103
+ <span style={{ fontSize: 12, fontWeight: 600 }}>{style.label}</span>
104
+ <span style={{ fontSize: 10, color: '#9ca3af' }}>{style.desc}</span>
105
+ </button>
106
+ ))}
107
+ </div>
108
+ )}
109
+ </div>
110
+ )
111
+ }
112
+
41
113
  export const TicketDetailClient: React.FC = () => {
42
114
  const { t } = useTranslation()
43
115
  const searchParams = useSearchParams()
44
116
  const ticketId = searchParams.get('id')
45
117
  const features = getFeatures()
118
+ const editorRef = useRef<RichTextEditorHandle>(null)
46
119
  const threadEndRef = useRef<HTMLDivElement>(null)
47
120
  const dropdownRef = useRef<HTMLDivElement>(null)
48
121
  const fileInputRef = useRef<HTMLInputElement>(null)
@@ -56,17 +129,21 @@ export const TicketDetailClient: React.FC = () => {
56
129
  const [loading, setLoading] = useState(true)
57
130
 
58
131
  const [replyBody, setReplyBody] = useState('')
132
+ const [replyHtml, setReplyHtml] = useState('')
59
133
  const [isInternal, setIsInternal] = useState(false)
60
134
  const [notifyClient, setNotifyClient] = useState(true)
61
135
  const [sending, setSending] = useState(false)
62
136
 
63
137
  const [showMenu, setShowMenu] = useState(false)
64
- const [clientTyping, setClientTyping] = useState(false)
138
+ const [clientTyping, setClientTyping] = useState(false)
65
139
  const [aiReplying, setAiReplying] = useState(false)
66
140
  const [aiRewriting, setAiRewriting] = useState(false)
67
141
  const [sentiment, setSentiment] = useState<{ emoji: string; label: string; color: string } | null>(null)
68
142
  const [statusUpdating, setStatusUpdating] = useState(false)
69
143
  const [showActivity, setShowActivity] = useState(false)
144
+ // Client Intelligence
145
+ const [clientSummary, setClientSummary] = useState<{ summary: string; recurringTopics?: { topic: string; count: number }[]; keyFacts?: string[] } | null>(null)
146
+ const [summaryLoading, setSummaryLoading] = useState(false)
70
147
  const [timerRunning, setTimerRunning] = useState(() => {
71
148
  if (typeof window === 'undefined' || !ticketId) return false
72
149
  return localStorage.getItem(`timer-run-${ticketId}`) === '1'
@@ -87,23 +164,23 @@ export const TicketDetailClient: React.FC = () => {
87
164
  const [macros, setMacros] = useState<Array<{ id: number; name: string }>>([])
88
165
  const [applyingMacro, setApplyingMacro] = useState(false)
89
166
 
90
- // Undo toast state
167
+ // #2 — Undo toast state
91
168
  const [undoToast, setUndoToast] = useState<{ msgId: string | number; timer: ReturnType<typeof setTimeout> } | null>(null)
92
169
 
93
- // Split modal state
170
+ // #2 — Split modal state
94
171
  const [splitModal, setSplitModal] = useState<{ messageId: string | number; preview: string } | null>(null)
95
172
  const [splitSubject, setSplitSubject] = useState('')
96
173
 
97
- // File upload state
174
+ // #5 — File upload state
98
175
  const [pendingFiles, setPendingFiles] = useState<File[]>([])
99
176
  const [composerDragOver, setComposerDragOver] = useState(false)
100
177
 
101
- // Tags state
178
+ // #6 — Tags state
102
179
  const [tags, setTags] = useState<string[]>([])
103
180
  const [addingTag, setAddingTag] = useState(false)
104
181
  const [newTagValue, setNewTagValue] = useState('')
105
182
 
106
- // ---- DATA FETCHING ----
183
+ // ─── DATA FETCHING ───
107
184
  const fetchAll = useCallback(async () => {
108
185
  if (!ticketId) return
109
186
  try {
@@ -119,6 +196,7 @@ export const TicketDetailClient: React.FC = () => {
119
196
  const d = await tr.json()
120
197
  setTicket(d)
121
198
  if (d.client && typeof d.client === 'object') setClient(d.client)
199
+ // #6 — Sync tags
122
200
  if (Array.isArray(d.tags)) setTags(d.tags.map((t: { tag?: string } | string) => typeof t === 'object' ? (t.tag || '') : t).filter(Boolean))
123
201
  }
124
202
  if (ter.ok) { const d = await ter.json(); setTimeEntries(d.docs || []) }
@@ -128,52 +206,77 @@ export const TicketDetailClient: React.FC = () => {
128
206
  setLoading(false)
129
207
  }, [ticketId])
130
208
 
209
+ // Circuit breaker: stop polling after consecutive failures
210
+ const failCountRef = useRef({ messages: 0, typing: 0, presence: 0 })
211
+ const MAX_FAILS = 3
212
+
131
213
  useEffect(() => { fetchAll() }, [fetchAll])
214
+ // Fetch client summary when ticket loads
132
215
  useEffect(() => {
216
+ if (!ticket) return
217
+ const clientId = typeof ticket.client === 'object' ? (ticket.client as any)?.id : ticket.client
218
+ if (!clientId) return
219
+ setSummaryLoading(true)
220
+ fetch(`/api/support/client-intelligence?clientId=${clientId}`, { credentials: 'include' })
221
+ .then((r) => r.ok ? r.json() : null)
222
+ .then((d) => { if (d) setClientSummary(d) })
223
+ .catch(() => {})
224
+ .finally(() => setSummaryLoading(false))
225
+ }, [ticket?.id]) // eslint-disable-line react-hooks/exhaustive-deps
226
+ useEffect(() => { // Poll 10s
133
227
  if (!ticketId || loading) return
134
228
  const iv = setInterval(async () => {
229
+ if (failCountRef.current.messages >= MAX_FAILS) return
135
230
  try {
136
231
  const [mr, tr] = await Promise.all([
137
232
  fetch(`/api/ticket-messages?where[ticket][equals]=${ticketId}&sort=createdAt&limit=200&depth=1`, { credentials: 'include' }),
138
233
  fetch(`/api/tickets/${ticketId}?depth=0`, { credentials: 'include' }),
139
234
  ])
140
- if (mr.ok) { const d = await mr.json(); setMessages(d.docs || []) }
141
- if (tr.ok) { const d = await tr.json(); setTicket((p) => p ? { ...p, ...d } : d) }
142
- } catch { /* */ }
235
+ if (mr.ok && tr.ok) {
236
+ failCountRef.current.messages = 0
237
+ const d = await mr.json(); setMessages(d.docs || [])
238
+ const td = await tr.json(); setTicket((p) => p ? { ...p, ...td } : td)
239
+ } else { failCountRef.current.messages++ }
240
+ } catch { failCountRef.current.messages++ }
143
241
  }, 10000)
144
242
  return () => clearInterval(iv)
145
243
  }, [ticketId, loading])
146
- useEffect(() => {
244
+ useEffect(() => { // Mark read
147
245
  if (!ticketId) return
148
246
  fetch(`/api/tickets/${ticketId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ lastAdminReadAt: new Date().toISOString() }) }).catch(() => {})
149
247
  }, [ticketId, messages.length])
150
- useEffect(() => {
248
+ useEffect(() => { // Typing
151
249
  if (!ticketId) return
152
250
  const iv = setInterval(async () => {
153
- try { const r = await fetch(`/api/support/typing?ticketId=${ticketId}`, { credentials: 'include' }); if (r.ok) { const d = await r.json(); setClientTyping(d.typing) } } catch (err) { console.warn('[support] typing poll error:', err) }
154
- }, 2000)
251
+ if (failCountRef.current.typing >= MAX_FAILS) return
252
+ try {
253
+ const r = await fetch(`/api/support/typing?ticketId=${ticketId}`, { credentials: 'include' })
254
+ if (r.ok) { failCountRef.current.typing = 0; const d = await r.json(); setClientTyping(d.typing) }
255
+ else { failCountRef.current.typing++ }
256
+ } catch { failCountRef.current.typing++ }
257
+ }, 3000)
155
258
  return () => clearInterval(iv)
156
259
  }, [ticketId])
157
- useEffect(() => {
260
+ useEffect(() => { // Sentiment
158
261
  if (!features.ai || messages.length === 0) return
159
- const last = [...messages].reverse().find((m) => m.authorType === 'client' || m.authorType === 'email')
160
- if (!last) return
161
- fetch('/api/support/ai', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ action: 'sentiment', text: last.body.slice(0, 500) }) })
262
+ // Use last 3 client messages for better context (not just the last one)
263
+ const clientMsgs = messages.filter((m) => m.authorType === 'client' || m.authorType === 'email').slice(-3)
264
+ if (clientMsgs.length === 0) return
265
+ const contextText = clientMsgs.map((m) => m.body).join('\n---\n').slice(0, 1000)
266
+ fetch('/api/support/ai', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ action: 'sentiment', text: contextText }) })
162
267
  .then((r) => r.json()).then((d) => {
163
- const raw = (d.sentiment || '').toLowerCase()
268
+ const raw = (d.sentiment || '').toLowerCase().replace(/[^a-zéèêàùûîôëüöç]/g, '')
164
269
  const map: Record<string, { emoji: string; label: string; color: string }> = {
165
- 'frustre': { emoji: ':(', label: 'Frustre', color: '#dc2626' },
166
- 'mecontent': { emoji: ':(', label: 'Mecontent', color: '#ea580c' },
167
- 'urgent': { emoji: '!', label: 'Urgent', color: '#dc2626' },
168
- 'neutre': { emoji: '-', label: 'Neutre', color: '#6b7280' },
169
- 'satisfait': { emoji: ':)', label: 'Satisfait', color: '#16a34a' },
270
+ 'frustré': { emoji: '😤', label: 'Frustré', color: '#dc2626' }, 'frustre': { emoji: '😤', label: 'Frustré', color: '#dc2626' },
271
+ 'mécontent': { emoji: '😠', label: 'Mécontent', color: '#ea580c' }, 'mecontent': { emoji: '😠', label: 'Mécontent', color: '#ea580c' },
272
+ 'urgent': { emoji: '🔥', label: 'Urgent', color: '#dc2626' }, 'neutre': { emoji: '😐', label: 'Neutre', color: '#6b7280' }, 'satisfait': { emoji: '😊', label: 'Satisfait', color: '#16a34a' },
170
273
  }
171
274
  const m = Object.keys(map).find((k) => raw.includes(k))
172
- setSentiment(m ? map[m] : { emoji: '-', label: 'Neutre', color: '#6b7280' })
275
+ setSentiment(m ? map[m] : { emoji: '😐', label: 'Neutre', color: '#6b7280' })
173
276
  }).catch(() => {})
174
277
  }, [messages.length, features.ai]) // eslint-disable-line react-hooks/exhaustive-deps
175
278
  useEffect(() => { threadEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages.length])
176
- useEffect(() => {
279
+ useEffect(() => { // Timer with localStorage persistence
177
280
  if (timerRunning) {
178
281
  localStorage.setItem(`timer-run-${ticketId}`, '1')
179
282
  localStorage.setItem(`timer-ts-${ticketId}`, String(Date.now()))
@@ -194,7 +297,7 @@ export const TicketDetailClient: React.FC = () => {
194
297
  return () => { if (timerRef.current) clearInterval(timerRef.current) }
195
298
  }, [timerRunning, ticketId]) // eslint-disable-line react-hooks/exhaustive-deps
196
299
 
197
- // Presence
300
+ // ─── PRESENCE / COLLISION DETECTION ───
198
301
  useEffect(() => {
199
302
  if (!ticketId) return
200
303
  const join = () => fetch('/api/support/presence', {
@@ -215,15 +318,17 @@ export const TicketDetailClient: React.FC = () => {
215
318
  useEffect(() => {
216
319
  if (!ticketId) return
217
320
  const poll = setInterval(async () => {
321
+ if (failCountRef.current.presence >= MAX_FAILS) return
218
322
  try {
219
323
  const r = await fetch(`/api/support/presence?ticketId=${ticketId}`, { credentials: 'include' })
220
- if (r.ok) { const d = await r.json(); setOtherViewers(d.viewers || []) }
221
- } catch { /* silent */ }
324
+ if (r.ok) { failCountRef.current.presence = 0; const d = await r.json(); setOtherViewers(d.viewers || []) }
325
+ else { failCountRef.current.presence++ }
326
+ } catch { failCountRef.current.presence++ }
222
327
  }, 5_000)
223
328
  return () => clearInterval(poll)
224
329
  }, [ticketId])
225
330
 
226
- // Fetch macros
331
+ // ─── FETCH MACROS ───
227
332
  useEffect(() => {
228
333
  fetch('/api/macros?where[isActive][equals]=true&depth=0&limit=50', { credentials: 'include' })
229
334
  .then((r) => r.ok ? r.json() : null)
@@ -231,7 +336,7 @@ export const TicketDetailClient: React.FC = () => {
231
336
  .catch(() => {})
232
337
  }, [])
233
338
 
234
- // Close dropdown on outside click
339
+ // #12 — Close dropdown on outside click
235
340
  useEffect(() => {
236
341
  if (!showMenu) return
237
342
  const handler = (e: MouseEvent) => {
@@ -243,19 +348,22 @@ export const TicketDetailClient: React.FC = () => {
243
348
  return () => document.removeEventListener('mousedown', handler)
244
349
  }, [showMenu])
245
350
 
246
- // Keyboard shortcuts
351
+ // #3 — Keyboard shortcuts
247
352
  useEffect(() => {
248
353
  const handler = (e: KeyboardEvent) => {
249
354
  const mod = e.metaKey || e.ctrlKey
355
+ // Cmd/Ctrl+Enter -> send
250
356
  if (mod && e.key === 'Enter') {
251
357
  e.preventDefault()
252
358
  const sendBtn = document.querySelector('[data-action="send-reply"]') as HTMLButtonElement | null
253
359
  if (sendBtn && !sendBtn.disabled) sendBtn.click()
254
360
  }
361
+ // Cmd/Ctrl+Shift+N -> toggle internal
255
362
  if (mod && e.shiftKey && e.key.toLowerCase() === 'n') {
256
363
  e.preventDefault()
257
364
  setIsInternal((prev) => !prev)
258
365
  }
366
+ // Escape -> close dropdown / split modal
259
367
  if (e.key === 'Escape') {
260
368
  setShowMenu(false)
261
369
  setSplitModal(null)
@@ -265,12 +373,12 @@ export const TicketDetailClient: React.FC = () => {
265
373
  return () => document.removeEventListener('keydown', handler)
266
374
  }, [])
267
375
 
268
- // ---- HANDLERS ----
376
+ // ─── HANDLERS ───
269
377
  const handleSend = async () => {
270
- if (!replyBody.trim() || !ticketId) return
378
+ if ((!replyBody.trim() && !replyHtml.trim()) || !ticketId) return
271
379
  setSending(true)
272
380
  try {
273
- // Upload pending files first
381
+ // #5 — Upload pending files first
274
382
  const uploadedLinks: string[] = []
275
383
  for (const file of pendingFiles) {
276
384
  const formData = new FormData()
@@ -284,19 +392,21 @@ export const TicketDetailClient: React.FC = () => {
284
392
  }
285
393
  } catch { /* silent */ }
286
394
  }
287
- const finalBody = uploadedLinks.length > 0 ? `${replyBody.trim()}\n\n${uploadedLinks.join('\n')}` : replyBody.trim()
395
+ const finalBody = uploadedLinks.length > 0 ? `${replyBody.trim()}\n\n${uploadedLinks.join('\n')}` : (replyBody.trim() || '[Contenu enrichi]')
396
+ const finalHtml = uploadedLinks.length > 0 ? `${replyHtml || replyBody.trim()}<br/><br/>${uploadedLinks.map((l) => l.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>')).join('<br/>')}` : (replyHtml || undefined)
288
397
 
289
398
  const res = await fetch('/api/ticket-messages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
290
- body: JSON.stringify({ ticket: Number(ticketId), body: finalBody, authorType: 'admin', isInternal, skipNotification: isInternal || !notifyClient }) })
291
- if (res.ok) { setReplyBody(''); setIsInternal(false); setPendingFiles([]); fetchAll() }
292
- } catch (err) { console.warn('[support] send reply error:', err) } finally { setSending(false) }
399
+ body: JSON.stringify({ ticket: Number(ticketId), body: finalBody, ...(finalHtml ? { bodyHtml: finalHtml } : {}), authorType: 'admin', isInternal, skipNotification: isInternal || !notifyClient }) })
400
+ if (res.ok) { setReplyBody(''); setReplyHtml(''); setIsInternal(false); setPendingFiles([]); editorRef.current?.clear(); fetchAll() }
401
+ } catch {} finally { setSending(false) }
293
402
  }
294
403
 
295
404
  const handleStatusChange = async (v: string) => {
296
405
  if (!ticketId) return; setStatusUpdating(true)
297
- try { await fetch(`/api/tickets/${ticketId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ status: v }) }); fetchAll() } catch (err) { console.warn('[support] status change error:', err) } finally { setStatusUpdating(false) }
406
+ try { await fetch(`/api/tickets/${ticketId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ status: v }) }); fetchAll() } catch {} finally { setStatusUpdating(false) }
298
407
  }
299
408
 
409
+ // #1 — Inline field patch
300
410
  const handleFieldPatch = async (field: string, value: string) => {
301
411
  if (!ticketId) return
302
412
  try {
@@ -305,6 +415,7 @@ export const TicketDetailClient: React.FC = () => {
305
415
  } catch { /* silent */ }
306
416
  }
307
417
 
418
+ // #2 — Delete with undo toast
308
419
  const handleDeleteMessage = (msgId: string | number) => {
309
420
  if (undoToast) clearTimeout(undoToast.timer)
310
421
  const timer = setTimeout(() => {
@@ -321,6 +432,7 @@ export const TicketDetailClient: React.FC = () => {
321
432
  }
322
433
  }
323
434
 
435
+ // #2 — Split ticket with modal
324
436
  const handleSplitConfirm = async () => {
325
437
  if (!splitModal || !splitSubject.trim()) return
326
438
  try {
@@ -337,21 +449,21 @@ export const TicketDetailClient: React.FC = () => {
337
449
  try {
338
450
  const r = await fetch('/api/support/ai', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
339
451
  body: JSON.stringify({ action: 'suggest_reply', messages: messages.slice(-10).map((m) => ({ authorType: m.authorType, body: m.body })), clientName: `${client?.firstName || ''} ${client?.lastName || ''}`.trim(), clientCompany: client?.company }) })
340
- if (r.ok) { const d = await r.json(); if (d.reply) { setReplyBody(d.reply) } }
341
- } catch (err) { console.warn('[support] AI suggest error:', err) } finally { setAiReplying(false) }
452
+ if (r.ok) { const d = await r.json(); if (d.reply) { setReplyBody(d.reply); setReplyHtml(d.reply.replace(/\n/g, '<br/>')); editorRef.current?.setContent(d.reply.replace(/\n/g, '<br/>')) } }
453
+ } catch {} finally { setAiReplying(false) }
342
454
  }
343
455
 
344
- const handleAiRewrite = async () => {
456
+ const handleAiRewrite = async (style: string = 'auto') => {
345
457
  if (!replyBody.trim()) return; setAiRewriting(true)
346
458
  try {
347
- const r = await fetch('/api/support/ai', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ action: 'rewrite', text: replyBody }) })
348
- if (r.ok) { const d = await r.json(); if (d.rewritten) { setReplyBody(d.rewritten) } }
349
- } catch (err) { console.warn('[support] AI rewrite error:', err) } finally { setAiRewriting(false) }
459
+ const r = await fetch('/api/support/ai', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ action: 'rewrite', text: replyBody, style }) })
460
+ if (r.ok) { const d = await r.json(); if (d.rewritten) { setReplyBody(d.rewritten); setReplyHtml(d.rewritten.replace(/\n/g, '<br/>')); editorRef.current?.setContent(d.rewritten.replace(/\n/g, '<br/>')) } }
461
+ } catch {} finally { setAiRewriting(false) }
350
462
  }
351
463
 
352
464
  const handleTimerSave = async () => {
353
465
  if (!ticketId || timerSeconds < 60) return
354
- try { await fetch('/api/time-entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ ticket: Number(ticketId), duration: Math.round(timerSeconds / 60), date: new Date().toISOString(), description: 'Timer' }) }); setTimerSeconds(0); setTimerRunning(false); fetchAll() } catch (err) { console.warn('[support] timer save error:', err) }
466
+ try { await fetch('/api/time-entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ ticket: Number(ticketId), duration: Math.round(timerSeconds / 60), date: new Date().toISOString(), description: 'Timer' }) }); setTimerSeconds(0); setTimerRunning(false); fetchAll() } catch {}
355
467
  }
356
468
 
357
469
  const handleApplyMacro = async (macroId: number) => {
@@ -363,9 +475,10 @@ export const TicketDetailClient: React.FC = () => {
363
475
  body: JSON.stringify({ macroId, ticketId: Number(ticketId) }),
364
476
  })
365
477
  if (r.ok) { fetchAll() }
366
- } catch (err) { console.warn('[support] apply macro error:', err) } finally { setApplyingMacro(false) }
478
+ } catch { /* silent */ } finally { setApplyingMacro(false) }
367
479
  }
368
480
 
481
+ // #5 — File handling
369
482
  const handleFileSelect = (files: FileList | null) => {
370
483
  if (!files) return
371
484
  setPendingFiles((prev) => [...prev, ...Array.from(files)])
@@ -377,6 +490,7 @@ export const TicketDetailClient: React.FC = () => {
377
490
  handleFileSelect(e.dataTransfer.files)
378
491
  }
379
492
 
493
+ // #6 — Tags
380
494
  const handleRemoveTag = async (tag: string) => {
381
495
  const newTags = tags.filter((t) => t !== tag)
382
496
  setTags(newTags)
@@ -398,75 +512,42 @@ export const TicketDetailClient: React.FC = () => {
398
512
  }
399
513
  }
400
514
 
401
- // ---- RENDER ----
515
+ // ─── RENDER ───
402
516
  if (!ticketId) return <div style={{ padding: 60, textAlign: 'center', color: '#94a3b8', fontSize: 14 }}>{t('detail.selectTicket')}</div>
403
517
  if (loading) return <div style={{ padding: 60, textAlign: 'center', color: '#94a3b8', fontSize: 14 }}>{t('common.loading')}</div>
404
518
  if (!ticket) return <div style={{ padding: 60, textAlign: 'center', color: '#94a3b8', fontSize: 14 }}>{t('detail.notFound')}</div>
405
519
 
406
- const st = STATUS[(ticket.status as string) || 'open'] || STATUS.open
520
+ const st = STATUS_STYLE[(ticket.status as string) || 'open'] || STATUS_STYLE.open
407
521
  const totalMin = timeEntries.reduce((a, e) => a + (e.duration || 0), 0)
408
522
  const initials = client ? `${(client.firstName?.[0] || '').toUpperCase()}${(client.lastName?.[0] || '').toUpperCase()}` : '?'
409
523
 
410
- const S: Record<string, React.CSSProperties> = {
411
- page: { padding: '16px 20px', maxWidth: 1200, margin: '0 auto' },
412
- topBar: { display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12, padding: '8px 0', borderBottom: '1px solid var(--theme-elevation-200)' },
413
- backLink: { fontSize: 18, textDecoration: 'none', color: 'var(--theme-elevation-500)', padding: '4px 8px' },
414
- ticketNumber: { fontWeight: 700, fontSize: 14, color: 'var(--theme-elevation-400)' },
415
- ticketSubject: { fontWeight: 600, fontSize: 15, color: 'var(--theme-text)', flex: 1 },
416
- statusChip: { padding: '4px 10px', borderRadius: 6, border: 'none', fontWeight: 600, fontSize: 12, cursor: 'pointer' },
417
- layout: { display: 'grid', gridTemplateColumns: '1fr 280px', gap: 20 },
418
- thread: { display: 'flex', flexDirection: 'column' as const, gap: 12, maxHeight: 'calc(100vh - 400px)', overflowY: 'auto' as const, padding: '8px 0' },
419
- message: { display: 'flex', gap: 10, padding: '12px 14px', borderRadius: 10, border: '1px solid var(--theme-elevation-150)', position: 'relative' as const },
420
- avatar: { width: 32, height: 32, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 11, fontWeight: 700, flexShrink: 0 },
421
- messageBody: { fontSize: 14, lineHeight: 1.6, color: 'var(--theme-text)', whiteSpace: 'pre-wrap' as const },
422
- composer: { marginTop: 12, border: '1px solid var(--theme-elevation-200)', borderRadius: 10, padding: 12 },
423
- composerInternal: { borderColor: '#fbbf24', background: '#fefce8' },
424
- textarea: { width: '100%', minHeight: 100, padding: 10, border: 'none', outline: 'none', resize: 'vertical' as const, fontSize: 14, fontFamily: 'inherit', background: 'transparent', color: 'var(--theme-text)' },
425
- composerFooter: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 8 },
426
- sendBtn: { padding: '8px 16px', borderRadius: 6, border: 'none', background: '#2563eb', color: '#fff', fontWeight: 600, fontSize: 13, cursor: 'pointer' },
427
- sidebar: { display: 'flex', flexDirection: 'column' as const, gap: 16 },
428
- sideSection: { padding: '12px 14px', borderRadius: 10, border: '1px solid var(--theme-elevation-150)', fontSize: 13 },
429
- sideSectionTitle: { fontSize: 12, fontWeight: 700, textTransform: 'uppercase' as const, color: 'var(--theme-elevation-500)', marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' },
430
- sideField: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 0' },
431
- sideLabel: { fontSize: 12, color: 'var(--theme-elevation-500)' },
432
- sideSelect: { padding: '2px 6px', borderRadius: 4, border: '1px solid var(--theme-elevation-200)', fontSize: 12, background: 'var(--theme-elevation-0)', color: 'var(--theme-text)' },
433
- sideValue: { fontSize: 12, fontWeight: 500, color: 'var(--theme-text)' },
434
- badge: { padding: '2px 6px', borderRadius: 4, fontSize: 10, fontWeight: 600 },
435
- tagChip: { display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px', borderRadius: 4, background: 'var(--theme-elevation-100)', fontSize: 11, fontWeight: 500 },
436
- tagRemove: { background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: 'var(--theme-elevation-400)', padding: 0 },
437
- tagInput: { padding: '2px 6px', borderRadius: 4, border: '1px solid var(--theme-elevation-200)', fontSize: 11, width: 80 },
438
- tagAddBtn: { padding: '2px 8px', borderRadius: 4, border: '1px dashed var(--theme-elevation-300)', background: 'none', fontSize: 11, cursor: 'pointer', color: 'var(--theme-elevation-400)' },
439
- toolbarBtn: { padding: '4px 10px', borderRadius: 4, border: '1px solid var(--theme-elevation-200)', background: 'var(--theme-elevation-0)', fontSize: 11, fontWeight: 700, cursor: 'pointer', color: 'var(--theme-text)' },
440
- dateSeparator: { textAlign: 'center' as const, padding: '8px 0', fontSize: 11, color: 'var(--theme-elevation-400)' },
441
- }
442
-
443
524
  return (
444
- <div style={S.page}>
525
+ <div className={s.page}>
445
526
  {/* TOP BAR */}
446
- <div style={S.topBar}>
447
- <Link href="/admin/support/inbox" style={S.backLink} aria-label="Retour">&larr;</Link>
448
- <div style={{ display: 'flex', alignItems: 'center', gap: 8, flex: 1 }}>
449
- <span style={S.ticketNumber}>{ticket.ticketNumber as string}</span>
450
- <span style={S.ticketSubject}>{ticket.subject as string}</span>
527
+ <div className={s.topBar}>
528
+ <Link href="/admin/support/inbox" className={s.backLink} aria-label="Retour à la boîte de réception">&larr;</Link>
529
+ <div className={s.ticketMeta}>
530
+ <span className={s.ticketNumber}>{ticket.ticketNumber as string}</span>
531
+ <span className={s.ticketSubject}>{ticket.subject as string}</span>
451
532
  </div>
452
- <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
453
- <select style={{ ...S.statusChip, background: st.bg, color: st.color }} value={(ticket.status as string) || 'open'} onChange={(e) => handleStatusChange(e.target.value)} disabled={statusUpdating}>
533
+ <div className={s.topBarRight}>
534
+ <select className={s.statusChip} style={{ background: st.bg, color: st.color }} value={(ticket.status as string) || 'open'} onChange={(e) => handleStatusChange(e.target.value)} disabled={statusUpdating} aria-label={t('ticket.status.label')}>
454
535
  <option value="open">{t('detail.statusOpen')}</option>
455
536
  <option value="waiting_client">{t('detail.statusWaiting')}</option>
456
537
  <option value="resolved">{t('detail.statusResolved')}</option>
457
538
  </select>
458
539
  {sentiment && features.ai && (
459
- <span style={{ ...S.badge, background: `${sentiment.color}12`, color: sentiment.color }}>
540
+ <span className={s.sentimentBadge} style={{ background: `${sentiment.color}12`, color: sentiment.color }}>
460
541
  {sentiment.emoji} {sentiment.label}
461
542
  </span>
462
543
  )}
463
- <div ref={dropdownRef} style={{ position: 'relative' }}>
464
- <button onClick={() => setShowMenu(!showMenu)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 18, color: 'var(--theme-elevation-500)' }}>&middot;&middot;&middot;</button>
544
+ <div className={s.dropdown} ref={dropdownRef}>
545
+ <button className={s.moreBtn} onClick={() => setShowMenu(!showMenu)} aria-label="Plus d&apos;options">&middot;&middot;&middot;</button>
465
546
  {showMenu && (
466
- <div style={{ position: 'absolute', right: 0, top: '100%', background: 'var(--theme-elevation-0)', border: '1px solid var(--theme-elevation-200)', borderRadius: 8, boxShadow: '0 4px 12px rgba(0,0,0,0.1)', zIndex: 50, minWidth: 160 }}>
467
- <button style={{ display: 'block', width: '100%', padding: '8px 14px', border: 'none', background: 'none', cursor: 'pointer', textAlign: 'left', fontSize: 13, color: 'var(--theme-text)' }} onClick={() => { navigator.clipboard.writeText(window.location.href); setShowMenu(false) }}>{t('detail.copyLink')}</button>
468
- <Link href={`/admin/collections/tickets/${ticketId}`} style={{ display: 'block', padding: '8px 14px', fontSize: 13, color: 'var(--theme-text)', textDecoration: 'none' }} onClick={() => setShowMenu(false)}>{t('detail.payloadView')}</Link>
469
- <a href={`/support/tickets/${ticketId}`} target="_blank" rel="noopener noreferrer" style={{ display: 'block', padding: '8px 14px', fontSize: 13, color: 'var(--theme-text)', textDecoration: 'none' }} onClick={() => setShowMenu(false)}>{t('detail.clientView')}</a>
547
+ <div className={s.dropdownMenu}>
548
+ <button className={s.dropdownItem} onClick={() => { navigator.clipboard.writeText(window.location.href); setShowMenu(false) }}>{t('detail.copyLink')}</button>
549
+ <Link href={`/admin/collections/tickets/${ticketId}`} className={s.dropdownItem} onClick={() => setShowMenu(false)}>{t('detail.payloadView')}</Link>
550
+ <a href={`/support/tickets/${ticketId}`} target="_blank" rel="noopener noreferrer" className={s.dropdownItem} onClick={() => setShowMenu(false)}>{t('detail.clientView')}</a>
470
551
  </div>
471
552
  )}
472
553
  </div>
@@ -475,58 +556,88 @@ export const TicketDetailClient: React.FC = () => {
475
556
 
476
557
  {/* PRESENCE BANNER */}
477
558
  {otherViewers.length > 0 && (
478
- <div style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 14px', marginBottom: 12, borderRadius: 8, background: '#fef3c7', border: '1px solid #fde68a', fontSize: 13, fontWeight: 500, color: '#92400e' }}>
479
- {otherViewers.map((v) => v.name).join(', ')} {otherViewers.length === 1 ? 'est aussi en train de voir ce ticket' : 'sont aussi en train de voir ce ticket'}
559
+ <div style={{
560
+ display: 'flex', alignItems: 'center', gap: 8,
561
+ padding: '8px 14px', marginBottom: 12, borderRadius: 8,
562
+ background: '#fef3c7', border: '1px solid #fde68a',
563
+ fontSize: 13, fontWeight: 500, color: '#92400e',
564
+ }}>
565
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" style={{ flexShrink: 0 }}><path d="M9 1L3 9h4l-1 6 6-8H8l1-6z" fill="#92400e"/></svg>
566
+ {otherViewers.length === 1 ? t('detail.viewingAlso', { names: otherViewers.map((v) => v.name).join(', ') }) : t('detail.viewingAlsoPlural', { names: otherViewers.map((v) => v.name).join(', ') })}
480
567
  </div>
481
568
  )}
482
569
 
483
570
  {/* LAYOUT */}
484
- <div style={S.layout}>
571
+ <div className={s.layout}>
485
572
  {/* LEFT: Conversation */}
486
- <div>
487
- <div style={S.thread}>
573
+ <div className={s.conversationCol}>
574
+ <div className={s.thread}>
488
575
  {messages.map((msg, idx) => {
489
576
  const prev = idx > 0 ? messages[idx - 1] : null
490
577
  const showDate = msg.createdAt && (!prev?.createdAt || new Date(msg.createdAt).toDateString() !== new Date(prev.createdAt).toDateString())
491
578
  const isAdmin = msg.authorType === 'admin'
579
+ // #2 — Hide message visually if pending delete
492
580
  const isPendingDelete = undoToast?.msgId === msg.id
493
581
 
494
582
  return (
495
583
  <React.Fragment key={msg.id}>
496
- {showDate && <div style={S.dateSeparator}><span>{dateLabel(msg.createdAt)}</span></div>}
497
- <div style={{ ...S.message, ...(msg.isInternal ? { borderColor: '#fbbf24', background: '#fefce8' } : {}), ...(isPendingDelete ? { opacity: 0.3, pointerEvents: 'none' as const } : {}) }}>
498
- <div style={{ ...S.avatar, backgroundColor: isAdmin ? '#2563eb' : msg.authorType === 'email' ? '#ea580c' : '#7c3aed' }}>
584
+ {showDate && <div className={s.dateSeparator}><span className={s.dateSeparatorText}>{dateLabel(msg.createdAt)}</span></div>}
585
+ <div className={`${s.message} ${msg.isInternal ? s.messageInternal : ''}`} style={isPendingDelete ? { opacity: 0.3, pointerEvents: 'none' } : undefined}>
586
+ {/* #10 Client avatar purple */}
587
+ <div className={s.avatar} style={{ backgroundColor: isAdmin ? '#2563eb' : msg.authorType === 'email' ? '#ea580c' : '#7c3aed' }}>
499
588
  {isAdmin ? 'CW' : initials}
500
589
  </div>
501
- <div style={{ flex: 1, minWidth: 0 }}>
502
- <div style={{ display: 'flex', gap: 8, alignItems: 'center', marginBottom: 4 }}>
503
- <span style={{ fontWeight: 600, fontSize: 13 }}>{isAdmin ? 'Support' : msg.authorType === 'email' ? 'Email' : client?.firstName || 'Client'}</span>
504
- <span style={{ fontSize: 11, color: 'var(--theme-elevation-400)' }}>{timeAgo(msg.createdAt)}</span>
505
- {msg.isInternal && <span style={{ ...S.badge, background: '#fef3c7', color: '#92400e' }}>Interne</span>}
506
- {msg.isSolution && <span style={{ ...S.badge, background: '#dcfce7', color: '#166534' }}>Solution</span>}
590
+ <div className={s.messageContent}>
591
+ <div className={s.messageHeader}>
592
+ <span className={s.messageAuthor}>{isAdmin ? 'Support' : msg.authorType === 'email' ? 'Email' : client?.firstName || 'Client'}</span>
593
+ <span className={s.messageTime}>{timeAgo(msg.createdAt)}</span>
594
+ {msg.isInternal && <span className={s.badge} style={{ background: '#fef3c7', color: '#92400e' }}>Interne</span>}
595
+ {msg.isSolution && <span className={s.badge} style={{ background: '#dcfce7', color: '#166534' }}>Solution</span>}
596
+ <span className={s.messageMeta}>
597
+ {isAdmin && !msg.isInternal && (() => {
598
+ const ext = msg as unknown as { emailOpenedAt?: string; emailSentAt?: string }
599
+ if (ext.emailOpenedAt) {
600
+ const d = new Date(ext.emailOpenedAt).toLocaleString('fr-FR', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
601
+ return <span style={{ color: '#16a34a', cursor: 'help' }} title={`Ouvert le ${d}`}>✓✓ Lu {d}</span>
602
+ }
603
+ if (ext.emailSentAt) {
604
+ const d = new Date(ext.emailSentAt).toLocaleString('fr-FR', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
605
+ return <span style={{ color: '#2563eb', cursor: 'help' }} title={`Envoyé le ${d}`}>✓ Envoyé {d}</span>
606
+ }
607
+ return <span style={{ color: '#94a3b8' }}>✓</span>
608
+ })()}
609
+ </span>
507
610
  </div>
508
- {msg.bodyHtml ? (
509
- <div style={S.messageBody} dangerouslySetInnerHTML={{ __html: msg.bodyHtml }} />
611
+ {(msg as unknown as { deletedAt?: string }).deletedAt ? (
612
+ <div className={s.messageBody} style={{ color: '#94a3b8', fontStyle: 'italic' }}>{t('detail.messageDeleted')}</div>
613
+ ) : msg.bodyHtml && hasCodeBlocks(msg.bodyHtml.replace(/<[^>]+>/g, '')) ? (
614
+ <CodeBlockRendererHtml html={msg.bodyHtml} />
615
+ ) : msg.bodyHtml ? (
616
+ <div className={`${s.messageBody} ${s.rteDisplay}`} dangerouslySetInnerHTML={{ __html: msg.bodyHtml }} />
617
+ ) : hasCodeBlocks(msg.body) ? (
618
+ <MessageWithCodeBlocks text={msg.body} style={{ fontSize: '13px', lineHeight: 1.5 }} />
510
619
  ) : (
511
- <div style={S.messageBody}>{msg.body}</div>
620
+ <div className={s.messageBody}>{msg.body}</div>
512
621
  )}
513
622
  {Array.isArray(msg.attachments) && msg.attachments.length > 0 && (
514
- <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 8 }}>
623
+ <div className={s.attachments}>
515
624
  {msg.attachments.map((att, i) => {
516
625
  const file = typeof att.file === 'object' ? att.file : null
517
626
  if (!file) return null
518
627
  return (file.mimeType || '').startsWith('image/')
519
- ? <a key={i} href={file.url || '#'} target="_blank" rel="noopener noreferrer"><img src={file.url || ''} alt="" style={{ maxWidth: 200, maxHeight: 120, borderRadius: 6 }} /></a>
520
- : <a key={i} href={file.url || '#'} target="_blank" rel="noopener noreferrer" style={{ fontSize: 12, color: '#2563eb' }}>PJ {file.filename || 'Fichier'}</a>
628
+ ? <a key={i} href={file.url || '#'} target="_blank" rel="noopener noreferrer"><img src={file.url || ''} alt="" className={s.attachmentImg} /></a>
629
+ : <a key={i} href={file.url || '#'} target="_blank" rel="noopener noreferrer" className={s.attachmentFile}>PJ {file.filename || 'Fichier'}</a>
521
630
  })}
522
631
  </div>
523
632
  )}
524
633
  </div>
525
- {/* Hover actions */}
526
- <div style={{ position: 'absolute', top: 8, right: 8, display: 'flex', gap: 4, opacity: 0.6 }}>
527
- <button style={{ ...S.toolbarBtn, color: '#dc2626', fontSize: 11 }} onClick={() => handleDeleteMessage(msg.id)}>{t('common.delete')}</button>
528
- {features.splitTicket && !msg.isInternal && <button style={{ ...S.toolbarBtn, fontSize: 11 }} onClick={() => { setSplitModal({ messageId: msg.id, preview: msg.body.slice(0, 200) }); setSplitSubject(`Split: ${ticket.subject}`) }}>{t('actions.extractMessage')}</button>}
529
- {isAdmin && !msg.isInternal && <button style={{ ...S.toolbarBtn, fontSize: 11 }} onClick={() => fetch('/api/support/resend-notification', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ messageId: msg.id }) })}>{t('actions.resendEmail')}</button>}
634
+ {/* Hover actions — icon buttons with aria-labels (#7) */}
635
+ <div className={s.messageActions}>
636
+ {/* #2 Undo toast instead of confirm() */}
637
+ <button className={`${s.actionIcon} ${s.danger}`} title={t('actions.deleteMessage')} aria-label={t('actions.deleteMessage')} onClick={() => handleDeleteMessage(msg.id)} style={{ fontSize: 11, width: 'auto', padding: '4px 8px' }}>{t('actions.deleteMessage')}</button>
638
+ {/* #2 Split modal instead of prompt() */}
639
+ {features.splitTicket && !msg.isInternal && <button className={s.actionIcon} title={t('actions.extractMessage')} aria-label={t('actions.extractToNewTicket')} onClick={() => { setSplitModal({ messageId: msg.id, preview: msg.body.slice(0, 200) }); setSplitSubject(`Split: ${ticket.subject}`) }} style={{ fontSize: 11, width: 'auto', padding: '4px 8px' }}>{t('actions.extractMessage')}</button>}
640
+ {isAdmin && !msg.isInternal && <button className={s.actionIcon} title={t('actions.resendEmail')} aria-label={t('actions.resendEmail')} onClick={() => fetch('/api/support/resend-notification', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ messageId: msg.id }) })} style={{ fontSize: 11, width: 'auto', padding: '4px 8px' }}>{t('actions.resendEmail')}</button>}
530
641
  </div>
531
642
  </div>
532
643
  </React.Fragment>
@@ -536,41 +647,56 @@ export const TicketDetailClient: React.FC = () => {
536
647
  </div>
537
648
 
538
649
  {clientTyping && (
539
- <div style={{ padding: '8px 14px', fontSize: 13, color: 'var(--theme-elevation-400)' }}>
540
- {client?.firstName || 'Client'} est en train d&apos;ecrire...
650
+ <div className={s.typing}>
651
+ <span className={s.typingDots}><span /><span /><span /></span>
652
+ {t('detail.typing', { name: client?.firstName || 'Client' })}
541
653
  </div>
542
654
  )}
543
655
 
544
656
  {/* COMPOSER */}
545
657
  <div
546
- style={{ ...S.composer, ...(isInternal ? S.composerInternal : {}), ...(composerDragOver ? { borderColor: '#2563eb', background: '#eff6ff' } : {}) }}
658
+ className={`${s.composer} ${isInternal ? s.composerInternal : ''} ${composerDragOver ? s.composerDragOver : ''}`}
547
659
  onDragOver={(e) => { e.preventDefault(); setComposerDragOver(true) }}
548
660
  onDragLeave={() => setComposerDragOver(false)}
549
661
  onDrop={handleFileDrop}
550
662
  >
551
- <div style={{ display: 'flex', gap: 6, marginBottom: 8, flexWrap: 'wrap' }}>
663
+ <div className={s.composerToolbar}>
552
664
  {features.ai && (
553
665
  <>
554
- <button style={S.toolbarBtn} onClick={handleAiSuggest} disabled={aiReplying || messages.length === 0}>{aiReplying ? '...' : t('detail.iaSuggestion')}</button>
555
- <button style={S.toolbarBtn} onClick={handleAiRewrite} disabled={aiRewriting || !replyBody.trim()}>{aiRewriting ? '...' : t('detail.rewrite')}</button>
666
+ <button className={s.toolbarBtn} data-tooltip={t('detail.iaSuggestion')} aria-label={t('detail.iaSuggestion')} onClick={handleAiSuggest} disabled={aiReplying || messages.length === 0} style={{ fontSize: 11, fontWeight: 700, padding: '4px 10px', width: 'auto' }}>{aiReplying ? '...' : `✨ ${t('detail.iaSuggestion')}`}</button>
667
+ <RewriteDropdown disabled={aiRewriting || !replyBody.trim()} loading={aiRewriting} onSelect={(style) => handleAiRewrite(style)} toolbarBtnClass={s.toolbarBtn} />
556
668
  </>
557
669
  )}
558
- <button style={S.toolbarBtn} onClick={() => fileInputRef.current?.click()}>{t('detail.file')}</button>
559
- <input ref={fileInputRef} type="file" multiple style={{ display: 'none' }} onChange={(e) => handleFileSelect(e.target.files)} />
670
+ <CodeBlockInserter
671
+ className={s.toolbarBtn}
672
+ onInsert={(block) => {
673
+ const nb = replyBody ? replyBody + block : block
674
+ setReplyBody(nb)
675
+ setReplyHtml(nb.replace(/\n/g, '<br/>'))
676
+ editorRef.current?.setContent(nb.replace(/\n/g, '<br/>'))
677
+ }}
678
+ />
679
+ {/* #5 — File upload button */}
680
+ <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>
681
+ <input ref={fileInputRef} type="file" multiple className={s.hiddenFileInput} onChange={(e) => handleFileSelect(e.target.files)} />
560
682
  {macros.length > 0 && (
561
683
  <select
562
- style={{ ...S.sideSelect, marginLeft: 0 }}
684
+ className={s.cannedSelect}
563
685
  onChange={(e) => { const id = Number(e.target.value); if (id) handleApplyMacro(id); e.target.value = '' }}
564
686
  disabled={applyingMacro}
687
+ style={{ marginLeft: 0 }}
688
+ aria-label="Appliquer une macro"
565
689
  >
566
690
  <option value="">{applyingMacro ? t('detail.applyingMacro') : t('detail.macros')}</option>
567
691
  {macros.map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
568
692
  </select>
569
693
  )}
694
+ <span className={s.toolbarDivider} />
695
+ {/* #9 — "Canned" -> "Réponses types" */}
570
696
  {features.canned && cannedResponses.length > 0 && (
571
- <select style={S.sideSelect} onChange={(e) => {
697
+ <select className={s.cannedSelect} aria-label="Réponses types" onChange={(e) => {
572
698
  const cr = cannedResponses.find((c) => String(c.id) === e.target.value)
573
- if (cr) { let b = cr.body; if (client) { b = b.replace(/\{\{client\.firstName\}\}/g, client.firstName).replace(/\{\{client\.company\}\}/g, client.company) }; setReplyBody(b) }
699
+ if (cr) { let b = cr.body; if (client) { b = b.replace(/\{\{client\.firstName\}\}/g, client.firstName).replace(/\{\{client\.company\}\}/g, client.company) }; setReplyBody(b); setReplyHtml(b.replace(/\n/g, '<br/>')); editorRef.current?.setContent(b.replace(/\n/g, '<br/>')) }
574
700
  e.target.value = ''
575
701
  }}>
576
702
  <option value="">{t('detail.cannedResponses')}</option>
@@ -578,28 +704,26 @@ export const TicketDetailClient: React.FC = () => {
578
704
  </select>
579
705
  )}
580
706
  </div>
581
- <textarea
582
- style={S.textarea}
583
- value={replyBody}
584
- onChange={(e) => setReplyBody(e.target.value)}
585
- placeholder={isInternal ? t('composer.placeholderInternal') : t('composer.placeholderReplyTo', { name: client?.firstName || 'client' })}
586
- />
707
+ <RichTextEditor ref={editorRef} onChange={(html, text) => { setReplyHtml(html); setReplyBody(text) }} placeholder={isInternal ? t('composer.placeholderInternal') : t('composer.placeholderReplyTo', { name: client?.firstName || 'client' })} minHeight={100} borderColor="transparent" />
708
+ {/* #5 — File upload preview */}
587
709
  {pendingFiles.length > 0 && (
588
- <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 6 }}>
710
+ <div className={s.uploadPreview}>
589
711
  {pendingFiles.map((f, i) => (
590
- <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 4, padding: '2px 8px', borderRadius: 4, background: 'var(--theme-elevation-100)', fontSize: 11 }}>
712
+ <div key={i} className={s.uploadPreviewItem}>
591
713
  <span>PJ {f.name}</span>
592
- <button style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: 'var(--theme-elevation-400)' }} onClick={() => setPendingFiles((prev) => prev.filter((_, j) => j !== i))}>&times;</button>
714
+ <button className={s.uploadRemoveBtn} aria-label={`Retirer ${f.name}`} onClick={() => setPendingFiles((prev) => prev.filter((_, j) => j !== i))}>&times;</button>
593
715
  </div>
594
716
  ))}
595
717
  </div>
596
718
  )}
597
- <div style={S.composerFooter}>
598
- <div style={{ display: 'flex', gap: 12, alignItems: 'center', fontSize: 12 }}>
719
+ <div className={s.composerFooter}>
720
+ <div className={s.composerOptions}>
721
+ {/* #9 — "Internal" -> "Note interne", "Notify" -> "Notifier" */}
599
722
  <label><input type="checkbox" checked={isInternal} onChange={(e) => setIsInternal(e.target.checked)} /> {t('detail.internalNote')}</label>
600
723
  <label><input type="checkbox" checked={notifyClient} onChange={(e) => setNotifyClient(e.target.checked)} disabled={isInternal} /> {t('detail.notify')}</label>
601
724
  </div>
602
- <button style={{ ...S.sendBtn, ...(isInternal ? { background: '#d97706' } : {}) }} onClick={handleSend} disabled={sending || !replyBody.trim()} data-action="send-reply">
725
+ {/* #9 "Send ->" -> "Envoyer ->" */}
726
+ <button className={`${s.sendBtn} ${isInternal ? s.sendBtnInternal : ''}`} onClick={handleSend} disabled={sending || !replyBody.trim()} data-action="send-reply">
603
727
  {sending ? t('detail.sending') : isInternal ? t('detail.sendNote') : t('detail.sendReply')}
604
728
  </button>
605
729
  </div>
@@ -607,40 +731,40 @@ export const TicketDetailClient: React.FC = () => {
607
731
  </div>
608
732
 
609
733
  {/* RIGHT: Sidebar */}
610
- <div style={S.sidebar}>
734
+ <div className={s.sidebar}>
611
735
  {client && (
612
- <div style={S.sideSection}>
613
- <div style={{ display: 'flex', gap: 10, alignItems: 'center', marginBottom: 8 }}>
614
- <div style={{ ...S.avatar, backgroundColor: '#7c3aed', width: 36, height: 36 }}>{initials}</div>
615
- <div>
616
- <div style={{ fontWeight: 600, fontSize: 13 }}>{client.firstName} {client.lastName}</div>
617
- <div style={{ fontSize: 12, color: 'var(--theme-elevation-500)' }}>{client.company}</div>
618
- <a href={`mailto:${client.email}`} style={{ fontSize: 11, color: '#2563eb' }}>{client.email}</a>
736
+ <div className={s.sideSection}>
737
+ <div className={s.clientCard}>
738
+ <div className={s.clientAvatar}>{initials}</div>
739
+ <div className={s.clientInfo}>
740
+ <div className={s.clientName}>{client.firstName} {client.lastName}</div>
741
+ <div className={s.clientCompany}>{client.company}</div>
742
+ <a href={`mailto:${client.email}`} className={s.clientEmail}>{client.email}</a>
619
743
  </div>
620
744
  </div>
621
- <div style={{ display: 'flex', gap: 6 }}>
622
- <Link href={`/admin/collections/support-clients/${client.id}`} style={{ padding: '4px 10px', borderRadius: 4, border: '1px solid var(--theme-elevation-200)', fontSize: 11, textDecoration: 'none', color: 'var(--theme-text)' }}>{t('client.clientSheet')}</Link>
623
- <button style={{ padding: '4px 10px', borderRadius: 4, border: '1px solid var(--theme-elevation-200)', fontSize: 11, background: 'none', cursor: 'pointer', color: 'var(--theme-text)' }} onClick={() => window.open(`/api/admin/impersonate?clientId=${client.id}`, '_blank')}>{t('client.clientPortal')}</button>
745
+ <div className={s.clientActions}>
746
+ <Link href={`/admin/collections/support-clients/${client.id}`} className={s.smallBtn}>{t('client.clientSheet')}</Link>
747
+ <button className={s.smallBtn} onClick={() => window.open(`/api/admin/impersonate?clientId=${client.id}`, '_blank')}>{t('client.clientPortal')}</button>
624
748
  </div>
625
749
  </div>
626
750
  )}
627
751
 
628
- {/* Editable sidebar fields */}
629
- <div style={S.sideSection}>
630
- <div style={S.sideSectionTitle}>{t('detail.details')}</div>
631
- <div style={S.sideField}>
632
- <span style={S.sideLabel}>{t('detail.priority')}</span>
633
- <select style={S.sideSelect} value={(ticket.priority as string) || 'normal'} onChange={(e) => handleFieldPatch('priority', e.target.value)}>
752
+ {/* #1 — Editable sidebar fields */}
753
+ <div className={s.sideSection}>
754
+ <div className={s.sideSectionTitle}>{t('detail.details')}</div>
755
+ <div className={s.sideField}>
756
+ <span className={s.sideLabel}>{t('detail.priority')}</span>
757
+ <select className={s.sideSelect} value={(ticket.priority as string) || 'normal'} onChange={(e) => handleFieldPatch('priority', e.target.value)} aria-label={t('detail.priority')}>
634
758
  <option value="low">{t('ticket.priority.low')}</option>
635
759
  <option value="normal">{t('ticket.priority.normal')}</option>
636
760
  <option value="high">{t('ticket.priority.high')}</option>
637
761
  <option value="urgent">{t('ticket.priority.urgent')}</option>
638
762
  </select>
639
763
  </div>
640
- <div style={S.sideField}>
641
- <span style={S.sideLabel}>{t('detail.category')}</span>
642
- <select style={S.sideSelect} value={(ticket.category as string) || ''} onChange={(e) => handleFieldPatch('category', e.target.value)}>
643
- <option value="">--</option>
764
+ <div className={s.sideField}>
765
+ <span className={s.sideLabel}>{t('detail.category')}</span>
766
+ <select className={s.sideSelect} value={(ticket.category as string) || ''} onChange={(e) => handleFieldPatch('category', e.target.value)} aria-label={t('detail.category')}>
767
+ <option value="">—</option>
644
768
  <option value="bug">{t('ticket.category.bug')}</option>
645
769
  <option value="content">{t('ticket.category.content')}</option>
646
770
  <option value="feature">{t('ticket.category.feature')}</option>
@@ -648,23 +772,23 @@ export const TicketDetailClient: React.FC = () => {
648
772
  <option value="hosting">{t('ticket.category.hosting')}</option>
649
773
  </select>
650
774
  </div>
651
- <div style={S.sideField}><span style={S.sideLabel}>{t('detail.source')}</span><span style={S.sideValue}>{(ticket.source as string) || t('ticket.source.portal')}</span></div>
652
- <div style={S.sideField}><span style={S.sideLabel}>{t('detail.assigned')}</span><span style={S.sideValue}>{typeof ticket.assignedTo === 'object' && ticket.assignedTo ? (ticket.assignedTo as { firstName?: string }).firstName || 'Admin' : '--'}</span></div>
775
+ <div className={s.sideField}><span className={s.sideLabel}>{t('detail.source')}</span><span className={s.sideValue}>{(ticket.source as string) || t('ticket.source.portal')}</span></div>
776
+ <div className={s.sideField}><span className={s.sideLabel}>{t('detail.assigned')}</span><span className={s.sideValue}>{typeof ticket.assignedTo === 'object' && ticket.assignedTo ? (ticket.assignedTo as { firstName?: string }).firstName || 'Admin' : ''}</span></div>
653
777
  </div>
654
778
 
655
- {/* Tags */}
656
- <div style={S.sideSection}>
657
- <div style={S.sideSectionTitle}>{t('detail.tags')}</div>
658
- <div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
779
+ {/* #6 — Tags section */}
780
+ <div className={s.sideSection}>
781
+ <div className={s.sideSectionTitle}>{t('detail.tags')}</div>
782
+ <div className={s.tagsWrap}>
659
783
  {tags.map((tag) => (
660
- <span key={tag} style={S.tagChip}>
784
+ <span key={tag} className={s.tagChip}>
661
785
  {tag}
662
- <button style={S.tagRemove} onClick={() => handleRemoveTag(tag)}>&times;</button>
786
+ <button className={s.tagRemove} aria-label={`Retirer le tag ${tag}`} onClick={() => handleRemoveTag(tag)}>&times;</button>
663
787
  </span>
664
788
  ))}
665
789
  {addingTag ? (
666
790
  <input
667
- style={S.tagInput}
791
+ className={s.tagInput}
668
792
  value={newTagValue}
669
793
  onChange={(e) => setNewTagValue(e.target.value)}
670
794
  onKeyDown={(e) => { if (e.key === 'Enter') handleAddTag(); if (e.key === 'Escape') { setAddingTag(false); setNewTagValue('') } }}
@@ -673,30 +797,128 @@ export const TicketDetailClient: React.FC = () => {
673
797
  autoFocus
674
798
  />
675
799
  ) : (
676
- <button style={S.tagAddBtn} onClick={() => setAddingTag(true)}>+ Tag</button>
800
+ <button className={s.tagAddBtn} onClick={() => setAddingTag(true)} aria-label="Ajouter un tag">+ Tag</button>
677
801
  )}
678
802
  </div>
679
803
  </div>
680
804
 
805
+ {/* ===== BILLING ===== */}
806
+ <div className={s.sideSection}>
807
+ <div className={s.sideSectionTitle}>Facturation</div>
808
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
809
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
810
+ <span style={{ fontSize: 12 }}>Type</span>
811
+ <select
812
+ value={(ticket as any)?.billingType || 'hourly'}
813
+ onChange={async (e) => {
814
+ try {
815
+ await fetch(`/api/tickets/${ticketId}`, {
816
+ method: 'PATCH', credentials: 'include',
817
+ headers: { 'Content-Type': 'application/json' },
818
+ body: JSON.stringify({ billingType: e.target.value }),
819
+ })
820
+ fetchAll()
821
+ } catch {}
822
+ }}
823
+ style={{ fontSize: 12, padding: '4px 8px', borderRadius: 6, border: '1px solid #e5e7eb', background: '#fff' }}
824
+ >
825
+ <option value="hourly">Au temps</option>
826
+ <option value="flat">Forfait</option>
827
+ </select>
828
+ </div>
829
+ {(ticket as any)?.billingType === 'flat' && (
830
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
831
+ <span style={{ fontSize: 12 }}>Montant forfait</span>
832
+ <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
833
+ <input
834
+ type="number"
835
+ defaultValue={(ticket as any)?.flatRateAmount ?? ''}
836
+ placeholder="0"
837
+ onBlur={async (e) => {
838
+ const val = e.target.value ? Number(e.target.value) : null
839
+ try {
840
+ await fetch(`/api/tickets/${ticketId}`, {
841
+ method: 'PATCH', credentials: 'include',
842
+ headers: { 'Content-Type': 'application/json' },
843
+ body: JSON.stringify({ flatRateAmount: val }),
844
+ })
845
+ fetchAll()
846
+ } catch {}
847
+ }}
848
+ style={{ fontSize: 12, padding: '4px 8px', borderRadius: 6, border: '1px solid #e5e7eb', width: 80, textAlign: 'right' }}
849
+ />
850
+ <span style={{ fontSize: 12, color: '#9ca3af' }}>€</span>
851
+ </div>
852
+ </div>
853
+ )}
854
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
855
+ <span style={{ fontSize: 12 }}>Montant facturé</span>
856
+ <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
857
+ <input
858
+ type="number"
859
+ defaultValue={(ticket as any)?.billedAmount ?? ''}
860
+ placeholder="0"
861
+ onBlur={async (e) => {
862
+ const val = e.target.value ? Number(e.target.value) : null
863
+ try {
864
+ await fetch(`/api/tickets/${ticketId}`, {
865
+ method: 'PATCH', credentials: 'include',
866
+ headers: { 'Content-Type': 'application/json' },
867
+ body: JSON.stringify({ billedAmount: val }),
868
+ })
869
+ fetchAll()
870
+ } catch {}
871
+ }}
872
+ style={{ fontSize: 12, padding: '4px 8px', borderRadius: 6, border: '1px solid #e5e7eb', width: 80, textAlign: 'right' }}
873
+ />
874
+ <span style={{ fontSize: 12, color: '#9ca3af' }}>€</span>
875
+ </div>
876
+ </div>
877
+ <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
878
+ <span style={{ fontSize: 12 }}>Paiement</span>
879
+ <select
880
+ value={(ticket as any)?.paymentStatus || 'unpaid'}
881
+ onChange={async (e) => {
882
+ try {
883
+ await fetch(`/api/tickets/${ticketId}`, {
884
+ method: 'PATCH', credentials: 'include',
885
+ headers: { 'Content-Type': 'application/json' },
886
+ body: JSON.stringify({ paymentStatus: e.target.value }),
887
+ })
888
+ fetchAll()
889
+ } catch {}
890
+ }}
891
+ style={{ fontSize: 12, padding: '4px 8px', borderRadius: 6, border: '1px solid #e5e7eb', background: '#fff' }}
892
+ >
893
+ <option value="unpaid">Non payé</option>
894
+ <option value="partial">Partiel</option>
895
+ <option value="paid">Payé</option>
896
+ </select>
897
+ </div>
898
+ </div>
899
+ </div>
900
+
681
901
  {features.timeTracking && (
682
- <div style={S.sideSection}>
683
- <div style={S.sideSectionTitle}>{t('detail.time')} <span style={{ fontWeight: 700, fontSize: 13, color: '#d97706' }}>{totalMin > 0 ? `${Math.floor(totalMin / 60)}h${String(totalMin % 60).padStart(2, '0')} ${t('detail.total')}` : '0min'}</span></div>
684
- <div style={{ display: 'flex', gap: 6, alignItems: 'center' }}>
685
- <span style={{ fontFamily: 'monospace', fontSize: 16, fontWeight: 700, color: timerRunning ? '#dc2626' : 'var(--theme-text)' }}>
902
+ <div className={s.sideSection}>
903
+ <div className={s.sideSectionTitle}>{t('detail.time')} <span style={{ fontWeight: 700, fontSize: 13, color: '#d97706' }}>{totalMin > 0 ? `${Math.floor(totalMin / 60)}h${String(totalMin % 60).padStart(2, '0')} ${t('detail.total')}` : '0min'}</span></div>
904
+ {/* Timer */}
905
+ <div className={s.timer}>
906
+ <span className={`${s.timerDisplay} ${timerRunning ? s.timerActive : ''}`}>
686
907
  {String(Math.floor(timerSeconds / 60)).padStart(2, '0')}:{String(timerSeconds % 60).padStart(2, '0')}
687
908
  </span>
688
909
  {!timerRunning ? (
689
- <button style={{ ...S.toolbarBtn, color: '#dc2626', borderColor: '#dc2626' }} onClick={() => setTimerRunning(true)}>{timerSeconds > 0 ? 'Play' : 'Go'}</button>
910
+ <button className={s.timerBtn} onClick={() => setTimerRunning(true)} style={{ color: '#dc2626', borderColor: '#dc2626' }} aria-label="Démarrer le timer">{timerSeconds > 0 ? '' : 'Go'}</button>
690
911
  ) : (
691
- <button style={S.toolbarBtn} onClick={() => setTimerRunning(false)}>Pause</button>
912
+ <button className={s.timerBtn} onClick={() => setTimerRunning(false)} aria-label="Mettre en pause le timer">⏸</button>
692
913
  )}
693
914
  {timerSeconds >= 60 && !timerRunning && (
694
- <button style={{ ...S.toolbarBtn, color: '#16a34a', borderColor: '#16a34a' }} onClick={() => { handleTimerSave(); localStorage.removeItem(`timer-sec-${ticketId}`); localStorage.removeItem(`timer-run-${ticketId}`) }}>Save {Math.round(timerSeconds / 60)}m</button>
915
+ <button className={s.timerBtn} onClick={() => { handleTimerSave(); localStorage.removeItem(`timer-sec-${ticketId}`); localStorage.removeItem(`timer-run-${ticketId}`) }} style={{ color: '#16a34a', borderColor: '#16a34a' }} aria-label="Sauvegarder le temps">💾 {Math.round(timerSeconds / 60)}m</button>
695
916
  )}
696
917
  </div>
918
+ {/* Manual time entry */}
697
919
  <div style={{ display: 'flex', gap: 6, marginTop: 8, alignItems: 'center' }}>
698
920
  <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" />
699
- <button style={{ ...S.toolbarBtn, fontSize: 11 }} onClick={async () => {
921
+ <button className={s.timerBtn} onClick={async () => {
700
922
  const input = document.getElementById('manual-time-input') as HTMLInputElement
701
923
  const mins = Number(input?.value)
702
924
  if (!mins || mins < 1 || !ticketId) return
@@ -704,9 +926,31 @@ export const TicketDetailClient: React.FC = () => {
704
926
  await fetch('/api/time-entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ ticket: Number(ticketId), duration: mins, date: new Date().toISOString(), description: 'Saisie manuelle' }) })
705
927
  if (input) input.value = ''
706
928
  fetchAll()
707
- } catch (err) { console.warn('[support] manual time entry error:', err) }
708
- }}>+ Ajouter</button>
929
+ } catch {}
930
+ }} style={{ fontSize: 11 }}>+ Ajouter</button>
709
931
  </div>
932
+ {/* Billing info */}
933
+ <div style={{ marginTop: 8, fontSize: 11, color: 'var(--theme-elevation-500)' }}>
934
+ <div style={{ display: 'flex', justifyContent: 'space-between', padding: '2px 0', alignItems: 'center' }}>
935
+ <span>Facturable</span>
936
+ <button
937
+ onClick={async () => {
938
+ const newVal = ticket.billable === false ? true : false
939
+ try { await fetch(`/api/tickets/${ticketId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ billable: newVal }) }); fetchAll() } catch {}
940
+ }}
941
+ style={{ fontWeight: 600, color: (ticket.billable !== false) ? '#16a34a' : '#dc2626', background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, textDecoration: 'underline' }}
942
+ >
943
+ {(ticket.billable !== false) ? 'Oui' : 'Non'}
944
+ </button>
945
+ </div>
946
+ {totalMin > 0 && (
947
+ <div style={{ display: 'flex', justifyContent: 'space-between', padding: '2px 0' }}>
948
+ <span>Montant estimé</span>
949
+ <span style={{ fontWeight: 700, color: 'var(--theme-text)' }}>{((totalMin / 60) * 60).toFixed(0)}€</span>
950
+ </div>
951
+ )}
952
+ </div>
953
+ {/* Time entries */}
710
954
  {timeEntries.length > 0 && (
711
955
  <div style={{ marginTop: 8, fontSize: 11 }}>
712
956
  {timeEntries.slice(0, 6).map((e) => (
@@ -721,51 +965,80 @@ export const TicketDetailClient: React.FC = () => {
721
965
  )}
722
966
 
723
967
  {features.activityLog && (
724
- <div style={S.sideSection}>
725
- <div style={S.sideSectionTitle}>
726
- <button onClick={() => setShowActivity(!showActivity)} style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: 12, fontWeight: 700, textTransform: 'uppercase' as const, color: 'var(--theme-elevation-500)' }}>
727
- Activite {showActivity ? '&#9662;' : '&#9656;'}
968
+ <div className={s.sideSection}>
969
+ <div className={s.sideSectionTitle}>
970
+ <button className={s.collapseBtn} onClick={() => setShowActivity(!showActivity)} aria-label={showActivity ? 'Masquer le journal' : 'Afficher le journal'}>
971
+ Activité {showActivity ? '' : ''}
728
972
  </button>
729
973
  </div>
730
974
  {showActivity && activityLog.slice(0, 8).map((a) => (
731
- <div key={a.id} style={{ display: 'flex', gap: 8, alignItems: 'flex-start', padding: '4px 0' }}>
732
- <div style={{ width: 6, height: 6, borderRadius: '50%', marginTop: 4, flexShrink: 0, backgroundColor: a.actorType === 'admin' ? '#2563eb' : a.actorType === 'system' ? '#6b7280' : '#16a34a' }} />
733
- <div>
734
- <div style={{ fontSize: 12, color: 'var(--theme-text)' }}>{(a.detail || a.action).slice(0, 60)}</div>
735
- <div style={{ fontSize: 11, color: 'var(--theme-elevation-400)' }}>{timeAgo(a.createdAt)}</div>
975
+ <div key={a.id} className={s.activityItem}>
976
+ <div className={s.activityDot} style={{ backgroundColor: a.actorType === 'admin' ? '#2563eb' : a.actorType === 'system' ? '#6b7280' : '#16a34a' }} />
977
+ <div className={s.activityContent}>
978
+ <div className={s.activityText}>{(a.detail || a.action).slice(0, 60)}</div>
979
+ <div className={s.activityTime}>{timeAgo(a.createdAt)}</div>
736
980
  </div>
737
981
  </div>
738
982
  ))}
739
983
  </div>
740
984
  )}
985
+
986
+ {/* Client Intelligence (compact) */}
987
+ {clientSummary && clientSummary.summary && (
988
+ <div className={s.sideSection} style={{ background: 'linear-gradient(135deg, rgba(37,99,235,0.03) 0%, rgba(139,92,246,0.03) 100%)' }}>
989
+ <div className={s.sideSectionTitle} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
990
+ <span style={{ fontSize: 13 }}>🧠</span> Résumé client
991
+ </div>
992
+ <p style={{ margin: '0 0 8px', fontSize: 11, lineHeight: 1.6, color: '#374151' }}>{clientSummary.summary}</p>
993
+ {clientSummary.recurringTopics && clientSummary.recurringTopics.length > 0 && (
994
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 6 }}>
995
+ {clientSummary.recurringTopics.slice(0, 3).map((tp, i) => (
996
+ <span key={i} style={{ padding: '2px 8px', borderRadius: 10, background: 'rgba(37,99,235,0.08)', color: '#2563eb', fontSize: 10, fontWeight: 600 }}>{tp.topic}</span>
997
+ ))}
998
+ </div>
999
+ )}
1000
+ {clientSummary.keyFacts && clientSummary.keyFacts.length > 0 && (
1001
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
1002
+ {clientSummary.keyFacts.slice(0, 3).map((f, i) => (
1003
+ <span key={i} style={{ padding: '2px 8px', borderRadius: 10, background: 'rgba(22,163,74,0.08)', color: '#16a34a', fontSize: 10, fontWeight: 600 }}>{f}</span>
1004
+ ))}
1005
+ </div>
1006
+ )}
1007
+ </div>
1008
+ )}
1009
+ {summaryLoading && (
1010
+ <div className={s.sideSection}>
1011
+ <div style={{ fontSize: 11, color: '#94a3b8', textAlign: 'center', padding: 8 }}>🧠 Chargement résumé...</div>
1012
+ </div>
1013
+ )}
741
1014
  </div>
742
1015
  </div>
743
1016
 
744
- {/* Undo delete toast */}
1017
+ {/* #2 — Undo delete toast */}
745
1018
  {undoToast && (
746
- <div style={{ position: 'fixed', bottom: 24, left: '50%', transform: 'translateX(-50%)', padding: '10px 20px', borderRadius: 8, background: '#1e293b', color: '#fff', fontSize: 13, display: 'flex', gap: 12, alignItems: 'center', zIndex: 100, boxShadow: '0 4px 12px rgba(0,0,0,0.2)' }} role="alert">
747
- <span>{t('detail.messageDeleted')}</span>
748
- <button onClick={handleUndoDelete} style={{ background: 'none', border: '1px solid rgba(255,255,255,0.3)', borderRadius: 4, padding: '4px 10px', color: '#fff', cursor: 'pointer', fontSize: 12, fontWeight: 600 }}>{t('detail.undo')}</button>
1019
+ <div className={s.undoToast} role="alert">
1020
+ <span>Message supprimé</span>
1021
+ <button className={s.undoBtn} onClick={handleUndoDelete}>Annuler</button>
749
1022
  </div>
750
1023
  )}
751
1024
 
752
- {/* Split ticket modal */}
1025
+ {/* #2 — Split ticket modal */}
753
1026
  {splitModal && (
754
- <div style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 }} onClick={(e) => { if (e.target === e.currentTarget) { setSplitModal(null); setSplitSubject('') } }}>
755
- <div style={{ background: 'var(--theme-elevation-0)', borderRadius: 12, padding: 24, maxWidth: 480, width: '100%', boxShadow: '0 8px 30px rgba(0,0,0,0.15)' }} role="dialog">
756
- <h3 style={{ margin: '0 0 12px', fontSize: 16, fontWeight: 700 }}>{t('detail.extractTitle')}</h3>
757
- <div style={{ padding: 10, background: 'var(--theme-elevation-50)', borderRadius: 6, fontSize: 13, color: 'var(--theme-elevation-500)', marginBottom: 12 }}>{splitModal.preview}</div>
1027
+ <div className={s.splitOverlay} onClick={(e) => { if (e.target === e.currentTarget) { setSplitModal(null); setSplitSubject('') } }}>
1028
+ <div className={s.splitModal} role="dialog" aria-label="Extraire dans un nouveau ticket">
1029
+ <h3 className={s.splitTitle}>Extraire dans un nouveau ticket</h3>
1030
+ <div className={s.splitPreview}>{splitModal.preview}</div>
758
1031
  <input
759
- style={{ width: '100%', padding: '8px 12px', borderRadius: 6, border: '1px solid var(--theme-elevation-200)', fontSize: 13, marginBottom: 12, color: 'var(--theme-text)', background: 'var(--theme-elevation-0)' }}
1032
+ className={s.splitInput}
760
1033
  value={splitSubject}
761
1034
  onChange={(e) => setSplitSubject(e.target.value)}
762
1035
  onKeyDown={(e) => { if (e.key === 'Enter') handleSplitConfirm() }}
763
- placeholder={t('detail.extractSubjectPlaceholder')}
1036
+ placeholder="Sujet du nouveau ticket..."
764
1037
  autoFocus
765
1038
  />
766
- <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
767
- <button style={{ padding: '6px 14px', borderRadius: 6, border: '1px solid var(--theme-elevation-200)', background: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--theme-text)' }} onClick={() => { setSplitModal(null); setSplitSubject('') }}>{t('common.cancel')}</button>
768
- <button style={{ ...S.sendBtn }} onClick={handleSplitConfirm} disabled={!splitSubject.trim()}>{t('ticket.createTicket')}</button>
1039
+ <div className={s.splitActions}>
1040
+ <button className={s.splitCancelBtn} onClick={() => { setSplitModal(null); setSplitSubject('') }}>Annuler</button>
1041
+ <button className={s.splitConfirmBtn} onClick={handleSplitConfirm} disabled={!splitSubject.trim()}>Créer le ticket</button>
769
1042
  </div>
770
1043
  </div>
771
1044
  </div>