@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.
- package/dist/index.cjs +333 -1
- package/dist/index.js +333 -1
- package/dist/views/CrmView/client.cjs +77 -0
- package/dist/views/CrmView/client.js +77 -0
- package/package.json +1 -1
- package/src/collections/ClientSummaries.ts +118 -0
- package/src/collections/Tickets.ts +11 -0
- package/src/collections/index.ts +1 -0
- package/src/endpoints/client-intelligence.ts +257 -0
- package/src/endpoints/index.ts +5 -1
- package/src/plugin.ts +2 -0
- package/src/views/CrmView/client.tsx +108 -1
|
@@ -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
|
package/src/collections/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/endpoints/index.ts
CHANGED
|
@@ -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)
|
|
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
|
|
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>
|