@consilioweb/payload-support 0.8.2 → 0.9.4
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/dist/components/RichTextEditor/index.cjs +233 -0
- package/dist/components/RichTextEditor/index.js +232 -0
- package/dist/components/TicketConversation/components/CodeBlock.cjs +24 -7
- package/dist/components/TicketConversation/components/CodeBlock.js +23 -9
- package/dist/index.cjs +19 -1
- package/dist/index.js +19 -1
- package/dist/views/BillingView/client.cjs +260 -103
- package/dist/views/BillingView/client.js +259 -103
- package/dist/views/ChatView/client.cjs +184 -137
- package/dist/views/ChatView/client.js +180 -137
- package/dist/views/CrmView/client.cjs +270 -122
- package/dist/views/CrmView/client.js +266 -122
- package/dist/views/EmailTrackingView/client.cjs +80 -69
- package/dist/views/EmailTrackingView/client.js +80 -70
- package/dist/views/ImportConversationView/client.cjs +127 -94
- package/dist/views/ImportConversationView/client.js +123 -94
- package/dist/views/LogsView/client.cjs +56 -58
- package/dist/views/LogsView/client.js +52 -58
- package/dist/views/NewTicketView/client.cjs +39 -55
- package/dist/views/NewTicketView/client.js +38 -55
- package/dist/views/PendingEmailsView/client.cjs +399 -102
- package/dist/views/PendingEmailsView/client.js +396 -103
- package/dist/views/SupportDashboardView/client.cjs +276 -137
- package/dist/views/SupportDashboardView/client.js +275 -137
- package/dist/views/TicketDetailView/client.cjs +487 -204
- package/dist/views/TicketDetailView/client.js +486 -204
- package/dist/views/TicketInboxView/client.cjs +62 -65
- package/dist/views/TicketInboxView/client.js +62 -66
- package/dist/views/TicketingSettingsView/client.cjs +10 -8
- package/dist/views/TicketingSettingsView/client.js +10 -8
- package/dist/views/TimeDashboardView/client.cjs +70 -59
- package/dist/views/TimeDashboardView/client.js +69 -59
- package/package.json +6 -2
- package/src/components/RichTextEditor/index.tsx +261 -0
- package/src/components/TicketConversation/components/CodeBlock.tsx +53 -14
- package/src/plugin.ts +2 -0
- package/src/utils/emailTemplate.ts +37 -0
- package/src/views/BillingView/client.tsx +362 -69
- package/src/views/ChatView/client.tsx +225 -140
- package/src/views/CrmView/client.tsx +447 -189
- package/src/views/EmailTrackingView/client.tsx +111 -71
- package/src/views/ImportConversationView/client.tsx +255 -70
- package/src/views/LogsView/client.tsx +85 -50
- package/src/views/NewTicketView/client.tsx +37 -53
- package/src/views/PendingEmailsView/client.tsx +512 -92
- package/src/views/SupportDashboardView/client.tsx +294 -134
- package/src/views/TicketDetailView/client.tsx +486 -213
- package/src/views/TicketInboxView/client.tsx +52 -61
- package/src/views/TicketingSettingsView/client.tsx +10 -9
- package/src/views/TimeDashboardView/client.tsx +184 -69
|
@@ -2,21 +2,95 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { useState, useEffect, useCallback } from 'react'
|
|
4
4
|
import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
|
|
5
|
-
import
|
|
5
|
+
import styles from '../../styles/CrmView.module.scss'
|
|
6
|
+
|
|
7
|
+
interface Client {
|
|
8
|
+
id: number
|
|
9
|
+
company: string
|
|
10
|
+
firstName: string
|
|
11
|
+
lastName: string
|
|
12
|
+
email: string
|
|
13
|
+
phone?: string
|
|
14
|
+
notes?: string
|
|
15
|
+
createdAt: string
|
|
16
|
+
}
|
|
6
17
|
|
|
7
|
-
interface Client { id: number; company: string; firstName: string; lastName: string; email: string; phone?: string; notes?: string; createdAt: string }
|
|
8
18
|
interface ClientDetail {
|
|
9
19
|
client: Client
|
|
10
|
-
tickets: {
|
|
11
|
-
|
|
12
|
-
|
|
20
|
+
tickets: {
|
|
21
|
+
id: number
|
|
22
|
+
ticketNumber: string
|
|
23
|
+
subject: string
|
|
24
|
+
status: string
|
|
25
|
+
priority: string
|
|
26
|
+
createdAt: string
|
|
27
|
+
}[]
|
|
28
|
+
projects: {
|
|
29
|
+
id: number
|
|
30
|
+
name: string
|
|
31
|
+
status: string
|
|
32
|
+
}[]
|
|
33
|
+
stats: {
|
|
34
|
+
totalTickets: number
|
|
35
|
+
openTickets: number
|
|
36
|
+
resolvedTickets: number
|
|
37
|
+
totalTimeMinutes: number
|
|
38
|
+
lastActivity: string | null
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const statusBadgeClass: Record<string, string> = {
|
|
43
|
+
open: 'badgeAccent',
|
|
44
|
+
waiting_client: 'badgeOrange',
|
|
45
|
+
resolved: 'badgeGreen',
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const statusLabelKeys: Record<string, string> = {
|
|
49
|
+
open: 'ticket.status.open',
|
|
50
|
+
waiting_client: 'ticket.status.waiting_client',
|
|
51
|
+
resolved: 'ticket.status.resolved',
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const projectStatusLabelKeys: Record<string, string> = {
|
|
55
|
+
active: 'crm.projectStatus.active',
|
|
56
|
+
paused: 'crm.projectStatus.paused',
|
|
57
|
+
completed: 'crm.projectStatus.completed',
|
|
13
58
|
}
|
|
14
59
|
|
|
15
|
-
const
|
|
16
|
-
|
|
60
|
+
const projectStatusBadgeClass: Record<string, string> = {
|
|
61
|
+
active: 'badgeGreen',
|
|
62
|
+
paused: 'badgeOrange',
|
|
63
|
+
completed: 'badgeMuted',
|
|
64
|
+
}
|
|
17
65
|
|
|
18
|
-
function formatDuration(minutes: number): string {
|
|
19
|
-
|
|
66
|
+
function formatDuration(minutes: number): string {
|
|
67
|
+
const h = Math.floor(minutes / 60)
|
|
68
|
+
const m = minutes % 60
|
|
69
|
+
if (h === 0) return `${m}min`
|
|
70
|
+
if (m === 0) return `${h}h`
|
|
71
|
+
return `${h}h${m}m`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function timeAgo(dateStr: string): string {
|
|
75
|
+
const diff = Date.now() - new Date(dateStr).getTime()
|
|
76
|
+
const days = Math.floor(diff / 86400000)
|
|
77
|
+
if (days === 0) return "Aujourd'hui"
|
|
78
|
+
if (days === 1) return 'Hier'
|
|
79
|
+
if (days < 30) return `Il y a ${days}j`
|
|
80
|
+
const months = Math.floor(days / 30)
|
|
81
|
+
return `Il y a ${months} mois`
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function getStatColorClass(key: string, detail: ClientDetail['stats']): string {
|
|
85
|
+
switch (key) {
|
|
86
|
+
case 'total': return styles.statAccent
|
|
87
|
+
case 'open': return detail.openTickets > 0 ? styles.statOrange : styles.statGreen
|
|
88
|
+
case 'resolved': return styles.statGreen
|
|
89
|
+
case 'time': return styles.statAmber
|
|
90
|
+
case 'activity': return styles.statAccent
|
|
91
|
+
default: return styles.statAccent
|
|
92
|
+
}
|
|
93
|
+
}
|
|
20
94
|
|
|
21
95
|
interface ClientSummary {
|
|
22
96
|
summary: string
|
|
@@ -27,7 +101,6 @@ interface ClientSummary {
|
|
|
27
101
|
messageCount: number
|
|
28
102
|
averageSatisfaction: number | null
|
|
29
103
|
generatedAt: string
|
|
30
|
-
aiModel: string
|
|
31
104
|
fromCache?: boolean
|
|
32
105
|
}
|
|
33
106
|
|
|
@@ -39,14 +112,15 @@ export const CrmClient: React.FC = () => {
|
|
|
39
112
|
const [selectedId, setSelectedId] = useState<number | null>(null)
|
|
40
113
|
const [detail, setDetail] = useState<ClientDetail | null>(null)
|
|
41
114
|
const [detailLoading, setDetailLoading] = useState(false)
|
|
115
|
+
// Merge clients
|
|
42
116
|
const [showMerge, setShowMerge] = useState(false)
|
|
43
117
|
const [mergeSearch, setMergeSearch] = useState('')
|
|
118
|
+
const [mergeResults, setMergeResults] = useState<Client[]>([])
|
|
119
|
+
const [merging, setMerging] = useState(false)
|
|
44
120
|
// Client Intelligence
|
|
45
121
|
const [intelligence, setIntelligence] = useState<ClientSummary | null>(null)
|
|
46
122
|
const [intelLoading, setIntelLoading] = useState(false)
|
|
47
123
|
const [intelRefreshing, setIntelRefreshing] = useState(false)
|
|
48
|
-
const [mergeResults, setMergeResults] = useState<Client[]>([])
|
|
49
|
-
const [merging, setMerging] = useState(false)
|
|
50
124
|
const [mergeSuccess, setMergeSuccess] = useState('')
|
|
51
125
|
|
|
52
126
|
const fetchClients = useCallback(async () => {
|
|
@@ -54,12 +128,18 @@ export const CrmClient: React.FC = () => {
|
|
|
54
128
|
const params = new URLSearchParams({ limit: '100', sort: 'company', depth: '0' })
|
|
55
129
|
if (search) params.set('where[company][like]', search)
|
|
56
130
|
const res = await fetch(`/api/support-clients?${params}`)
|
|
57
|
-
if (res.ok) {
|
|
131
|
+
if (res.ok) {
|
|
132
|
+
const json = await res.json()
|
|
133
|
+
setClients(json.docs || [])
|
|
134
|
+
}
|
|
58
135
|
} catch { /* silent */ }
|
|
59
136
|
setLoading(false)
|
|
60
137
|
}, [search])
|
|
61
138
|
|
|
62
|
-
useEffect(() => {
|
|
139
|
+
useEffect(() => {
|
|
140
|
+
const timeout = setTimeout(fetchClients, 300)
|
|
141
|
+
return () => clearTimeout(timeout)
|
|
142
|
+
}, [fetchClients])
|
|
63
143
|
|
|
64
144
|
const fetchDetail = useCallback(async (clientId: number) => {
|
|
65
145
|
setDetailLoading(true)
|
|
@@ -70,16 +150,45 @@ export const CrmClient: React.FC = () => {
|
|
|
70
150
|
fetch(`/api/projects?where[client][equals]=${clientId}&depth=0`),
|
|
71
151
|
fetch(`/api/time-entries?limit=0&depth=0`),
|
|
72
152
|
])
|
|
153
|
+
|
|
73
154
|
const client = clientRes.ok ? await clientRes.json() : null
|
|
74
155
|
const tickets = ticketsRes.ok ? (await ticketsRes.json()).docs || [] : []
|
|
75
156
|
const projects = projectsRes.ok ? (await projectsRes.json()).docs || [] : []
|
|
157
|
+
|
|
158
|
+
// Get time entries for this client's tickets
|
|
76
159
|
const ticketIds = tickets.map((t: { id: number }) => t.id)
|
|
77
160
|
let totalTimeMinutes = 0
|
|
78
|
-
if (timeRes.ok) {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
161
|
+
if (timeRes.ok) {
|
|
162
|
+
const timeData = await timeRes.json()
|
|
163
|
+
totalTimeMinutes = timeData.docs
|
|
164
|
+
.filter((e: { ticket: number }) => ticketIds.includes(e.ticket))
|
|
165
|
+
.reduce((sum: number, e: { duration: number }) => sum + (e.duration || 0), 0)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const openTickets = tickets.filter((t: { status: string }) =>
|
|
169
|
+
['open', 'waiting_client'].includes(t.status),
|
|
170
|
+
).length
|
|
171
|
+
const resolvedTickets = tickets.filter((t: { status: string }) =>
|
|
172
|
+
t.status === 'resolved',
|
|
173
|
+
).length
|
|
174
|
+
|
|
175
|
+
const lastActivity = tickets.length > 0 ? tickets[0].createdAt : null
|
|
176
|
+
|
|
177
|
+
setDetail({
|
|
178
|
+
client,
|
|
179
|
+
tickets,
|
|
180
|
+
projects,
|
|
181
|
+
stats: {
|
|
182
|
+
totalTickets: tickets.length,
|
|
183
|
+
openTickets,
|
|
184
|
+
resolvedTickets,
|
|
185
|
+
totalTimeMinutes,
|
|
186
|
+
lastActivity,
|
|
187
|
+
},
|
|
188
|
+
})
|
|
189
|
+
} catch {
|
|
190
|
+
setDetail(null)
|
|
191
|
+
}
|
|
83
192
|
setDetailLoading(false)
|
|
84
193
|
}, [])
|
|
85
194
|
|
|
@@ -87,9 +196,7 @@ export const CrmClient: React.FC = () => {
|
|
|
87
196
|
if (force) setIntelRefreshing(true); else setIntelLoading(true)
|
|
88
197
|
try {
|
|
89
198
|
const method = force ? 'POST' : 'GET'
|
|
90
|
-
const url = force
|
|
91
|
-
? '/api/support/client-intelligence'
|
|
92
|
-
: `/api/support/client-intelligence?clientId=${clientId}`
|
|
199
|
+
const url = force ? '/api/support/client-intelligence' : `/api/support/client-intelligence?clientId=${clientId}`
|
|
93
200
|
const opts: RequestInit = { method, credentials: 'include', headers: { 'Content-Type': 'application/json' } }
|
|
94
201
|
if (force) opts.body = JSON.stringify({ clientId })
|
|
95
202
|
const res = await fetch(url, opts)
|
|
@@ -98,14 +205,25 @@ export const CrmClient: React.FC = () => {
|
|
|
98
205
|
setIntelLoading(false); setIntelRefreshing(false)
|
|
99
206
|
}, [])
|
|
100
207
|
|
|
101
|
-
const selectClient = (id: number) => {
|
|
208
|
+
const selectClient = (id: number) => {
|
|
209
|
+
setSelectedId(id)
|
|
210
|
+
fetchDetail(id)
|
|
211
|
+
fetchIntelligence(id)
|
|
212
|
+
setIntelligence(null)
|
|
213
|
+
setShowMerge(false)
|
|
214
|
+
setMergeSuccess('')
|
|
215
|
+
}
|
|
102
216
|
|
|
217
|
+
// Merge: search for target client
|
|
103
218
|
useEffect(() => {
|
|
104
219
|
if (!mergeSearch || mergeSearch.length < 2) { setMergeResults([]); return }
|
|
105
220
|
const timer = setTimeout(async () => {
|
|
106
221
|
try {
|
|
107
|
-
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`)
|
|
108
|
-
if (res.ok) {
|
|
222
|
+
const res = await fetch(`/api/support-clients?where[or][0][company][like]=${encodeURIComponent(mergeSearch)}&where[or][1][email][like]=${encodeURIComponent(mergeSearch)}&where[or][2][firstName][like]=${encodeURIComponent(mergeSearch)}&limit=10&depth=0`)
|
|
223
|
+
if (res.ok) {
|
|
224
|
+
const json = await res.json()
|
|
225
|
+
setMergeResults((json.docs || []).filter((c: Client) => c.id !== selectedId))
|
|
226
|
+
}
|
|
109
227
|
} catch { /* silent */ }
|
|
110
228
|
}, 300)
|
|
111
229
|
return () => clearTimeout(timer)
|
|
@@ -113,199 +231,339 @@ export const CrmClient: React.FC = () => {
|
|
|
113
231
|
|
|
114
232
|
const handleMerge = async (targetId: number) => {
|
|
115
233
|
if (!selectedId || !detail) return
|
|
116
|
-
|
|
234
|
+
const targetClient = mergeResults.find((c) => c.id === targetId) || clients.find((c) => c.id === targetId)
|
|
235
|
+
if (!confirm(`Fusionner "${detail.client.company} (${detail.client.email})" dans "${targetClient?.company || targetId}" ?\n\nTous les tickets, messages et projets seront transférés.\nLe client "${detail.client.company}" sera supprimé.\n\nCette action est irréversible.`)) return
|
|
236
|
+
|
|
117
237
|
setMerging(true)
|
|
118
238
|
try {
|
|
119
|
-
const res = await fetch('/api/support/merge-clients', {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
239
|
+
const res = await fetch('/api/support/merge-clients', {
|
|
240
|
+
method: 'POST',
|
|
241
|
+
headers: { 'Content-Type': 'application/json' },
|
|
242
|
+
body: JSON.stringify({ sourceId: selectedId, targetId }),
|
|
243
|
+
})
|
|
244
|
+
if (res.ok) {
|
|
245
|
+
const data = await res.json()
|
|
246
|
+
setMergeSuccess(data.message)
|
|
247
|
+
setShowMerge(false)
|
|
248
|
+
setSelectedId(targetId)
|
|
249
|
+
fetchDetail(targetId)
|
|
250
|
+
fetchClients()
|
|
251
|
+
} else {
|
|
252
|
+
const err = await res.json().catch(() => ({ error: 'Erreur inconnue' }))
|
|
253
|
+
alert(`Erreur : ${err.error}`)
|
|
254
|
+
}
|
|
255
|
+
} catch {
|
|
256
|
+
alert('Erreur réseau')
|
|
257
|
+
}
|
|
123
258
|
setMerging(false)
|
|
124
259
|
}
|
|
125
260
|
|
|
126
|
-
const S: Record<string, React.CSSProperties> = {
|
|
127
|
-
page: { padding: '20px 30px', maxWidth: 1200, margin: '0 auto' },
|
|
128
|
-
grid: { display: 'grid', gridTemplateColumns: '300px 1fr', gap: 20 },
|
|
129
|
-
sidebar: { borderRight: '1px solid var(--theme-elevation-200)', paddingRight: 16 },
|
|
130
|
-
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)' },
|
|
131
|
-
clientItem: { padding: '10px 12px', borderRadius: 8, cursor: 'pointer', marginBottom: 4, border: '1px solid transparent' },
|
|
132
|
-
clientItemActive: { background: 'var(--theme-elevation-50)', borderColor: 'var(--theme-elevation-200)' },
|
|
133
|
-
statsGrid: { display: 'grid', gridTemplateColumns: 'repeat(5, 1fr)', gap: 8, marginBottom: 16 },
|
|
134
|
-
statCard: { padding: '10px 12px', borderRadius: 8, border: '1px solid var(--theme-elevation-150)', textAlign: 'center' as const },
|
|
135
|
-
table: { width: '100%', borderCollapse: 'collapse' as const, fontSize: 13 },
|
|
136
|
-
th: { textAlign: 'left' as const, padding: '6px 8px', borderBottom: '1px solid var(--theme-elevation-200)', fontSize: 11, color: 'var(--theme-elevation-500)' },
|
|
137
|
-
td: { padding: '6px 8px', borderBottom: '1px solid var(--theme-elevation-100)' },
|
|
138
|
-
badge: { padding: '2px 6px', borderRadius: 4, fontSize: 10, fontWeight: 600 },
|
|
139
|
-
}
|
|
140
|
-
|
|
141
261
|
return (
|
|
142
|
-
<div
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
<div
|
|
262
|
+
<div className={styles.page}>
|
|
263
|
+
{/* Header */}
|
|
264
|
+
<div className={styles.header}>
|
|
265
|
+
<div>
|
|
266
|
+
<h1 className={styles.title}>{t('crm.title')}</h1>
|
|
267
|
+
<div className={styles.subtitle}>
|
|
268
|
+
{t('crm.subtitle')}
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
146
271
|
</div>
|
|
147
272
|
|
|
148
|
-
<div
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
<div>
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
273
|
+
<div className={styles.grid}>
|
|
274
|
+
{/* Left: Client list */}
|
|
275
|
+
<div className={styles.sidebar}>
|
|
276
|
+
<div className={styles.sidebarSearch}>
|
|
277
|
+
<input
|
|
278
|
+
type="text"
|
|
279
|
+
placeholder={t('crm.searchPlaceholder')}
|
|
280
|
+
value={search}
|
|
281
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
282
|
+
className={styles.searchInput}
|
|
283
|
+
/>
|
|
284
|
+
</div>
|
|
285
|
+
<div className={styles.clientList}>
|
|
286
|
+
{loading ? (
|
|
287
|
+
<div className={styles.emptyState}>
|
|
288
|
+
<div className={styles.skeleton} />
|
|
289
|
+
<div className={styles.skeleton} />
|
|
290
|
+
<div className={styles.skeleton} />
|
|
291
|
+
<div className={styles.skeleton} />
|
|
292
|
+
</div>
|
|
293
|
+
) : clients.length === 0 ? (
|
|
294
|
+
<div className={styles.emptyState}>
|
|
295
|
+
{t('crm.noClientFound')}
|
|
296
|
+
</div>
|
|
297
|
+
) : (
|
|
298
|
+
clients.map((c) => (
|
|
299
|
+
<div
|
|
300
|
+
key={c.id}
|
|
301
|
+
onClick={() => selectClient(c.id)}
|
|
302
|
+
className={`${styles.clientItem} ${selectedId === c.id ? styles.clientItemActive : ''}`}
|
|
303
|
+
>
|
|
304
|
+
<div className={styles.clientCompany}>{c.company}</div>
|
|
305
|
+
<div className={styles.clientName}>
|
|
306
|
+
{c.firstName} {c.lastName}
|
|
307
|
+
</div>
|
|
308
|
+
<div className={styles.clientEmail}>{c.email}</div>
|
|
159
309
|
</div>
|
|
160
|
-
))
|
|
310
|
+
))
|
|
311
|
+
)}
|
|
161
312
|
</div>
|
|
162
313
|
</div>
|
|
163
314
|
|
|
315
|
+
{/* Right: Client detail */}
|
|
164
316
|
<div>
|
|
165
|
-
{!selectedId ?
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
317
|
+
{!selectedId ? (
|
|
318
|
+
<div className={styles.placeholder}>
|
|
319
|
+
{t('crm.selectClient')}
|
|
320
|
+
</div>
|
|
321
|
+
) : detailLoading ? (
|
|
322
|
+
<div className={styles.loadingCard}>
|
|
323
|
+
<div className={styles.skeleton} />
|
|
324
|
+
<div className={styles.skeleton} />
|
|
325
|
+
<div className={styles.skeleton} />
|
|
326
|
+
<div className={styles.skeleton} />
|
|
327
|
+
</div>
|
|
328
|
+
) : detail ? (
|
|
329
|
+
<div className={styles.detailStack}>
|
|
330
|
+
{/* Client info header */}
|
|
331
|
+
<div className={styles.clientHeader}>
|
|
332
|
+
<div>
|
|
333
|
+
<h2 className={styles.clientHeaderName}>
|
|
334
|
+
{detail.client.company}
|
|
335
|
+
</h2>
|
|
336
|
+
<div className={styles.clientHeaderContact}>
|
|
337
|
+
{detail.client.firstName} {detail.client.lastName}
|
|
176
338
|
</div>
|
|
177
|
-
<div
|
|
178
|
-
<
|
|
179
|
-
|
|
339
|
+
<div className={styles.clientContactRow}>
|
|
340
|
+
<span>{detail.client.email}</span>
|
|
341
|
+
{detail.client.phone && <span>{detail.client.phone}</span>}
|
|
180
342
|
</div>
|
|
343
|
+
{detail.client.notes && (
|
|
344
|
+
<div className={styles.clientNotes}>
|
|
345
|
+
{detail.client.notes}
|
|
346
|
+
</div>
|
|
347
|
+
)}
|
|
181
348
|
</div>
|
|
349
|
+
<div className={styles.headerActions}>
|
|
350
|
+
<a
|
|
351
|
+
href={`/admin/collections/support-clients/${detail.client.id}`}
|
|
352
|
+
className={styles.btnPrimary}
|
|
353
|
+
>
|
|
354
|
+
{t('crm.editButton')}
|
|
355
|
+
</a>
|
|
356
|
+
<button
|
|
357
|
+
onClick={() => { setShowMerge(!showMerge); setMergeSearch(''); setMergeResults([]) }}
|
|
358
|
+
className={styles.btnWarning}
|
|
359
|
+
>
|
|
360
|
+
{t('crm.mergeButton')}
|
|
361
|
+
</button>
|
|
362
|
+
</div>
|
|
363
|
+
</div>
|
|
182
364
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
{
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
{mergeResults.map((c) => (
|
|
190
|
-
<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 }}>
|
|
191
|
-
<strong>{c.company}</strong> -- {c.firstName} {c.lastName} <span style={{ color: 'var(--theme-elevation-400)', fontSize: 11 }}>{c.email}</span>
|
|
192
|
-
</button>
|
|
193
|
-
))}
|
|
194
|
-
</div>
|
|
195
|
-
)}
|
|
365
|
+
{/* Merge success */}
|
|
366
|
+
{mergeSuccess && (
|
|
367
|
+
<div className={styles.successBanner}>
|
|
368
|
+
{mergeSuccess}
|
|
369
|
+
</div>
|
|
370
|
+
)}
|
|
196
371
|
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
{
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
372
|
+
{/* Merge panel */}
|
|
373
|
+
{showMerge && (
|
|
374
|
+
<div className={styles.mergePanel}>
|
|
375
|
+
<h4 className={styles.mergePanelTitle}>
|
|
376
|
+
{t('crm.mergeTitle')}
|
|
377
|
+
</h4>
|
|
378
|
+
<p className={styles.mergePanelDesc}>
|
|
379
|
+
Tous les tickets, messages, projets et enquetes de <strong>{detail.client.company}</strong> seront transferes vers le client cible. Le client actuel sera supprime.
|
|
380
|
+
</p>
|
|
381
|
+
<input
|
|
382
|
+
type="text"
|
|
383
|
+
value={mergeSearch}
|
|
384
|
+
onChange={(e) => setMergeSearch(e.target.value)}
|
|
385
|
+
placeholder={t('crm.mergeSearchPlaceholder')}
|
|
386
|
+
className={styles.mergeInput}
|
|
387
|
+
/>
|
|
388
|
+
{mergeResults.length > 0 && (
|
|
389
|
+
<div className={styles.mergeResultList}>
|
|
390
|
+
{mergeResults.map((c) => (
|
|
391
|
+
<button
|
|
392
|
+
key={c.id}
|
|
393
|
+
onClick={() => handleMerge(c.id)}
|
|
394
|
+
disabled={merging}
|
|
395
|
+
className={styles.mergeResultItem}
|
|
396
|
+
>
|
|
397
|
+
<div>
|
|
398
|
+
<strong>{c.company}</strong>
|
|
399
|
+
<span className={styles.mergeResultName}>{c.firstName} {c.lastName}</span>
|
|
400
|
+
</div>
|
|
401
|
+
<span className={styles.mergeResultEmail}>{c.email}</span>
|
|
402
|
+
</button>
|
|
403
|
+
))}
|
|
209
404
|
</div>
|
|
210
|
-
)
|
|
405
|
+
)}
|
|
406
|
+
{mergeSearch.length >= 2 && mergeResults.length === 0 && (
|
|
407
|
+
<p className={styles.mergeEmpty}>{t('crm.mergeNoClient')}</p>
|
|
408
|
+
)}
|
|
211
409
|
</div>
|
|
410
|
+
)}
|
|
212
411
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
>
|
|
224
|
-
|
|
225
|
-
|
|
412
|
+
{/* Stats row */}
|
|
413
|
+
<div className={styles.statsGrid}>
|
|
414
|
+
{[
|
|
415
|
+
{ key: 'total', label: t('crm.stats.totalTickets'), value: String(detail.stats.totalTickets) },
|
|
416
|
+
{ key: 'open', label: t('crm.stats.openTickets'), value: String(detail.stats.openTickets) },
|
|
417
|
+
{ key: 'resolved', label: t('crm.stats.resolvedTickets'), value: String(detail.stats.resolvedTickets) },
|
|
418
|
+
{ key: 'time', label: t('crm.stats.timeSpent'), value: formatDuration(detail.stats.totalTimeMinutes) },
|
|
419
|
+
{ key: 'activity', label: t('crm.stats.lastActivity'), value: detail.stats.lastActivity ? timeAgo(detail.stats.lastActivity) : '-' },
|
|
420
|
+
].map((stat) => (
|
|
421
|
+
<div key={stat.key} className={styles.statCard}>
|
|
422
|
+
<div className={styles.statLabel}>{stat.label}</div>
|
|
423
|
+
<div className={`${styles.statValue} ${getStatColorClass(stat.key, detail.stats)}`}>
|
|
424
|
+
{stat.value}
|
|
425
|
+
</div>
|
|
226
426
|
</div>
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
427
|
+
))}
|
|
428
|
+
</div>
|
|
429
|
+
|
|
430
|
+
{/* Projects */}
|
|
431
|
+
{detail.projects.length > 0 && (
|
|
432
|
+
<div className={styles.sectionCard}>
|
|
433
|
+
<h3 className={styles.sectionTitle}>
|
|
434
|
+
{t('crm.sections.projects')} ({detail.projects.length})
|
|
435
|
+
</h3>
|
|
436
|
+
<div className={styles.projectList}>
|
|
437
|
+
{detail.projects.map((p) => (
|
|
438
|
+
<a
|
|
439
|
+
key={p.id}
|
|
440
|
+
href={`/admin/collections/projects/${p.id}`}
|
|
441
|
+
className={styles.projectChip}
|
|
442
|
+
>
|
|
443
|
+
{p.name}
|
|
444
|
+
<span className={`${styles.badge} ${styles[projectStatusBadgeClass[p.status] || 'badgeAccent']}`}>
|
|
445
|
+
{t(projectStatusLabelKeys[p.status] || 'crm.projectStatus.active')}
|
|
446
|
+
</span>
|
|
447
|
+
</a>
|
|
448
|
+
))}
|
|
449
|
+
</div>
|
|
450
|
+
</div>
|
|
451
|
+
)}
|
|
452
|
+
|
|
453
|
+
{/* Client Intelligence */}
|
|
454
|
+
<div className={styles.sectionCard} style={{ background: 'linear-gradient(135deg, rgba(37,99,235,0.03) 0%, rgba(139,92,246,0.03) 100%)' }}>
|
|
455
|
+
<div className={styles.sectionHeader}>
|
|
456
|
+
<h3 className={styles.sectionTitle} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
|
|
457
|
+
<span style={{ fontSize: 16 }}>🧠</span> Résumé IA
|
|
458
|
+
</h3>
|
|
459
|
+
<button
|
|
460
|
+
onClick={() => selectedId && fetchIntelligence(selectedId, true)}
|
|
461
|
+
disabled={intelRefreshing}
|
|
462
|
+
className={styles.viewAllLink}
|
|
463
|
+
style={{ cursor: 'pointer', background: 'none', border: 'none', fontWeight: 600 }}
|
|
464
|
+
>
|
|
465
|
+
{intelRefreshing ? '⏳ Génération...' : '🔄 Actualiser'}
|
|
466
|
+
</button>
|
|
467
|
+
</div>
|
|
468
|
+
{intelLoading ? (
|
|
469
|
+
<div style={{ padding: 20, textAlign: 'center', color: '#94a3b8', fontSize: 13 }}>Chargement du résumé...</div>
|
|
470
|
+
) : intelligence ? (
|
|
471
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
|
472
|
+
<p style={{ margin: 0, fontSize: 13, lineHeight: 1.6 }}>{intelligence.summary}</p>
|
|
473
|
+
{intelligence.recurringTopics?.length > 0 && (
|
|
474
|
+
<div>
|
|
475
|
+
<div style={{ fontSize: 11, fontWeight: 700, color: '#6b7280', marginBottom: 6, textTransform: 'uppercase' }}>Sujets récurrents</div>
|
|
476
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
|
477
|
+
{intelligence.recurringTopics.map((tp, i) => (
|
|
478
|
+
<span key={i} style={{ padding: '3px 10px', borderRadius: 12, background: 'rgba(37,99,235,0.08)', color: '#2563eb', fontSize: 11, fontWeight: 600 }}>{tp.topic} ({tp.count}x)</span>
|
|
479
|
+
))}
|
|
255
480
|
</div>
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
<div>
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
481
|
+
</div>
|
|
482
|
+
)}
|
|
483
|
+
{intelligence.patterns?.length > 0 && (
|
|
484
|
+
<div>
|
|
485
|
+
<div style={{ fontSize: 11, fontWeight: 700, color: '#6b7280', marginBottom: 6, textTransform: 'uppercase' }}>Patterns</div>
|
|
486
|
+
<ul style={{ margin: 0, paddingLeft: 18, fontSize: 12, lineHeight: 1.8 }}>
|
|
487
|
+
{intelligence.patterns.map((p, i) => <li key={i}>{p}</li>)}
|
|
488
|
+
</ul>
|
|
489
|
+
</div>
|
|
490
|
+
)}
|
|
491
|
+
{intelligence.keyFacts?.length > 0 && (
|
|
492
|
+
<div>
|
|
493
|
+
<div style={{ fontSize: 11, fontWeight: 700, color: '#6b7280', marginBottom: 6, textTransform: 'uppercase' }}>Faits clés</div>
|
|
494
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
|
|
495
|
+
{intelligence.keyFacts.map((f, i) => (
|
|
496
|
+
<span key={i} style={{ padding: '3px 10px', borderRadius: 12, background: 'rgba(22,163,74,0.08)', color: '#16a34a', fontSize: 11, fontWeight: 600 }}>{f}</span>
|
|
497
|
+
))}
|
|
269
498
|
</div>
|
|
270
|
-
)}
|
|
271
|
-
|
|
272
|
-
{/* Meta */}
|
|
273
|
-
<div style={{ fontSize: 10, color: 'var(--theme-elevation-400)', display: 'flex', gap: 12, marginTop: 4 }}>
|
|
274
|
-
<span>{intelligence.ticketCount} tickets analysés</span>
|
|
275
|
-
<span>{intelligence.messageCount} messages</span>
|
|
276
|
-
{intelligence.averageSatisfaction && <span>Satisfaction: {intelligence.averageSatisfaction}/5</span>}
|
|
277
|
-
{intelligence.fromCache && <span>Cache</span>}
|
|
278
|
-
{intelligence.generatedAt && <span>Généré {timeAgo(intelligence.generatedAt)}</span>}
|
|
279
499
|
</div>
|
|
500
|
+
)}
|
|
501
|
+
<div style={{ fontSize: 10, color: '#9ca3af', display: 'flex', gap: 12 }}>
|
|
502
|
+
<span>{intelligence.ticketCount} tickets</span>
|
|
503
|
+
<span>{intelligence.messageCount} messages</span>
|
|
504
|
+
{intelligence.averageSatisfaction && <span>Satisfaction: {intelligence.averageSatisfaction}/5</span>}
|
|
505
|
+
{intelligence.fromCache && <span>• Cache</span>}
|
|
280
506
|
</div>
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
507
|
+
</div>
|
|
508
|
+
) : (
|
|
509
|
+
<div style={{ padding: 16, textAlign: 'center', color: '#94a3b8', fontSize: 13 }}>
|
|
510
|
+
Cliquez sur « Actualiser » pour générer le résumé IA.
|
|
511
|
+
</div>
|
|
512
|
+
)}
|
|
513
|
+
</div>
|
|
287
514
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
<td style={{ ...S.td, fontSize: 12, color: 'var(--theme-elevation-400)' }}>{timeAgo(tk.createdAt)}</td>
|
|
301
|
-
</tr>
|
|
302
|
-
))}
|
|
303
|
-
</tbody>
|
|
304
|
-
</table>
|
|
305
|
-
)}
|
|
515
|
+
{/* Tickets */}
|
|
516
|
+
<div className={styles.sectionCard}>
|
|
517
|
+
<div className={styles.sectionHeader}>
|
|
518
|
+
<h3 className={styles.sectionTitle}>
|
|
519
|
+
{t('crm.sections.tickets')} ({detail.tickets.length})
|
|
520
|
+
</h3>
|
|
521
|
+
<a
|
|
522
|
+
href={`/admin/collections/tickets?where[client][equals]=${detail.client.id}`}
|
|
523
|
+
className={styles.sectionLink}
|
|
524
|
+
>
|
|
525
|
+
{t('crm.sections.viewAll')} →
|
|
526
|
+
</a>
|
|
306
527
|
</div>
|
|
528
|
+
{detail.tickets.length === 0 ? (
|
|
529
|
+
<div className={styles.ticketEmpty}>
|
|
530
|
+
{t('crm.noTickets')}
|
|
531
|
+
</div>
|
|
532
|
+
) : (
|
|
533
|
+
<table className={styles.table}>
|
|
534
|
+
<thead>
|
|
535
|
+
<tr>
|
|
536
|
+
<th>{t('crm.tableHeaders.number')}</th>
|
|
537
|
+
<th>{t('crm.tableHeaders.subject')}</th>
|
|
538
|
+
<th>{t('crm.tableHeaders.status')}</th>
|
|
539
|
+
<th>{t('crm.tableHeaders.date')}</th>
|
|
540
|
+
</tr>
|
|
541
|
+
</thead>
|
|
542
|
+
<tbody>
|
|
543
|
+
{detail.tickets.map((tk) => (
|
|
544
|
+
<tr key={tk.id}>
|
|
545
|
+
<td>
|
|
546
|
+
<a href={`/admin/collections/tickets/${tk.id}`} className={styles.ticketLink}>
|
|
547
|
+
{tk.ticketNumber}
|
|
548
|
+
</a>
|
|
549
|
+
</td>
|
|
550
|
+
<td className={styles.ticketSubject}>{tk.subject}</td>
|
|
551
|
+
<td className={styles.ticketStatusCell}>
|
|
552
|
+
<span className={`${styles.badge} ${styles[statusBadgeClass[tk.status] || 'badgeAccent']}`}>
|
|
553
|
+
{t(statusLabelKeys[tk.status] || 'ticket.status.open')}
|
|
554
|
+
</span>
|
|
555
|
+
</td>
|
|
556
|
+
<td className={styles.ticketDate}>
|
|
557
|
+
{timeAgo(tk.createdAt)}
|
|
558
|
+
</td>
|
|
559
|
+
</tr>
|
|
560
|
+
))}
|
|
561
|
+
</tbody>
|
|
562
|
+
</table>
|
|
563
|
+
)}
|
|
307
564
|
</div>
|
|
308
|
-
|
|
565
|
+
</div>
|
|
566
|
+
) : null}
|
|
309
567
|
</div>
|
|
310
568
|
</div>
|
|
311
569
|
</div>
|