@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,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
|