@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.
Files changed (50) hide show
  1. package/dist/components/RichTextEditor/index.cjs +233 -0
  2. package/dist/components/RichTextEditor/index.js +232 -0
  3. package/dist/components/TicketConversation/components/CodeBlock.cjs +24 -7
  4. package/dist/components/TicketConversation/components/CodeBlock.js +23 -9
  5. package/dist/index.cjs +19 -1
  6. package/dist/index.js +19 -1
  7. package/dist/views/BillingView/client.cjs +260 -103
  8. package/dist/views/BillingView/client.js +259 -103
  9. package/dist/views/ChatView/client.cjs +184 -137
  10. package/dist/views/ChatView/client.js +180 -137
  11. package/dist/views/CrmView/client.cjs +270 -122
  12. package/dist/views/CrmView/client.js +266 -122
  13. package/dist/views/EmailTrackingView/client.cjs +80 -69
  14. package/dist/views/EmailTrackingView/client.js +80 -70
  15. package/dist/views/ImportConversationView/client.cjs +127 -94
  16. package/dist/views/ImportConversationView/client.js +123 -94
  17. package/dist/views/LogsView/client.cjs +56 -58
  18. package/dist/views/LogsView/client.js +52 -58
  19. package/dist/views/NewTicketView/client.cjs +39 -55
  20. package/dist/views/NewTicketView/client.js +38 -55
  21. package/dist/views/PendingEmailsView/client.cjs +399 -102
  22. package/dist/views/PendingEmailsView/client.js +396 -103
  23. package/dist/views/SupportDashboardView/client.cjs +276 -137
  24. package/dist/views/SupportDashboardView/client.js +275 -137
  25. package/dist/views/TicketDetailView/client.cjs +487 -204
  26. package/dist/views/TicketDetailView/client.js +486 -204
  27. package/dist/views/TicketInboxView/client.cjs +62 -65
  28. package/dist/views/TicketInboxView/client.js +62 -66
  29. package/dist/views/TicketingSettingsView/client.cjs +10 -8
  30. package/dist/views/TicketingSettingsView/client.js +10 -8
  31. package/dist/views/TimeDashboardView/client.cjs +70 -59
  32. package/dist/views/TimeDashboardView/client.js +69 -59
  33. package/package.json +6 -2
  34. package/src/components/RichTextEditor/index.tsx +261 -0
  35. package/src/components/TicketConversation/components/CodeBlock.tsx +53 -14
  36. package/src/plugin.ts +2 -0
  37. package/src/utils/emailTemplate.ts +37 -0
  38. package/src/views/BillingView/client.tsx +362 -69
  39. package/src/views/ChatView/client.tsx +225 -140
  40. package/src/views/CrmView/client.tsx +447 -189
  41. package/src/views/EmailTrackingView/client.tsx +111 -71
  42. package/src/views/ImportConversationView/client.tsx +255 -70
  43. package/src/views/LogsView/client.tsx +85 -50
  44. package/src/views/NewTicketView/client.tsx +37 -53
  45. package/src/views/PendingEmailsView/client.tsx +512 -92
  46. package/src/views/SupportDashboardView/client.tsx +294 -134
  47. package/src/views/TicketDetailView/client.tsx +486 -213
  48. package/src/views/TicketInboxView/client.tsx +52 -61
  49. package/src/views/TicketingSettingsView/client.tsx +10 -9
  50. 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 s from '../../styles/CrmView.module.scss'
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: { 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 }
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 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' }
60
+ const projectStatusBadgeClass: Record<string, string> = {
61
+ active: 'badgeGreen',
62
+ paused: 'badgeOrange',
63
+ completed: 'badgeMuted',
64
+ }
17
65
 
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` }
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) { const json = await res.json(); setClients(json.docs || []) }
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(() => { const timeout = setTimeout(fetchClients, 300); return () => clearTimeout(timeout) }, [fetchClients])
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) { 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) }
79
- const openTickets = tickets.filter((t: { status: string }) => ['open', 'waiting_client'].includes(t.status)).length
80
- const resolvedTickets = tickets.filter((t: { status: string }) => t.status === 'resolved').length
81
- setDetail({ client, tickets, projects, stats: { totalTickets: tickets.length, openTickets, resolvedTickets, totalTimeMinutes, lastActivity: tickets.length > 0 ? tickets[0].createdAt : null } })
82
- } catch { setDetail(null) }
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) => { setSelectedId(id); fetchDetail(id); fetchIntelligence(id); setShowMerge(false); setMergeSuccess(''); setIntelligence(null) }
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) { const json = await res.json(); setMergeResults((json.docs || []).filter((c: Client) => c.id !== selectedId)) }
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
- if (!confirm(`Fusionner ce client dans un autre ? Cette action est irreversible.`)) return
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', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ sourceId: selectedId, targetId }) })
120
- if (res.ok) { const data = await res.json(); setMergeSuccess(data.message); setShowMerge(false); setSelectedId(targetId); fetchDetail(targetId); fetchClients() }
121
- else { const err = await res.json().catch(() => ({ error: 'Erreur inconnue' })); alert(`Erreur : ${err.error}`) }
122
- } catch { alert('Erreur reseau') }
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 style={S.page}>
143
- <div style={{ marginBottom: 20 }}>
144
- <h1 style={{ fontSize: 22, fontWeight: 700, margin: 0, color: 'var(--theme-text)' }}>{t('crm.title')}</h1>
145
- <div style={{ fontSize: 13, color: 'var(--theme-elevation-500)', marginTop: 4 }}>{t('crm.subtitle')}</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 style={S.grid}>
149
- <div style={S.sidebar}>
150
- <input type="text" placeholder={t('crm.searchPlaceholder')} value={search} onChange={(e) => setSearch(e.target.value)} style={S.searchInput} />
151
- <div>
152
- {loading ? <div style={{ padding: 20, textAlign: 'center', color: '#94a3b8' }}>{t('common.loading')}</div>
153
- : clients.length === 0 ? <div style={{ padding: 20, textAlign: 'center', color: '#94a3b8' }}>{t('crm.noClientFound')}</div>
154
- : clients.map((c) => (
155
- <div key={c.id} onClick={() => selectClient(c.id)} style={{ ...S.clientItem, ...(selectedId === c.id ? S.clientItemActive : {}) }}>
156
- <div style={{ fontWeight: 600, fontSize: 13 }}>{c.company}</div>
157
- <div style={{ fontSize: 12, color: 'var(--theme-elevation-500)' }}>{c.firstName} {c.lastName}</div>
158
- <div style={{ fontSize: 11, color: 'var(--theme-elevation-400)' }}>{c.email}</div>
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 ? <div style={{ padding: 60, textAlign: 'center', color: '#94a3b8' }}>{t('crm.selectClient')}</div>
166
- : detailLoading ? <div style={{ padding: 60, textAlign: 'center', color: '#94a3b8' }}>{t('common.loading')}</div>
167
- : detail ? (
168
- <div>
169
- {/* Client header */}
170
- <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
171
- <div>
172
- <h2 style={{ fontSize: 20, fontWeight: 700, margin: 0 }}>{detail.client.company}</h2>
173
- <div style={{ fontSize: 13, color: 'var(--theme-elevation-500)' }}>{detail.client.firstName} {detail.client.lastName}</div>
174
- <div style={{ fontSize: 12, color: 'var(--theme-elevation-400)' }}>{detail.client.email} {detail.client.phone && `-- ${detail.client.phone}`}</div>
175
- {detail.client.notes && <div style={{ fontSize: 12, color: 'var(--theme-elevation-500)', marginTop: 4, fontStyle: 'italic' }}>{detail.client.notes}</div>}
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 style={{ display: 'flex', gap: 8 }}>
178
- <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>
179
- <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>
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
- {mergeSuccess && <div style={{ padding: '8px 14px', borderRadius: 6, background: '#dcfce7', color: '#166534', fontSize: 13, marginBottom: 12 }}>{mergeSuccess}</div>}
184
-
185
- {showMerge && (
186
- <div style={{ padding: 16, borderRadius: 8, border: '1px solid #fde68a', background: '#fefce8', marginBottom: 16 }}>
187
- <h4 style={{ margin: '0 0 8px', fontSize: 14 }}>{t('crm.mergeTitle')}</h4>
188
- <input type="text" value={mergeSearch} onChange={(e) => setMergeSearch(e.target.value)} placeholder={t('crm.mergeSearchPlaceholder')} style={S.searchInput} />
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
- {/* Stats */}
198
- <div style={S.statsGrid}>
199
- {[
200
- { label: t('crm.stats.totalTickets'), value: String(detail.stats.totalTickets) },
201
- { label: t('crm.stats.openTickets'), value: String(detail.stats.openTickets) },
202
- { label: t('crm.stats.resolvedTickets'), value: String(detail.stats.resolvedTickets) },
203
- { label: t('crm.stats.timeSpent'), value: formatDuration(detail.stats.totalTimeMinutes) },
204
- { label: t('crm.stats.lastActivity'), value: detail.stats.lastActivity ? timeAgo(detail.stats.lastActivity) : '-' },
205
- ].map((stat) => (
206
- <div key={stat.label} style={S.statCard}>
207
- <div style={{ fontSize: 11, color: 'var(--theme-elevation-500)', marginBottom: 2 }}>{stat.label}</div>
208
- <div style={{ fontSize: 18, fontWeight: 700, color: 'var(--theme-text)' }}>{stat.value}</div>
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
- {/* Client Intelligence */}
214
- <div style={{ padding: 16, borderRadius: 10, border: '1px solid var(--theme-elevation-150)', marginBottom: 16, background: 'linear-gradient(135deg, rgba(37,99,235,0.03) 0%, rgba(139,92,246,0.03) 100%)' }}>
215
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12 }}>
216
- <h3 style={{ fontSize: 14, fontWeight: 700, margin: 0, display: 'flex', alignItems: 'center', gap: 6 }}>
217
- <span style={{ fontSize: 16 }}>🧠</span> Résumé IA
218
- </h3>
219
- <button
220
- onClick={() => selectedId && fetchIntelligence(selectedId, true)}
221
- disabled={intelRefreshing}
222
- style={{ padding: '4px 10px', borderRadius: 6, border: '1px solid var(--theme-elevation-200)', background: 'var(--theme-elevation-0)', fontSize: 11, fontWeight: 600, cursor: 'pointer', color: 'var(--theme-text)' }}
223
- >
224
- {intelRefreshing ? '⏳ Génération...' : '🔄 Actualiser'}
225
- </button>
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
- {intelLoading ? (
228
- <div style={{ padding: 20, textAlign: 'center', color: 'var(--theme-elevation-400)', fontSize: 13 }}>Chargement du résumé...</div>
229
- ) : intelligence ? (
230
- <div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
231
- {/* Summary */}
232
- <p style={{ margin: 0, fontSize: 13, lineHeight: 1.6, color: 'var(--theme-text)' }}>{intelligence.summary}</p>
233
-
234
- {/* Recurring Topics */}
235
- {intelligence.recurringTopics && intelligence.recurringTopics.length > 0 && (
236
- <div>
237
- <div style={{ fontSize: 11, fontWeight: 700, color: 'var(--theme-elevation-500)', marginBottom: 6, textTransform: 'uppercase' }}>Sujets récurrents</div>
238
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
239
- {intelligence.recurringTopics.map((t, i) => (
240
- <span key={i} style={{ padding: '3px 10px', borderRadius: 12, background: 'rgba(37,99,235,0.08)', color: '#2563eb', fontSize: 11, fontWeight: 600 }}>
241
- {t.topic} ({t.count}x)
242
- </span>
243
- ))}
244
- </div>
245
- </div>
246
- )}
247
-
248
- {/* Patterns */}
249
- {intelligence.patterns && intelligence.patterns.length > 0 && (
250
- <div>
251
- <div style={{ fontSize: 11, fontWeight: 700, color: 'var(--theme-elevation-500)', marginBottom: 6, textTransform: 'uppercase' }}>Patterns détectés</div>
252
- <ul style={{ margin: 0, paddingLeft: 18, fontSize: 12, color: 'var(--theme-text)', lineHeight: 1.8 }}>
253
- {intelligence.patterns.map((p, i) => <li key={i}>{p}</li>)}
254
- </ul>
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
- {/* Key Facts */}
259
- {intelligence.keyFacts && intelligence.keyFacts.length > 0 && (
260
- <div>
261
- <div style={{ fontSize: 11, fontWeight: 700, color: 'var(--theme-elevation-500)', marginBottom: 6, textTransform: 'uppercase' }}>Faits clés</div>
262
- <div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
263
- {intelligence.keyFacts.map((f, i) => (
264
- <span key={i} style={{ padding: '3px 10px', borderRadius: 12, background: 'rgba(22,163,74,0.08)', color: '#16a34a', fontSize: 11, fontWeight: 600 }}>
265
- {f}
266
- </span>
267
- ))}
268
- </div>
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
- <div style={{ padding: 16, textAlign: 'center', color: 'var(--theme-elevation-400)', fontSize: 13 }}>
283
- Cliquez sur "Actualiser" pour générer le résumé IA de ce client.
284
- </div>
285
- )}
286
- </div>
507
+ </div>
508
+ ) : (
509
+ <div style={{ padding: 16, textAlign: 'center', color: '#94a3b8', fontSize: 13 }}>
510
+ Cliquez sur &laquo; Actualiser &raquo; pour générer le résumé IA.
511
+ </div>
512
+ )}
513
+ </div>
287
514
 
288
- {/* Tickets table */}
289
- <div style={{ padding: 16, borderRadius: 10, border: '1px solid var(--theme-elevation-150)', marginBottom: 16 }}>
290
- <h3 style={{ fontSize: 14, fontWeight: 700, margin: '0 0 8px' }}>{t('crm.sections.tickets')} ({detail.tickets.length})</h3>
291
- {detail.tickets.length === 0 ? <div style={{ color: '#94a3b8', fontSize: 13 }}>{t('crm.noTickets')}</div> : (
292
- <table style={S.table}>
293
- <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>
294
- <tbody>
295
- {detail.tickets.map((tk) => (
296
- <tr key={tk.id}>
297
- <td style={S.td}><a href={`/admin/support/ticket?id=${tk.id}`} style={{ color: '#2563eb', fontWeight: 600, textDecoration: 'none' }}>{tk.ticketNumber}</a></td>
298
- <td style={S.td}>{tk.subject}</td>
299
- <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>
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')} &rarr;
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
- ) : null}
565
+ </div>
566
+ ) : null}
309
567
  </div>
310
568
  </div>
311
569
  </div>