@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
|
@@ -14,9 +14,14 @@ interface BillingTicket {
|
|
|
14
14
|
id: number
|
|
15
15
|
ticketNumber: string
|
|
16
16
|
subject: string
|
|
17
|
+
status: string
|
|
17
18
|
entries: BillingEntry[]
|
|
18
19
|
totalMinutes: number
|
|
19
20
|
billedAmount: number | null
|
|
21
|
+
hasNoTimeEntries: boolean
|
|
22
|
+
aiSummary: string | null
|
|
23
|
+
aiSummaryGeneratedAt: string | null
|
|
24
|
+
aiSummaryStatus: string | null
|
|
20
25
|
}
|
|
21
26
|
|
|
22
27
|
interface BillingGroup {
|
|
@@ -31,6 +36,7 @@ interface BillingData {
|
|
|
31
36
|
groups: BillingGroup[]
|
|
32
37
|
grandTotalMinutes: number
|
|
33
38
|
grandTotalBilledAmount: number
|
|
39
|
+
ticketsWithoutTime: number
|
|
34
40
|
}
|
|
35
41
|
|
|
36
42
|
interface ProjectOption {
|
|
@@ -88,6 +94,9 @@ export const BillingClient: React.FC = () => {
|
|
|
88
94
|
const [projects, setProjects] = useState<ProjectOption[]>([])
|
|
89
95
|
const [projectsLoaded, setProjectsLoaded] = useState(false)
|
|
90
96
|
const [copied, setCopied] = useState(false)
|
|
97
|
+
const [hideEmpty, setHideEmpty] = useState(false)
|
|
98
|
+
const [expandedSummaries, setExpandedSummaries] = useState<Set<number>>(new Set())
|
|
99
|
+
const [regeneratingIds, setRegeneratingIds] = useState<Set<number>>(new Set())
|
|
91
100
|
const [billedTickets, setBilledTickets] = useState<Set<number>>(() => {
|
|
92
101
|
if (typeof window === 'undefined') return new Set()
|
|
93
102
|
try {
|
|
@@ -106,16 +115,33 @@ export const BillingClient: React.FC = () => {
|
|
|
106
115
|
})
|
|
107
116
|
}, [])
|
|
108
117
|
|
|
109
|
-
const
|
|
118
|
+
const toggleSummary = useCallback((ticketId: number) => {
|
|
119
|
+
setExpandedSummaries((prev) => {
|
|
120
|
+
const next = new Set(prev)
|
|
121
|
+
if (next.has(ticketId)) next.delete(ticketId)
|
|
122
|
+
else next.add(ticketId)
|
|
123
|
+
return next
|
|
124
|
+
})
|
|
125
|
+
}, [])
|
|
126
|
+
|
|
127
|
+
const visibleGroups = React.useMemo(() => {
|
|
128
|
+
if (!data) return []
|
|
129
|
+
if (!hideEmpty) return data.groups
|
|
130
|
+
return data.groups
|
|
131
|
+
.map((g) => ({ ...g, tickets: g.tickets.filter((t) => !t.hasNoTimeEntries) }))
|
|
132
|
+
.filter((g) => g.tickets.length > 0)
|
|
133
|
+
}, [data, hideEmpty])
|
|
134
|
+
|
|
135
|
+
const allTicketIds = visibleGroups.flatMap((g) => g.tickets.map((t) => t.id))
|
|
110
136
|
const allBilled = allTicketIds.length > 0 && allTicketIds.every((id) => billedTickets.has(id))
|
|
111
137
|
const toggleAll = useCallback(() => {
|
|
112
138
|
setBilledTickets((prev) => {
|
|
113
|
-
const ids =
|
|
139
|
+
const ids = visibleGroups.flatMap((g) => g.tickets.map((t) => t.id))
|
|
114
140
|
const next = ids.every((id) => prev.has(id)) ? new Set<number>() : new Set(ids)
|
|
115
141
|
localStorage.setItem('billing-checked-tickets', JSON.stringify([...next]))
|
|
116
142
|
return next
|
|
117
143
|
})
|
|
118
|
-
}, [
|
|
144
|
+
}, [visibleGroups])
|
|
119
145
|
|
|
120
146
|
const loadProjects = useCallback(async () => {
|
|
121
147
|
if (projectsLoaded) return
|
|
@@ -129,7 +155,6 @@ export const BillingClient: React.FC = () => {
|
|
|
129
155
|
setProjectsLoaded(true)
|
|
130
156
|
}, [projectsLoaded])
|
|
131
157
|
|
|
132
|
-
// Load projects on mount
|
|
133
158
|
React.useEffect(() => { loadProjects() }, [loadProjects])
|
|
134
159
|
|
|
135
160
|
const fetchBilling = useCallback(async () => {
|
|
@@ -152,27 +177,70 @@ export const BillingClient: React.FC = () => {
|
|
|
152
177
|
setTo(range.to)
|
|
153
178
|
}
|
|
154
179
|
|
|
180
|
+
const requestSynthesis = useCallback(async (ticketId: number, force: boolean) => {
|
|
181
|
+
setRegeneratingIds((prev) => new Set(prev).add(ticketId))
|
|
182
|
+
try {
|
|
183
|
+
const params = new URLSearchParams({ ticketId: String(ticketId) })
|
|
184
|
+
if (force) params.set('force', 'true')
|
|
185
|
+
const res = await fetch(`/api/support/ticket-synthesis?${params}`, { method: 'POST' })
|
|
186
|
+
if (res.ok) {
|
|
187
|
+
const json = await res.json() as { summary: string; generatedAt: string; status: string }
|
|
188
|
+
setData((prev) => {
|
|
189
|
+
if (!prev) return prev
|
|
190
|
+
return {
|
|
191
|
+
...prev,
|
|
192
|
+
groups: prev.groups.map((g) => ({
|
|
193
|
+
...g,
|
|
194
|
+
tickets: g.tickets.map((t) => t.id === ticketId
|
|
195
|
+
? { ...t, aiSummary: json.summary, aiSummaryGeneratedAt: json.generatedAt, aiSummaryStatus: 'done' }
|
|
196
|
+
: t,
|
|
197
|
+
),
|
|
198
|
+
})),
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
// Auto-expand once we have a summary
|
|
202
|
+
setExpandedSummaries((prev) => new Set(prev).add(ticketId))
|
|
203
|
+
}
|
|
204
|
+
} catch (err) {
|
|
205
|
+
console.error('[billing] Synthesis error:', err)
|
|
206
|
+
} finally {
|
|
207
|
+
setRegeneratingIds((prev) => {
|
|
208
|
+
const next = new Set(prev)
|
|
209
|
+
next.delete(ticketId)
|
|
210
|
+
return next
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
}, [])
|
|
214
|
+
|
|
155
215
|
const copyRecap = useCallback(() => {
|
|
156
216
|
if (!data) return
|
|
157
|
-
|
|
158
217
|
const lines: string[] = []
|
|
159
218
|
lines.push(`PRE-FACTURATION — Du ${from} au ${to}`)
|
|
160
219
|
lines.push(`Taux horaire : ${rate} EUR/h`)
|
|
161
220
|
lines.push('='.repeat(50))
|
|
162
221
|
|
|
163
|
-
for (const group of
|
|
222
|
+
for (const group of visibleGroups) {
|
|
164
223
|
lines.push('')
|
|
165
224
|
lines.push(`PROJET : ${group.project?.name || 'Sans projet'}`)
|
|
166
225
|
if (group.client?.company) lines.push(`Client : ${group.client.company}`)
|
|
167
226
|
lines.push('-'.repeat(40))
|
|
168
227
|
|
|
169
228
|
for (const ticket of group.tickets) {
|
|
170
|
-
|
|
229
|
+
const flag = ticket.hasNoTimeEntries ? ' [AUCUN TEMPS SAISI]' : ''
|
|
230
|
+
lines.push(` ${ticket.ticketNumber} — ${ticket.subject}${flag}`)
|
|
171
231
|
for (const entry of ticket.entries) {
|
|
172
232
|
lines.push(` ${entry.date} | ${formatDuration(entry.duration)} | ${entry.description || '-'}`)
|
|
173
233
|
}
|
|
174
|
-
|
|
175
|
-
|
|
234
|
+
if (ticket.entries.length > 0) {
|
|
235
|
+
const ticketAmount = ticket.billedAmount || Number(formatAmount(ticket.totalMinutes, rate))
|
|
236
|
+
lines.push(` Sous-total : ${formatDuration(ticket.totalMinutes)} = ${ticketAmount.toFixed(2)} EUR${ticket.billedAmount ? ' (forfait)' : ''}`)
|
|
237
|
+
}
|
|
238
|
+
if (ticket.aiSummary) {
|
|
239
|
+
lines.push(' Detail des actions :')
|
|
240
|
+
for (const detailLine of ticket.aiSummary.split('\n')) {
|
|
241
|
+
lines.push(` ${detailLine}`)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
176
244
|
}
|
|
177
245
|
const groupAmount = group.totalBilledAmount > 0
|
|
178
246
|
? group.totalBilledAmount
|
|
@@ -187,13 +255,18 @@ export const BillingClient: React.FC = () => {
|
|
|
187
255
|
navigator.clipboard.writeText(lines.join('\n'))
|
|
188
256
|
setCopied(true)
|
|
189
257
|
setTimeout(() => setCopied(false), 2000)
|
|
190
|
-
}, [data, from, to, rate])
|
|
258
|
+
}, [data, visibleGroups, from, to, rate])
|
|
259
|
+
|
|
260
|
+
const copyTicketSummary = useCallback((ticket: BillingTicket) => {
|
|
261
|
+
const lines = [`${ticket.ticketNumber} — ${ticket.subject}`]
|
|
262
|
+
if (ticket.aiSummary) lines.push('', ticket.aiSummary)
|
|
263
|
+
navigator.clipboard.writeText(lines.join('\n'))
|
|
264
|
+
}, [])
|
|
191
265
|
|
|
192
|
-
const totalTickets =
|
|
266
|
+
const totalTickets = visibleGroups.reduce((sum, g) => sum + g.tickets.length, 0)
|
|
193
267
|
|
|
194
268
|
return (
|
|
195
269
|
<div className={styles.page}>
|
|
196
|
-
{/* Header */}
|
|
197
270
|
<div className={styles.header}>
|
|
198
271
|
<div>
|
|
199
272
|
<h1 className={styles.title}>{t('billing.title')}</h1>
|
|
@@ -201,7 +274,6 @@ export const BillingClient: React.FC = () => {
|
|
|
201
274
|
</div>
|
|
202
275
|
</div>
|
|
203
276
|
|
|
204
|
-
{/* Filters */}
|
|
205
277
|
<div className={styles.filters}>
|
|
206
278
|
<div className={styles.quickPeriod}>
|
|
207
279
|
<button className={styles.btnPrimary} onClick={() => setPeriod(getMonthRange(0))}>{t('billing.filters.thisMonth')}</button>
|
|
@@ -221,11 +293,7 @@ export const BillingClient: React.FC = () => {
|
|
|
221
293
|
</div>
|
|
222
294
|
<div className={styles.fieldGroup}>
|
|
223
295
|
<label className={styles.label}>{t('billing.filters.project')}</label>
|
|
224
|
-
<select
|
|
225
|
-
value={projectId}
|
|
226
|
-
onChange={(e) => setProjectId(e.target.value)}
|
|
227
|
-
className={styles.select}
|
|
228
|
-
>
|
|
296
|
+
<select value={projectId} onChange={(e) => setProjectId(e.target.value)} className={styles.select}>
|
|
229
297
|
<option value="">{t('ticket.allProjects')}</option>
|
|
230
298
|
{projects.map((p) => (
|
|
231
299
|
<option key={p.id} value={p.id}>{p.name}</option>
|
|
@@ -245,62 +313,52 @@ export const BillingClient: React.FC = () => {
|
|
|
245
313
|
<span className={styles.rateUnit}>{t('billing.filters.rateUnit')}</span>
|
|
246
314
|
</div>
|
|
247
315
|
</div>
|
|
248
|
-
<button
|
|
249
|
-
className={styles.btnPrimary}
|
|
250
|
-
onClick={fetchBilling}
|
|
251
|
-
disabled={loading}
|
|
252
|
-
>
|
|
316
|
+
<button className={styles.btnPrimary} onClick={fetchBilling} disabled={loading}>
|
|
253
317
|
{loading ? t('billing.filters.loading') : t('billing.filters.load')}
|
|
254
318
|
</button>
|
|
255
319
|
</div>
|
|
256
320
|
</div>
|
|
257
321
|
|
|
258
|
-
{/* Results */}
|
|
259
322
|
{data && (
|
|
260
323
|
<>
|
|
261
|
-
{data.
|
|
262
|
-
<div className={styles.
|
|
263
|
-
|
|
324
|
+
{data.ticketsWithoutTime > 0 && (
|
|
325
|
+
<div className={styles.warningBanner}>
|
|
326
|
+
<span>
|
|
327
|
+
<strong>{data.ticketsWithoutTime}</strong> ticket{data.ticketsWithoutTime > 1 ? 's' : ''} actif{data.ticketsWithoutTime > 1 ? 's' : ''} sans temps saisi sur la periode.
|
|
328
|
+
</span>
|
|
329
|
+
<label className={styles.toggleLabel}>
|
|
330
|
+
<input type="checkbox" checked={hideEmpty} onChange={(e) => setHideEmpty(e.target.checked)} />
|
|
331
|
+
<span>Masquer ces tickets</span>
|
|
332
|
+
</label>
|
|
264
333
|
</div>
|
|
334
|
+
)}
|
|
335
|
+
|
|
336
|
+
{visibleGroups.length === 0 ? (
|
|
337
|
+
<div className={styles.empty}>{t('billing.empty')}</div>
|
|
265
338
|
) : (
|
|
266
339
|
<>
|
|
267
|
-
{
|
|
268
|
-
{data.groups.map((group, gi) => (
|
|
340
|
+
{visibleGroups.map((group, gi) => (
|
|
269
341
|
<div key={gi} className={styles.groupCard}>
|
|
270
|
-
{/* Project header */}
|
|
271
342
|
<div className={styles.groupHeader}>
|
|
272
343
|
<div>
|
|
273
|
-
<span className={styles.groupName}>
|
|
274
|
-
{group.project?.name || 'Sans projet'}
|
|
275
|
-
</span>
|
|
344
|
+
<span className={styles.groupName}>{group.project?.name || 'Sans projet'}</span>
|
|
276
345
|
{group.client?.company && (
|
|
277
|
-
<span className={styles.groupClient}>
|
|
278
|
-
— {group.client.company}
|
|
279
|
-
</span>
|
|
346
|
+
<span className={styles.groupClient}>— {group.client.company}</span>
|
|
280
347
|
)}
|
|
281
348
|
</div>
|
|
282
349
|
<div className={styles.groupTotals}>
|
|
283
|
-
<div className={styles.groupDuration}>
|
|
284
|
-
{formatDuration(group.totalMinutes)}
|
|
285
|
-
</div>
|
|
350
|
+
<div className={styles.groupDuration}>{formatDuration(group.totalMinutes)}</div>
|
|
286
351
|
{group.totalBilledAmount > 0 ? (
|
|
287
352
|
<>
|
|
288
|
-
<div className={styles.groupAmountBilled}>
|
|
289
|
-
|
|
290
|
-
</div>
|
|
291
|
-
<div className={styles.groupAmountStrike}>
|
|
292
|
-
{formatAmount(group.totalMinutes, rate)} EUR (temps)
|
|
293
|
-
</div>
|
|
353
|
+
<div className={styles.groupAmountBilled}>{group.totalBilledAmount.toFixed(2)} EUR facture</div>
|
|
354
|
+
<div className={styles.groupAmountStrike}>{formatAmount(group.totalMinutes, rate)} EUR (temps)</div>
|
|
294
355
|
</>
|
|
295
356
|
) : (
|
|
296
|
-
<div className={styles.groupAmount}>
|
|
297
|
-
{formatAmount(group.totalMinutes, rate)} EUR
|
|
298
|
-
</div>
|
|
357
|
+
<div className={styles.groupAmount}>{formatAmount(group.totalMinutes, rate)} EUR</div>
|
|
299
358
|
)}
|
|
300
359
|
</div>
|
|
301
360
|
</div>
|
|
302
361
|
|
|
303
|
-
{/* Tickets table */}
|
|
304
362
|
<table className={styles.table}>
|
|
305
363
|
<thead>
|
|
306
364
|
<tr>
|
|
@@ -334,64 +392,144 @@ export const BillingClient: React.FC = () => {
|
|
|
334
392
|
<tbody>
|
|
335
393
|
{group.tickets.map((ticket) => {
|
|
336
394
|
const isBilled = billedTickets.has(ticket.id)
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
{
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
title={isBilled ? 'Marquer comme non facture' : 'Marquer comme facture'}
|
|
351
|
-
/>
|
|
352
|
-
</td>
|
|
353
|
-
<td className={`${styles.td} ${styles.bold} ${isBilled ? styles.strikethrough : ''}`} rowSpan={ticket.entries.length}>
|
|
354
|
-
<a
|
|
355
|
-
href={`/admin/support/ticket?id=${ticket.id}`}
|
|
356
|
-
className={styles.ticketLink}
|
|
357
|
-
>
|
|
358
|
-
{ticket.ticketNumber}
|
|
359
|
-
</a>
|
|
360
|
-
</td>
|
|
361
|
-
<td className={`${styles.tdLeft} ${isBilled ? styles.strikethrough : ''}`} rowSpan={ticket.entries.length}>
|
|
362
|
-
{ticket.subject}
|
|
363
|
-
</td>
|
|
364
|
-
</>
|
|
365
|
-
) : null}
|
|
366
|
-
<td className={styles.td}>{entry.date}</td>
|
|
367
|
-
<td className={styles.td}>{formatDuration(entry.duration)}</td>
|
|
368
|
-
<td className={`${styles.tdLeft} ${styles.secondary}`}>
|
|
369
|
-
{entry.description || '-'}
|
|
395
|
+
const isExpanded = expandedSummaries.has(ticket.id)
|
|
396
|
+
const isRegenerating = regeneratingIds.has(ticket.id)
|
|
397
|
+
const rowSpan = Math.max(ticket.entries.length, 1)
|
|
398
|
+
const renderTicketHeaderCells = () => (
|
|
399
|
+
<>
|
|
400
|
+
<td className={styles.td} rowSpan={rowSpan} style={{ verticalAlign: 'middle' }}>
|
|
401
|
+
<input
|
|
402
|
+
type="checkbox"
|
|
403
|
+
checked={isBilled}
|
|
404
|
+
onChange={() => toggleBilled(ticket.id)}
|
|
405
|
+
className={styles.checkbox}
|
|
406
|
+
title={isBilled ? 'Marquer comme non facture' : 'Marquer comme facture'}
|
|
407
|
+
/>
|
|
370
408
|
</td>
|
|
371
|
-
<td className={`${styles.td} ${styles.bold}`}>
|
|
372
|
-
{
|
|
409
|
+
<td className={`${styles.td} ${styles.bold} ${isBilled ? styles.strikethrough : ''}`} rowSpan={rowSpan}>
|
|
410
|
+
<a href={`/admin/support/ticket?id=${ticket.id}`} className={styles.ticketLink}>
|
|
411
|
+
{ticket.ticketNumber}
|
|
412
|
+
</a>
|
|
413
|
+
<button
|
|
414
|
+
className={styles.summaryBtn}
|
|
415
|
+
onClick={() => toggleSummary(ticket.id)}
|
|
416
|
+
title={isExpanded ? 'Masquer le detail' : 'Afficher le detail IA'}
|
|
417
|
+
>
|
|
418
|
+
{isExpanded ? '▼' : '▶'} IA
|
|
419
|
+
</button>
|
|
373
420
|
</td>
|
|
374
|
-
{
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
421
|
+
<td className={`${styles.tdLeft} ${isBilled ? styles.strikethrough : ''}`} rowSpan={rowSpan}>
|
|
422
|
+
{ticket.subject}
|
|
423
|
+
{ticket.hasNoTimeEntries && (
|
|
424
|
+
<span className={styles.noTimeBadge} title="Aucun temps saisi sur la periode">
|
|
425
|
+
⚠ Aucun temps saisi
|
|
426
|
+
</span>
|
|
427
|
+
)}
|
|
428
|
+
</td>
|
|
429
|
+
</>
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
const rows: React.ReactNode[] = []
|
|
433
|
+
|
|
434
|
+
if (ticket.entries.length === 0) {
|
|
435
|
+
rows.push(
|
|
436
|
+
<tr
|
|
437
|
+
key={`${ticket.id}-empty`}
|
|
438
|
+
className={`${isBilled ? styles.tableRowBilled : styles.tableRowNoTime}`}
|
|
439
|
+
>
|
|
440
|
+
{renderTicketHeaderCells()}
|
|
441
|
+
<td className={`${styles.td} ${styles.secondary}`} colSpan={5}>
|
|
442
|
+
<em>Pas de saisie de temps. Verifier si du temps a ete oublie.</em>
|
|
443
|
+
</td>
|
|
444
|
+
</tr>,
|
|
445
|
+
)
|
|
446
|
+
} else {
|
|
447
|
+
ticket.entries.forEach((entry, ei) => {
|
|
448
|
+
rows.push(
|
|
449
|
+
<tr
|
|
450
|
+
key={`${ticket.id}-${ei}`}
|
|
451
|
+
className={`${isBilled ? styles.tableRowBilled : styles.tableRow} ${!isBilled && ei % 2 === 0 ? styles.tableRowEven : ''} ${!isBilled && ei % 2 !== 0 ? styles.tableRowOdd : ''}`}
|
|
378
452
|
>
|
|
379
|
-
{
|
|
453
|
+
{ei === 0 ? renderTicketHeaderCells() : null}
|
|
454
|
+
<td className={styles.td}>{entry.date}</td>
|
|
455
|
+
<td className={styles.td}>{formatDuration(entry.duration)}</td>
|
|
456
|
+
<td className={`${styles.tdLeft} ${styles.secondary}`}>
|
|
457
|
+
{entry.description || '-'}
|
|
458
|
+
</td>
|
|
459
|
+
<td className={`${styles.td} ${styles.bold}`}>
|
|
460
|
+
{formatAmount(entry.duration, rate)} EUR
|
|
461
|
+
</td>
|
|
462
|
+
{ei === 0 ? (
|
|
463
|
+
<td
|
|
464
|
+
className={`${styles.td} ${ticket.billedAmount ? styles.billedAmount : styles.secondary}`}
|
|
465
|
+
rowSpan={ticket.entries.length}
|
|
466
|
+
>
|
|
467
|
+
{ticket.billedAmount ? `${ticket.billedAmount.toFixed(2)} EUR` : '-'}
|
|
468
|
+
</td>
|
|
469
|
+
) : null}
|
|
470
|
+
</tr>,
|
|
471
|
+
)
|
|
472
|
+
})
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
if (isExpanded) {
|
|
476
|
+
rows.push(
|
|
477
|
+
<tr key={`${ticket.id}-summary`} className={styles.summaryRow}>
|
|
478
|
+
<td colSpan={8} className={styles.summaryCell}>
|
|
479
|
+
<div className={styles.summaryHeader}>
|
|
480
|
+
<strong>Synthese IA des actions</strong>
|
|
481
|
+
<div className={styles.summaryActions}>
|
|
482
|
+
{ticket.aiSummaryGeneratedAt && (
|
|
483
|
+
<span className={styles.summaryMeta}>
|
|
484
|
+
Genere le {new Date(ticket.aiSummaryGeneratedAt).toLocaleString('fr-FR')}
|
|
485
|
+
</span>
|
|
486
|
+
)}
|
|
487
|
+
{ticket.aiSummary && (
|
|
488
|
+
<button
|
|
489
|
+
className={styles.summaryAction}
|
|
490
|
+
onClick={() => copyTicketSummary(ticket)}
|
|
491
|
+
>
|
|
492
|
+
Copier
|
|
493
|
+
</button>
|
|
494
|
+
)}
|
|
495
|
+
<button
|
|
496
|
+
className={styles.summaryAction}
|
|
497
|
+
onClick={() => requestSynthesis(ticket.id, !!ticket.aiSummary)}
|
|
498
|
+
disabled={isRegenerating}
|
|
499
|
+
>
|
|
500
|
+
{isRegenerating
|
|
501
|
+
? 'Generation...'
|
|
502
|
+
: ticket.aiSummary ? 'Regenerer' : 'Generer'}
|
|
503
|
+
</button>
|
|
504
|
+
</div>
|
|
505
|
+
</div>
|
|
506
|
+
{ticket.aiSummaryStatus === 'pending' && !ticket.aiSummary ? (
|
|
507
|
+
<div className={styles.summaryEmpty}>
|
|
508
|
+
Generation en cours en arriere-plan. Cliquer sur "Generer" pour forcer.
|
|
509
|
+
</div>
|
|
510
|
+
) : ticket.aiSummary ? (
|
|
511
|
+
<pre className={styles.summaryText}>{ticket.aiSummary}</pre>
|
|
512
|
+
) : (
|
|
513
|
+
<div className={styles.summaryEmpty}>
|
|
514
|
+
Pas de synthese disponible. La synthese est generee automatiquement quand le ticket passe en "resolu", ou manuellement via le bouton "Generer".
|
|
515
|
+
</div>
|
|
516
|
+
)}
|
|
380
517
|
</td>
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
518
|
+
</tr>,
|
|
519
|
+
)
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
return rows
|
|
384
523
|
})}
|
|
385
524
|
</tbody>
|
|
386
525
|
</table>
|
|
387
526
|
</div>
|
|
388
527
|
))}
|
|
389
528
|
|
|
390
|
-
{/* Grand total */}
|
|
391
529
|
<div className={styles.grandTotal}>
|
|
392
530
|
<div>
|
|
393
531
|
<div className={styles.totalMeta}>
|
|
394
|
-
{totalTickets} ticket{totalTickets > 1 ? 's' : ''}
|
|
532
|
+
{totalTickets} ticket{totalTickets > 1 ? 's' : ''} affiche{totalTickets > 1 ? 's' : ''}
|
|
395
533
|
{billedTickets.size > 0 && (
|
|
396
534
|
<span className={styles.totalChecked}>
|
|
397
535
|
({allTicketIds.filter((id) => billedTickets.has(id)).length} coche{allTicketIds.filter((id) => billedTickets.has(id)).length > 1 ? 's' : ''})
|
|
@@ -407,16 +545,10 @@ export const BillingClient: React.FC = () => {
|
|
|
407
545
|
</div>
|
|
408
546
|
</div>
|
|
409
547
|
<div className={styles.totalActions}>
|
|
410
|
-
<button
|
|
411
|
-
className={allBilled ? styles.btnSecondary : styles.btnGreen}
|
|
412
|
-
onClick={toggleAll}
|
|
413
|
-
>
|
|
548
|
+
<button className={allBilled ? styles.btnSecondary : styles.btnGreen} onClick={toggleAll}>
|
|
414
549
|
{allBilled ? t('billing.totals.uncheckAll') : t('billing.totals.checkAll')}
|
|
415
550
|
</button>
|
|
416
|
-
<button
|
|
417
|
-
className={copied ? styles.btnSuccess : styles.btnAmber}
|
|
418
|
-
onClick={copyRecap}
|
|
419
|
-
>
|
|
551
|
+
<button className={copied ? styles.btnSuccess : styles.btnAmber} onClick={copyRecap}>
|
|
420
552
|
{copied ? t('billing.totals.copiedRecap') : t('billing.totals.copyRecap')}
|
|
421
553
|
</button>
|
|
422
554
|
</div>
|