@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.
- package/LICENSE +21 -0
- package/README.md +525 -0
- package/dist/client.cjs +7 -0
- package/dist/client.d.cts +3 -0
- package/dist/client.d.ts +3 -0
- package/dist/client.js +5 -0
- package/dist/index.cjs +7766 -0
- package/dist/index.d.cts +384 -0
- package/dist/index.d.ts +384 -0
- package/dist/index.js +7730 -0
- package/dist/views.d.cts +30 -0
- package/dist/views.d.ts +30 -0
- package/package.json +131 -0
- package/src/client.ts +1 -0
- package/src/collections/AuthLogs.ts +65 -0
- package/src/collections/CannedResponses.ts +69 -0
- package/src/collections/ChatMessages.ts +98 -0
- package/src/collections/EmailLogs.ts +94 -0
- package/src/collections/KnowledgeBase.ts +99 -0
- package/src/collections/Macros.ts +98 -0
- package/src/collections/PendingEmails.ts +122 -0
- package/src/collections/SatisfactionSurveys.ts +98 -0
- package/src/collections/SlaPolicies.ts +123 -0
- package/src/collections/SupportClients.ts +210 -0
- package/src/collections/TicketActivityLog.ts +81 -0
- package/src/collections/TicketMessages.ts +364 -0
- package/src/collections/TicketStatuses.ts +108 -0
- package/src/collections/Tickets.ts +704 -0
- package/src/collections/TimeEntries.ts +105 -0
- package/src/collections/WebhookEndpoints.ts +96 -0
- package/src/collections/index.ts +16 -0
- package/src/components/TicketConversation/components/AISummaryPanel.tsx +85 -0
- package/src/components/TicketConversation/components/ActionPanels.tsx +140 -0
- package/src/components/TicketConversation/components/ActivityLog.tsx +39 -0
- package/src/components/TicketConversation/components/ClientBar.tsx +37 -0
- package/src/components/TicketConversation/components/ClientHistory.tsx +117 -0
- package/src/components/TicketConversation/components/CodeBlock.tsx +186 -0
- package/src/components/TicketConversation/components/CodeBlockInserter.tsx +166 -0
- package/src/components/TicketConversation/components/QuickActions.tsx +82 -0
- package/src/components/TicketConversation/components/TicketHeader.tsx +91 -0
- package/src/components/TicketConversation/components/TimeTrackingPanel.tsx +161 -0
- package/src/components/TicketConversation/config.ts +82 -0
- package/src/components/TicketConversation/constants.ts +74 -0
- package/src/components/TicketConversation/context.ts +63 -0
- package/src/components/TicketConversation/hooks/useAI.ts +180 -0
- package/src/components/TicketConversation/hooks/useMessageActions.ts +131 -0
- package/src/components/TicketConversation/hooks/useReply.ts +190 -0
- package/src/components/TicketConversation/hooks/useTicketActions.ts +205 -0
- package/src/components/TicketConversation/hooks/useTimeTracking.ts +107 -0
- package/src/components/TicketConversation/hooks/useTranslation.ts +116 -0
- package/src/components/TicketConversation/index.tsx +1110 -0
- package/src/components/TicketConversation/locales/en.json +878 -0
- package/src/components/TicketConversation/locales/fr.json +878 -0
- package/src/components/TicketConversation/types.ts +54 -0
- package/src/components/TicketConversation/utils.ts +25 -0
- package/src/endpoints/admin-chat-stream.ts +238 -0
- package/src/endpoints/admin-chat.ts +263 -0
- package/src/endpoints/admin-stats.ts +200 -0
- package/src/endpoints/ai.ts +199 -0
- package/src/endpoints/apply-macro.ts +144 -0
- package/src/endpoints/auth-2fa.ts +163 -0
- package/src/endpoints/auto-close.ts +175 -0
- package/src/endpoints/billing.ts +167 -0
- package/src/endpoints/bulk-action.ts +103 -0
- package/src/endpoints/chat-stream.ts +127 -0
- package/src/endpoints/chat.ts +188 -0
- package/src/endpoints/chatbot.ts +113 -0
- package/src/endpoints/delete-account.ts +129 -0
- package/src/endpoints/email-stats.ts +109 -0
- package/src/endpoints/export-csv.ts +84 -0
- package/src/endpoints/export-data.ts +104 -0
- package/src/endpoints/import-conversation.ts +307 -0
- package/src/endpoints/index.ts +154 -0
- package/src/endpoints/login.ts +92 -0
- package/src/endpoints/merge-clients.ts +132 -0
- package/src/endpoints/merge-tickets.ts +137 -0
- package/src/endpoints/oauth-google.ts +179 -0
- package/src/endpoints/pending-emails-process.ts +224 -0
- package/src/endpoints/presence.ts +104 -0
- package/src/endpoints/process-scheduled.ts +144 -0
- package/src/endpoints/purge-logs.ts +58 -0
- package/src/endpoints/resend-notification.ts +99 -0
- package/src/endpoints/round-robin-config.ts +92 -0
- package/src/endpoints/satisfaction.ts +93 -0
- package/src/endpoints/search.ts +106 -0
- package/src/endpoints/seed-kb.ts +153 -0
- package/src/endpoints/settings.ts +144 -0
- package/src/endpoints/signature.ts +93 -0
- package/src/endpoints/sla-check.ts +124 -0
- package/src/endpoints/split-ticket.ts +131 -0
- package/src/endpoints/statuses.ts +45 -0
- package/src/endpoints/track-open.ts +154 -0
- package/src/endpoints/typing.ts +101 -0
- package/src/endpoints/user-prefs.ts +125 -0
- package/src/hooks/checkSLA.ts +414 -0
- package/src/hooks/ticketStatusEmail.ts +182 -0
- package/src/index.ts +51 -0
- package/src/plugin.ts +157 -0
- package/src/portal/LiveChat.tsx +1353 -0
- package/src/portal/auth/ChatWidget.tsx +350 -0
- package/src/portal/auth/ChatbotWidget.tsx +285 -0
- package/src/portal/auth/SupportHeader.tsx +409 -0
- package/src/portal/auth/dashboard/DashboardClient.tsx +650 -0
- package/src/portal/auth/dashboard/page.tsx +84 -0
- package/src/portal/auth/faq/FAQSearch.tsx +117 -0
- package/src/portal/auth/faq/page.tsx +199 -0
- package/src/portal/auth/layout.tsx +61 -0
- package/src/portal/auth/profile/page.tsx +705 -0
- package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +74 -0
- package/src/portal/auth/tickets/detail/CollapsibleMessages.tsx +46 -0
- package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +50 -0
- package/src/portal/auth/tickets/detail/MessageActions.tsx +158 -0
- package/src/portal/auth/tickets/detail/PrintButton.tsx +16 -0
- package/src/portal/auth/tickets/detail/ReadReceipt.tsx +34 -0
- package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +74 -0
- package/src/portal/auth/tickets/detail/SatisfactionForm.tsx +156 -0
- package/src/portal/auth/tickets/detail/TicketPolling.tsx +57 -0
- package/src/portal/auth/tickets/detail/TicketReplyForm.tsx +294 -0
- package/src/portal/auth/tickets/detail/TypingIndicator.tsx +58 -0
- package/src/portal/auth/tickets/detail/page.tsx +738 -0
- package/src/portal/auth/tickets/new/page.tsx +515 -0
- package/src/portal/forgot-password/page.tsx +114 -0
- package/src/portal/layout.tsx +26 -0
- package/src/portal/locales/en.json +374 -0
- package/src/portal/locales/fr.json +374 -0
- package/src/portal/login/page.tsx +351 -0
- package/src/portal/page.tsx +162 -0
- package/src/portal/register/page.tsx +281 -0
- package/src/portal/reset-password/page.tsx +152 -0
- package/src/styles/BillingView.module.scss +311 -0
- package/src/styles/ChatView.module.scss +438 -0
- package/src/styles/CommandPalette.module.scss +160 -0
- package/src/styles/CrmView.module.scss +554 -0
- package/src/styles/EmailTracking.module.scss +238 -0
- package/src/styles/ImportConversation.module.scss +267 -0
- package/src/styles/Layout.module.scss +55 -0
- package/src/styles/Logs.module.scss +164 -0
- package/src/styles/NewTicket.module.scss +143 -0
- package/src/styles/PendingEmails.module.scss +629 -0
- package/src/styles/SupportDashboard.module.scss +649 -0
- package/src/styles/TicketDetail.module.scss +1043 -0
- package/src/styles/TicketInbox.module.scss +296 -0
- package/src/styles/TicketingSettings.module.scss +358 -0
- package/src/styles/TimeDashboard.module.scss +287 -0
- package/src/styles/_tokens.scss +78 -0
- package/src/styles/theme.css +633 -0
- package/src/types.ts +255 -0
- package/src/utils/adminNotification.ts +38 -0
- package/src/utils/auth.ts +46 -0
- package/src/utils/emailTemplate.ts +343 -0
- package/src/utils/fireWebhooks.ts +84 -0
- package/src/utils/index.ts +22 -0
- package/src/utils/rateLimiter.ts +52 -0
- package/src/utils/readSettings.ts +67 -0
- package/src/utils/slugs.ts +54 -0
- package/src/utils/webhookDispatcher.ts +120 -0
- package/src/views/BillingView/client.tsx +137 -0
- package/src/views/BillingView/index.tsx +33 -0
- package/src/views/ChatView/client.tsx +294 -0
- package/src/views/ChatView/index.tsx +33 -0
- package/src/views/CrmView/client.tsx +206 -0
- package/src/views/CrmView/index.tsx +33 -0
- package/src/views/EmailTrackingView/client.tsx +124 -0
- package/src/views/EmailTrackingView/index.tsx +33 -0
- package/src/views/ImportConversationView/client.tsx +133 -0
- package/src/views/ImportConversationView/index.tsx +33 -0
- package/src/views/LogsView/client.tsx +151 -0
- package/src/views/LogsView/index.tsx +30 -0
- package/src/views/NewTicketView/client.tsx +227 -0
- package/src/views/NewTicketView/index.tsx +30 -0
- package/src/views/PendingEmailsView/client.tsx +177 -0
- package/src/views/PendingEmailsView/index.tsx +33 -0
- package/src/views/SupportDashboardView/client.tsx +424 -0
- package/src/views/SupportDashboardView/index.tsx +33 -0
- package/src/views/TicketDetailView/client.tsx +775 -0
- package/src/views/TicketDetailView/index.tsx +33 -0
- package/src/views/TicketInboxView/client.tsx +313 -0
- package/src/views/TicketInboxView/index.tsx +30 -0
- package/src/views/TicketingSettingsView/client.tsx +866 -0
- package/src/views/TicketingSettingsView/index.tsx +33 -0
- package/src/views/TimeDashboardView/client.tsx +144 -0
- package/src/views/TimeDashboardView/index.tsx +33 -0
- package/src/views/shared/AdminViewHeader.tsx +69 -0
- package/src/views/shared/ErrorBoundary.tsx +68 -0
- package/src/views/shared/Skeleton.tsx +125 -0
- package/src/views/shared/adminTokens.ts +37 -0
- package/src/views/shared/config.ts +82 -0
- package/src/views/shared/index.ts +6 -0
- 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, '&').replace(/</g, '<').replace(/>/g, '>').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, '&').replace(/</g, '<').replace(/>/g, '>').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
|
+
}
|