@consilioweb/payload-support 0.7.0 → 0.8.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.
@@ -0,0 +1,118 @@
1
+ import type { CollectionConfig } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs.js'
3
+
4
+ /**
5
+ * Client Intelligence Summaries
6
+ * Stores AI-generated summaries per client: recurring topics, patterns, satisfaction trends.
7
+ * Summaries are cached and refreshed on-demand or when a ticket is resolved.
8
+ */
9
+ export function createClientSummariesCollection(slugs: CollectionSlugs): CollectionConfig {
10
+ return {
11
+ slug: 'client-summaries',
12
+ labels: { singular: 'Résumé client', plural: 'Résumés clients' },
13
+ admin: {
14
+ group: 'Support',
15
+ hidden: true, // Not directly editable — managed via API
16
+ defaultColumns: ['client', 'generatedAt', 'ticketCount'],
17
+ useAsTitle: 'clientName',
18
+ },
19
+ fields: [
20
+ {
21
+ name: 'client',
22
+ type: 'relationship',
23
+ relationTo: slugs.supportClients,
24
+ required: true,
25
+ unique: true,
26
+ index: true,
27
+ label: 'Client',
28
+ },
29
+ {
30
+ name: 'clientName',
31
+ type: 'text',
32
+ label: 'Nom client',
33
+ admin: { readOnly: true },
34
+ },
35
+ // ── AI-generated content ──
36
+ {
37
+ name: 'summary',
38
+ type: 'textarea',
39
+ label: 'Résumé global',
40
+ admin: { readOnly: true },
41
+ },
42
+ {
43
+ name: 'recurringTopics',
44
+ type: 'json',
45
+ label: 'Sujets récurrents',
46
+ admin: { readOnly: true },
47
+ // Array of { topic: string, count: number, lastSeen: string }
48
+ },
49
+ {
50
+ name: 'patterns',
51
+ type: 'json',
52
+ label: 'Patterns détectés',
53
+ admin: { readOnly: true },
54
+ // Array of strings: "Revient souvent pour X", "Préfère le tutoiement", etc.
55
+ },
56
+ {
57
+ name: 'keyFacts',
58
+ type: 'json',
59
+ label: 'Faits clés',
60
+ admin: { readOnly: true },
61
+ // Array of strings: "Hébergé chez OVH", "Site WordPress", etc.
62
+ },
63
+ // ── Stats ──
64
+ {
65
+ name: 'ticketCount',
66
+ type: 'number',
67
+ label: 'Nombre de tickets analysés',
68
+ defaultValue: 0,
69
+ admin: { readOnly: true },
70
+ },
71
+ {
72
+ name: 'messageCount',
73
+ type: 'number',
74
+ label: 'Nombre de messages analysés',
75
+ defaultValue: 0,
76
+ admin: { readOnly: true },
77
+ },
78
+ {
79
+ name: 'averageSatisfaction',
80
+ type: 'number',
81
+ label: 'Satisfaction moyenne',
82
+ admin: { readOnly: true },
83
+ },
84
+ {
85
+ name: 'firstTicketAt',
86
+ type: 'date',
87
+ label: 'Premier ticket',
88
+ admin: { readOnly: true },
89
+ },
90
+ {
91
+ name: 'lastTicketAt',
92
+ type: 'date',
93
+ label: 'Dernier ticket',
94
+ admin: { readOnly: true },
95
+ },
96
+ // ── Meta ──
97
+ {
98
+ name: 'generatedAt',
99
+ type: 'date',
100
+ label: 'Généré le',
101
+ admin: { readOnly: true, date: { displayFormat: 'dd/MM/yyyy HH:mm' } },
102
+ },
103
+ {
104
+ name: 'aiModel',
105
+ type: 'text',
106
+ label: 'Modèle IA utilisé',
107
+ admin: { readOnly: true },
108
+ },
109
+ ],
110
+ access: {
111
+ create: ({ req }) => req.user?.collection === 'users',
112
+ read: ({ req }) => req.user?.collection === 'users',
113
+ update: ({ req }) => req.user?.collection === 'users',
114
+ delete: ({ req }) => req.user?.collection === 'users',
115
+ },
116
+ timestamps: true,
117
+ }
118
+ }
@@ -343,6 +343,17 @@ function createFireTicketWebhooks(slugs: CollectionSlugs): CollectionAfterChange
343
343
  subject: doc.subject,
344
344
  previousStatus: previousDoc.status,
345
345
  })
346
+
347
+ // Invalidate client summary cache so it refreshes on next view
348
+ const clientId = typeof doc.client === 'object' ? doc.client?.id : doc.client
349
+ if (clientId) {
350
+ payload.update({
351
+ collection: 'client-summaries',
352
+ where: { client: { equals: clientId } },
353
+ data: { generatedAt: new Date(0).toISOString() }, // Force cache expiry
354
+ overrideAccess: true,
355
+ }).catch(() => { /* silent — collection might not exist yet */ })
356
+ }
346
357
  }
347
358
 
348
359
  // ticket_assigned
@@ -14,3 +14,4 @@ export { createWebhookEndpointsCollection } from './WebhookEndpoints'
14
14
  export { createSlaPoliciesCollection } from './SlaPolicies'
15
15
  export { createMacrosCollection } from './Macros'
16
16
  export { createTicketStatusesCollection } from './TicketStatuses'
17
+ export { createClientSummariesCollection } from './ClientSummaries'
@@ -0,0 +1,257 @@
1
+ import type { Endpoint } from 'payload'
2
+ import type { CollectionSlugs } from '../utils/slugs'
3
+ import { requireAdmin, handleAuthError } from '../utils/auth'
4
+ import { readSupportSettings, type SupportSettings } from '../utils/readSettings'
5
+
6
+ function getClient(aiSettings: SupportSettings['ai']) {
7
+ const Anthropic = require('@anthropic-ai/sdk').default
8
+ if (aiSettings.provider === 'ollama') {
9
+ const baseURL = process.env.OLLAMA_API_URL || 'https://ollama.orkelis.app/v1'
10
+ return new Anthropic({ apiKey: 'ollama', baseURL })
11
+ }
12
+ return new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY })
13
+ }
14
+
15
+ function getModel(aiSettings: SupportSettings['ai']): string {
16
+ return aiSettings.model || 'claude-haiku-4-5-20251001'
17
+ }
18
+
19
+ const CACHE_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours
20
+
21
+ /**
22
+ * GET /api/support/client-intelligence?clientId=X
23
+ * Returns cached summary or generates a new one.
24
+ *
25
+ * POST /api/support/client-intelligence?clientId=X
26
+ * Force-refreshes the summary.
27
+ */
28
+ export function createClientIntelligenceEndpoint(slugs: CollectionSlugs): Endpoint[] {
29
+ const getHandler = async (req: any) => {
30
+ try {
31
+ requireAdmin(req, slugs)
32
+ const payload = req.payload
33
+ const url = new URL(req.url || '', 'http://localhost')
34
+ const clientId = url.searchParams.get('clientId')
35
+ if (!clientId) return Response.json({ error: 'clientId required' }, { status: 400 })
36
+
37
+ // Check cache
38
+ const existing = await payload.find({
39
+ collection: 'client-summaries',
40
+ where: { client: { equals: Number(clientId) } },
41
+ limit: 1,
42
+ depth: 0,
43
+ overrideAccess: true,
44
+ })
45
+
46
+ if (existing.docs.length > 0) {
47
+ const cached = existing.docs[0]
48
+ const age = Date.now() - new Date(cached.generatedAt || 0).getTime()
49
+ if (age < CACHE_TTL_MS) {
50
+ return Response.json({ ...cached, fromCache: true })
51
+ }
52
+ }
53
+
54
+ // Generate new summary
55
+ return await generateSummary(payload, clientId, slugs, existing.docs[0]?.id)
56
+ } catch (error) {
57
+ const authResponse = handleAuthError(error)
58
+ if (authResponse) return authResponse
59
+ console.error('[client-intelligence] Error:', error)
60
+ return Response.json({ error: 'Internal server error' }, { status: 500 })
61
+ }
62
+ }
63
+
64
+ const postHandler = async (req: any) => {
65
+ try {
66
+ requireAdmin(req, slugs)
67
+ const payload = req.payload
68
+ const body = await req.json?.() || {}
69
+ const clientId = body.clientId
70
+ if (!clientId) return Response.json({ error: 'clientId required' }, { status: 400 })
71
+
72
+ // Find existing to update
73
+ const existing = await payload.find({
74
+ collection: 'client-summaries',
75
+ where: { client: { equals: Number(clientId) } },
76
+ limit: 1,
77
+ depth: 0,
78
+ overrideAccess: true,
79
+ })
80
+
81
+ return await generateSummary(payload, clientId, slugs, existing.docs[0]?.id)
82
+ } catch (error) {
83
+ const authResponse = handleAuthError(error)
84
+ if (authResponse) return authResponse
85
+ console.error('[client-intelligence] Refresh error:', error)
86
+ return Response.json({ error: 'Internal server error' }, { status: 500 })
87
+ }
88
+ }
89
+
90
+ return [
91
+ { path: '/support/client-intelligence', method: 'get', handler: getHandler },
92
+ { path: '/support/client-intelligence', method: 'post', handler: postHandler },
93
+ ]
94
+ }
95
+
96
+ async function generateSummary(
97
+ payload: any,
98
+ clientId: string,
99
+ slugs: CollectionSlugs,
100
+ existingId?: number,
101
+ ) {
102
+ const aiSettings = (await readSupportSettings(payload)).ai
103
+ if (!aiSettings.enableSynthesis) {
104
+ return Response.json({ error: 'AI synthesis disabled in settings' }, { status: 400 })
105
+ }
106
+
107
+ // 1. Fetch client info
108
+ const client = await payload.findByID({
109
+ collection: slugs.supportClients,
110
+ id: Number(clientId),
111
+ depth: 0,
112
+ overrideAccess: true,
113
+ })
114
+ if (!client) return Response.json({ error: 'Client not found' }, { status: 404 })
115
+
116
+ const clientName = [client.firstName, client.lastName].filter(Boolean).join(' ') || client.company || client.email
117
+
118
+ // 2. Fetch all tickets for this client
119
+ const tickets = await payload.find({
120
+ collection: slugs.tickets,
121
+ where: { client: { equals: Number(clientId) } },
122
+ sort: '-createdAt',
123
+ limit: 50,
124
+ depth: 0,
125
+ overrideAccess: true,
126
+ })
127
+
128
+ if (tickets.totalDocs === 0) {
129
+ return Response.json({
130
+ summary: 'Aucun ticket pour ce client.',
131
+ recurringTopics: [],
132
+ patterns: [],
133
+ keyFacts: [],
134
+ ticketCount: 0,
135
+ messageCount: 0,
136
+ })
137
+ }
138
+
139
+ // 3. Fetch messages for recent tickets (last 20)
140
+ const ticketIds = tickets.docs.slice(0, 20).map((t: any) => t.id)
141
+ const messages = await payload.find({
142
+ collection: slugs.ticketMessages,
143
+ where: { ticket: { in: ticketIds.join(',') } },
144
+ sort: 'createdAt',
145
+ limit: 200,
146
+ depth: 0,
147
+ overrideAccess: true,
148
+ })
149
+
150
+ // 4. Fetch satisfaction surveys
151
+ let avgSatisfaction: number | null = null
152
+ try {
153
+ const surveys = await payload.find({
154
+ collection: slugs.satisfactionSurveys || 'satisfaction-surveys',
155
+ where: { client: { equals: Number(clientId) } },
156
+ limit: 50,
157
+ depth: 0,
158
+ overrideAccess: true,
159
+ })
160
+ if (surveys.totalDocs > 0) {
161
+ const ratings = surveys.docs.filter((s: any) => s.rating).map((s: any) => s.rating)
162
+ if (ratings.length > 0) avgSatisfaction = Math.round((ratings.reduce((a: number, b: number) => a + b, 0) / ratings.length) * 10) / 10
163
+ }
164
+ } catch { /* satisfaction collection might not exist */ }
165
+
166
+ // 5. Build context for AI
167
+ const ticketSummaries = tickets.docs.map((t: any) => {
168
+ const msgs = messages.docs.filter((m: any) => {
169
+ const mTicket = typeof m.ticket === 'object' ? m.ticket.id : m.ticket
170
+ return mTicket === t.id
171
+ })
172
+ const clientMsgs = msgs.filter((m: any) => m.authorType === 'client' || m.authorType === 'email')
173
+ const adminMsgs = msgs.filter((m: any) => m.authorType === 'admin')
174
+ return `Ticket ${t.ticketNumber} (${t.status}) — "${t.subject}"
175
+ Client: ${clientMsgs.map((m: any) => m.body?.slice(0, 200)).join(' | ')}
176
+ Admin: ${adminMsgs.map((m: any) => m.body?.slice(0, 200)).join(' | ')}`
177
+ }).join('\n\n')
178
+
179
+ const prompt = `Tu es un assistant d'analyse CRM pour un support technique. Analyse l'historique complet de ce client et génère un rapport structuré.
180
+
181
+ CLIENT : ${clientName} (${client.company || 'pas de société'})
182
+ Email : ${client.email}
183
+ Nombre de tickets : ${tickets.totalDocs}
184
+ Satisfaction moyenne : ${avgSatisfaction ?? 'non évaluée'}
185
+
186
+ HISTORIQUE DES TICKETS :
187
+ ${ticketSummaries.slice(0, 4000)}
188
+
189
+ Réponds en JSON strict (pas de markdown, pas de commentaires) avec cette structure :
190
+ {
191
+ "summary": "Résumé global du client en 2-3 phrases (qui il est, ce qu'il demande habituellement, son niveau de satisfaction)",
192
+ "recurringTopics": [{"topic": "nom du sujet", "count": N, "lastSeen": "YYYY-MM-DD"}],
193
+ "patterns": ["pattern 1 observé", "pattern 2 observé"],
194
+ "keyFacts": ["fait clé 1 sur le client", "fait clé 2"]
195
+ }
196
+
197
+ Sois factuel. Ne dépasse pas 5 items par tableau. Réponds UNIQUEMENT avec le JSON.`
198
+
199
+ // 6. Call AI
200
+ const anthropic = getClient(aiSettings)
201
+ const model = getModel(aiSettings)
202
+
203
+ const res = await anthropic.messages.create({
204
+ model,
205
+ max_tokens: 1000,
206
+ messages: [{ role: 'user', content: prompt }],
207
+ })
208
+
209
+ const rawText = res.content[0].type === 'text' ? res.content[0].text : '{}'
210
+
211
+ // 7. Parse AI response
212
+ let parsed: any = {}
213
+ try {
214
+ // Extract JSON from potential markdown fences
215
+ const jsonMatch = rawText.match(/\{[\s\S]*\}/)
216
+ if (jsonMatch) parsed = JSON.parse(jsonMatch[0])
217
+ } catch {
218
+ parsed = { summary: rawText, recurringTopics: [], patterns: [], keyFacts: [] }
219
+ }
220
+
221
+ // 8. Save to DB
222
+ const data = {
223
+ client: Number(clientId),
224
+ clientName,
225
+ summary: parsed.summary || 'Résumé non disponible',
226
+ recurringTopics: parsed.recurringTopics || [],
227
+ patterns: parsed.patterns || [],
228
+ keyFacts: parsed.keyFacts || [],
229
+ ticketCount: tickets.totalDocs,
230
+ messageCount: messages.totalDocs,
231
+ averageSatisfaction: avgSatisfaction,
232
+ firstTicketAt: tickets.docs[tickets.docs.length - 1]?.createdAt || null,
233
+ lastTicketAt: tickets.docs[0]?.createdAt || null,
234
+ generatedAt: new Date().toISOString(),
235
+ aiModel: model,
236
+ }
237
+
238
+ let saved: any
239
+ if (existingId) {
240
+ saved = await payload.update({
241
+ collection: 'client-summaries',
242
+ id: existingId,
243
+ data,
244
+ overrideAccess: true,
245
+ })
246
+ } else {
247
+ saved = await payload.create({
248
+ collection: 'client-summaries',
249
+ data,
250
+ overrideAccess: true,
251
+ })
252
+ }
253
+
254
+ console.log(`[client-intelligence] Generated summary for ${clientName} (${tickets.totalDocs} tickets, ${messages.totalDocs} messages)`)
255
+
256
+ return Response.json({ ...saved, fromCache: false })
257
+ }
@@ -3,6 +3,7 @@ import type { CollectionSlugs } from '../utils/slugs'
3
3
  import type { SupportFeatures } from '../types'
4
4
 
5
5
  import { createAiEndpoint } from './ai'
6
+ import { createClientIntelligenceEndpoint } from './client-intelligence'
6
7
  import { createSearchEndpoint } from './search'
7
8
  import { createBulkActionEndpoint } from './bulk-action'
8
9
  import { createMergeTicketsEndpoint } from './merge-tickets'
@@ -117,7 +118,10 @@ export function createSupportEndpoints(slugs: CollectionSlugs, options?: Support
117
118
  ]
118
119
 
119
120
  // Conditional endpoints based on feature flags
120
- if (!f || f.ai !== false) endpoints.push(createAiEndpoint(slugs))
121
+ if (!f || f.ai !== false) {
122
+ endpoints.push(createAiEndpoint(slugs))
123
+ endpoints.push(...createClientIntelligenceEndpoint(slugs))
124
+ }
121
125
  if (!f || f.bulkActions !== false) endpoints.push(createBulkActionEndpoint(slugs))
122
126
  if (!f || f.merge !== false) endpoints.push(createMergeTicketsEndpoint(slugs))
123
127
  if (!f || f.splitTicket !== false) endpoints.push(createSplitTicketEndpoint(slugs))
package/src/plugin.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  createSlaPoliciesCollection,
21
21
  createMacrosCollection,
22
22
  createTicketStatusesCollection,
23
+ createClientSummariesCollection,
23
24
  } from './collections'
24
25
 
25
26
  function viewConfig(component: string, path: string): AdminViewConfig {
@@ -99,6 +100,7 @@ export function supportPlugin(config?: SupportPluginConfig): Plugin {
99
100
  if (features.customStatuses !== false) supportCollections.push(createTicketStatusesCollection(slugs))
100
101
  if (features.chat) supportCollections.push(createChatMessagesCollection(slugs))
101
102
  if (features.pendingEmails) supportCollections.push(createPendingEmailsCollection(slugs))
103
+ if (features.ai !== false) supportCollections.push(createClientSummariesCollection(slugs))
102
104
 
103
105
  // ─── Admin Views ─────────────────────────────────────
104
106
 
@@ -18,6 +18,19 @@ const statusColors: Record<string, string> = { open: '#3b82f6', waiting_client:
18
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
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
20
 
21
+ interface ClientSummary {
22
+ summary: string
23
+ recurringTopics: { topic: string; count: number; lastSeen: string }[]
24
+ patterns: string[]
25
+ keyFacts: string[]
26
+ ticketCount: number
27
+ messageCount: number
28
+ averageSatisfaction: number | null
29
+ generatedAt: string
30
+ aiModel: string
31
+ fromCache?: boolean
32
+ }
33
+
21
34
  export const CrmClient: React.FC = () => {
22
35
  const { t } = useTranslation()
23
36
  const [clients, setClients] = useState<Client[]>([])
@@ -28,6 +41,10 @@ export const CrmClient: React.FC = () => {
28
41
  const [detailLoading, setDetailLoading] = useState(false)
29
42
  const [showMerge, setShowMerge] = useState(false)
30
43
  const [mergeSearch, setMergeSearch] = useState('')
44
+ // Client Intelligence
45
+ const [intelligence, setIntelligence] = useState<ClientSummary | null>(null)
46
+ const [intelLoading, setIntelLoading] = useState(false)
47
+ const [intelRefreshing, setIntelRefreshing] = useState(false)
31
48
  const [mergeResults, setMergeResults] = useState<Client[]>([])
32
49
  const [merging, setMerging] = useState(false)
33
50
  const [mergeSuccess, setMergeSuccess] = useState('')
@@ -66,7 +83,22 @@ export const CrmClient: React.FC = () => {
66
83
  setDetailLoading(false)
67
84
  }, [])
68
85
 
69
- const selectClient = (id: number) => { setSelectedId(id); fetchDetail(id); setShowMerge(false); setMergeSuccess('') }
86
+ const fetchIntelligence = useCallback(async (clientId: number, force = false) => {
87
+ if (force) setIntelRefreshing(true); else setIntelLoading(true)
88
+ try {
89
+ const method = force ? 'POST' : 'GET'
90
+ const url = force
91
+ ? '/api/support/client-intelligence'
92
+ : `/api/support/client-intelligence?clientId=${clientId}`
93
+ const opts: RequestInit = { method, credentials: 'include', headers: { 'Content-Type': 'application/json' } }
94
+ if (force) opts.body = JSON.stringify({ clientId })
95
+ const res = await fetch(url, opts)
96
+ if (res.ok) setIntelligence(await res.json())
97
+ } catch { /* silent */ }
98
+ setIntelLoading(false); setIntelRefreshing(false)
99
+ }, [])
100
+
101
+ const selectClient = (id: number) => { setSelectedId(id); fetchDetail(id); fetchIntelligence(id); setShowMerge(false); setMergeSuccess(''); setIntelligence(null) }
70
102
 
71
103
  useEffect(() => {
72
104
  if (!mergeSearch || mergeSearch.length < 2) { setMergeResults([]); return }
@@ -178,6 +210,81 @@ export const CrmClient: React.FC = () => {
178
210
  ))}
179
211
  </div>
180
212
 
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>
226
+ </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>
255
+ </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>
269
+ </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
+ </div>
280
+ </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>
287
+
181
288
  {/* Tickets table */}
182
289
  <div style={{ padding: 16, borderRadius: 10, border: '1px solid var(--theme-elevation-150)', marginBottom: 16 }}>
183
290
  <h3 style={{ fontSize: 14, fontWeight: 700, margin: '0 0 8px' }}>{t('crm.sections.tickets')} ({detail.tickets.length})</h3>