@consilioweb/payload-support 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +525 -0
  3. package/dist/client.cjs +7 -0
  4. package/dist/client.d.cts +3 -0
  5. package/dist/client.d.ts +3 -0
  6. package/dist/client.js +5 -0
  7. package/dist/index.cjs +7766 -0
  8. package/dist/index.d.cts +384 -0
  9. package/dist/index.d.ts +384 -0
  10. package/dist/index.js +7730 -0
  11. package/dist/views.d.cts +30 -0
  12. package/dist/views.d.ts +30 -0
  13. package/package.json +131 -0
  14. package/src/client.ts +1 -0
  15. package/src/collections/AuthLogs.ts +65 -0
  16. package/src/collections/CannedResponses.ts +69 -0
  17. package/src/collections/ChatMessages.ts +98 -0
  18. package/src/collections/EmailLogs.ts +94 -0
  19. package/src/collections/KnowledgeBase.ts +99 -0
  20. package/src/collections/Macros.ts +98 -0
  21. package/src/collections/PendingEmails.ts +122 -0
  22. package/src/collections/SatisfactionSurveys.ts +98 -0
  23. package/src/collections/SlaPolicies.ts +123 -0
  24. package/src/collections/SupportClients.ts +210 -0
  25. package/src/collections/TicketActivityLog.ts +81 -0
  26. package/src/collections/TicketMessages.ts +364 -0
  27. package/src/collections/TicketStatuses.ts +108 -0
  28. package/src/collections/Tickets.ts +704 -0
  29. package/src/collections/TimeEntries.ts +105 -0
  30. package/src/collections/WebhookEndpoints.ts +96 -0
  31. package/src/collections/index.ts +16 -0
  32. package/src/components/TicketConversation/components/AISummaryPanel.tsx +85 -0
  33. package/src/components/TicketConversation/components/ActionPanels.tsx +140 -0
  34. package/src/components/TicketConversation/components/ActivityLog.tsx +39 -0
  35. package/src/components/TicketConversation/components/ClientBar.tsx +37 -0
  36. package/src/components/TicketConversation/components/ClientHistory.tsx +117 -0
  37. package/src/components/TicketConversation/components/CodeBlock.tsx +186 -0
  38. package/src/components/TicketConversation/components/CodeBlockInserter.tsx +166 -0
  39. package/src/components/TicketConversation/components/QuickActions.tsx +82 -0
  40. package/src/components/TicketConversation/components/TicketHeader.tsx +91 -0
  41. package/src/components/TicketConversation/components/TimeTrackingPanel.tsx +161 -0
  42. package/src/components/TicketConversation/config.ts +82 -0
  43. package/src/components/TicketConversation/constants.ts +74 -0
  44. package/src/components/TicketConversation/context.ts +63 -0
  45. package/src/components/TicketConversation/hooks/useAI.ts +180 -0
  46. package/src/components/TicketConversation/hooks/useMessageActions.ts +131 -0
  47. package/src/components/TicketConversation/hooks/useReply.ts +190 -0
  48. package/src/components/TicketConversation/hooks/useTicketActions.ts +205 -0
  49. package/src/components/TicketConversation/hooks/useTimeTracking.ts +107 -0
  50. package/src/components/TicketConversation/hooks/useTranslation.ts +116 -0
  51. package/src/components/TicketConversation/index.tsx +1110 -0
  52. package/src/components/TicketConversation/locales/en.json +878 -0
  53. package/src/components/TicketConversation/locales/fr.json +878 -0
  54. package/src/components/TicketConversation/types.ts +54 -0
  55. package/src/components/TicketConversation/utils.ts +25 -0
  56. package/src/endpoints/admin-chat-stream.ts +238 -0
  57. package/src/endpoints/admin-chat.ts +263 -0
  58. package/src/endpoints/admin-stats.ts +200 -0
  59. package/src/endpoints/ai.ts +199 -0
  60. package/src/endpoints/apply-macro.ts +144 -0
  61. package/src/endpoints/auth-2fa.ts +163 -0
  62. package/src/endpoints/auto-close.ts +175 -0
  63. package/src/endpoints/billing.ts +167 -0
  64. package/src/endpoints/bulk-action.ts +103 -0
  65. package/src/endpoints/chat-stream.ts +127 -0
  66. package/src/endpoints/chat.ts +188 -0
  67. package/src/endpoints/chatbot.ts +113 -0
  68. package/src/endpoints/delete-account.ts +129 -0
  69. package/src/endpoints/email-stats.ts +109 -0
  70. package/src/endpoints/export-csv.ts +84 -0
  71. package/src/endpoints/export-data.ts +104 -0
  72. package/src/endpoints/import-conversation.ts +307 -0
  73. package/src/endpoints/index.ts +154 -0
  74. package/src/endpoints/login.ts +92 -0
  75. package/src/endpoints/merge-clients.ts +132 -0
  76. package/src/endpoints/merge-tickets.ts +137 -0
  77. package/src/endpoints/oauth-google.ts +179 -0
  78. package/src/endpoints/pending-emails-process.ts +224 -0
  79. package/src/endpoints/presence.ts +104 -0
  80. package/src/endpoints/process-scheduled.ts +144 -0
  81. package/src/endpoints/purge-logs.ts +58 -0
  82. package/src/endpoints/resend-notification.ts +99 -0
  83. package/src/endpoints/round-robin-config.ts +92 -0
  84. package/src/endpoints/satisfaction.ts +93 -0
  85. package/src/endpoints/search.ts +106 -0
  86. package/src/endpoints/seed-kb.ts +153 -0
  87. package/src/endpoints/settings.ts +144 -0
  88. package/src/endpoints/signature.ts +93 -0
  89. package/src/endpoints/sla-check.ts +124 -0
  90. package/src/endpoints/split-ticket.ts +131 -0
  91. package/src/endpoints/statuses.ts +45 -0
  92. package/src/endpoints/track-open.ts +154 -0
  93. package/src/endpoints/typing.ts +101 -0
  94. package/src/endpoints/user-prefs.ts +125 -0
  95. package/src/hooks/checkSLA.ts +414 -0
  96. package/src/hooks/ticketStatusEmail.ts +182 -0
  97. package/src/index.ts +51 -0
  98. package/src/plugin.ts +157 -0
  99. package/src/portal/LiveChat.tsx +1353 -0
  100. package/src/portal/auth/ChatWidget.tsx +350 -0
  101. package/src/portal/auth/ChatbotWidget.tsx +285 -0
  102. package/src/portal/auth/SupportHeader.tsx +409 -0
  103. package/src/portal/auth/dashboard/DashboardClient.tsx +650 -0
  104. package/src/portal/auth/dashboard/page.tsx +84 -0
  105. package/src/portal/auth/faq/FAQSearch.tsx +117 -0
  106. package/src/portal/auth/faq/page.tsx +199 -0
  107. package/src/portal/auth/layout.tsx +61 -0
  108. package/src/portal/auth/profile/page.tsx +705 -0
  109. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +74 -0
  110. package/src/portal/auth/tickets/detail/CollapsibleMessages.tsx +46 -0
  111. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +50 -0
  112. package/src/portal/auth/tickets/detail/MessageActions.tsx +158 -0
  113. package/src/portal/auth/tickets/detail/PrintButton.tsx +16 -0
  114. package/src/portal/auth/tickets/detail/ReadReceipt.tsx +34 -0
  115. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +74 -0
  116. package/src/portal/auth/tickets/detail/SatisfactionForm.tsx +156 -0
  117. package/src/portal/auth/tickets/detail/TicketPolling.tsx +57 -0
  118. package/src/portal/auth/tickets/detail/TicketReplyForm.tsx +294 -0
  119. package/src/portal/auth/tickets/detail/TypingIndicator.tsx +58 -0
  120. package/src/portal/auth/tickets/detail/page.tsx +738 -0
  121. package/src/portal/auth/tickets/new/page.tsx +515 -0
  122. package/src/portal/forgot-password/page.tsx +114 -0
  123. package/src/portal/layout.tsx +26 -0
  124. package/src/portal/locales/en.json +374 -0
  125. package/src/portal/locales/fr.json +374 -0
  126. package/src/portal/login/page.tsx +351 -0
  127. package/src/portal/page.tsx +162 -0
  128. package/src/portal/register/page.tsx +281 -0
  129. package/src/portal/reset-password/page.tsx +152 -0
  130. package/src/styles/BillingView.module.scss +311 -0
  131. package/src/styles/ChatView.module.scss +438 -0
  132. package/src/styles/CommandPalette.module.scss +160 -0
  133. package/src/styles/CrmView.module.scss +554 -0
  134. package/src/styles/EmailTracking.module.scss +238 -0
  135. package/src/styles/ImportConversation.module.scss +267 -0
  136. package/src/styles/Layout.module.scss +55 -0
  137. package/src/styles/Logs.module.scss +164 -0
  138. package/src/styles/NewTicket.module.scss +143 -0
  139. package/src/styles/PendingEmails.module.scss +629 -0
  140. package/src/styles/SupportDashboard.module.scss +649 -0
  141. package/src/styles/TicketDetail.module.scss +1043 -0
  142. package/src/styles/TicketInbox.module.scss +296 -0
  143. package/src/styles/TicketingSettings.module.scss +358 -0
  144. package/src/styles/TimeDashboard.module.scss +287 -0
  145. package/src/styles/_tokens.scss +78 -0
  146. package/src/styles/theme.css +633 -0
  147. package/src/types.ts +255 -0
  148. package/src/utils/adminNotification.ts +38 -0
  149. package/src/utils/auth.ts +46 -0
  150. package/src/utils/emailTemplate.ts +343 -0
  151. package/src/utils/fireWebhooks.ts +84 -0
  152. package/src/utils/index.ts +22 -0
  153. package/src/utils/rateLimiter.ts +52 -0
  154. package/src/utils/readSettings.ts +67 -0
  155. package/src/utils/slugs.ts +54 -0
  156. package/src/utils/webhookDispatcher.ts +120 -0
  157. package/src/views/BillingView/client.tsx +137 -0
  158. package/src/views/BillingView/index.tsx +33 -0
  159. package/src/views/ChatView/client.tsx +294 -0
  160. package/src/views/ChatView/index.tsx +33 -0
  161. package/src/views/CrmView/client.tsx +206 -0
  162. package/src/views/CrmView/index.tsx +33 -0
  163. package/src/views/EmailTrackingView/client.tsx +124 -0
  164. package/src/views/EmailTrackingView/index.tsx +33 -0
  165. package/src/views/ImportConversationView/client.tsx +133 -0
  166. package/src/views/ImportConversationView/index.tsx +33 -0
  167. package/src/views/LogsView/client.tsx +151 -0
  168. package/src/views/LogsView/index.tsx +30 -0
  169. package/src/views/NewTicketView/client.tsx +227 -0
  170. package/src/views/NewTicketView/index.tsx +30 -0
  171. package/src/views/PendingEmailsView/client.tsx +177 -0
  172. package/src/views/PendingEmailsView/index.tsx +33 -0
  173. package/src/views/SupportDashboardView/client.tsx +424 -0
  174. package/src/views/SupportDashboardView/index.tsx +33 -0
  175. package/src/views/TicketDetailView/client.tsx +775 -0
  176. package/src/views/TicketDetailView/index.tsx +33 -0
  177. package/src/views/TicketInboxView/client.tsx +313 -0
  178. package/src/views/TicketInboxView/index.tsx +30 -0
  179. package/src/views/TicketingSettingsView/client.tsx +866 -0
  180. package/src/views/TicketingSettingsView/index.tsx +33 -0
  181. package/src/views/TimeDashboardView/client.tsx +144 -0
  182. package/src/views/TimeDashboardView/index.tsx +33 -0
  183. package/src/views/shared/AdminViewHeader.tsx +69 -0
  184. package/src/views/shared/ErrorBoundary.tsx +68 -0
  185. package/src/views/shared/Skeleton.tsx +125 -0
  186. package/src/views/shared/adminTokens.ts +37 -0
  187. package/src/views/shared/config.ts +82 -0
  188. package/src/views/shared/index.ts +6 -0
  189. package/src/views.ts +16 -0
@@ -0,0 +1,1110 @@
1
+ 'use client'
2
+
3
+ import React, { useState, useEffect, useCallback, useRef } from 'react'
4
+ import { useDocumentInfo } from '@payloadcms/ui'
5
+ import type { Message, TimeEntry, ClientInfo, CannedResponse, ActivityEntry, SatisfactionSurvey } from './types'
6
+ import type { RichTextEditorHandle } from './context'
7
+ import { C, s } from './constants'
8
+ import { getDateLabel, formatMessageDate } from './utils'
9
+ import { CodeBlockRenderer, CodeBlockRendererHtml } from './components/CodeBlock'
10
+ import { CodeBlockInserter } from './components/CodeBlockInserter'
11
+ import { TicketHeader } from './components/TicketHeader'
12
+ import { ClientBar } from './components/ClientBar'
13
+ import { QuickActions } from './components/QuickActions'
14
+ import { AISummaryPanel } from './components/AISummaryPanel'
15
+ import { MergePanel, ExtMessagePanel, SnoozePanel } from './components/ActionPanels'
16
+ import { ActivityLog } from './components/ActivityLog'
17
+ import { ClientHistory } from './components/ClientHistory'
18
+ import { TimeTrackingPanel } from './components/TimeTrackingPanel'
19
+ import { useTimeTracking } from './hooks/useTimeTracking'
20
+ import { useMessageActions } from './hooks/useMessageActions'
21
+ import { useTicketActions } from './hooks/useTicketActions'
22
+ import { useReply } from './hooks/useReply'
23
+ import { useAI } from './hooks/useAI'
24
+ import { getFeatures, type TicketingFeatures } from './config'
25
+ import '../../styles/theme.css'
26
+
27
+ // Inline skeleton replacement (no external dependency)
28
+ function SkeletonText({ lines = 3 }: { lines?: number }) {
29
+ return (
30
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '8px', padding: '12px 0' }}>
31
+ {Array.from({ length: lines }).map((_, i) => (
32
+ <div
33
+ key={i}
34
+ style={{
35
+ height: '14px',
36
+ borderRadius: '4px',
37
+ backgroundColor: '#e2e8f0',
38
+ width: i === lines - 1 ? '60%' : '100%',
39
+ animation: 'pulse 1.5s ease-in-out infinite',
40
+ }}
41
+ />
42
+ ))}
43
+ </div>
44
+ )
45
+ }
46
+
47
+ // Inline layout styles (replacing Layout.module.scss)
48
+ const layoutStyles = {
49
+ root: { padding: '12px 0' } as React.CSSProperties,
50
+ twoColumns: {
51
+ display: 'grid',
52
+ gridTemplateColumns: '1fr 320px',
53
+ gap: '16px',
54
+ alignItems: 'start',
55
+ } as React.CSSProperties,
56
+ mainColumn: { minWidth: 0 } as React.CSSProperties,
57
+ sideColumn: {
58
+ display: 'flex',
59
+ flexDirection: 'column' as const,
60
+ gap: '12px',
61
+ position: 'sticky' as const,
62
+ top: '80px',
63
+ } as React.CSSProperties,
64
+ }
65
+
66
+ const TicketConversation: React.FC = () => {
67
+ const { id } = useDocumentInfo()
68
+ const [features] = useState<TicketingFeatures>(() => getFeatures())
69
+ const [messages, setMessages] = useState<Message[]>([])
70
+ const [timeEntries, setTimeEntries] = useState<TimeEntry[]>([])
71
+ const [client, setClient] = useState<ClientInfo | null>(null)
72
+ const [cannedResponses, setCannedResponses] = useState<CannedResponse[]>([])
73
+ const [activityLog, setActivityLog] = useState<ActivityEntry[]>([])
74
+ const [satisfaction, setSatisfaction] = useState<SatisfactionSurvey | null>(null)
75
+ const [loading, setLoading] = useState(true)
76
+ const [messagesCollapsed, setMessagesCollapsed] = useState(true)
77
+ // Search
78
+ const [searchQuery, setSearchQuery] = useState('')
79
+ // Copy link feedback
80
+ const [copiedLink, setCopiedLink] = useState<'admin' | 'client' | null>(null)
81
+ // Notification sound: track previous message count
82
+ const prevMessageCountRef = useRef<number>(0)
83
+ // Typing indicator
84
+ const [clientTyping, setClientTyping] = useState(false)
85
+ const [clientTypingName, setClientTypingName] = useState('')
86
+ const typingLastSent = useRef(0)
87
+ // Current ticket status + number + source
88
+ const [currentStatus, setCurrentStatus] = useState<string>('')
89
+ const [ticketNumber, setTicketNumber] = useState<string>('')
90
+ const [ticketSubject, setTicketSubject] = useState<string>('')
91
+ const [ticketSource, setTicketSource] = useState<string>('')
92
+ const [chatSession, setChatSession] = useState<string>('')
93
+ // Client history
94
+ const [clientTickets, setClientTickets] = useState<Array<{ id: number; ticketNumber: string; subject: string; status: string; createdAt: string }>>([])
95
+ const [clientProjects, setClientProjects] = useState<Array<{ id: number; name: string; status: string }>>([])
96
+ const [clientNotes, setClientNotes] = useState<string>('')
97
+ const [savingNotes, setSavingNotes] = useState(false)
98
+ const [notesSaved, setNotesSaved] = useState(false)
99
+ const [lastClientReadAt, setLastClientReadAt] = useState<string | null>(null)
100
+
101
+ const fetchAll = useCallback(async () => {
102
+ if (!id) return
103
+ try {
104
+ const [msgRes, timeRes, ticketRes, cannedRes, activityRes, csatRes] = await Promise.all([
105
+ fetch(`/api/ticket-messages?where[ticket][equals]=${id}&sort=createdAt&limit=200&depth=1`, { credentials: 'include' }),
106
+ fetch(`/api/time-entries?where[ticket][equals]=${id}&sort=-date&limit=50&depth=0`, { credentials: 'include' }),
107
+ fetch(`/api/tickets/${id}?depth=1`, { credentials: 'include' }),
108
+ fetch(`/api/canned-responses?sort=sortOrder&limit=50&depth=0`, { credentials: 'include' }),
109
+ fetch(`/api/ticket-activity-log?where[ticket][equals]=${id}&sort=-createdAt&limit=50&depth=0`, { credentials: 'include' }),
110
+ fetch(`/api/satisfaction-surveys?where[ticket][equals]=${id}&limit=1&depth=0`, { credentials: 'include' }),
111
+ ])
112
+
113
+ if (msgRes.ok) {
114
+ const d = await msgRes.json()
115
+ setMessages(d.docs || [])
116
+ }
117
+ if (timeRes.ok) {
118
+ const d = await timeRes.json()
119
+ setTimeEntries(d.docs || [])
120
+ }
121
+ let resolvedChatSession = ''
122
+ if (ticketRes.ok) {
123
+ const d = await ticketRes.json()
124
+ if (d.client && typeof d.client === 'object') {
125
+ setClient(d.client)
126
+ }
127
+ setSnoozeUntil(d.snoozeUntil || null)
128
+ setLastClientReadAt(d.lastClientReadAt || null)
129
+ setCurrentStatus(d.status || '')
130
+ setTicketNumber(d.ticketNumber || '')
131
+ setTicketSubject(d.subject || '')
132
+ setTicketSource(d.source || '')
133
+ setChatSession(d.chatSession || '')
134
+ resolvedChatSession = d.chatSession || ''
135
+
136
+ // Fetch client history (tickets + projects + notes)
137
+ const clientId = typeof d.client === 'object' ? d.client?.id : d.client
138
+ if (clientId) {
139
+ const [clientTicketsRes, projectsRes, clientDetailRes] = await Promise.all([
140
+ fetch(`/api/tickets?where[client][equals]=${clientId}&where[id][not_equals]=${id}&sort=-createdAt&limit=5&depth=0`, { credentials: 'include' }),
141
+ fetch(`/api/projects?where[client][contains]=${clientId}&depth=0`, { credentials: 'include' }),
142
+ fetch(`/api/support-clients/${clientId}?depth=0`, { credentials: 'include' }),
143
+ ])
144
+ if (clientTicketsRes.ok) {
145
+ const ctData = await clientTicketsRes.json()
146
+ setClientTickets((ctData.docs || []).map((t: { id: number; ticketNumber: string; subject: string; status: string; createdAt: string }) => ({
147
+ id: t.id, ticketNumber: t.ticketNumber, subject: t.subject, status: t.status, createdAt: t.createdAt,
148
+ })))
149
+ }
150
+ if (projectsRes.ok) {
151
+ const pData = await projectsRes.json()
152
+ setClientProjects((pData.docs || []).map((p: { id: number; name: string; status: string }) => ({
153
+ id: p.id, name: p.name, status: p.status,
154
+ })))
155
+ }
156
+ if (clientDetailRes.ok) {
157
+ const cdData = await clientDetailRes.json()
158
+ setClientNotes(cdData.notes || '')
159
+ }
160
+ }
161
+ }
162
+ if (cannedRes.ok) {
163
+ const d = await cannedRes.json()
164
+ setCannedResponses(d.docs || [])
165
+ }
166
+ if (activityRes.ok) {
167
+ const d = await activityRes.json()
168
+ setActivityLog(d.docs || [])
169
+ }
170
+ if (csatRes.ok) {
171
+ const d = await csatRes.json()
172
+ setSatisfaction(d.docs?.[0] || null)
173
+ }
174
+
175
+ // Fetch chat messages if this ticket is linked to a chat session
176
+ if (resolvedChatSession) {
177
+ try {
178
+ const chatRes = await fetch(`/api/support/admin-chat?session=${encodeURIComponent(resolvedChatSession)}`, { credentials: 'include' })
179
+ if (chatRes.ok) {
180
+ const chatData = await chatRes.json()
181
+ const chatMsgs: Message[] = (chatData.messages || [])
182
+ .filter((cm: { senderType: string }) => cm.senderType !== 'system')
183
+ .map((cm: { id: string | number; message: string; senderType: string; createdAt: string }) => ({
184
+ id: `chat-${cm.id}`,
185
+ body: cm.message,
186
+ authorType: cm.senderType === 'agent' ? 'admin' as const : 'client' as const,
187
+ isInternal: false,
188
+ createdAt: cm.createdAt,
189
+ fromChat: true,
190
+ }))
191
+
192
+ // Merge with ticket messages, deduplicate by matching body+time
193
+ setMessages((prev) => {
194
+ const ticketMsgs = prev
195
+ const merged = [...ticketMsgs]
196
+
197
+ for (const chatMsg of chatMsgs) {
198
+ // Skip if a ticket-message with same body exists within 5 seconds
199
+ const isDuplicate = ticketMsgs.some((tm) => {
200
+ if (tm.body !== chatMsg.body) return false
201
+ const timeDiff = Math.abs(new Date(tm.createdAt).getTime() - new Date(chatMsg.createdAt).getTime())
202
+ return timeDiff < 5000
203
+ })
204
+ if (!isDuplicate) {
205
+ merged.push(chatMsg)
206
+ }
207
+ }
208
+
209
+ return merged.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime())
210
+ })
211
+ }
212
+ } catch (err) {
213
+ console.warn('[TicketConversation] Chat fetch error:', err)
214
+ }
215
+ }
216
+ } catch (err) { console.warn('[TicketConversation] Fetch error:', err) } finally {
217
+ setLoading(false)
218
+ }
219
+ }, [id])
220
+
221
+ useEffect(() => { fetchAll() }, [fetchAll])
222
+
223
+ // Mark ticket as read by admin
224
+ useEffect(() => {
225
+ if (!id) return
226
+ fetch(`/api/tickets/${id}`, {
227
+ method: 'PATCH',
228
+ headers: { 'Content-Type': 'application/json' },
229
+ credentials: 'include',
230
+ body: JSON.stringify({ lastAdminReadAt: new Date().toISOString() }),
231
+ }).catch(() => {})
232
+ }, [id, messages.length]) // re-mark on new messages
233
+
234
+ // Typing indicator: poll for client typing (circuit breaker after 3 fails)
235
+ const typingFailCount = useRef(0)
236
+ useEffect(() => {
237
+ if (!id) return
238
+ typingFailCount.current = 0
239
+ const poll = async () => {
240
+ if (typingFailCount.current >= 3) return
241
+ try {
242
+ const res = await fetch(`/api/support/typing?ticketId=${id}`, { credentials: 'include' })
243
+ if (res.ok) { typingFailCount.current = 0; const data = await res.json(); setClientTyping(data.typing); setClientTypingName(data.name || '') }
244
+ else { typingFailCount.current++ }
245
+ } catch { typingFailCount.current++ }
246
+ }
247
+ poll()
248
+ const interval = setInterval(poll, 3000)
249
+ return () => clearInterval(interval)
250
+ }, [id])
251
+
252
+ // Send admin typing signal (called from reply editor onChange)
253
+ const sendAdminTyping = useCallback(() => {
254
+ if (!id) return
255
+ const now = Date.now()
256
+ if (now - typingLastSent.current < 3000) return
257
+ typingLastSent.current = now
258
+ fetch('/api/support/typing', {
259
+ method: 'POST',
260
+ headers: { 'Content-Type': 'application/json' },
261
+ credentials: 'include',
262
+ body: JSON.stringify({ ticketId: id }),
263
+ }).catch(() => {})
264
+ }, [id])
265
+
266
+ // Time tracking
267
+ const tt = useTimeTracking(id, fetchAll)
268
+ const { duration, setDuration, timeDescription, setTimeDescription, addingTime, timeSuccess, timerRunning, timerSeconds, setTimerSeconds, timerDescription, setTimerDescription, handleAddTime, handleTimerStart, handleTimerStop, handleTimerSave, handleTimerDiscard } = tt
269
+
270
+ // Message actions (edit, delete, resend, toggle author, split)
271
+ const ma = useMessageActions(id, client, fetchAll)
272
+ const { togglingAuthor, editingMsg, editBody, editHtml, setEditHtml, savingEdit, handleEditStart, handleEditSave, handleEditCancel, deletingMsg, handleDelete, resendingMsg, resendSuccess, handleResend, handleToggleAuthor, handleSplitMessage, setEditBody } = ma
273
+
274
+ // Ticket actions (status, merge, snooze, ext msg, next ticket)
275
+ const ta = useTicketActions(id, fetchAll)
276
+ const { statusUpdating, handleStatusChange, showMerge, setShowMerge, mergeTarget, setMergeTarget, mergeTargetInfo, setMergeTargetInfo, mergeError, setMergeError, merging, handleMergeLookup, handleMerge, showExtMsg, setShowExtMsg, extMsgBody, setExtMsgBody, extMsgAuthor, setExtMsgAuthor, extMsgDate, setExtMsgDate, extMsgFiles, setExtMsgFiles, sendingExtMsg, handleExtFileChange, handleSendExtMsg, showSnooze, setShowSnooze, snoozeUntil, setSnoozeUntil, snoozeSaving, handleSnooze, showNextTicket, setShowNextTicket, nextTicketId, nextTicketInfo, handleNextTicket } = ta
277
+
278
+ // Reply composer
279
+ const replyEditorRef = useRef<RichTextEditorHandle>(null)
280
+ const rp = useReply(id, client, cannedResponses, ticketNumber, ticketSubject, fetchAll, handleNextTicket, replyEditorRef)
281
+ const { fileInputRef, replyBody, setReplyBody, replyHtml, setReplyHtml, replyFiles, setReplyFiles, isInternal, setIsInternal, notifyClient, setNotifyClient, sendAsClient, setSendAsClient, sending, showSchedule, setShowSchedule, scheduleDate, setScheduleDate, handleEditorFileUpload, handleCannedSelect, handleReplyFileChange, handleSendReply, handleScheduleReply } = rp
282
+
283
+ // AI features (needs replyBody/setReplyBody from useReply)
284
+ const ai = useAI(messages, client, ticketSubject, replyBody, setReplyBody, setReplyHtml, replyEditorRef)
285
+ const { clientSentiment, aiReplying, handleAiSuggestReply, aiRewriting, handleAiRewrite, showAiSummary, setShowAiSummary, aiSummary, aiGenerating, handleAiGenerate, aiSaving, aiSaved, handleAiSave: aiSaveRaw } = ai
286
+ const handleAiSave = () => aiSaveRaw(id, fetchAll)
287
+
288
+ // Auto-refresh: poll for new messages, status changes and activity every 15s
289
+ const [pollExpired, setPollExpired] = useState(false)
290
+ useEffect(() => {
291
+ if (!id || loading || pollExpired) return
292
+
293
+ const poll = async () => {
294
+ try {
295
+ const [msgRes, ticketRes, activityRes] = await Promise.all([
296
+ fetch(`/api/ticket-messages?where[ticket][equals]=${id}&sort=createdAt&limit=200&depth=1`, { credentials: 'include' }),
297
+ fetch(`/api/tickets/${id}?depth=0`, { credentials: 'include' }),
298
+ fetch(`/api/ticket-activity-log?where[ticket][equals]=${id}&sort=-createdAt&limit=50&depth=0`, { credentials: 'include' }),
299
+ ])
300
+
301
+ if (msgRes.status === 401 || msgRes.status === 403) { setPollExpired(true); return }
302
+
303
+ if (msgRes.ok) {
304
+ const d = await msgRes.json()
305
+ setMessages(d.docs || [])
306
+ }
307
+ if (ticketRes.ok) {
308
+ const d = await ticketRes.json()
309
+ setCurrentStatus(d.status || '')
310
+ setSnoozeUntil(d.snoozeUntil || null)
311
+ setLastClientReadAt(d.lastClientReadAt || null)
312
+ }
313
+ if (activityRes.ok) {
314
+ const d = await activityRes.json()
315
+ setActivityLog(d.docs || [])
316
+ }
317
+ } catch { /* silent fail for poll */ }
318
+ }
319
+
320
+ const interval = setInterval(poll, 15000)
321
+ return () => clearInterval(interval)
322
+ }, [id, loading, pollExpired])
323
+
324
+ // Keyboard shortcuts
325
+ useEffect(() => {
326
+ const handleKeyDown = (e: KeyboardEvent) => {
327
+ // Ctrl/Cmd + Enter -> send reply
328
+ if ((e.ctrlKey || e.metaKey) && e.key === 'Enter' && !e.shiftKey) {
329
+ e.preventDefault()
330
+ const sendBtn = document.querySelector('[data-action="send-reply"]') as HTMLButtonElement
331
+ if (sendBtn && !sendBtn.disabled) sendBtn.click()
332
+ }
333
+ // Ctrl/Cmd + Shift + N -> toggle internal note
334
+ if ((e.ctrlKey || e.metaKey) && e.shiftKey && (e.key === 'N' || e.key === 'n')) {
335
+ e.preventDefault()
336
+ setIsInternal(prev => !prev)
337
+ }
338
+ }
339
+ window.addEventListener('keydown', handleKeyDown)
340
+ return () => window.removeEventListener('keydown', handleKeyDown)
341
+ }, [])
342
+
343
+
344
+
345
+
346
+ // Notification sound for new client messages
347
+ const playNotificationSound = useCallback(() => {
348
+ try {
349
+ const ctx = new (window.AudioContext || (window as unknown as { webkitAudioContext: typeof AudioContext }).webkitAudioContext)()
350
+ const osc = ctx.createOscillator()
351
+ const gain = ctx.createGain()
352
+ osc.connect(gain)
353
+ gain.connect(ctx.destination)
354
+ osc.frequency.value = 800
355
+ osc.type = 'sine'
356
+ gain.gain.value = 0.1
357
+ osc.start()
358
+ gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.3)
359
+ osc.stop(ctx.currentTime + 0.3)
360
+ } catch { /* silent */ }
361
+ }, [])
362
+
363
+ // Detect new client messages and play sound
364
+ useEffect(() => {
365
+ const currentCount = messages.length
366
+ if (prevMessageCountRef.current > 0 && currentCount > prevMessageCountRef.current) {
367
+ // Check if the newest message is from a client
368
+ const lastMsg = messages[messages.length - 1]
369
+ if (lastMsg && lastMsg.authorType !== 'admin') {
370
+ playNotificationSound()
371
+ }
372
+ }
373
+ prevMessageCountRef.current = currentCount
374
+ }, [messages, playNotificationSound])
375
+
376
+ // AI Suggest/Rewrite handlers moved to useAI hook
377
+
378
+ // Copy link handler
379
+ const handleCopyLink = (type: 'admin' | 'client') => {
380
+ const url = type === 'admin'
381
+ ? `${window.location.origin}/admin/collections/tickets/${id}`
382
+ : `${window.location.origin}/support/tickets/${id}`
383
+ navigator.clipboard.writeText(url)
384
+ setCopiedLink(type)
385
+ setTimeout(() => setCopiedLink(null), 2000)
386
+ }
387
+
388
+ if (!id) {
389
+ return (
390
+ <div style={{ padding: '16px', color: '#666', fontStyle: 'italic' }}>
391
+ Enregistrez le ticket pour voir le tableau de bord.
392
+ </div>
393
+ )
394
+ }
395
+
396
+ const totalMinutes = timeEntries.reduce((sum, e) => sum + (e.duration || 0), 0)
397
+
398
+ // Contextual status transitions
399
+ const statusTransitions: Array<{ status: string; label: string; color: string }> = (() => {
400
+ switch (currentStatus) {
401
+ case 'open':
402
+ return [
403
+ { status: 'waiting_client', label: 'Attente client', color: C.statusWaiting },
404
+ { status: 'resolved', label: 'Résolu', color: C.statusResolved },
405
+ ]
406
+ case 'waiting_client':
407
+ return [
408
+ { status: 'open', label: 'Ouvrir', color: C.statusOpen },
409
+ { status: 'resolved', label: 'Résolu', color: C.statusResolved },
410
+ ]
411
+ case 'resolved':
412
+ return [
413
+ { status: 'open', label: 'Rouvrir', color: C.statusOpen },
414
+ ]
415
+ default:
416
+ return [
417
+ { status: 'open', label: 'Ouvrir', color: C.statusOpen },
418
+ { status: 'waiting_client', label: 'Attente client', color: C.statusWaiting },
419
+ { status: 'resolved', label: 'Résolu', color: C.statusResolved },
420
+ ]
421
+ }
422
+ })()
423
+
424
+ return (
425
+ <div style={layoutStyles.root}>
426
+ {/* ===== 1. COMPACT HEADER ===== */}
427
+ <TicketHeader
428
+ ticketNumber={ticketNumber}
429
+ currentStatus={currentStatus}
430
+ clientSentiment={clientSentiment}
431
+ ticketSource={ticketSource}
432
+ chatSession={chatSession}
433
+ snoozeUntil={snoozeUntil}
434
+ satisfaction={satisfaction}
435
+ copiedLink={copiedLink}
436
+ onCopyLink={handleCopyLink}
437
+ />
438
+
439
+ {/* ===== 2. CLIENT COMPACT ===== */}
440
+ {client && <ClientBar client={client} />}
441
+
442
+
443
+ <div style={layoutStyles.twoColumns}>
444
+ <div style={layoutStyles.mainColumn}>
445
+ {/* ===== 3. CONVERSATION THREAD ===== */}
446
+ <div style={{ marginBottom: '4px', display: 'flex', alignItems: 'center', gap: '10px' }}>
447
+ <h3 style={{ fontSize: '14px', fontWeight: 600, margin: 0, display: 'flex', alignItems: 'center', gap: '8px' }}>
448
+ Conversation <span style={s.badge('#f1f5f9', '#475569')}>{messages.length}</span>
449
+ </h3>
450
+ <div style={{ flex: 1 }}>
451
+ <input
452
+ type="text"
453
+ value={searchQuery}
454
+ onChange={(e) => setSearchQuery(e.target.value)}
455
+ placeholder="Rechercher..."
456
+ style={{ ...s.input, width: '100%', fontSize: '12px', padding: '6px 10px' }}
457
+ />
458
+ </div>
459
+ </div>
460
+
461
+ {loading ? (
462
+ <SkeletonText lines={4} />
463
+ ) : messages.length === 0 ? (
464
+ <p style={{ color: '#999', fontStyle: 'italic', padding: '12px 0' }}>Aucun message.</p>
465
+ ) : (
466
+ <div style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '16px', paddingRight: '4px' }}>
467
+ {(() => {
468
+ const filtered = messages.filter((msg) => !searchQuery.trim() || msg.body.toLowerCase().includes(searchQuery.toLowerCase()))
469
+ const isSearching = searchQuery.trim().length > 0
470
+ const VISIBLE_COUNT = 3
471
+ const showCollapse = !isSearching && messagesCollapsed && filtered.length > VISIBLE_COUNT
472
+ const visibleMessages = showCollapse ? filtered.slice(-VISIBLE_COUNT) : filtered
473
+ const hiddenCount = filtered.length - VISIBLE_COUNT
474
+
475
+ return (
476
+ <>
477
+ {showCollapse && hiddenCount > 0 && (
478
+ <button
479
+ onClick={() => setMessagesCollapsed(false)}
480
+ style={{
481
+ background: 'none', border: `1px dashed ${C.border}`, borderRadius: '6px',
482
+ padding: '8px', cursor: 'pointer', color: C.textMuted, fontSize: '12px',
483
+ fontWeight: 600, textAlign: 'center',
484
+ }}
485
+ >
486
+ Voir les {hiddenCount} message{hiddenCount > 1 ? 's' : ''} précédent{hiddenCount > 1 ? 's' : ''}
487
+ </button>
488
+ )}
489
+ {!messagesCollapsed && filtered.length > 1 && !isSearching && (
490
+ <button
491
+ onClick={() => setMessagesCollapsed(true)}
492
+ style={{
493
+ background: 'none', border: `1px dashed ${C.border}`, borderRadius: '6px',
494
+ padding: '8px', cursor: 'pointer', color: C.textMuted, fontSize: '12px',
495
+ fontWeight: 600, textAlign: 'center',
496
+ }}
497
+ >
498
+ Masquer les anciens messages
499
+ </button>
500
+ )}
501
+ {visibleMessages.map((msg, msgIdx) => {
502
+ const borderColor = msg.isInternal ? C.internalBorder : msg.fromChat ? '#bae6fd' : msg.authorType === 'admin' ? '#bfdbfe' : msg.authorType === 'email' ? '#fed7aa' : C.clientBorder
503
+ const bgColor = msg.isInternal ? C.internalBg : msg.fromChat ? '#f0f9ff' : msg.authorType === 'admin' ? C.adminBg : msg.authorType === 'email' ? C.emailBg : C.clientBg
504
+ const prevVisMsg = msgIdx > 0 ? visibleMessages[msgIdx - 1] : null
505
+ const showDateSep = msg.createdAt && (!prevVisMsg?.createdAt || new Date(msg.createdAt).toDateString() !== new Date(prevVisMsg.createdAt).toDateString())
506
+ return (
507
+ <React.Fragment key={msg.id}>
508
+ {showDateSep && (
509
+ <div style={{ display: 'flex', alignItems: 'center', gap: '12px', padding: '4px 0' }}>
510
+ <div style={{ flex: 1, borderTop: `1px solid ${C.border}` }} />
511
+ <span style={{ fontSize: '11px', fontWeight: 600, color: C.textMuted, whiteSpace: 'nowrap' }}>{getDateLabel(msg.createdAt)}</span>
512
+ <div style={{ flex: 1, borderTop: `1px solid ${C.border}` }} />
513
+ </div>
514
+ )}
515
+ <div style={{ display: 'flex', gap: '8px', alignItems: 'flex-start' }}>
516
+ {/* Avatar */}
517
+ <div style={{
518
+ flexShrink: 0, width: '28px', height: '28px', borderRadius: '50%',
519
+ display: 'flex', alignItems: 'center', justifyContent: 'center',
520
+ fontSize: '10px', fontWeight: 700, color: '#fff', marginTop: '2px',
521
+ backgroundColor: msg.authorType === 'admin' ? C.blue : msg.authorType === 'email' ? C.orange : '#94a3b8',
522
+ }}>
523
+ {msg.authorType === 'admin' ? 'CW' : client ? `${(client.firstName?.[0] || '').toUpperCase()}${(client.lastName?.[0] || '').toUpperCase()}` || '?' : '?'}
524
+ </div>
525
+ <div
526
+ style={{
527
+ flex: 1, padding: '10px 14px', borderRadius: '8px',
528
+ border: msg.isInternal ? `1px dashed ${C.internalBorder}` : `1px solid ${borderColor}`,
529
+ backgroundColor: bgColor,
530
+ }}
531
+ >
532
+ <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '4px', fontSize: '12px', alignItems: 'center' }}>
533
+ <span style={{ fontWeight: 600, display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
534
+ {msg.authorType === 'email' ? (
535
+ <span style={s.badge(C.emailBg, C.orange)}>Email</span>
536
+ ) : (
537
+ <select
538
+ value={msg.authorType}
539
+ onChange={(e) => handleToggleAuthor(msg.id, e.target.value === 'admin' ? 'client' : 'admin')}
540
+ disabled={togglingAuthor === msg.id}
541
+ style={{
542
+ fontSize: '12px', fontWeight: 600, padding: '2px 6px', borderRadius: '4px',
543
+ border: `1px solid ${C.border}`, cursor: 'pointer',
544
+ backgroundColor: msg.authorType === 'admin' ? '#eff6ff' : '#f9fafb',
545
+ color: '#374151',
546
+ opacity: togglingAuthor === msg.id ? 0.5 : 1,
547
+ }}
548
+ >
549
+ <option value="admin">Support</option>
550
+ <option value="client">Client</option>
551
+ </select>
552
+ )}
553
+ {msg.fromChat && <span style={s.badge('#e0f2fe', '#0284c7')}>Chat</span>}
554
+ {msg.isInternal && <span style={s.badge('#fef3c7', '#92400e')}>Interne</span>}
555
+ {(msg as unknown as { scheduledAt?: string; scheduledSent?: boolean }).scheduledAt && (() => {
556
+ const sched = msg as unknown as { scheduledAt: string; scheduledSent?: boolean }
557
+ const scheduledDate = new Date(sched.scheduledAt).toLocaleString('fr-FR', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
558
+ const createdDate = new Date(msg.createdAt).toLocaleString('fr-FR', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
559
+ return sched.scheduledSent ? (
560
+ <span style={s.badge('#f0fdf4', '#16a34a')}>{'\u2713'} Programmé le {createdDate} — rédigé le {scheduledDate !== createdDate ? scheduledDate : createdDate}</span>
561
+ ) : (
562
+ <span style={s.badge('#f3e8ff', '#7c3aed')}>{'\u23F0'} Rédigé le {createdDate} — envoi programmé le {scheduledDate}</span>
563
+ )
564
+ })()}
565
+ </span>
566
+ <span style={{ color: C.textMuted, fontWeight: 500, fontSize: '11px', display: 'inline-flex', alignItems: 'center', gap: '6px' }}>
567
+ {formatMessageDate(msg.createdAt)}
568
+ {(msg as unknown as { editedAt?: string }).editedAt && (
569
+ <span style={{ fontSize: '10px', color: '#6b7280', fontStyle: 'italic' }}>(modifié)</span>
570
+ )}
571
+ {/* Split button */}
572
+ {!msg.isInternal && !(msg as unknown as { deletedAt?: string }).deletedAt && (
573
+ <button
574
+ onClick={() => handleSplitMessage(msg.id, ticketSubject)}
575
+ style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '10px', color: '#6b7280', padding: 0 }}
576
+ title="Extraire en nouveau ticket"
577
+ >
578
+ {'\u2197'} Extraire
579
+ </button>
580
+ )}
581
+ {msg.authorType === 'admin' && !msg.isInternal && (() => {
582
+ const msgExt = msg as unknown as { emailSentAt?: string; emailSentTo?: string; emailOpenedAt?: string }
583
+ const isRead = lastClientReadAt && msg.createdAt && new Date(msg.createdAt) < new Date(lastClientReadAt)
584
+ const sentAt = msgExt.emailSentAt
585
+ const openedAt = msgExt.emailOpenedAt
586
+ const sentTo = msgExt.emailSentTo
587
+
588
+ if (openedAt) {
589
+ const openDate = new Date(openedAt).toLocaleString('fr-FR', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' })
590
+ return (
591
+ <span title={`Envoyé à ${sentTo || '?'} — Ouvert le ${openDate}`} style={{ fontSize: '10px', color: '#16a34a', fontWeight: 600, cursor: 'help' }}>
592
+ &#9993; Ouvert {openDate}
593
+ </span>
594
+ )
595
+ }
596
+ if (sentAt) {
597
+ const sentDate = new Date(sentAt).toLocaleString('fr-FR', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Paris' })
598
+ return (
599
+ <span title={`Envoyé à ${sentTo || '?'} le ${sentDate}`} style={{ fontSize: '10px', color: '#2563eb', fontWeight: 600, cursor: 'help' }}>
600
+ &#9993; Envoyé à {sentTo} — {sentDate}
601
+ </span>
602
+ )
603
+ }
604
+ return (
605
+ <span style={{ fontSize: '10px', color: isRead ? '#16a34a' : '#94a3b8', fontWeight: 600 }}>
606
+ {isRead ? '\u2713\u2713 Lu' : '\u2713 Envoy\u00e9'}
607
+ </span>
608
+ )
609
+ })()}
610
+ </span>
611
+ </div>
612
+ {/* Body or edit mode */}
613
+ {editingMsg === msg.id ? (
614
+ <div style={{ marginTop: '6px' }}>
615
+ <textarea
616
+ value={editBody}
617
+ onChange={(e) => { setEditBody(e.target.value); setEditHtml(e.target.value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br />')) }}
618
+ rows={4}
619
+ style={{ ...s.input, width: '100%', resize: 'vertical', fontSize: '13px' }}
620
+ />
621
+ <div style={{ display: 'flex', gap: '8px', marginTop: '8px' }}>
622
+ <button onClick={() => handleEditSave(msg.id)} disabled={savingEdit || (!editBody.trim() && !editHtml)} style={{ ...s.btn(C.blue, savingEdit), fontSize: '11px', padding: '5px 12px' }}>
623
+ {savingEdit ? '...' : 'Enregistrer'}
624
+ </button>
625
+ <button onClick={handleEditCancel} style={{ ...s.ghostBtn('#6b7280'), fontSize: '11px', padding: '5px 12px' }}>
626
+ Annuler
627
+ </button>
628
+ </div>
629
+ </div>
630
+ ) : (msg as unknown as { deletedAt?: string }).deletedAt ? (
631
+ <div style={{ fontSize: '13px', color: '#94a3b8', fontStyle: 'italic' }}>Ce message a été supprimé.</div>
632
+ ) : msg.bodyHtml ? (
633
+ <>
634
+ <div
635
+ className="rte-display"
636
+ style={{ fontSize: '13px', color: '#374151', lineHeight: 1.5 }}
637
+ dangerouslySetInnerHTML={{ __html: msg.bodyHtml }}
638
+ />
639
+ <CodeBlockRendererHtml html={msg.bodyHtml} />
640
+ </>
641
+ ) : (
642
+ <>
643
+ <div style={{ whiteSpace: 'pre-wrap', fontSize: '13px', color: '#374151', lineHeight: 1.5 }}>
644
+ {searchQuery.trim() ? (
645
+ msg.body.split(new RegExp(`(${searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi')).map((part, i) =>
646
+ part.toLowerCase() === searchQuery.toLowerCase()
647
+ ? <mark key={i} style={{ backgroundColor: '#fde68a', borderRadius: '2px', padding: '0 2px', fontWeight: 600 }}>{part}</mark>
648
+ : part
649
+ )
650
+ ) : msg.body}
651
+ </div>
652
+ <CodeBlockRenderer text={msg.body} />
653
+ </>
654
+ )}
655
+ {/* Attachments */}
656
+ {Array.isArray(msg.attachments) && msg.attachments.length > 0 && (
657
+ <div style={{ marginTop: '8px', display: 'flex', gap: '8px', flexWrap: 'wrap' }}>
658
+ {msg.attachments.map((att, i) => {
659
+ const file = typeof att.file === 'object' ? att.file : null
660
+ if (!file) return null
661
+ const mime = (file.mimeType || file.filename || '').toLowerCase()
662
+ const isImage = mime.includes('image/') || /\.(png|jpg|jpeg|webp|gif|svg)$/i.test(file.filename || '')
663
+ const isGif = mime.includes('image/gif') || /\.gif$/i.test(file.filename || '')
664
+ const isVideo = mime.includes('video/') || /\.(mp4|webm|mov|avi)$/i.test(file.filename || '')
665
+
666
+ if (isImage) {
667
+ return (
668
+ <a key={i} href={file.url || '#'} target="_blank" rel="noopener noreferrer" style={{ display: 'block' }}>
669
+ <img
670
+ src={file.url || ''}
671
+ alt={file.filename || 'Image'}
672
+ style={{
673
+ maxWidth: isGif ? 300 : 240, maxHeight: 200, borderRadius: '6px',
674
+ border: `1px solid ${C.border}`, objectFit: 'cover', cursor: 'pointer',
675
+ }}
676
+ />
677
+ </a>
678
+ )
679
+ }
680
+
681
+ if (isVideo) {
682
+ return (
683
+ <video
684
+ key={i}
685
+ src={file.url || ''}
686
+ controls
687
+ preload="metadata"
688
+ style={{
689
+ maxWidth: 360, maxHeight: 240, borderRadius: '6px',
690
+ border: `1px solid ${C.border}`, backgroundColor: '#000',
691
+ }}
692
+ />
693
+ )
694
+ }
695
+
696
+ return (
697
+ <a
698
+ key={i}
699
+ href={file.url || '#'}
700
+ target="_blank"
701
+ rel="noopener noreferrer"
702
+ style={{
703
+ display: 'inline-flex', alignItems: 'center', gap: '4px',
704
+ padding: '4px 10px', borderRadius: '4px', border: `1px solid ${C.border}`,
705
+ fontSize: '11px', fontWeight: 600, color: '#374151', textDecoration: 'none',
706
+ backgroundColor: C.white,
707
+ }}
708
+ >
709
+ {'\uD83D\uDCCE'} {file.filename || 'Fichier'}
710
+ </a>
711
+ )
712
+ })}
713
+ </div>
714
+ )}
715
+ {/* Message actions (disabled for chat-only messages) */}
716
+ {editingMsg !== msg.id && !msg.fromChat && (
717
+ <div style={{ marginTop: '6px', display: 'flex', gap: '12px', alignItems: 'center' }}>
718
+ <button
719
+ type="button"
720
+ onClick={() => handleEditStart(msg)}
721
+ style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: '11px', color: C.textSecondary, padding: 0, fontWeight: 600, textDecoration: 'underline' }}
722
+ >
723
+ Modifier
724
+ </button>
725
+ <button
726
+ type="button"
727
+ onClick={() => handleDelete(msg.id)}
728
+ disabled={deletingMsg === msg.id}
729
+ style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: '11px', color: '#ef4444', padding: 0, fontWeight: 600, textDecoration: 'underline', opacity: deletingMsg === msg.id ? 0.3 : 1 }}
730
+ >
731
+ Supprimer
732
+ </button>
733
+ {msg.authorType === 'admin' && !msg.isInternal && (
734
+ <button
735
+ type="button"
736
+ onClick={() => handleResend(msg.id)}
737
+ disabled={resendingMsg === msg.id}
738
+ style={{ border: 'none', background: 'none', cursor: 'pointer', fontSize: '11px', color: '#2563eb', padding: 0, fontWeight: 600, textDecoration: 'underline', opacity: resendingMsg === msg.id ? 0.3 : 1 }}
739
+ >
740
+ {resendingMsg === msg.id ? 'Envoi...' : resendSuccess === msg.id ? 'Envoyé !' : 'Renvoyer email'}
741
+ </button>
742
+ )}
743
+ </div>
744
+ )}
745
+ </div>
746
+ </div>
747
+ </React.Fragment>
748
+ )
749
+ })}
750
+ </>
751
+ )
752
+ })()}
753
+ </div>
754
+ )}
755
+
756
+ {/* Typing indicator */}
757
+ {clientTyping && (
758
+ <div style={{
759
+ display: 'flex', alignItems: 'center', gap: 8, padding: '8px 12px',
760
+ fontSize: 12, color: '#7c3aed', fontWeight: 500,
761
+ }}>
762
+ <span style={{ display: 'flex', gap: 2 }}>
763
+ <span style={{ width: 5, height: 5, borderRadius: '50%', backgroundColor: '#7c3aed', animation: 'bounce 1s infinite', animationDelay: '0ms' }} />
764
+ <span style={{ width: 5, height: 5, borderRadius: '50%', backgroundColor: '#7c3aed', animation: 'bounce 1s infinite', animationDelay: '150ms' }} />
765
+ <span style={{ width: 5, height: 5, borderRadius: '50%', backgroundColor: '#7c3aed', animation: 'bounce 1s infinite', animationDelay: '300ms' }} />
766
+ </span>
767
+ {clientTypingName || 'Client'} est en train d&apos;écrire...
768
+ </div>
769
+ )}
770
+
771
+ {/* ===== 4. REPLY EDITOR ===== */}
772
+ <div style={{ marginBottom: '16px' }}>
773
+ {/* Quick reply pills + canned responses + AI suggest */}
774
+ <div style={{ display: 'flex', gap: '6px', alignItems: 'center', overflowX: 'auto', paddingBottom: '8px', marginBottom: '6px', flexWrap: 'wrap' }}>
775
+ {[
776
+ 'Bien reçu, je regarde ça !',
777
+ 'C\'est corrigé !',
778
+ 'Pouvez-vous préciser ?',
779
+ 'Je reviens vers vous rapidement',
780
+ 'Pouvez-vous m\'envoyer une capture d\'écran ?',
781
+ ].map((text) => (
782
+ <button
783
+ key={text}
784
+ type="button"
785
+ onClick={() => {
786
+ setReplyBody(text)
787
+ const html = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
788
+ setReplyHtml(html)
789
+ if (replyEditorRef.current?.setContent) {
790
+ replyEditorRef.current.setContent(html)
791
+ }
792
+ }}
793
+ onMouseEnter={(e) => { (e.currentTarget as HTMLButtonElement).style.backgroundColor = '#f1f5f9' }}
794
+ onMouseLeave={(e) => { (e.currentTarget as HTMLButtonElement).style.backgroundColor = 'white' }}
795
+ style={{
796
+ padding: '3px 10px',
797
+ borderRadius: '14px',
798
+ border: `1px solid ${C.border}`,
799
+ backgroundColor: 'white',
800
+ fontSize: '11px',
801
+ fontWeight: 500,
802
+ color: '#475569',
803
+ cursor: 'pointer',
804
+ whiteSpace: 'nowrap',
805
+ }}
806
+ >
807
+ {text}
808
+ </button>
809
+ ))}
810
+ {features.ai && (
811
+ <button
812
+ onClick={handleAiSuggestReply}
813
+ disabled={aiReplying || messages.length === 0}
814
+ style={{ ...s.outlineBtn('#7c3aed', aiReplying || messages.length === 0), fontSize: '11px', padding: '3px 10px', borderRadius: '14px' }}
815
+ >
816
+ {aiReplying ? 'Génération...' : 'Suggestion IA'}
817
+ </button>
818
+ )}
819
+ {features.ai && (
820
+ <button
821
+ onClick={handleAiRewrite}
822
+ disabled={aiRewriting || !replyBody.trim()}
823
+ style={{ ...s.outlineBtn('#0891b2', aiRewriting || !replyBody.trim()), fontSize: '11px', padding: '3px 10px', borderRadius: '14px' }}
824
+ >
825
+ {aiRewriting ? 'Reformulation...' : 'Reformuler'}
826
+ </button>
827
+ )}
828
+ <CodeBlockInserter
829
+ style={{ ...s.outlineBtn('#059669', false), fontSize: '11px', padding: '3px 10px', borderRadius: '14px' }}
830
+ onInsert={(block) => {
831
+ const nb = replyBody ? replyBody + block : block
832
+ setReplyBody(nb)
833
+ setReplyHtml(nb.replace(/\n/g, '<br/>'))
834
+ replyEditorRef.current?.setContent(nb.replace(/\n/g, '<br/>'))
835
+ }}
836
+ />
837
+ {features.canned && cannedResponses.length > 0 && (
838
+ <>
839
+ <select onChange={handleCannedSelect} style={{ ...s.input, fontSize: '11px', padding: '3px 8px', fontWeight: 600 }}>
840
+ <option value="">Réponse rapide...</option>
841
+ {cannedResponses.map((cr) => (
842
+ <option key={cr.id} value={String(cr.id)}>{cr.title}</option>
843
+ ))}
844
+ </select>
845
+ <span
846
+ title="Variables disponibles : {{client.firstName}}, {{client.lastName}}, {{client.company}}, {{client.email}}, {{ticket.number}}, {{ticket.subject}}, {{agent.name}}"
847
+ style={{ cursor: 'help', fontSize: '13px', color: C.textMuted }}
848
+ >
849
+ &#9432;
850
+ </span>
851
+ </>
852
+ )}
853
+ </div>
854
+ {/* Reply textarea (replacing RichTextEditor) */}
855
+ <div style={{ border: `1px solid ${C.border}`, borderRadius: '8px', overflow: 'hidden' }}>
856
+ <textarea
857
+ value={replyBody}
858
+ onChange={(e) => {
859
+ const text = e.target.value
860
+ setReplyBody(text)
861
+ setReplyHtml(text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br />'))
862
+ sendAdminTyping()
863
+ }}
864
+ placeholder="Écrire une réponse au client..."
865
+ style={{
866
+ width: '100%',
867
+ minHeight: '120px',
868
+ padding: '12px',
869
+ border: 'none',
870
+ outline: 'none',
871
+ fontSize: '14px',
872
+ lineHeight: 1.5,
873
+ resize: 'vertical',
874
+ fontFamily: 'inherit',
875
+ color: '#374151',
876
+ backgroundColor: 'transparent',
877
+ boxSizing: 'border-box',
878
+ }}
879
+ />
880
+ </div>
881
+ {/* Attachments */}
882
+ <div style={{ marginTop: '8px', display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' }}>
883
+ <input
884
+ ref={fileInputRef}
885
+ type="file"
886
+ multiple
887
+ onChange={handleReplyFileChange}
888
+ style={{ display: 'none' }}
889
+ accept="image/*,.pdf,.doc,.docx,.txt,.zip"
890
+ />
891
+ <button
892
+ type="button"
893
+ onClick={() => fileInputRef.current?.click()}
894
+ style={{ ...s.ghostBtn('#6b7280'), fontSize: '12px', padding: '5px 10px' }}
895
+ >
896
+ + Pièce jointe
897
+ </button>
898
+ {replyFiles.length > 0 && (
899
+ <>
900
+ {replyFiles.map((file, i) => (
901
+ <span key={i} style={{ ...s.badge('#f1f5f9', '#374151'), display: 'inline-flex', alignItems: 'center', gap: '4px' }}>
902
+ {'\uD83D\uDCCE'} {file.name}
903
+ <button
904
+ type="button"
905
+ onClick={() => setReplyFiles((prev) => prev.filter((_, idx) => idx !== i))}
906
+ style={{ border: 'none', background: 'none', color: '#ef4444', fontWeight: 700, cursor: 'pointer', fontSize: '14px', lineHeight: 1 }}
907
+ >
908
+ {'\u00D7'}
909
+ </button>
910
+ </span>
911
+ ))}
912
+ </>
913
+ )}
914
+ </div>
915
+ {/* Send row */}
916
+ <div style={{ display: 'flex', alignItems: 'center', gap: '10px', flexWrap: 'wrap', marginTop: '8px' }}>
917
+ <select
918
+ value={sendAsClient ? 'client' : 'admin'}
919
+ onChange={(e) => setSendAsClient(e.target.value === 'client')}
920
+ style={{ ...s.input, fontSize: '12px', padding: '6px 8px', fontWeight: 600 }}
921
+ >
922
+ <option value="admin">En tant que : Support</option>
923
+ <option value="client">En tant que : Client</option>
924
+ </select>
925
+ <label style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '12px', cursor: 'pointer', fontWeight: 600 }}>
926
+ <input type="checkbox" checked={isInternal} onChange={(e) => { setIsInternal(e.target.checked); if (e.target.checked) setNotifyClient(false) }} style={{ width: '14px', height: '14px', accentColor: C.amber }} />
927
+ Note interne
928
+ </label>
929
+ {!isInternal && (
930
+ <label style={{ display: 'flex', alignItems: 'center', gap: '5px', fontSize: '12px', cursor: 'pointer', fontWeight: 600 }}>
931
+ <input type="checkbox" checked={notifyClient} onChange={(e) => setNotifyClient(e.target.checked)} style={{ width: '14px', height: '14px', accentColor: '#16a34a' }} />
932
+ Envoyer au client
933
+ </label>
934
+ )}
935
+ <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' }}>
936
+ {sending ? 'Envoi...' : isInternal ? 'Ajouter note' : notifyClient ? 'Envoyer + Notifier' : 'Sauvegarder'}
937
+ </button>
938
+ {!isInternal && (
939
+ <button
940
+ onClick={() => setShowSchedule(!showSchedule)}
941
+ disabled={!replyBody.trim() && !replyHtml}
942
+ style={{ ...s.outlineBtn('#7c3aed', !replyBody.trim() && !replyHtml), fontSize: '12px', padding: '8px 12px' }}
943
+ title="Programmer l'envoi à une date/heure précise"
944
+ >
945
+ {'\u23F0'}
946
+ </button>
947
+ )}
948
+ </div>
949
+ {showSchedule && (
950
+ <div style={{ display: 'flex', gap: '8px', alignItems: 'center', marginTop: '8px', padding: '10px 14px', borderRadius: '8px', backgroundColor: '#faf5ff', border: '1px solid #e9d5ff' }}>
951
+ <span style={{ fontSize: '12px', fontWeight: 600, color: '#7c3aed' }}>Programmer pour :</span>
952
+ <input
953
+ type="datetime-local"
954
+ value={scheduleDate}
955
+ onChange={(e) => setScheduleDate(e.target.value)}
956
+ min={new Date().toISOString().slice(0, 16)}
957
+ style={{ ...s.input, fontSize: '12px', width: 'auto' }}
958
+ />
959
+ <button
960
+ onClick={handleScheduleReply}
961
+ disabled={sending || !scheduleDate || (!replyBody.trim() && !replyHtml)}
962
+ style={{ ...s.btn('#7c3aed', sending || !scheduleDate || (!replyBody.trim() && !replyHtml)), fontSize: '12px', padding: '6px 14px' }}
963
+ >
964
+ {sending ? '...' : '\u23F0 Programmer'}
965
+ </button>
966
+ <button onClick={() => setShowSchedule(false)} style={{ background: 'none', border: 'none', cursor: 'pointer', color: '#94a3b8', fontSize: '14px' }}>{'\u2715'}</button>
967
+ </div>
968
+ )}
969
+ <div style={{ fontSize: '11px', color: C.textMuted, marginTop: '4px', textAlign: 'right' }}>
970
+ {'\u2318'}Enter pour envoyer &middot; {'\u2318'}{'\u21E7'}N note interne
971
+ </div>
972
+ </div>
973
+
974
+ {/* ===== SEPARATOR ===== */}
975
+ <div style={{ borderTop: `1px solid ${C.border}`, marginBottom: '16px' }} />
976
+
977
+ {/* ===== 5. QUICK ACTIONS ===== */}
978
+ <QuickActions
979
+ statusTransitions={statusTransitions}
980
+ statusUpdating={statusUpdating}
981
+ onStatusChange={handleStatusChange}
982
+ snoozeUntil={snoozeUntil}
983
+ snoozeSaving={snoozeSaving}
984
+ onCancelSnooze={() => handleSnooze(null)}
985
+ showMerge={showMerge}
986
+ showExtMsg={showExtMsg}
987
+ showSnooze={showSnooze}
988
+ onToggleMerge={() => { setShowMerge(!showMerge); setShowExtMsg(false); setShowSnooze(false) }}
989
+ onToggleExtMsg={() => { setShowExtMsg(!showExtMsg); setShowMerge(false); setShowSnooze(false) }}
990
+ onToggleSnooze={() => { setShowSnooze(!showSnooze); setShowMerge(false); setShowExtMsg(false) }}
991
+ onNextTicket={handleNextTicket}
992
+ showNextTicket={showNextTicket}
993
+ nextTicketId={nextTicketId}
994
+ nextTicketInfo={nextTicketInfo}
995
+ onCloseNextTicket={() => setShowNextTicket(false)}
996
+ />
997
+
998
+ {/* ===== 6. AI SUMMARY ===== */}
999
+ {features.ai && <AISummaryPanel
1000
+ showAiSummary={showAiSummary}
1001
+ setShowAiSummary={setShowAiSummary}
1002
+ aiSummary={aiSummary}
1003
+ aiGenerating={aiGenerating}
1004
+ aiSaving={aiSaving}
1005
+ aiSaved={aiSaved}
1006
+ handleAiGenerate={handleAiGenerate}
1007
+ handleAiSave={handleAiSave}
1008
+ />}
1009
+
1010
+ {/* ===== 7. CONDITIONAL PANELS ===== */}
1011
+ {features.merge && showMerge && (
1012
+ <MergePanel
1013
+ mergeTarget={mergeTarget} setMergeTarget={setMergeTarget}
1014
+ mergeTargetInfo={mergeTargetInfo} setMergeTargetInfo={setMergeTargetInfo}
1015
+ mergeError={mergeError} setMergeError={setMergeError}
1016
+ merging={merging}
1017
+ handleMergeLookup={handleMergeLookup} handleMerge={handleMerge}
1018
+ />
1019
+ )}
1020
+ {features.externalMessages && showExtMsg && (
1021
+ <ExtMessagePanel
1022
+ extMsgBody={extMsgBody} setExtMsgBody={setExtMsgBody}
1023
+ extMsgAuthor={extMsgAuthor} setExtMsgAuthor={setExtMsgAuthor}
1024
+ extMsgDate={extMsgDate} setExtMsgDate={setExtMsgDate}
1025
+ extMsgFiles={extMsgFiles} setExtMsgFiles={setExtMsgFiles}
1026
+ sendingExtMsg={sendingExtMsg}
1027
+ handleSendExtMsg={handleSendExtMsg} handleExtFileChange={handleExtFileChange}
1028
+ />
1029
+ )}
1030
+ {features.snooze && showSnooze && (
1031
+ <SnoozePanel snoozeSaving={snoozeSaving} handleSnooze={handleSnooze} />
1032
+ )}
1033
+
1034
+
1035
+ </div>
1036
+ <div style={layoutStyles.sideColumn}>
1037
+ {/* ===== 8. TIME TRACKING ===== */}
1038
+ <TimeTrackingPanel
1039
+ timeEntries={timeEntries}
1040
+ totalMinutes={totalMinutes}
1041
+ timerRunning={timerRunning}
1042
+ timerSeconds={timerSeconds}
1043
+ setTimerSeconds={setTimerSeconds}
1044
+ timerDescription={timerDescription}
1045
+ setTimerDescription={setTimerDescription}
1046
+ handleTimerStart={handleTimerStart}
1047
+ handleTimerStop={handleTimerStop}
1048
+ handleTimerSave={handleTimerSave}
1049
+ handleTimerDiscard={handleTimerDiscard}
1050
+ duration={duration}
1051
+ setDuration={setDuration}
1052
+ timeDescription={timeDescription}
1053
+ setTimeDescription={setTimeDescription}
1054
+ handleAddTime={handleAddTime}
1055
+ addingTime={addingTime}
1056
+ timeSuccess={timeSuccess}
1057
+ />
1058
+
1059
+ {/* ===== 9. CLIENT HISTORY ===== */}
1060
+ {features.clientHistory && client && (
1061
+ <ClientHistory
1062
+ client={client}
1063
+ clientTickets={clientTickets}
1064
+ clientProjects={clientProjects}
1065
+ clientNotes={clientNotes}
1066
+ onNotesChange={(v) => { setClientNotes(v); setNotesSaved(false) }}
1067
+ onNotesSave={async () => {
1068
+ if (!client) return
1069
+ setSavingNotes(true)
1070
+ try {
1071
+ const res = await fetch(`/api/support-clients/${client.id}`, {
1072
+ method: 'PATCH',
1073
+ headers: { 'Content-Type': 'application/json' },
1074
+ credentials: 'include',
1075
+ body: JSON.stringify({ notes: clientNotes }),
1076
+ })
1077
+ if (res.ok) { setNotesSaved(true); setTimeout(() => setNotesSaved(false), 3000) }
1078
+ } catch { /* ignore */ } finally { setSavingNotes(false) }
1079
+ }}
1080
+ savingNotes={savingNotes}
1081
+ notesSaved={notesSaved}
1082
+ />
1083
+ )}
1084
+
1085
+ {/* ===== 10. ACTIVITY LOG (collapsible) ===== */}
1086
+ {features.activityLog && <ActivityLog activityLog={activityLog} />}
1087
+
1088
+ {/* ===== 11. EXPORT CSV ===== */}
1089
+ <div style={s.section}>
1090
+ <a
1091
+ href="/api/support/export-csv"
1092
+ target="_blank"
1093
+ rel="noopener noreferrer"
1094
+ style={{ ...s.ghostBtn('#6b7280'), fontSize: '12px', textDecoration: 'none', display: 'inline-block' }}
1095
+ >
1096
+ Exporter tous les tickets (CSV)
1097
+ </a>
1098
+ </div>
1099
+
1100
+
1101
+ </div>
1102
+ </div>
1103
+
1104
+ </div>
1105
+ )
1106
+ }
1107
+
1108
+
1109
+
1110
+ export default TicketConversation