@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.
@@ -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 allTicketIds = data?.groups.flatMap((g) => g.tickets.map((t) => t.id)) || []
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 = data?.groups.flatMap((g) => g.tickets.map((t) => t.id)) || []
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
- }, [data])
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 data.groups) {
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
- lines.push(` ${ticket.ticketNumber} ${ticket.subject}`)
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
- const ticketAmount = ticket.billedAmount || Number(formatAmount(ticket.totalMinutes, rate))
175
- lines.push(` Sous-total : ${formatDuration(ticket.totalMinutes)} = ${ticketAmount.toFixed(2)} EUR${ticket.billedAmount ? ' (forfait)' : ''}`)
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 = data?.groups.reduce((sum, g) => sum + g.tickets.length, 0) || 0
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.groups.length === 0 ? (
262
- <div className={styles.empty}>
263
- {t('billing.empty')}
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
- {/* Project groups */}
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
- {group.totalBilledAmount.toFixed(2)} EUR facture
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
- return ticket.entries.map((entry, ei) => (
338
- <tr
339
- key={`${ticket.id}-${ei}`}
340
- className={`${isBilled ? styles.tableRowBilled : styles.tableRow} ${!isBilled && ei % 2 === 0 ? styles.tableRowEven : ''} ${!isBilled && ei % 2 !== 0 ? styles.tableRowOdd : ''}`}
341
- >
342
- {ei === 0 ? (
343
- <>
344
- <td className={styles.td} rowSpan={ticket.entries.length} style={{ verticalAlign: 'middle' }}>
345
- <input
346
- type="checkbox"
347
- checked={isBilled}
348
- onChange={() => toggleBilled(ticket.id)}
349
- className={styles.checkbox}
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
- {formatAmount(entry.duration, rate)} EUR
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
- {ei === 0 ? (
375
- <td
376
- className={`${styles.td} ${ticket.billedAmount ? styles.billedAmount : styles.secondary}`}
377
- rowSpan={ticket.entries.length}
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
- {ticket.billedAmount ? `${ticket.billedAmount.toFixed(2)} EUR` : '-'}
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
- ) : null}
382
- </tr>
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' : ''} facturable{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>