@consilioweb/payload-support 0.9.11 → 0.9.13
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 +290 -6
- package/dist/index.d.cts +18 -1
- package/dist/index.d.ts +18 -1
- package/dist/index.js +290 -7
- package/dist/styles/BillingView.module.scss +132 -0
- package/dist/views/BillingView/client.cjs +287 -170
- package/dist/views/BillingView/client.js +287 -170
- package/package.json +1 -1
- package/src/collections/Tickets.ts +78 -0
- package/src/endpoints/billing.ts +70 -9
- package/src/endpoints/index.ts +3 -0
- package/src/endpoints/ticket-synthesis.ts +67 -0
- package/src/index.ts +2 -0
- package/src/styles/BillingView.module.scss +132 -0
- package/src/utils/generateTicketSynthesis.ts +178 -0
- package/src/views/BillingView/client.tsx +234 -102
|
@@ -13,6 +13,7 @@ import { dispatchWebhook } from '../utils/webhookDispatcher'
|
|
|
13
13
|
import { readSupportSettings } from '../utils/readSettings'
|
|
14
14
|
import { createTicketStatusEmail } from '../hooks/ticketStatusEmail'
|
|
15
15
|
import { createAssignSlaDeadlines, createCheckSlaOnResolve } from '../hooks/checkSLA'
|
|
16
|
+
import { generateTicketSynthesis } from '../utils/generateTicketSynthesis'
|
|
16
17
|
|
|
17
18
|
// ─── Hooks ───────────────────────────────────────────────
|
|
18
19
|
|
|
@@ -154,6 +155,40 @@ function createTrackSLA(slugs: CollectionSlugs): CollectionAfterChangeHook {
|
|
|
154
155
|
}
|
|
155
156
|
}
|
|
156
157
|
|
|
158
|
+
function createTrackAiSummaryOnResolve(slugs: CollectionSlugs): CollectionAfterChangeHook {
|
|
159
|
+
return async ({ doc, previousDoc, operation, req }) => {
|
|
160
|
+
if (operation !== 'update' || !previousDoc) return doc
|
|
161
|
+
const wasResolved = previousDoc.status === 'resolved'
|
|
162
|
+
const isResolved = doc.status === 'resolved'
|
|
163
|
+
|
|
164
|
+
// Reopening: clear the cached summary so the next resolve regenerates it
|
|
165
|
+
if (wasResolved && !isResolved && doc.aiSummary) {
|
|
166
|
+
try {
|
|
167
|
+
await req.payload.update({
|
|
168
|
+
collection: slugs.tickets,
|
|
169
|
+
id: doc.id,
|
|
170
|
+
data: { aiSummary: null, aiSummaryGeneratedAt: null, aiSummaryStatus: null },
|
|
171
|
+
overrideAccess: true,
|
|
172
|
+
})
|
|
173
|
+
} catch (err) {
|
|
174
|
+
console.error('[support] Failed to clear ai summary on reopen:', err)
|
|
175
|
+
}
|
|
176
|
+
return doc
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Just resolved: trigger generation if not already cached.
|
|
180
|
+
// Fire-and-forget so the admin UI doesn't block on the LLM call.
|
|
181
|
+
if (!wasResolved && isResolved && !doc.aiSummary) {
|
|
182
|
+
// setImmediate keeps the response snappy; failures are logged inside the util.
|
|
183
|
+
setImmediate(() => {
|
|
184
|
+
generateTicketSynthesis({ payload: req.payload, slugs, ticketId: doc.id })
|
|
185
|
+
.catch((err) => console.error('[support] Background ai synthesis failed:', err))
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
return doc
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
157
192
|
function createLogTicketActivity(slugs: CollectionSlugs): CollectionAfterChangeHook {
|
|
158
193
|
return async ({ doc, previousDoc, operation, req }) => {
|
|
159
194
|
if (operation !== 'update' || !previousDoc) return doc
|
|
@@ -596,6 +631,48 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
|
|
|
596
631
|
admin: { initCollapsed: true },
|
|
597
632
|
fields: billingFields,
|
|
598
633
|
},
|
|
634
|
+
// AI Synthesis collapsible — auto-filled when ticket is resolved
|
|
635
|
+
{
|
|
636
|
+
type: 'collapsible', label: 'Synthese IA',
|
|
637
|
+
admin: {
|
|
638
|
+
initCollapsed: true,
|
|
639
|
+
description: 'Recap factuel genere automatiquement au passage en resolu. Sert au copier-coller dans devis/factures.',
|
|
640
|
+
},
|
|
641
|
+
fields: [
|
|
642
|
+
{
|
|
643
|
+
name: 'aiSummary',
|
|
644
|
+
type: 'textarea',
|
|
645
|
+
label: 'Synthese',
|
|
646
|
+
admin: {
|
|
647
|
+
readOnly: true,
|
|
648
|
+
rows: 8,
|
|
649
|
+
description: 'Vide tant que le ticket n\'est pas resolu. Effacee si le ticket est reouvert.',
|
|
650
|
+
},
|
|
651
|
+
},
|
|
652
|
+
{
|
|
653
|
+
type: 'row',
|
|
654
|
+
fields: [
|
|
655
|
+
{
|
|
656
|
+
name: 'aiSummaryGeneratedAt',
|
|
657
|
+
type: 'date',
|
|
658
|
+
label: 'Genere le',
|
|
659
|
+
admin: { readOnly: true, width: '50%', date: { displayFormat: 'dd/MM/yyyy HH:mm' } },
|
|
660
|
+
},
|
|
661
|
+
{
|
|
662
|
+
name: 'aiSummaryStatus',
|
|
663
|
+
type: 'select',
|
|
664
|
+
label: 'Statut',
|
|
665
|
+
options: [
|
|
666
|
+
{ label: 'En cours', value: 'pending' },
|
|
667
|
+
{ label: 'Genere', value: 'done' },
|
|
668
|
+
{ label: 'Erreur', value: 'error' },
|
|
669
|
+
],
|
|
670
|
+
admin: { readOnly: true, width: '50%' },
|
|
671
|
+
},
|
|
672
|
+
],
|
|
673
|
+
},
|
|
674
|
+
],
|
|
675
|
+
},
|
|
599
676
|
// SLA & Delais
|
|
600
677
|
{
|
|
601
678
|
type: 'collapsible', label: 'SLA & Delais',
|
|
@@ -710,6 +787,7 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
|
|
|
710
787
|
],
|
|
711
788
|
afterChange: [
|
|
712
789
|
createTrackSLA(slugs),
|
|
790
|
+
createTrackAiSummaryOnResolve(slugs),
|
|
713
791
|
createAutoCalculateSLA(slugs),
|
|
714
792
|
createAssignSlaDeadlines(slugs, notificationSlug),
|
|
715
793
|
createCheckSlaOnResolve(slugs, notificationSlug),
|
package/src/endpoints/billing.ts
CHANGED
|
@@ -8,6 +8,10 @@ const MAX_PAGES = 50
|
|
|
8
8
|
/**
|
|
9
9
|
* GET /api/support/billing?from=...&to=...&projectId=...
|
|
10
10
|
* Admin-only endpoint returning billing data.
|
|
11
|
+
*
|
|
12
|
+
* Includes ALL billable tickets active during the period (i.e. updated/created/resolved
|
|
13
|
+
* between from and to) — even those without any time entries, so the admin can spot
|
|
14
|
+
* tickets where time was forgotten.
|
|
11
15
|
*/
|
|
12
16
|
export function createBillingEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
13
17
|
return {
|
|
@@ -29,12 +33,43 @@ export function createBillingEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
29
33
|
|
|
30
34
|
const projectId = url.searchParams.get('projectId')
|
|
31
35
|
|
|
36
|
+
// Build an exclusive upper-bound for ISO datetime fields:
|
|
37
|
+
// when filtering on updatedAt / resolvedAt with `to=2026-04-30`, we want to include
|
|
38
|
+
// events that happened on April 30 after 00:00.
|
|
39
|
+
const toExclusive = new Date(to)
|
|
40
|
+
toExclusive.setDate(toExclusive.getDate() + 1)
|
|
41
|
+
const toExclusiveIso = toExclusive.toISOString()
|
|
42
|
+
|
|
32
43
|
const ticketWhere: Where = {
|
|
33
|
-
|
|
34
|
-
|
|
44
|
+
and: [
|
|
45
|
+
{ billable: { equals: true } },
|
|
46
|
+
...(projectId ? [{ project: { equals: Number(projectId) } } as Where] : []),
|
|
47
|
+
{
|
|
48
|
+
or: [
|
|
49
|
+
{
|
|
50
|
+
and: [
|
|
51
|
+
{ updatedAt: { greater_than_equal: from } },
|
|
52
|
+
{ updatedAt: { less_than: toExclusiveIso } },
|
|
53
|
+
],
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
and: [
|
|
57
|
+
{ createdAt: { greater_than_equal: from } },
|
|
58
|
+
{ createdAt: { less_than: toExclusiveIso } },
|
|
59
|
+
],
|
|
60
|
+
},
|
|
61
|
+
{
|
|
62
|
+
and: [
|
|
63
|
+
{ resolvedAt: { greater_than_equal: from } },
|
|
64
|
+
{ resolvedAt: { less_than: toExclusiveIso } },
|
|
65
|
+
],
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
},
|
|
69
|
+
],
|
|
35
70
|
}
|
|
36
71
|
|
|
37
|
-
// Paginate tickets
|
|
72
|
+
// Paginate tickets
|
|
38
73
|
const allTickets: Array<Record<string, unknown>> = []
|
|
39
74
|
let ticketPage = 1
|
|
40
75
|
let ticketHasMore = true
|
|
@@ -53,7 +88,7 @@ export function createBillingEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
53
88
|
ticketPage++
|
|
54
89
|
}
|
|
55
90
|
|
|
56
|
-
// Paginate time entries
|
|
91
|
+
// Paginate time entries within the period
|
|
57
92
|
const allEntries: Array<Record<string, unknown>> = []
|
|
58
93
|
let entryPage = 1
|
|
59
94
|
let entryHasMore = true
|
|
@@ -94,15 +129,27 @@ export function createBillingEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
94
129
|
const projectGroups = new Map<string, {
|
|
95
130
|
project: { id: number; name: string } | null
|
|
96
131
|
client: { company: string } | null
|
|
97
|
-
tickets: Array<{
|
|
132
|
+
tickets: Array<{
|
|
133
|
+
id: number
|
|
134
|
+
ticketNumber: string
|
|
135
|
+
subject: string
|
|
136
|
+
status: string
|
|
137
|
+
entries: Array<{ duration: number; description: string; date: string }>
|
|
138
|
+
totalMinutes: number
|
|
139
|
+
billedAmount: number | null
|
|
140
|
+
hasNoTimeEntries: boolean
|
|
141
|
+
aiSummary: string | null
|
|
142
|
+
aiSummaryGeneratedAt: string | null
|
|
143
|
+
aiSummaryStatus: string | null
|
|
144
|
+
}>
|
|
98
145
|
totalMinutes: number
|
|
99
146
|
totalBilledAmount: number
|
|
100
147
|
}>()
|
|
101
148
|
|
|
102
149
|
for (const ticket of allTickets) {
|
|
103
150
|
const t = ticket as any
|
|
104
|
-
const ticketEntries = entriesByTicket.get(t.id)
|
|
105
|
-
|
|
151
|
+
const ticketEntries = entriesByTicket.get(t.id) || []
|
|
152
|
+
const hasNoTimeEntries = ticketEntries.length === 0
|
|
106
153
|
|
|
107
154
|
const project = typeof t.project === 'object' && t.project
|
|
108
155
|
? { id: t.project.id, name: t.project.name || 'Sans nom' }
|
|
@@ -138,9 +185,14 @@ export function createBillingEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
138
185
|
id: t.id,
|
|
139
186
|
ticketNumber: t.ticketNumber || '',
|
|
140
187
|
subject: t.subject || '',
|
|
188
|
+
status: t.status || '',
|
|
141
189
|
entries: ticketEntries,
|
|
142
190
|
totalMinutes: ticketTotalMinutes,
|
|
143
191
|
billedAmount,
|
|
192
|
+
hasNoTimeEntries,
|
|
193
|
+
aiSummary: t.aiSummary || null,
|
|
194
|
+
aiSummaryGeneratedAt: t.aiSummaryGeneratedAt || null,
|
|
195
|
+
aiSummaryStatus: t.aiSummaryStatus || null,
|
|
144
196
|
})
|
|
145
197
|
projectGroups.get(projectKey)!.totalMinutes += ticketTotalMinutes
|
|
146
198
|
if (billedAmount) projectGroups.get(projectKey)!.totalBilledAmount += billedAmount
|
|
@@ -149,8 +201,17 @@ export function createBillingEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
149
201
|
const groups = Array.from(projectGroups.values())
|
|
150
202
|
const grandTotalMinutes = groups.reduce((sum, g) => sum + g.totalMinutes, 0)
|
|
151
203
|
const grandTotalBilledAmount = groups.reduce((sum, g) => sum + g.totalBilledAmount, 0)
|
|
152
|
-
|
|
153
|
-
|
|
204
|
+
const ticketsWithoutTime = groups.reduce(
|
|
205
|
+
(sum, g) => sum + g.tickets.filter((t) => t.hasNoTimeEntries).length,
|
|
206
|
+
0,
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
return new Response(JSON.stringify({
|
|
210
|
+
groups,
|
|
211
|
+
grandTotalMinutes,
|
|
212
|
+
grandTotalBilledAmount,
|
|
213
|
+
ticketsWithoutTime,
|
|
214
|
+
}), {
|
|
154
215
|
headers: {
|
|
155
216
|
'Content-Type': 'application/json',
|
|
156
217
|
'Cache-Control': 'private, max-age=300, stale-while-revalidate=600',
|
package/src/endpoints/index.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { createAdminChatGetEndpoint, createAdminChatPostEndpoint } from './admin
|
|
|
25
25
|
import { createAdminChatStreamEndpoint } from './admin-chat-stream'
|
|
26
26
|
import { createAdminStatsEndpoint } from './admin-stats'
|
|
27
27
|
import { createBillingEndpoint } from './billing'
|
|
28
|
+
import { createTicketSynthesisEndpoint } from './ticket-synthesis'
|
|
28
29
|
import { createEmailStatsEndpoint } from './email-stats'
|
|
29
30
|
import { createSatisfactionEndpoint } from './satisfaction'
|
|
30
31
|
import { createTrackOpenEndpoint } from './track-open'
|
|
@@ -65,6 +66,7 @@ export { createAdminChatGetEndpoint, createAdminChatPostEndpoint } from './admin
|
|
|
65
66
|
export { createAdminChatStreamEndpoint } from './admin-chat-stream'
|
|
66
67
|
export { createAdminStatsEndpoint } from './admin-stats'
|
|
67
68
|
export { createBillingEndpoint } from './billing'
|
|
69
|
+
export { createTicketSynthesisEndpoint } from './ticket-synthesis'
|
|
68
70
|
export { createEmailStatsEndpoint } from './email-stats'
|
|
69
71
|
export { createSatisfactionEndpoint } from './satisfaction'
|
|
70
72
|
export { createTrackOpenEndpoint } from './track-open'
|
|
@@ -121,6 +123,7 @@ export function createSupportEndpoints(slugs: CollectionSlugs, options?: Support
|
|
|
121
123
|
if (!f || f.ai !== false) {
|
|
122
124
|
endpoints.push(createAiEndpoint(slugs))
|
|
123
125
|
endpoints.push(...createClientIntelligenceEndpoint(slugs))
|
|
126
|
+
endpoints.push(createTicketSynthesisEndpoint(slugs))
|
|
124
127
|
}
|
|
125
128
|
if (!f || f.bulkActions !== false) endpoints.push(createBulkActionEndpoint(slugs))
|
|
126
129
|
if (!f || f.merge !== false) endpoints.push(createMergeTicketsEndpoint(slugs))
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { Endpoint } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import { requireAdmin, handleAuthError } from '../utils/auth'
|
|
4
|
+
import { generateTicketSynthesis } from '../utils/generateTicketSynthesis'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* POST /api/support/ticket-synthesis?ticketId=X[&force=true]
|
|
8
|
+
*
|
|
9
|
+
* Admin-only. Generates (or returns the cached) AI bullet-point synthesis for a single ticket.
|
|
10
|
+
* The synthesis is persisted on the ticket itself (aiSummary, aiSummaryGeneratedAt, aiSummaryStatus).
|
|
11
|
+
*
|
|
12
|
+
* Cache behaviour:
|
|
13
|
+
* - If aiSummary already exists and force is not set, returns the cached value (status: cached).
|
|
14
|
+
* - If force=true, regenerates regardless of existing summary.
|
|
15
|
+
* - The hook on Tickets clears aiSummary when a ticket is reopened, so the next pass through
|
|
16
|
+
* "resolved" status will trigger a regeneration automatically.
|
|
17
|
+
*/
|
|
18
|
+
export function createTicketSynthesisEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
19
|
+
return {
|
|
20
|
+
path: '/support/ticket-synthesis',
|
|
21
|
+
method: 'post',
|
|
22
|
+
handler: async (req) => {
|
|
23
|
+
try {
|
|
24
|
+
requireAdmin(req, slugs)
|
|
25
|
+
const payload = req.payload
|
|
26
|
+
|
|
27
|
+
const url = new URL(req.url || '', 'http://localhost')
|
|
28
|
+
const ticketIdRaw = url.searchParams.get('ticketId')
|
|
29
|
+
const force = url.searchParams.get('force') === 'true'
|
|
30
|
+
|
|
31
|
+
if (!ticketIdRaw) {
|
|
32
|
+
return Response.json({ error: 'ticketId required' }, { status: 400 })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const ticketId = Number(ticketIdRaw)
|
|
36
|
+
if (Number.isNaN(ticketId)) {
|
|
37
|
+
return Response.json({ error: 'ticketId must be a number' }, { status: 400 })
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
if (!force) {
|
|
41
|
+
const existing = await payload.findByID({
|
|
42
|
+
collection: slugs.tickets as any,
|
|
43
|
+
id: ticketId,
|
|
44
|
+
depth: 0,
|
|
45
|
+
overrideAccess: true,
|
|
46
|
+
}) as { aiSummary?: string; aiSummaryGeneratedAt?: string; aiSummaryStatus?: string } | null
|
|
47
|
+
|
|
48
|
+
if (existing?.aiSummary && existing.aiSummaryStatus === 'done') {
|
|
49
|
+
return Response.json({
|
|
50
|
+
summary: existing.aiSummary,
|
|
51
|
+
generatedAt: existing.aiSummaryGeneratedAt,
|
|
52
|
+
status: 'cached',
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const result = await generateTicketSynthesis({ payload, slugs, ticketId })
|
|
58
|
+
return Response.json(result)
|
|
59
|
+
} catch (err) {
|
|
60
|
+
const authResponse = handleAuthError(err)
|
|
61
|
+
if (authResponse) return authResponse
|
|
62
|
+
console.error('[support/ticket-synthesis] Error:', err)
|
|
63
|
+
return Response.json({ error: 'Internal server error' }, { status: 500 })
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -25,6 +25,8 @@ export { readSupportSettings, readUserPrefs, DEFAULT_SETTINGS, DEFAULT_USER_PREF
|
|
|
25
25
|
export type { SupportSettings, UserPrefs } from './utils/readSettings'
|
|
26
26
|
export { createAdminNotification } from './utils/adminNotification'
|
|
27
27
|
export { dispatchWebhook } from './utils/webhookDispatcher'
|
|
28
|
+
export { generateTicketSynthesis } from './utils/generateTicketSynthesis'
|
|
29
|
+
export type { TicketSynthesisResult } from './utils/generateTicketSynthesis'
|
|
28
30
|
|
|
29
31
|
// Hooks
|
|
30
32
|
export { createAssignSlaDeadlines, createCheckSlaOnResolve, createCheckSlaOnReply, calculateBusinessHoursDeadline } from './hooks/checkSLA'
|
|
@@ -276,6 +276,138 @@
|
|
|
276
276
|
color: #16a34a;
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
// Warning banner — tickets without time
|
|
280
|
+
.warningBanner {
|
|
281
|
+
padding: 10px 16px;
|
|
282
|
+
border: 1px solid #f59e0b;
|
|
283
|
+
border-radius: 8px;
|
|
284
|
+
background: rgba(245, 158, 11, 0.08);
|
|
285
|
+
margin-bottom: 16px;
|
|
286
|
+
display: flex;
|
|
287
|
+
justify-content: space-between;
|
|
288
|
+
align-items: center;
|
|
289
|
+
flex-wrap: wrap;
|
|
290
|
+
gap: 12px;
|
|
291
|
+
font-size: 13px;
|
|
292
|
+
color: var(--theme-text);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
.toggleLabel {
|
|
296
|
+
display: flex;
|
|
297
|
+
align-items: center;
|
|
298
|
+
gap: 6px;
|
|
299
|
+
cursor: pointer;
|
|
300
|
+
font-weight: 600;
|
|
301
|
+
user-select: none;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
.noTimeBadge {
|
|
305
|
+
display: inline-block;
|
|
306
|
+
margin-left: 8px;
|
|
307
|
+
padding: 2px 8px;
|
|
308
|
+
border-radius: 999px;
|
|
309
|
+
background: rgba(245, 158, 11, 0.15);
|
|
310
|
+
color: #b45309;
|
|
311
|
+
font-size: 11px;
|
|
312
|
+
font-weight: 700;
|
|
313
|
+
white-space: nowrap;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
.tableRowNoTime {
|
|
317
|
+
border-bottom: 1px solid var(--theme-elevation-200);
|
|
318
|
+
background: rgba(245, 158, 11, 0.04);
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// AI summary toggle button
|
|
322
|
+
.summaryBtn {
|
|
323
|
+
display: inline-block;
|
|
324
|
+
margin-left: 6px;
|
|
325
|
+
padding: 1px 6px;
|
|
326
|
+
border-radius: 4px;
|
|
327
|
+
border: 1px solid var(--theme-elevation-300);
|
|
328
|
+
background: var(--theme-elevation-100);
|
|
329
|
+
color: var(--theme-elevation-500);
|
|
330
|
+
font-size: 10px;
|
|
331
|
+
font-weight: 600;
|
|
332
|
+
cursor: pointer;
|
|
333
|
+
vertical-align: middle;
|
|
334
|
+
transition: background-color 100ms, color 100ms;
|
|
335
|
+
|
|
336
|
+
&:hover {
|
|
337
|
+
background: #2563eb;
|
|
338
|
+
color: #fff;
|
|
339
|
+
border-color: #2563eb;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// AI summary expanded row
|
|
344
|
+
.summaryRow {
|
|
345
|
+
background: var(--theme-elevation-50);
|
|
346
|
+
border-bottom: 1px solid var(--theme-elevation-300);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
.summaryCell {
|
|
350
|
+
padding: 12px 16px 16px;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.summaryHeader {
|
|
354
|
+
display: flex;
|
|
355
|
+
justify-content: space-between;
|
|
356
|
+
align-items: center;
|
|
357
|
+
margin-bottom: 8px;
|
|
358
|
+
flex-wrap: wrap;
|
|
359
|
+
gap: 8px;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
.summaryActions {
|
|
363
|
+
display: flex;
|
|
364
|
+
align-items: center;
|
|
365
|
+
gap: 8px;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
.summaryMeta {
|
|
369
|
+
font-size: 11px;
|
|
370
|
+
color: var(--theme-elevation-500);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
.summaryAction {
|
|
374
|
+
padding: 4px 10px;
|
|
375
|
+
border-radius: 6px;
|
|
376
|
+
border: 1px solid var(--theme-elevation-300);
|
|
377
|
+
background: var(--theme-elevation-100);
|
|
378
|
+
color: var(--theme-text);
|
|
379
|
+
font-size: 12px;
|
|
380
|
+
font-weight: 600;
|
|
381
|
+
cursor: pointer;
|
|
382
|
+
transition: background-color 100ms;
|
|
383
|
+
|
|
384
|
+
&:hover { background: var(--theme-elevation-200); }
|
|
385
|
+
&:disabled { cursor: not-allowed; opacity: 0.5; }
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
.summaryText {
|
|
389
|
+
margin: 0;
|
|
390
|
+
padding: 10px 12px;
|
|
391
|
+
background: var(--theme-elevation-100);
|
|
392
|
+
border: 1px solid var(--theme-elevation-300);
|
|
393
|
+
border-radius: 6px;
|
|
394
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Inter', system-ui, sans-serif;
|
|
395
|
+
font-size: 13px;
|
|
396
|
+
color: var(--theme-text);
|
|
397
|
+
white-space: pre-wrap;
|
|
398
|
+
word-break: break-word;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
.summaryEmpty {
|
|
402
|
+
padding: 10px 12px;
|
|
403
|
+
background: var(--theme-elevation-100);
|
|
404
|
+
border: 1px dashed var(--theme-elevation-300);
|
|
405
|
+
border-radius: 6px;
|
|
406
|
+
color: var(--theme-elevation-500);
|
|
407
|
+
font-size: 12px;
|
|
408
|
+
font-style: italic;
|
|
409
|
+
}
|
|
410
|
+
|
|
279
411
|
// Grand total
|
|
280
412
|
.grandTotal {
|
|
281
413
|
padding: 16px;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { Payload } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from './slugs'
|
|
3
|
+
import { readSupportSettings, type SupportSettings } from './readSettings'
|
|
4
|
+
|
|
5
|
+
function getClient(aiSettings: SupportSettings['ai']) {
|
|
6
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
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
|
+
interface TicketMessageDoc {
|
|
20
|
+
authorType?: string
|
|
21
|
+
body?: string
|
|
22
|
+
createdAt?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface TicketDoc {
|
|
26
|
+
id: number | string
|
|
27
|
+
subject?: string
|
|
28
|
+
ticketNumber?: string
|
|
29
|
+
client?: number | { id: number; firstName?: string; lastName?: string; company?: string } | null
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface TicketSynthesisResult {
|
|
33
|
+
summary: string
|
|
34
|
+
generatedAt: string
|
|
35
|
+
status: 'done' | 'error' | 'skipped'
|
|
36
|
+
reason?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Generates a billing-oriented bullet-point synthesis for a single support ticket.
|
|
41
|
+
* Persists the result on the ticket (aiSummary, aiSummaryGeneratedAt, aiSummaryStatus).
|
|
42
|
+
* Designed to be called from an HTTP endpoint OR from a Payload hook (async fire-and-forget).
|
|
43
|
+
*/
|
|
44
|
+
export async function generateTicketSynthesis(args: {
|
|
45
|
+
payload: Payload
|
|
46
|
+
slugs: CollectionSlugs
|
|
47
|
+
ticketId: number | string
|
|
48
|
+
}): Promise<TicketSynthesisResult> {
|
|
49
|
+
const { payload, slugs, ticketId } = args
|
|
50
|
+
|
|
51
|
+
const settings = await readSupportSettings(payload)
|
|
52
|
+
if (settings.ai.enableSynthesis === false) {
|
|
53
|
+
return { summary: '', generatedAt: new Date().toISOString(), status: 'skipped', reason: 'synthesis disabled' }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ticket = await payload.findByID({
|
|
57
|
+
collection: slugs.tickets as any,
|
|
58
|
+
id: ticketId,
|
|
59
|
+
depth: 1,
|
|
60
|
+
overrideAccess: true,
|
|
61
|
+
}) as TicketDoc
|
|
62
|
+
|
|
63
|
+
if (!ticket) {
|
|
64
|
+
return { summary: '', generatedAt: new Date().toISOString(), status: 'error', reason: 'ticket not found' }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Mark as pending so the UI can show a spinner
|
|
68
|
+
await payload.update({
|
|
69
|
+
collection: slugs.tickets as any,
|
|
70
|
+
id: ticketId,
|
|
71
|
+
data: { aiSummaryStatus: 'pending' },
|
|
72
|
+
overrideAccess: true,
|
|
73
|
+
}).catch(() => { /* non-blocking */ })
|
|
74
|
+
|
|
75
|
+
// Load all ticket messages, ordered chronologically
|
|
76
|
+
const messagesResult = await payload.find({
|
|
77
|
+
collection: slugs.ticketMessages as any,
|
|
78
|
+
where: { ticket: { equals: ticketId } },
|
|
79
|
+
sort: 'createdAt',
|
|
80
|
+
limit: 500,
|
|
81
|
+
depth: 0,
|
|
82
|
+
overrideAccess: true,
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
const messages = messagesResult.docs as TicketMessageDoc[]
|
|
86
|
+
|
|
87
|
+
if (messages.length === 0) {
|
|
88
|
+
const generatedAt = new Date().toISOString()
|
|
89
|
+
await payload.update({
|
|
90
|
+
collection: slugs.tickets as any,
|
|
91
|
+
id: ticketId,
|
|
92
|
+
data: {
|
|
93
|
+
aiSummary: '(Aucun message dans ce ticket)',
|
|
94
|
+
aiSummaryGeneratedAt: generatedAt,
|
|
95
|
+
aiSummaryStatus: 'done',
|
|
96
|
+
},
|
|
97
|
+
overrideAccess: true,
|
|
98
|
+
})
|
|
99
|
+
return { summary: '(Aucun message dans ce ticket)', generatedAt, status: 'done' }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const conversation = messages
|
|
103
|
+
.map((m) => {
|
|
104
|
+
const author = m.authorType === 'admin' ? 'Support' : 'Client'
|
|
105
|
+
const date = m.createdAt
|
|
106
|
+
? new Date(m.createdAt).toLocaleDateString('fr-FR', {
|
|
107
|
+
day: 'numeric',
|
|
108
|
+
month: 'short',
|
|
109
|
+
hour: '2-digit',
|
|
110
|
+
minute: '2-digit',
|
|
111
|
+
timeZone: 'Europe/Paris',
|
|
112
|
+
})
|
|
113
|
+
: ''
|
|
114
|
+
return `[${date}] ${author}: ${m.body || ''}`
|
|
115
|
+
})
|
|
116
|
+
.join('\n\n')
|
|
117
|
+
|
|
118
|
+
const clientObj = typeof ticket.client === 'object' && ticket.client ? ticket.client : null
|
|
119
|
+
const clientCompany = clientObj?.company || ''
|
|
120
|
+
const clientName = clientObj ? [clientObj.firstName, clientObj.lastName].filter(Boolean).join(' ') : ''
|
|
121
|
+
|
|
122
|
+
const prompt = `Tu es un consultant technique qui prepare un recap factuel pour une facturation client.
|
|
123
|
+
|
|
124
|
+
Sujet du ticket : ${ticket.subject || '(sans sujet)'}
|
|
125
|
+
Client : ${clientName || 'Inconnu'}${clientCompany ? ` — ${clientCompany}` : ''}
|
|
126
|
+
|
|
127
|
+
Conversation complete du ticket :
|
|
128
|
+
${conversation}
|
|
129
|
+
|
|
130
|
+
Genere un recap factuel sous forme d'une liste a puces courtes et actionnables, decrivant CE QUI A ETE FAIT cote support pendant ce ticket. C'est destine a etre colle dans un devis ou une facture.
|
|
131
|
+
|
|
132
|
+
Regles strictes :
|
|
133
|
+
- Une puce = une action realisee, formulee en groupe nominal court (ex : "Diagnostic configuration DNS et authentification Mailchimp")
|
|
134
|
+
- Pas de phrases completes, pas de "j'ai fait", pas de pronoms
|
|
135
|
+
- Pas de salutations, pas d'introduction, pas de conclusion
|
|
136
|
+
- Pas de markdown autre que les puces "- "
|
|
137
|
+
- 5 a 10 puces maximum, ordonnees chronologiquement
|
|
138
|
+
- Ne mentionne PAS le client par son nom dans les puces
|
|
139
|
+
- Si le ticket n'a pas abouti, decris quand meme le travail d'analyse realise
|
|
140
|
+
|
|
141
|
+
Reponds UNIQUEMENT avec la liste de puces, rien d'autre.`
|
|
142
|
+
|
|
143
|
+
const anthropic = getClient(settings.ai)
|
|
144
|
+
const model = getModel(settings.ai)
|
|
145
|
+
|
|
146
|
+
try {
|
|
147
|
+
const res = await anthropic.messages.create({
|
|
148
|
+
model,
|
|
149
|
+
max_tokens: 600,
|
|
150
|
+
messages: [{ role: 'user', content: prompt }],
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
const summary = res.content[0]?.type === 'text' ? res.content[0].text.trim() : ''
|
|
154
|
+
const generatedAt = new Date().toISOString()
|
|
155
|
+
|
|
156
|
+
await payload.update({
|
|
157
|
+
collection: slugs.tickets as any,
|
|
158
|
+
id: ticketId,
|
|
159
|
+
data: {
|
|
160
|
+
aiSummary: summary,
|
|
161
|
+
aiSummaryGeneratedAt: generatedAt,
|
|
162
|
+
aiSummaryStatus: 'done',
|
|
163
|
+
},
|
|
164
|
+
overrideAccess: true,
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
return { summary, generatedAt, status: 'done' }
|
|
168
|
+
} catch (err) {
|
|
169
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
170
|
+
await payload.update({
|
|
171
|
+
collection: slugs.tickets as any,
|
|
172
|
+
id: ticketId,
|
|
173
|
+
data: { aiSummaryStatus: 'error' },
|
|
174
|
+
overrideAccess: true,
|
|
175
|
+
}).catch(() => { /* non-blocking */ })
|
|
176
|
+
return { summary: '', generatedAt: new Date().toISOString(), status: 'error', reason: message }
|
|
177
|
+
}
|
|
178
|
+
}
|