@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,131 @@
1
+ import { useState } from 'react'
2
+ import type { Message, ClientInfo } from '../types'
3
+
4
+ export function useMessageActions(
5
+ id: string | number | undefined,
6
+ client: ClientInfo | null,
7
+ fetchAll: () => void,
8
+ ) {
9
+ const [togglingAuthor, setTogglingAuthor] = useState<string | number | null>(null)
10
+ const [editingMsg, setEditingMsg] = useState<string | number | null>(null)
11
+ const [editBody, setEditBody] = useState('')
12
+ const [editHtml, setEditHtml] = useState('')
13
+ const [savingEdit, setSavingEdit] = useState(false)
14
+ const [deletingMsg, setDeletingMsg] = useState<string | number | null>(null)
15
+ const [resendingMsg, setResendingMsg] = useState<string | number | null>(null)
16
+ const [resendSuccess, setResendSuccess] = useState<string | number | null>(null)
17
+
18
+ const handleEditStart = (msg: Message) => {
19
+ setEditingMsg(msg.id)
20
+ setEditBody(msg.body)
21
+ setEditHtml(msg.bodyHtml || msg.body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br />'))
22
+ }
23
+
24
+ const handleEditSave = async (msgId: string | number) => {
25
+ if (!editBody.trim()) return
26
+ setSavingEdit(true)
27
+ try {
28
+ const res = await fetch(`/api/ticket-messages/${msgId}`, {
29
+ method: 'PATCH',
30
+ headers: { 'Content-Type': 'application/json' },
31
+ credentials: 'include',
32
+ body: JSON.stringify({ body: editBody.trim(), ...(editHtml ? { bodyHtml: editHtml } : {}) }),
33
+ })
34
+ if (res.ok) {
35
+ setEditingMsg(null)
36
+ setEditBody('')
37
+ fetchAll()
38
+ }
39
+ } catch { /* ignore */ } finally {
40
+ setSavingEdit(false)
41
+ }
42
+ }
43
+
44
+ const handleEditCancel = () => {
45
+ setEditingMsg(null)
46
+ setEditBody('')
47
+ setEditHtml('')
48
+ }
49
+
50
+ const handleDelete = async (msgId: string | number) => {
51
+ if (!window.confirm('Supprimer ce message ?')) return
52
+ setDeletingMsg(msgId)
53
+ try {
54
+ const res = await fetch(`/api/ticket-messages/${msgId}`, {
55
+ method: 'DELETE',
56
+ credentials: 'include',
57
+ })
58
+ if (res.ok) fetchAll()
59
+ } catch { /* ignore */ } finally {
60
+ setDeletingMsg(null)
61
+ }
62
+ }
63
+
64
+ const handleResend = async (msgId: string | number) => {
65
+ setResendingMsg(msgId)
66
+ setResendSuccess(null)
67
+ try {
68
+ const res = await fetch('/api/support/resend-notification', {
69
+ method: 'POST',
70
+ headers: { 'Content-Type': 'application/json' },
71
+ credentials: 'include',
72
+ body: JSON.stringify({ messageId: msgId }),
73
+ })
74
+ if (res.ok) {
75
+ setResendSuccess(msgId)
76
+ setTimeout(() => setResendSuccess(null), 3000)
77
+ }
78
+ } catch { /* ignore */ } finally {
79
+ setResendingMsg(null)
80
+ }
81
+ }
82
+
83
+ const handleToggleAuthor = async (msgId: string | number, currentType: string) => {
84
+ const newType = currentType === 'admin' ? 'client' : 'admin'
85
+ setTogglingAuthor(msgId)
86
+ try {
87
+ const patchData: Record<string, unknown> = { authorType: newType }
88
+ if (newType === 'client' && client) {
89
+ patchData.authorClient = client.id
90
+ }
91
+ const res = await fetch(`/api/ticket-messages/${msgId}`, {
92
+ method: 'PATCH',
93
+ headers: { 'Content-Type': 'application/json' },
94
+ credentials: 'include',
95
+ body: JSON.stringify(patchData),
96
+ })
97
+ if (res.ok) fetchAll()
98
+ } catch { /* ignore */ } finally {
99
+ setTogglingAuthor(null)
100
+ }
101
+ }
102
+
103
+ const handleSplitMessage = async (msgId: string | number, ticketSubject: string) => {
104
+ const subject = prompt('Sujet du nouveau ticket :', `Split: ${ticketSubject}`)
105
+ if (!subject) return
106
+ try {
107
+ const res = await fetch('/api/support/split-ticket', {
108
+ method: 'POST',
109
+ headers: { 'Content-Type': 'application/json' },
110
+ credentials: 'include',
111
+ body: JSON.stringify({ messageId: msgId, subject }),
112
+ })
113
+ if (res.ok) {
114
+ const data = await res.json()
115
+ alert(`Ticket ${data.ticketNumber} créé`)
116
+ fetchAll()
117
+ }
118
+ } catch { /* ignore */ }
119
+ }
120
+
121
+ return {
122
+ togglingAuthor,
123
+ editingMsg, editBody, editHtml, setEditHtml, savingEdit,
124
+ handleEditStart, handleEditSave, handleEditCancel,
125
+ deletingMsg, handleDelete,
126
+ resendingMsg, resendSuccess, handleResend,
127
+ handleToggleAuthor,
128
+ handleSplitMessage,
129
+ setEditBody,
130
+ }
131
+ }
@@ -0,0 +1,190 @@
1
+ import { useState, useRef, useCallback } from 'react'
2
+ import type { ClientInfo, CannedResponse } from '../types'
3
+ import type { RichTextEditorHandle } from '../context'
4
+
5
+ export function useReply(
6
+ id: string | number | undefined,
7
+ client: ClientInfo | null,
8
+ cannedResponses: CannedResponse[],
9
+ ticketNumber: string,
10
+ ticketSubject: string,
11
+ fetchAll: () => void,
12
+ handleNextTicket: () => void,
13
+ replyEditorRef: React.RefObject<RichTextEditorHandle | null>,
14
+ ) {
15
+ const fileInputRef = useRef<HTMLInputElement>(null)
16
+ const [replyBody, setReplyBody] = useState('')
17
+ const [replyHtml, setReplyHtml] = useState('')
18
+ const [replyFiles, setReplyFiles] = useState<File[]>([])
19
+ const [isInternal, setIsInternal] = useState(false)
20
+ const [notifyClient, setNotifyClient] = useState(false)
21
+ const [sendAsClient, setSendAsClient] = useState(false)
22
+ const [sending, setSending] = useState(false)
23
+ const [showSchedule, setShowSchedule] = useState(false)
24
+ const [scheduleDate, setScheduleDate] = useState('')
25
+
26
+ const handleEditorFileUpload = useCallback(async (file: File): Promise<string | null> => {
27
+ if (file.size > 5 * 1024 * 1024) return null
28
+ const formData = new FormData()
29
+ formData.append('file', file)
30
+ formData.append('_payload', JSON.stringify({ alt: file.name }))
31
+ try {
32
+ const res = await fetch('/api/media', { method: 'POST', credentials: 'include', body: formData })
33
+ if (res.ok) {
34
+ const data = await res.json()
35
+ return data.doc?.url || null
36
+ }
37
+ } catch { /* ignore */ }
38
+ return null
39
+ }, [])
40
+
41
+ const replaceCannedVariables = useCallback((text: string): string => {
42
+ let result = text
43
+ if (client) {
44
+ result = result.replace(/\{\{client\.firstName\}\}/g, client.firstName || 'Client')
45
+ result = result.replace(/\{\{client\.lastName\}\}/g, client.lastName || '')
46
+ result = result.replace(/\{\{client\.company\}\}/g, client.company || '')
47
+ result = result.replace(/\{\{client\.email\}\}/g, client.email || '')
48
+ result = result.replace(/\{\{clientName\}\}/g, client.firstName || 'Client')
49
+ }
50
+ result = result.replace(/\{\{ticket\.number\}\}/g, ticketNumber || '')
51
+ result = result.replace(/\{\{ticket\.subject\}\}/g, ticketSubject || '')
52
+ result = result.replace(/\{\{agent\.name\}\}/g, 'Support')
53
+ return result
54
+ }, [client, ticketNumber, ticketSubject])
55
+
56
+ const handleCannedSelect = (e: React.ChangeEvent<HTMLSelectElement>) => {
57
+ const selected = cannedResponses.find((cr) => String(cr.id) === e.target.value)
58
+ if (selected) {
59
+ const body = replaceCannedVariables(selected.body)
60
+ const html = body.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br />')
61
+ setReplyBody(body)
62
+ setReplyHtml(html)
63
+ replyEditorRef.current?.setContent(html)
64
+ }
65
+ e.target.value = ''
66
+ }
67
+
68
+ const handleReplyFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
69
+ if (e.target.files) {
70
+ const maxSize = 1 * 1024 * 1024
71
+ const newFiles = Array.from(e.target.files!)
72
+ const tooLarge = newFiles.filter((f) => f.size > maxSize)
73
+ if (tooLarge.length > 0) {
74
+ alert(`Fichier(s) trop volumineux (max 1 Mo) : ${tooLarge.map((f) => f.name).join(', ')}`)
75
+ }
76
+ setReplyFiles((prev) => [...prev, ...newFiles.filter((f) => f.size <= maxSize)])
77
+ }
78
+ if (fileInputRef.current) fileInputRef.current.value = ''
79
+ }
80
+
81
+ const uploadFiles = async (files: File[]): Promise<number[]> => {
82
+ const uploadedIds: number[] = []
83
+ for (const file of files) {
84
+ if (file.size > 1 * 1024 * 1024) continue
85
+ const formData = new FormData()
86
+ formData.append('file', file)
87
+ formData.append('_payload', JSON.stringify({ alt: file.name }))
88
+ const uploadRes = await fetch('/api/media', { method: 'POST', credentials: 'include', body: formData })
89
+ if (uploadRes.ok) {
90
+ const d = await uploadRes.json()
91
+ if (d.doc?.id) uploadedIds.push(d.doc.id)
92
+ }
93
+ }
94
+ return uploadedIds
95
+ }
96
+
97
+ const resetReply = () => {
98
+ setReplyBody('')
99
+ setReplyHtml('')
100
+ setReplyFiles([])
101
+ setIsInternal(false)
102
+ setNotifyClient(false)
103
+ setSendAsClient(false)
104
+ replyEditorRef.current?.clear()
105
+ }
106
+
107
+ const handleSendReply = async () => {
108
+ if (!replyBody.trim() || !id) return
109
+ setSending(true)
110
+ try {
111
+ const uploadedIds = await uploadFiles(replyFiles)
112
+ const finalBody = replyBody.trim() || (replyHtml ? '[Contenu enrichi]' : '')
113
+ const messageData: Record<string, unknown> = {
114
+ ticket: id,
115
+ body: finalBody,
116
+ ...(replyHtml ? { bodyHtml: replyHtml } : {}),
117
+ authorType: sendAsClient ? 'client' : 'admin',
118
+ isInternal,
119
+ skipNotification: isInternal || !notifyClient,
120
+ ...(sendAsClient && client ? { authorClient: client.id } : {}),
121
+ }
122
+ if (uploadedIds.length > 0) {
123
+ messageData.attachments = uploadedIds.map((mid) => ({ file: mid }))
124
+ }
125
+
126
+ const res = await fetch('/api/ticket-messages', {
127
+ method: 'POST',
128
+ headers: { 'Content-Type': 'application/json' },
129
+ credentials: 'include',
130
+ body: JSON.stringify(messageData),
131
+ })
132
+ if (res.ok) {
133
+ resetReply()
134
+ fetchAll()
135
+ if (!isInternal) handleNextTicket()
136
+ }
137
+ } catch { /* ignore */ } finally {
138
+ setSending(false)
139
+ }
140
+ }
141
+
142
+ const handleScheduleReply = async () => {
143
+ if (!replyBody.trim() || !id || !scheduleDate) return
144
+ setSending(true)
145
+ try {
146
+ const uploadedIds = await uploadFiles(replyFiles)
147
+ const messageData: Record<string, unknown> = {
148
+ ticket: id,
149
+ body: replyBody.trim(),
150
+ ...(replyHtml ? { bodyHtml: replyHtml } : {}),
151
+ authorType: 'admin',
152
+ isInternal: false,
153
+ skipNotification: true,
154
+ scheduledAt: new Date(scheduleDate).toISOString(),
155
+ scheduledSent: false,
156
+ }
157
+ if (uploadedIds.length > 0) {
158
+ messageData.attachments = uploadedIds.map((mid) => ({ file: mid }))
159
+ }
160
+
161
+ const res = await fetch('/api/ticket-messages', {
162
+ method: 'POST',
163
+ headers: { 'Content-Type': 'application/json' },
164
+ credentials: 'include',
165
+ body: JSON.stringify(messageData),
166
+ })
167
+ if (res.ok) {
168
+ resetReply()
169
+ setShowSchedule(false)
170
+ setScheduleDate('')
171
+ fetchAll()
172
+ }
173
+ } catch { /* ignore */ } finally {
174
+ setSending(false)
175
+ }
176
+ }
177
+
178
+ return {
179
+ fileInputRef,
180
+ replyBody, setReplyBody, replyHtml, setReplyHtml,
181
+ replyFiles, setReplyFiles,
182
+ isInternal, setIsInternal,
183
+ notifyClient, setNotifyClient,
184
+ sendAsClient, setSendAsClient,
185
+ sending,
186
+ showSchedule, setShowSchedule, scheduleDate, setScheduleDate,
187
+ handleEditorFileUpload, handleCannedSelect, handleReplyFileChange,
188
+ handleSendReply, handleScheduleReply,
189
+ }
190
+ }
@@ -0,0 +1,205 @@
1
+ import { useState, useRef } from 'react'
2
+
3
+ export function useTicketActions(
4
+ id: string | number | undefined,
5
+ fetchAll: () => void,
6
+ ) {
7
+ const [statusUpdating, setStatusUpdating] = useState(false)
8
+ // Merge
9
+ const [showMerge, setShowMerge] = useState(false)
10
+ const [mergeTarget, setMergeTarget] = useState('')
11
+ const [mergeTargetInfo, setMergeTargetInfo] = useState<{ id: number; ticketNumber: string; subject: string } | null>(null)
12
+ const [merging, setMerging] = useState(false)
13
+ const [mergeError, setMergeError] = useState('')
14
+ // External message
15
+ const [showExtMsg, setShowExtMsg] = useState(false)
16
+ const [extMsgBody, setExtMsgBody] = useState('')
17
+ const [extMsgAuthor, setExtMsgAuthor] = useState<'client' | 'admin'>('client')
18
+ const [extMsgDate, setExtMsgDate] = useState('')
19
+ const [extMsgFiles, setExtMsgFiles] = useState<File[]>([])
20
+ const [sendingExtMsg, setSendingExtMsg] = useState(false)
21
+ const extFileInputRef = useRef<HTMLInputElement>(null)
22
+ // Snooze
23
+ const [showSnooze, setShowSnooze] = useState(false)
24
+ const [snoozeUntil, setSnoozeUntil] = useState<string | null>(null)
25
+ const [snoozeSaving, setSnoozeSaving] = useState(false)
26
+ // Next ticket
27
+ const [showNextTicket, setShowNextTicket] = useState(false)
28
+ const [nextTicketId, setNextTicketId] = useState<number | null>(null)
29
+ const [nextTicketInfo, setNextTicketInfo] = useState<string>('')
30
+
31
+ const handleStatusChange = async (newStatus: string) => {
32
+ if (!id) return
33
+ setStatusUpdating(true)
34
+ try {
35
+ await fetch(`/api/tickets/${id}`, {
36
+ method: 'PATCH',
37
+ headers: { 'Content-Type': 'application/json' },
38
+ credentials: 'include',
39
+ body: JSON.stringify({ status: newStatus }),
40
+ })
41
+ window.location.reload()
42
+ } catch { /* ignore */ } finally {
43
+ setStatusUpdating(false)
44
+ }
45
+ }
46
+
47
+ const handleMergeLookup = async () => {
48
+ if (!mergeTarget.trim()) return
49
+ setMergeTargetInfo(null)
50
+ setMergeError('')
51
+ try {
52
+ const searchVal = mergeTarget.trim().toUpperCase()
53
+ const res = await fetch(`/api/tickets?where[ticketNumber][equals]=${encodeURIComponent(searchVal)}&limit=1&depth=0`, { credentials: 'include' })
54
+ if (res.ok) {
55
+ const d = await res.json()
56
+ if (d.docs?.length > 0) {
57
+ const t = d.docs[0]
58
+ if (String(t.id) === String(id)) {
59
+ setMergeError('Impossible de fusionner un ticket avec lui-même')
60
+ } else {
61
+ setMergeTargetInfo({ id: t.id, ticketNumber: t.ticketNumber, subject: t.subject })
62
+ }
63
+ } else {
64
+ setMergeError('Ticket introuvable')
65
+ }
66
+ }
67
+ } catch { setMergeError('Erreur de recherche') }
68
+ }
69
+
70
+ const handleMerge = async () => {
71
+ if (!mergeTargetInfo || !id) return
72
+ setMerging(true)
73
+ setMergeError('')
74
+ try {
75
+ const res = await fetch('/api/support/merge-tickets', {
76
+ method: 'POST',
77
+ headers: { 'Content-Type': 'application/json' },
78
+ credentials: 'include',
79
+ body: JSON.stringify({ sourceTicketId: id, targetTicketId: mergeTargetInfo.id }),
80
+ })
81
+ if (res.ok) {
82
+ window.location.href = `/admin/collections/tickets/${mergeTargetInfo.id}`
83
+ } else {
84
+ const d = await res.json().catch(() => ({}))
85
+ setMergeError(d.error || 'Erreur de fusion')
86
+ }
87
+ } catch { setMergeError('Erreur réseau') } finally {
88
+ setMerging(false)
89
+ }
90
+ }
91
+
92
+ const handleExtFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
93
+ if (e.target.files) {
94
+ const maxSize = 1 * 1024 * 1024
95
+ const newFiles = Array.from(e.target.files)
96
+ const tooLarge = newFiles.filter((f) => f.size > maxSize)
97
+ if (tooLarge.length > 0) {
98
+ alert(`Fichier(s) trop volumineux (max 1 Mo) : ${tooLarge.map((f) => f.name).join(', ')}`)
99
+ }
100
+ setExtMsgFiles((prev) => [...prev, ...newFiles.filter((f) => f.size <= maxSize)])
101
+ }
102
+ if (extFileInputRef.current) extFileInputRef.current.value = ''
103
+ }
104
+
105
+ const handleSendExtMsg = async () => {
106
+ if (!extMsgBody.trim() || !id) return
107
+ setSendingExtMsg(true)
108
+ try {
109
+ const uploadedIds: number[] = []
110
+ for (const file of extMsgFiles) {
111
+ if (file.size > 1 * 1024 * 1024) continue
112
+ const formData = new FormData()
113
+ formData.append('file', file)
114
+ formData.append('_payload', JSON.stringify({ alt: file.name }))
115
+ const uploadRes = await fetch('/api/media', { method: 'POST', credentials: 'include', body: formData })
116
+ if (uploadRes.ok) {
117
+ const d = await uploadRes.json()
118
+ if (d.doc?.id) uploadedIds.push(d.doc.id)
119
+ }
120
+ }
121
+
122
+ const messageData: Record<string, unknown> = {
123
+ ticket: id,
124
+ body: extMsgBody.trim(),
125
+ authorType: extMsgAuthor,
126
+ isInternal: false,
127
+ skipNotification: true,
128
+ }
129
+ if (extMsgDate) {
130
+ messageData.createdAt = new Date(extMsgDate).toISOString()
131
+ }
132
+ if (uploadedIds.length > 0) {
133
+ messageData.attachments = uploadedIds.map((mid) => ({ file: mid }))
134
+ }
135
+
136
+ const res = await fetch('/api/ticket-messages', {
137
+ method: 'POST',
138
+ headers: { 'Content-Type': 'application/json' },
139
+ credentials: 'include',
140
+ body: JSON.stringify(messageData),
141
+ })
142
+ if (res.ok) {
143
+ setExtMsgBody('')
144
+ setExtMsgDate('')
145
+ setExtMsgFiles([])
146
+ setShowExtMsg(false)
147
+ fetchAll()
148
+ }
149
+ } catch { /* ignore */ } finally {
150
+ setSendingExtMsg(false)
151
+ }
152
+ }
153
+
154
+ const handleSnooze = async (days: number | null, customDate?: string) => {
155
+ if (!id) return
156
+ setSnoozeSaving(true)
157
+ try {
158
+ let newSnooze: string | null = null
159
+ if (days !== null) {
160
+ const d = new Date()
161
+ d.setDate(d.getDate() + days)
162
+ newSnooze = d.toISOString()
163
+ } else if (customDate) {
164
+ newSnooze = new Date(customDate).toISOString()
165
+ }
166
+
167
+ await fetch(`/api/tickets/${id}`, {
168
+ method: 'PATCH',
169
+ headers: { 'Content-Type': 'application/json' },
170
+ credentials: 'include',
171
+ body: JSON.stringify({ snoozeUntil: newSnooze }),
172
+ })
173
+ setSnoozeUntil(newSnooze)
174
+ setShowSnooze(false)
175
+ fetchAll()
176
+ } catch { /* ignore */ } finally {
177
+ setSnoozeSaving(false)
178
+ }
179
+ }
180
+
181
+ const handleNextTicket = async () => {
182
+ try {
183
+ const res = await fetch('/api/tickets?where[status][equals]=open&sort=updatedAt&limit=1&depth=0', { credentials: 'include' })
184
+ if (res.ok) {
185
+ const d = await res.json()
186
+ if (d.docs?.length > 0 && String(d.docs[0].id) !== String(id)) {
187
+ setNextTicketId(d.docs[0].id)
188
+ setNextTicketInfo(`${d.docs[0].ticketNumber} — ${d.docs[0].subject}`)
189
+ } else {
190
+ setNextTicketId(null)
191
+ setNextTicketInfo('Aucun autre ticket ouvert')
192
+ }
193
+ setShowNextTicket(true)
194
+ }
195
+ } catch { /* ignore */ }
196
+ }
197
+
198
+ return {
199
+ statusUpdating, handleStatusChange,
200
+ showMerge, setShowMerge, mergeTarget, setMergeTarget, mergeTargetInfo, setMergeTargetInfo, mergeError, setMergeError, merging, handleMergeLookup, handleMerge,
201
+ showExtMsg, setShowExtMsg, extMsgBody, setExtMsgBody, extMsgAuthor, setExtMsgAuthor, extMsgDate, setExtMsgDate, extMsgFiles, setExtMsgFiles, sendingExtMsg, handleExtFileChange, handleSendExtMsg,
202
+ showSnooze, setShowSnooze, snoozeUntil, setSnoozeUntil, snoozeSaving, handleSnooze,
203
+ showNextTicket, setShowNextTicket, nextTicketId, nextTicketInfo, handleNextTicket,
204
+ }
205
+ }
@@ -0,0 +1,107 @@
1
+ import { useState, useRef, useEffect } from 'react'
2
+
3
+ export function useTimeTracking(id: string | number | undefined, fetchAll: () => void) {
4
+ const [duration, setDuration] = useState('')
5
+ const [timeDescription, setTimeDescription] = useState('')
6
+ const [addingTime, setAddingTime] = useState(false)
7
+ const [timeSuccess, setTimeSuccess] = useState('')
8
+ const [timerRunning, setTimerRunning] = useState(false)
9
+ const [timerSeconds, setTimerSeconds] = useState(0)
10
+ const [timerDescription, setTimerDescription] = useState('')
11
+ const timerIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
12
+ const timerKeepAliveRef = useRef<ReturnType<typeof setInterval> | null>(null)
13
+
14
+ const handleAddTime = async () => {
15
+ if (!duration || !id) return
16
+ setAddingTime(true)
17
+ setTimeSuccess('')
18
+ try {
19
+ const res = await fetch('/api/time-entries', {
20
+ method: 'POST',
21
+ headers: { 'Content-Type': 'application/json' },
22
+ credentials: 'include',
23
+ body: JSON.stringify({
24
+ ticket: id,
25
+ duration: parseInt(duration, 10),
26
+ date: new Date().toISOString(),
27
+ description: timeDescription.trim() || undefined,
28
+ }),
29
+ })
30
+ if (res.ok) {
31
+ setDuration('')
32
+ setTimeDescription('')
33
+ setTimeSuccess(`${duration} min ajoutées`)
34
+ setTimeout(() => setTimeSuccess(''), 3000)
35
+ fetchAll()
36
+ }
37
+ } catch { /* ignore */ } finally {
38
+ setAddingTime(false)
39
+ }
40
+ }
41
+
42
+ const handleTimerStart = (reset = false) => {
43
+ setTimerRunning(true)
44
+ if (reset) setTimerSeconds(0)
45
+ timerIntervalRef.current = setInterval(() => {
46
+ setTimerSeconds((prev) => prev + 1)
47
+ }, 1000)
48
+ timerKeepAliveRef.current = setInterval(() => {
49
+ fetch('/api/users/me', { credentials: 'include' }).catch(() => {})
50
+ }, 5 * 60 * 1000)
51
+ }
52
+
53
+ const handleTimerStop = () => {
54
+ setTimerRunning(false)
55
+ if (timerIntervalRef.current) { clearInterval(timerIntervalRef.current); timerIntervalRef.current = null }
56
+ if (timerKeepAliveRef.current) { clearInterval(timerKeepAliveRef.current); timerKeepAliveRef.current = null }
57
+ }
58
+
59
+ const handleTimerSave = async () => {
60
+ if (!id || timerSeconds < 60) return
61
+ const minutes = Math.round(timerSeconds / 60)
62
+ setAddingTime(true)
63
+ try {
64
+ const res = await fetch('/api/time-entries', {
65
+ method: 'POST',
66
+ headers: { 'Content-Type': 'application/json' },
67
+ credentials: 'include',
68
+ body: JSON.stringify({
69
+ ticket: id,
70
+ duration: minutes,
71
+ date: new Date().toISOString(),
72
+ description: timerDescription.trim() || 'Timer',
73
+ }),
74
+ })
75
+ if (res.ok) {
76
+ setTimeSuccess(`${minutes} min ajoutées (timer)`)
77
+ setTimeout(() => setTimeSuccess(''), 3000)
78
+ setTimerSeconds(0)
79
+ setTimerDescription('')
80
+ fetchAll()
81
+ }
82
+ } catch { /* ignore */ } finally {
83
+ setAddingTime(false)
84
+ }
85
+ }
86
+
87
+ const handleTimerDiscard = () => {
88
+ setTimerSeconds(0)
89
+ setTimerDescription('')
90
+ }
91
+
92
+ // Cleanup timer on unmount
93
+ useEffect(() => {
94
+ return () => {
95
+ if (timerIntervalRef.current) clearInterval(timerIntervalRef.current)
96
+ if (timerKeepAliveRef.current) clearInterval(timerKeepAliveRef.current)
97
+ }
98
+ }, [])
99
+
100
+ return {
101
+ duration, setDuration,
102
+ timeDescription, setTimeDescription,
103
+ addingTime, timeSuccess,
104
+ timerRunning, timerSeconds, setTimerSeconds, timerDescription, setTimerDescription,
105
+ handleAddTime, handleTimerStart, handleTimerStop, handleTimerSave, handleTimerDiscard,
106
+ }
107
+ }