@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,294 @@
1
+ 'use client'
2
+
3
+ import React, { useState, useEffect, useRef, useCallback } from 'react'
4
+ import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
5
+ import s from '../../styles/ChatView.module.scss'
6
+
7
+ interface ChatSession {
8
+ session: string
9
+ client: { id: number; firstName?: string; lastName?: string; company?: string; email?: string } | number
10
+ lastMessage: string
11
+ lastMessageAt: string
12
+ senderType: string
13
+ status: string
14
+ messageCount: number
15
+ unreadCount: number
16
+ }
17
+
18
+ interface ChatMessage {
19
+ id: string
20
+ senderType: 'client' | 'agent' | 'system'
21
+ message: string
22
+ createdAt: string
23
+ agent?: { firstName?: string; lastName?: string } | null
24
+ }
25
+
26
+ export const ChatViewClient: React.FC = () => {
27
+ const { t } = useTranslation()
28
+ const [sessions, setSessions] = useState<{ active: ChatSession[]; closed: ChatSession[] }>({ active: [], closed: [] })
29
+ const [selectedSession, setSelectedSession] = useState<string | null>(null)
30
+ const [messages, setMessages] = useState<ChatMessage[]>([])
31
+ const [input, setInput] = useState('')
32
+ const [sending, setSending] = useState(false)
33
+ const [showClosed, setShowClosed] = useState(false)
34
+ const [loading, setLoading] = useState(true)
35
+ const [cannedResponses, setCannedResponses] = useState<{ id: string | number; title: string; body: string }[]>([])
36
+ const messagesEndRef = useRef<HTMLDivElement>(null)
37
+ const lastFetchRef = useRef<string | null>(null)
38
+ const [sessionExpired, setSessionExpired] = useState(false)
39
+ const sessionsESRef = useRef<EventSource | null>(null)
40
+ const messagesESRef = useRef<EventSource | null>(null)
41
+
42
+ const fetchSessions = useCallback(async () => {
43
+ try {
44
+ const res = await fetch('/api/support/admin-chat')
45
+ if (res.status === 401 || res.status === 403) { setSessionExpired(true); return }
46
+ if (res.ok) {
47
+ const data = await res.json()
48
+ setSessions({ active: data.active || [], closed: data.closed || [] })
49
+ }
50
+ } catch { /* ignore */ }
51
+ setLoading(false)
52
+ }, [])
53
+
54
+ // SSE for session list with polling fallback
55
+ useEffect(() => {
56
+ if (sessionExpired) return
57
+
58
+ // Always fetch once for initial data
59
+ fetchSessions()
60
+
61
+ if (typeof EventSource !== 'undefined') {
62
+ const es = new EventSource('/api/support/admin-chat-stream')
63
+ sessionsESRef.current = es
64
+
65
+ es.onmessage = (event) => {
66
+ try {
67
+ const parsed = JSON.parse(event.data)
68
+ if (parsed.type === 'sessions' && parsed.data) {
69
+ setSessions({ active: parsed.data.active || [], closed: parsed.data.closed || [] })
70
+ setLoading(false)
71
+ }
72
+ } catch { /* ignore parse errors */ }
73
+ }
74
+
75
+ es.onerror = () => {
76
+ // SSE failed, fall back to polling
77
+ es.close()
78
+ sessionsESRef.current = null
79
+ const iv = setInterval(fetchSessions, 5000)
80
+ return () => clearInterval(iv)
81
+ }
82
+
83
+ return () => {
84
+ es.close()
85
+ sessionsESRef.current = null
86
+ }
87
+ }
88
+
89
+ // Fallback: polling
90
+ const iv = setInterval(fetchSessions, 5000)
91
+ return () => clearInterval(iv)
92
+ }, [fetchSessions, sessionExpired])
93
+
94
+ useEffect(() => {
95
+ fetch('/api/canned-responses?sort=sortOrder&limit=50&depth=0', { credentials: 'include' })
96
+ .then((res) => res.ok ? res.json() : null)
97
+ .then((data) => { if (data?.docs) setCannedResponses(data.docs) })
98
+ .catch(() => {})
99
+ }, [])
100
+
101
+ // SSE for messages in selected session with polling fallback
102
+ useEffect(() => {
103
+ if (!selectedSession) return
104
+
105
+ const fetchMessages = async () => {
106
+ try {
107
+ const after = lastFetchRef.current || ''
108
+ const url = `/api/support/admin-chat?session=${selectedSession}${after ? `&after=${after}` : ''}`
109
+ const res = await fetch(url)
110
+ if (res.ok) {
111
+ const data = await res.json()
112
+ if (!lastFetchRef.current) {
113
+ setMessages(data.messages || [])
114
+ } else if (data.messages?.length > 0) {
115
+ setMessages((prev) => {
116
+ const ids = new Set(prev.map((m) => m.id))
117
+ const newMsgs = data.messages.filter((m: ChatMessage) => !ids.has(m.id))
118
+ return newMsgs.length > 0 ? [...prev, ...newMsgs] : prev
119
+ })
120
+ }
121
+ if (data.messages?.length > 0) {
122
+ lastFetchRef.current = data.messages[data.messages.length - 1].createdAt
123
+ }
124
+ }
125
+ } catch { /* ignore */ }
126
+ }
127
+
128
+ // Always load initial messages via REST
129
+ lastFetchRef.current = null
130
+ fetchMessages()
131
+
132
+ // Then try SSE for real-time updates
133
+ if (typeof EventSource !== 'undefined') {
134
+ const es = new EventSource(`/api/support/admin-chat-stream?session=${selectedSession}`)
135
+ messagesESRef.current = es
136
+
137
+ es.onmessage = (event) => {
138
+ try {
139
+ const parsed = JSON.parse(event.data)
140
+ if (parsed.type === 'messages' && parsed.data?.length > 0) {
141
+ setMessages((prev) => {
142
+ const ids = new Set(prev.map((m) => m.id))
143
+ const newMsgs = parsed.data.filter((m: ChatMessage) => !ids.has(m.id))
144
+ return newMsgs.length > 0 ? [...prev, ...newMsgs] : prev
145
+ })
146
+ }
147
+ } catch { /* ignore parse errors */ }
148
+ }
149
+
150
+ es.onerror = () => {
151
+ // SSE failed, fall back to polling
152
+ es.close()
153
+ messagesESRef.current = null
154
+ const iv = setInterval(fetchMessages, 3000)
155
+ // Store interval for cleanup — use a local ref
156
+ ;(fetchMessages as any)._fallbackIv = iv
157
+ }
158
+
159
+ return () => {
160
+ es.close()
161
+ messagesESRef.current = null
162
+ if ((fetchMessages as any)._fallbackIv) clearInterval((fetchMessages as any)._fallbackIv)
163
+ }
164
+ }
165
+
166
+ // Fallback: polling
167
+ const iv = setInterval(fetchMessages, 3000)
168
+ return () => clearInterval(iv)
169
+ }, [selectedSession])
170
+
171
+ useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages])
172
+
173
+ const sendMessage = async (e: React.FormEvent) => {
174
+ e.preventDefault()
175
+ if (!input.trim() || !selectedSession || sending) return
176
+ setSending(true)
177
+ try {
178
+ const res = await fetch('/api/support/admin-chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'send', session: selectedSession, message: input.trim() }) })
179
+ if (res.ok) { const data = await res.json(); setMessages((prev) => [...prev, data.message]); lastFetchRef.current = data.message.createdAt; setInput('') }
180
+ } catch { /* ignore */ }
181
+ setSending(false)
182
+ }
183
+
184
+ const closeSession = async () => {
185
+ if (!selectedSession) return
186
+ try {
187
+ await fetch('/api/support/admin-chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action: 'close', session: selectedSession }) })
188
+ setSelectedSession(null)
189
+ fetchSessions()
190
+ } catch { /* ignore */ }
191
+ }
192
+
193
+ const getClientName = (client: ChatSession['client']): string => {
194
+ if (typeof client === 'number') return `Client #${client}`
195
+ const parts = [client.firstName, client.lastName].filter(Boolean)
196
+ if (parts.length > 0) return parts.join(' ')
197
+ return client.email || `Client #${client.id}`
198
+ }
199
+
200
+ const displayedSessions = showClosed ? sessions.closed : sessions.active
201
+
202
+ const S: Record<string, React.CSSProperties> = {
203
+ page: { padding: '20px 30px', maxWidth: 1200, margin: '0 auto' },
204
+ container: { display: 'grid', gridTemplateColumns: '320px 1fr', gap: 16, minHeight: 'calc(100vh - 300px)' },
205
+ sidebar: { borderRight: '1px solid var(--theme-elevation-200)' },
206
+ sessionItem: { display: 'block', width: '100%', padding: '10px 14px', border: 'none', background: 'none', cursor: 'pointer', textAlign: 'left' as const, borderBottom: '1px solid var(--theme-elevation-100)', fontSize: 13 },
207
+ sessionActive: { background: 'var(--theme-elevation-50)' },
208
+ chatPanel: { display: 'flex', flexDirection: 'column' as const },
209
+ messagesArea: { flex: 1, overflowY: 'auto' as const, padding: '12px 0' },
210
+ bubble: { maxWidth: '70%', padding: '8px 12px', borderRadius: 10, marginBottom: 8, fontSize: 14 },
211
+ bubbleAgent: { background: '#dbeafe', color: '#1e3a5f', marginLeft: 'auto' },
212
+ bubbleClient: { background: 'var(--theme-elevation-100)', color: 'var(--theme-text)' },
213
+ bubbleSystem: { margin: '4px auto', padding: '4px 12px', fontSize: 11, color: '#6b7280', textAlign: 'center' as const },
214
+ composer: { borderTop: '1px solid var(--theme-elevation-200)', padding: '8px 0' },
215
+ composerInput: { flex: 1, padding: '8px 12px', borderRadius: 8, border: '1px solid var(--theme-elevation-200)', fontSize: 13, background: 'var(--theme-elevation-0)', color: 'var(--theme-text)' },
216
+ sendBtn: { padding: '8px 16px', borderRadius: 8, background: '#2563eb', color: '#fff', border: 'none', fontWeight: 600, cursor: 'pointer', fontSize: 13 },
217
+ tabsRow: { display: 'flex', gap: 4, padding: '8px 14px', borderBottom: '1px solid var(--theme-elevation-200)' },
218
+ tab: { padding: '4px 10px', borderRadius: 6, border: 'none', background: 'none', cursor: 'pointer', fontSize: 12, color: 'var(--theme-elevation-500)' },
219
+ tabActive: { background: 'var(--theme-elevation-100)', fontWeight: 700, color: 'var(--theme-text)' },
220
+ }
221
+
222
+ return (
223
+ <div style={S.page}>
224
+ <div style={{ marginBottom: 16 }}>
225
+ <h1 style={{ fontSize: 22, fontWeight: 700, margin: 0, color: 'var(--theme-text)' }}>{t('chat.title')}</h1>
226
+ <p style={{ color: 'var(--theme-elevation-500)', fontSize: 13, margin: '4px 0 0' }}>{sessions.active.length !== 1 ? t('chat.sessionCountPlural', { count: String(sessions.active.length) }) : t('chat.sessionCount', { count: String(sessions.active.length) })}</p>
227
+ </div>
228
+
229
+ <div style={S.container}>
230
+ <div style={S.sidebar}>
231
+ <div style={S.tabsRow}>
232
+ <button onClick={() => setShowClosed(false)} style={{ ...S.tab, ...(!showClosed ? S.tabActive : {}) }}>{t('chat.tabs.active')} ({sessions.active.length})</button>
233
+ <button onClick={() => setShowClosed(true)} style={{ ...S.tab, ...(showClosed ? S.tabActive : {}) }}>{t('chat.tabs.closed')} ({sessions.closed.length})</button>
234
+ </div>
235
+ <div>
236
+ {loading ? <div style={{ padding: 20, textAlign: 'center', color: '#94a3b8' }}>{t('common.loading')}</div>
237
+ : displayedSessions.length === 0 ? <div style={{ padding: 20, textAlign: 'center', color: '#94a3b8' }}>{showClosed ? t('chat.noSessionClosed') : t('chat.noSessionActive')}</div>
238
+ : displayedSessions.map((s) => (
239
+ <button key={s.session} onClick={() => setSelectedSession(s.session)} style={{ ...S.sessionItem, ...(selectedSession === s.session ? S.sessionActive : {}) }}>
240
+ <div style={{ display: 'flex', justifyContent: 'space-between' }}>
241
+ <span style={{ fontWeight: 600 }}>{getClientName(s.client)}</span>
242
+ {s.unreadCount > 0 && <span style={{ padding: '1px 6px', borderRadius: 10, background: '#dc2626', color: '#fff', fontSize: 10, fontWeight: 700 }}>{s.unreadCount}</span>}
243
+ </div>
244
+ <div style={{ fontSize: 12, color: 'var(--theme-elevation-500)', marginTop: 2, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{s.lastMessage}</div>
245
+ <div style={{ fontSize: 11, color: 'var(--theme-elevation-400)', marginTop: 2 }}>{new Date(s.lastMessageAt).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })} -- {s.messageCount} {t('chat.msg')}</div>
246
+ </button>
247
+ ))}
248
+ </div>
249
+ </div>
250
+
251
+ <div style={S.chatPanel}>
252
+ {!selectedSession ? (
253
+ <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#94a3b8', fontSize: 14 }}>{t('chat.selectSession')}</div>
254
+ ) : (
255
+ <>
256
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '8px 14px', borderBottom: '1px solid var(--theme-elevation-200)' }}>
257
+ <span style={{ fontFamily: 'monospace', fontSize: 12, color: 'var(--theme-elevation-500)' }}>{selectedSession}</span>
258
+ <button onClick={closeSession} style={{ padding: '4px 12px', borderRadius: 6, border: '1px solid #dc2626', background: 'none', color: '#dc2626', fontSize: 12, cursor: 'pointer' }}>{t('chat.closeChat')}</button>
259
+ </div>
260
+ <div style={S.messagesArea}>
261
+ {messages.map((msg) => (
262
+ <div key={msg.id} style={{ display: 'flex', flexDirection: msg.senderType === 'agent' ? 'row-reverse' : 'row', padding: '2px 14px' }}>
263
+ {msg.senderType === 'system' ? (
264
+ <div style={S.bubbleSystem}>{msg.message}</div>
265
+ ) : (
266
+ <div style={{ ...S.bubble, ...(msg.senderType === 'agent' ? S.bubbleAgent : S.bubbleClient) }}>
267
+ <div style={{ fontSize: 11, fontWeight: 600, marginBottom: 2 }}>{msg.senderType === 'agent' ? t('chat.you') : t('chat.clientLabel')}</div>
268
+ <div>{msg.message}</div>
269
+ <div style={{ fontSize: 10, color: msg.senderType === 'agent' ? '#1e40af' : 'var(--theme-elevation-400)', marginTop: 2 }}>{new Date(msg.createdAt).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}</div>
270
+ </div>
271
+ )}
272
+ </div>
273
+ ))}
274
+ <div ref={messagesEndRef} />
275
+ </div>
276
+ <form onSubmit={sendMessage} style={S.composer}>
277
+ {cannedResponses.length > 0 && (
278
+ <select onChange={(e) => { const cr = cannedResponses.find((c) => String(c.id) === e.target.value); if (cr) setInput(cr.body); e.target.value = '' }} style={{ padding: '4px 8px', borderRadius: 6, border: '1px solid var(--theme-elevation-200)', fontSize: 11, marginBottom: 6, color: 'var(--theme-text)', background: 'var(--theme-elevation-0)' }}>
279
+ <option value="">{t('chat.quickReply')}</option>
280
+ {cannedResponses.map((cr) => <option key={cr.id} value={String(cr.id)}>{cr.title}</option>)}
281
+ </select>
282
+ )}
283
+ <div style={{ display: 'flex', gap: 8 }}>
284
+ <input type="text" value={input} onChange={(e) => setInput(e.target.value)} placeholder={t('chat.inputPlaceholder')} maxLength={2000} style={S.composerInput} autoFocus />
285
+ <button type="submit" disabled={!input.trim() || sending} style={S.sendBtn}>{t('chat.sendButton')}</button>
286
+ </div>
287
+ </form>
288
+ </>
289
+ )}
290
+ </div>
291
+ </div>
292
+ </div>
293
+ )
294
+ }
@@ -0,0 +1,33 @@
1
+ import type { AdminViewServerProps } from 'payload'
2
+ import { DefaultTemplate } from '@payloadcms/next/templates'
3
+ import { redirect } from 'next/navigation'
4
+ import React from 'react'
5
+ import { AdminErrorBoundary } from '../shared/ErrorBoundary'
6
+ import { ChatViewClient } from './client'
7
+
8
+ export const ChatView: React.FC<AdminViewServerProps> = ({ initPageResult }) => {
9
+ const { req, visibleEntities } = initPageResult
10
+
11
+ if (!req.user) {
12
+ redirect('/admin/login')
13
+ }
14
+
15
+ return (
16
+ <DefaultTemplate
17
+ i18n={req.i18n}
18
+ locale={initPageResult.locale}
19
+ params={{}}
20
+ payload={req.payload}
21
+ permissions={initPageResult.permissions}
22
+ searchParams={{}}
23
+ user={req.user}
24
+ visibleEntities={visibleEntities}
25
+ >
26
+ <AdminErrorBoundary viewName="ChatView">
27
+ <ChatViewClient />
28
+ </AdminErrorBoundary>
29
+ </DefaultTemplate>
30
+ )
31
+ }
32
+
33
+ export default ChatView
@@ -0,0 +1,206 @@
1
+ 'use client'
2
+
3
+ import React, { useState, useEffect, useCallback } from 'react'
4
+ import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
5
+ import s from '../../styles/CrmView.module.scss'
6
+
7
+ interface Client { id: number; company: string; firstName: string; lastName: string; email: string; phone?: string; notes?: string; createdAt: string }
8
+ interface ClientDetail {
9
+ client: Client
10
+ tickets: { id: number; ticketNumber: string; subject: string; status: string; priority: string; createdAt: string }[]
11
+ projects: { id: number; name: string; status: string }[]
12
+ stats: { totalTickets: number; openTickets: number; resolvedTickets: number; totalTimeMinutes: number; lastActivity: string | null }
13
+ }
14
+
15
+ const statusLabelKeys: Record<string, string> = { open: 'ticket.status.open', waiting_client: 'ticket.status.waiting_client', resolved: 'ticket.status.resolved' }
16
+ const statusColors: Record<string, string> = { open: '#3b82f6', waiting_client: '#f59e0b', resolved: '#22c55e' }
17
+
18
+ function formatDuration(minutes: number): string { const h = Math.floor(minutes / 60); const m = minutes % 60; if (h === 0) return `${m}min`; if (m === 0) return `${h}h`; return `${h}h${m}m` }
19
+ function timeAgo(dateStr: string): string { const diff = Date.now() - new Date(dateStr).getTime(); const days = Math.floor(diff / 86400000); if (days === 0) return "Aujourd'hui"; if (days === 1) return 'Hier'; if (days < 30) return `Il y a ${days}j`; return `Il y a ${Math.floor(days / 30)} mois` }
20
+
21
+ export const CrmClient: React.FC = () => {
22
+ const { t } = useTranslation()
23
+ const [clients, setClients] = useState<Client[]>([])
24
+ const [loading, setLoading] = useState(true)
25
+ const [search, setSearch] = useState('')
26
+ const [selectedId, setSelectedId] = useState<number | null>(null)
27
+ const [detail, setDetail] = useState<ClientDetail | null>(null)
28
+ const [detailLoading, setDetailLoading] = useState(false)
29
+ const [showMerge, setShowMerge] = useState(false)
30
+ const [mergeSearch, setMergeSearch] = useState('')
31
+ const [mergeResults, setMergeResults] = useState<Client[]>([])
32
+ const [merging, setMerging] = useState(false)
33
+ const [mergeSuccess, setMergeSuccess] = useState('')
34
+
35
+ const fetchClients = useCallback(async () => {
36
+ try {
37
+ const params = new URLSearchParams({ limit: '100', sort: 'company', depth: '0' })
38
+ if (search) params.set('where[company][like]', search)
39
+ const res = await fetch(`/api/support-clients?${params}`)
40
+ if (res.ok) { const json = await res.json(); setClients(json.docs || []) }
41
+ } catch { /* silent */ }
42
+ setLoading(false)
43
+ }, [search])
44
+
45
+ useEffect(() => { const timeout = setTimeout(fetchClients, 300); return () => clearTimeout(timeout) }, [fetchClients])
46
+
47
+ const fetchDetail = useCallback(async (clientId: number) => {
48
+ setDetailLoading(true)
49
+ try {
50
+ const [clientRes, ticketsRes, projectsRes, timeRes] = await Promise.all([
51
+ fetch(`/api/support-clients/${clientId}?depth=0`),
52
+ fetch(`/api/tickets?where[client][equals]=${clientId}&sort=-createdAt&limit=20&depth=0`),
53
+ fetch(`/api/projects?where[client][equals]=${clientId}&depth=0`),
54
+ fetch(`/api/time-entries?limit=0&depth=0`),
55
+ ])
56
+ const client = clientRes.ok ? await clientRes.json() : null
57
+ const tickets = ticketsRes.ok ? (await ticketsRes.json()).docs || [] : []
58
+ const projects = projectsRes.ok ? (await projectsRes.json()).docs || [] : []
59
+ const ticketIds = tickets.map((t: { id: number }) => t.id)
60
+ let totalTimeMinutes = 0
61
+ if (timeRes.ok) { const timeData = await timeRes.json(); totalTimeMinutes = timeData.docs.filter((e: { ticket: number }) => ticketIds.includes(e.ticket)).reduce((sum: number, e: { duration: number }) => sum + (e.duration || 0), 0) }
62
+ const openTickets = tickets.filter((t: { status: string }) => ['open', 'waiting_client'].includes(t.status)).length
63
+ const resolvedTickets = tickets.filter((t: { status: string }) => t.status === 'resolved').length
64
+ setDetail({ client, tickets, projects, stats: { totalTickets: tickets.length, openTickets, resolvedTickets, totalTimeMinutes, lastActivity: tickets.length > 0 ? tickets[0].createdAt : null } })
65
+ } catch { setDetail(null) }
66
+ setDetailLoading(false)
67
+ }, [])
68
+
69
+ const selectClient = (id: number) => { setSelectedId(id); fetchDetail(id); setShowMerge(false); setMergeSuccess('') }
70
+
71
+ useEffect(() => {
72
+ if (!mergeSearch || mergeSearch.length < 2) { setMergeResults([]); return }
73
+ const timer = setTimeout(async () => {
74
+ try {
75
+ const res = await fetch(`/api/support-clients?where[or][0][company][like]=${encodeURIComponent(mergeSearch)}&where[or][1][email][like]=${encodeURIComponent(mergeSearch)}&limit=10&depth=0`)
76
+ if (res.ok) { const json = await res.json(); setMergeResults((json.docs || []).filter((c: Client) => c.id !== selectedId)) }
77
+ } catch { /* silent */ }
78
+ }, 300)
79
+ return () => clearTimeout(timer)
80
+ }, [mergeSearch, selectedId])
81
+
82
+ const handleMerge = async (targetId: number) => {
83
+ if (!selectedId || !detail) return
84
+ if (!confirm(`Fusionner ce client dans un autre ? Cette action est irreversible.`)) return
85
+ setMerging(true)
86
+ try {
87
+ const res = await fetch('/api/support/merge-clients', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sourceId: selectedId, targetId }) })
88
+ if (res.ok) { const data = await res.json(); setMergeSuccess(data.message); setShowMerge(false); setSelectedId(targetId); fetchDetail(targetId); fetchClients() }
89
+ else { const err = await res.json().catch(() => ({ error: 'Erreur inconnue' })); alert(`Erreur : ${err.error}`) }
90
+ } catch { alert('Erreur reseau') }
91
+ setMerging(false)
92
+ }
93
+
94
+ const S: Record<string, React.CSSProperties> = {
95
+ page: { padding: '20px 30px', maxWidth: 1200, margin: '0 auto' },
96
+ grid: { display: 'grid', gridTemplateColumns: '300px 1fr', gap: 20 },
97
+ sidebar: { borderRight: '1px solid var(--theme-elevation-200)', paddingRight: 16 },
98
+ searchInput: { width: '100%', padding: '8px 12px', borderRadius: 8, border: '1px solid var(--theme-elevation-200)', fontSize: 13, marginBottom: 12, color: 'var(--theme-text)', background: 'var(--theme-elevation-0)' },
99
+ clientItem: { padding: '10px 12px', borderRadius: 8, cursor: 'pointer', marginBottom: 4, border: '1px solid transparent' },
100
+ clientItemActive: { background: 'var(--theme-elevation-50)', borderColor: 'var(--theme-elevation-200)' },
101
+ statsGrid: { display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 8, marginBottom: 16 },
102
+ statCard: { padding: '10px 12px', borderRadius: 8, border: '1px solid var(--theme-elevation-150)', textAlign: 'center' as const },
103
+ table: { width: '100%', borderCollapse: 'collapse' as const, fontSize: 13 },
104
+ th: { textAlign: 'left' as const, padding: '6px 8px', borderBottom: '1px solid var(--theme-elevation-200)', fontSize: 11, color: 'var(--theme-elevation-500)' },
105
+ td: { padding: '6px 8px', borderBottom: '1px solid var(--theme-elevation-100)' },
106
+ badge: { padding: '2px 6px', borderRadius: 4, fontSize: 10, fontWeight: 600 },
107
+ }
108
+
109
+ return (
110
+ <div style={S.page}>
111
+ <div style={{ marginBottom: 20 }}>
112
+ <h1 style={{ fontSize: 22, fontWeight: 700, margin: 0, color: 'var(--theme-text)' }}>{t('crm.title')}</h1>
113
+ <div style={{ fontSize: 13, color: 'var(--theme-elevation-500)', marginTop: 4 }}>{t('crm.subtitle')}</div>
114
+ </div>
115
+
116
+ <div style={S.grid}>
117
+ <div style={S.sidebar}>
118
+ <input type="text" placeholder={t('crm.searchPlaceholder')} value={search} onChange={(e) => setSearch(e.target.value)} style={S.searchInput} />
119
+ <div>
120
+ {loading ? <div style={{ padding: 20, textAlign: 'center', color: '#94a3b8' }}>{t('common.loading')}</div>
121
+ : clients.length === 0 ? <div style={{ padding: 20, textAlign: 'center', color: '#94a3b8' }}>{t('crm.noClientFound')}</div>
122
+ : clients.map((c) => (
123
+ <div key={c.id} onClick={() => selectClient(c.id)} style={{ ...S.clientItem, ...(selectedId === c.id ? S.clientItemActive : {}) }}>
124
+ <div style={{ fontWeight: 600, fontSize: 13 }}>{c.company}</div>
125
+ <div style={{ fontSize: 12, color: 'var(--theme-elevation-500)' }}>{c.firstName} {c.lastName}</div>
126
+ <div style={{ fontSize: 11, color: 'var(--theme-elevation-400)' }}>{c.email}</div>
127
+ </div>
128
+ ))}
129
+ </div>
130
+ </div>
131
+
132
+ <div>
133
+ {!selectedId ? <div style={{ padding: 60, textAlign: 'center', color: '#94a3b8' }}>{t('crm.selectClient')}</div>
134
+ : detailLoading ? <div style={{ padding: 60, textAlign: 'center', color: '#94a3b8' }}>{t('common.loading')}</div>
135
+ : detail ? (
136
+ <div>
137
+ {/* Client header */}
138
+ <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
139
+ <div>
140
+ <h2 style={{ fontSize: 20, fontWeight: 700, margin: 0 }}>{detail.client.company}</h2>
141
+ <div style={{ fontSize: 13, color: 'var(--theme-elevation-500)' }}>{detail.client.firstName} {detail.client.lastName}</div>
142
+ <div style={{ fontSize: 12, color: 'var(--theme-elevation-400)' }}>{detail.client.email} {detail.client.phone && `-- ${detail.client.phone}`}</div>
143
+ {detail.client.notes && <div style={{ fontSize: 12, color: 'var(--theme-elevation-500)', marginTop: 4, fontStyle: 'italic' }}>{detail.client.notes}</div>}
144
+ </div>
145
+ <div style={{ display: 'flex', gap: 8 }}>
146
+ <a href={`/admin/collections/support-clients/${detail.client.id}`} style={{ padding: '6px 14px', borderRadius: 6, background: '#2563eb', color: '#fff', fontSize: 12, fontWeight: 600, textDecoration: 'none' }}>{t('crm.editButton')}</a>
147
+ <button onClick={() => { setShowMerge(!showMerge); setMergeSearch(''); setMergeResults([]) }} style={{ padding: '6px 14px', borderRadius: 6, border: '1px solid #d97706', background: 'none', color: '#d97706', fontSize: 12, fontWeight: 600, cursor: 'pointer' }}>{t('crm.mergeButton')}</button>
148
+ </div>
149
+ </div>
150
+
151
+ {mergeSuccess && <div style={{ padding: '8px 14px', borderRadius: 6, background: '#dcfce7', color: '#166534', fontSize: 13, marginBottom: 12 }}>{mergeSuccess}</div>}
152
+
153
+ {showMerge && (
154
+ <div style={{ padding: 16, borderRadius: 8, border: '1px solid #fde68a', background: '#fefce8', marginBottom: 16 }}>
155
+ <h4 style={{ margin: '0 0 8px', fontSize: 14 }}>{t('crm.mergeTitle')}</h4>
156
+ <input type="text" value={mergeSearch} onChange={(e) => setMergeSearch(e.target.value)} placeholder={t('crm.mergeSearchPlaceholder')} style={S.searchInput} />
157
+ {mergeResults.map((c) => (
158
+ <button key={c.id} onClick={() => handleMerge(c.id)} disabled={merging} style={{ display: 'block', width: '100%', padding: '8px 12px', border: '1px solid var(--theme-elevation-200)', borderRadius: 6, background: 'var(--theme-elevation-0)', cursor: 'pointer', textAlign: 'left', marginBottom: 4, fontSize: 13 }}>
159
+ <strong>{c.company}</strong> -- {c.firstName} {c.lastName} <span style={{ color: 'var(--theme-elevation-400)', fontSize: 11 }}>{c.email}</span>
160
+ </button>
161
+ ))}
162
+ </div>
163
+ )}
164
+
165
+ {/* Stats */}
166
+ <div style={S.statsGrid}>
167
+ {[
168
+ { label: t('crm.stats.totalTickets'), value: String(detail.stats.totalTickets) },
169
+ { label: t('crm.stats.openTickets'), value: String(detail.stats.openTickets) },
170
+ { label: t('crm.stats.resolvedTickets'), value: String(detail.stats.resolvedTickets) },
171
+ { label: t('crm.stats.timeSpent'), value: formatDuration(detail.stats.totalTimeMinutes) },
172
+ { label: t('crm.stats.lastActivity'), value: detail.stats.lastActivity ? timeAgo(detail.stats.lastActivity) : '-' },
173
+ ].map((stat) => (
174
+ <div key={stat.label} style={S.statCard}>
175
+ <div style={{ fontSize: 11, color: 'var(--theme-elevation-500)', marginBottom: 2 }}>{stat.label}</div>
176
+ <div style={{ fontSize: 18, fontWeight: 700, color: 'var(--theme-text)' }}>{stat.value}</div>
177
+ </div>
178
+ ))}
179
+ </div>
180
+
181
+ {/* Tickets table */}
182
+ <div style={{ padding: 16, borderRadius: 10, border: '1px solid var(--theme-elevation-150)', marginBottom: 16 }}>
183
+ <h3 style={{ fontSize: 14, fontWeight: 700, margin: '0 0 8px' }}>{t('crm.sections.tickets')} ({detail.tickets.length})</h3>
184
+ {detail.tickets.length === 0 ? <div style={{ color: '#94a3b8', fontSize: 13 }}>{t('crm.noTickets')}</div> : (
185
+ <table style={S.table}>
186
+ <thead><tr><th style={S.th}>{t('crm.tableHeaders.number')}</th><th style={S.th}>{t('crm.tableHeaders.subject')}</th><th style={S.th}>{t('crm.tableHeaders.status')}</th><th style={S.th}>{t('crm.tableHeaders.date')}</th></tr></thead>
187
+ <tbody>
188
+ {detail.tickets.map((tk) => (
189
+ <tr key={tk.id}>
190
+ <td style={S.td}><a href={`/admin/support/ticket?id=${tk.id}`} style={{ color: '#2563eb', fontWeight: 600, textDecoration: 'none' }}>{tk.ticketNumber}</a></td>
191
+ <td style={S.td}>{tk.subject}</td>
192
+ <td style={S.td}><span style={{ ...S.badge, background: `${statusColors[tk.status] || '#94a3b8'}20`, color: statusColors[tk.status] || '#94a3b8' }}>{statusLabelKeys[tk.status] ? t(statusLabelKeys[tk.status]) : tk.status}</span></td>
193
+ <td style={{ ...S.td, fontSize: 12, color: 'var(--theme-elevation-400)' }}>{timeAgo(tk.createdAt)}</td>
194
+ </tr>
195
+ ))}
196
+ </tbody>
197
+ </table>
198
+ )}
199
+ </div>
200
+ </div>
201
+ ) : null}
202
+ </div>
203
+ </div>
204
+ </div>
205
+ )
206
+ }
@@ -0,0 +1,33 @@
1
+ import type { AdminViewServerProps } from 'payload'
2
+ import { DefaultTemplate } from '@payloadcms/next/templates'
3
+ import { redirect } from 'next/navigation'
4
+ import React from 'react'
5
+ import { AdminErrorBoundary } from '../shared/ErrorBoundary'
6
+ import { CrmClient } from './client'
7
+
8
+ export const CrmView: React.FC<AdminViewServerProps> = ({ initPageResult }) => {
9
+ const { req, visibleEntities } = initPageResult
10
+
11
+ if (!req.user) {
12
+ redirect('/admin/login')
13
+ }
14
+
15
+ return (
16
+ <DefaultTemplate
17
+ i18n={req.i18n}
18
+ locale={initPageResult.locale}
19
+ params={{}}
20
+ payload={req.payload}
21
+ permissions={initPageResult.permissions}
22
+ searchParams={{}}
23
+ user={req.user}
24
+ visibleEntities={visibleEntities}
25
+ >
26
+ <AdminErrorBoundary viewName="CrmView">
27
+ <CrmClient />
28
+ </AdminErrorBoundary>
29
+ </DefaultTemplate>
30
+ )
31
+ }
32
+
33
+ export default CrmView