@consilioweb/payload-support 0.8.2 → 0.9.5

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.
Files changed (50) hide show
  1. package/dist/components/RichTextEditor/index.cjs +233 -0
  2. package/dist/components/RichTextEditor/index.js +232 -0
  3. package/dist/components/TicketConversation/components/CodeBlock.cjs +24 -7
  4. package/dist/components/TicketConversation/components/CodeBlock.js +23 -9
  5. package/dist/index.cjs +19 -1
  6. package/dist/index.js +19 -1
  7. package/dist/views/BillingView/client.cjs +260 -103
  8. package/dist/views/BillingView/client.js +259 -103
  9. package/dist/views/ChatView/client.cjs +184 -137
  10. package/dist/views/ChatView/client.js +180 -137
  11. package/dist/views/CrmView/client.cjs +270 -122
  12. package/dist/views/CrmView/client.js +266 -122
  13. package/dist/views/EmailTrackingView/client.cjs +80 -69
  14. package/dist/views/EmailTrackingView/client.js +80 -70
  15. package/dist/views/ImportConversationView/client.cjs +127 -94
  16. package/dist/views/ImportConversationView/client.js +123 -94
  17. package/dist/views/LogsView/client.cjs +56 -58
  18. package/dist/views/LogsView/client.js +52 -58
  19. package/dist/views/NewTicketView/client.cjs +39 -55
  20. package/dist/views/NewTicketView/client.js +38 -55
  21. package/dist/views/PendingEmailsView/client.cjs +399 -102
  22. package/dist/views/PendingEmailsView/client.js +396 -103
  23. package/dist/views/SupportDashboardView/client.cjs +276 -137
  24. package/dist/views/SupportDashboardView/client.js +275 -137
  25. package/dist/views/TicketDetailView/client.cjs +487 -204
  26. package/dist/views/TicketDetailView/client.js +486 -204
  27. package/dist/views/TicketInboxView/client.cjs +62 -65
  28. package/dist/views/TicketInboxView/client.js +62 -66
  29. package/dist/views/TicketingSettingsView/client.cjs +10 -8
  30. package/dist/views/TicketingSettingsView/client.js +10 -8
  31. package/dist/views/TimeDashboardView/client.cjs +70 -59
  32. package/dist/views/TimeDashboardView/client.js +69 -59
  33. package/package.json +7 -3
  34. package/src/components/RichTextEditor/index.tsx +261 -0
  35. package/src/components/TicketConversation/components/CodeBlock.tsx +53 -14
  36. package/src/plugin.ts +2 -0
  37. package/src/utils/emailTemplate.ts +37 -0
  38. package/src/views/BillingView/client.tsx +362 -69
  39. package/src/views/ChatView/client.tsx +225 -140
  40. package/src/views/CrmView/client.tsx +447 -189
  41. package/src/views/EmailTrackingView/client.tsx +111 -71
  42. package/src/views/ImportConversationView/client.tsx +255 -70
  43. package/src/views/LogsView/client.tsx +85 -50
  44. package/src/views/NewTicketView/client.tsx +37 -53
  45. package/src/views/PendingEmailsView/client.tsx +512 -92
  46. package/src/views/SupportDashboardView/client.tsx +294 -134
  47. package/src/views/TicketDetailView/client.tsx +486 -213
  48. package/src/views/TicketInboxView/client.tsx +52 -61
  49. package/src/views/TicketingSettingsView/client.tsx +10 -9
  50. package/src/views/TimeDashboardView/client.tsx +184 -69
@@ -2,18 +2,80 @@
2
2
 
3
3
  import React, { useState, useCallback } from 'react'
4
4
  import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
5
- import s from '../../styles/BillingView.module.scss'
5
+ import styles from '../../styles/BillingView.module.scss'
6
6
 
7
- interface BillingEntry { duration: number; description: string; date: string }
8
- interface BillingTicket { id: number; ticketNumber: string; subject: string; entries: BillingEntry[]; totalMinutes: number; billedAmount: number | null }
9
- interface BillingGroup { project: { id: number; name: string } | null; client: { company: string } | null; tickets: BillingTicket[]; totalMinutes: number; totalBilledAmount: number }
10
- interface BillingData { groups: BillingGroup[]; grandTotalMinutes: number; grandTotalBilledAmount: number }
11
- interface ProjectOption { id: number; name: string }
7
+ interface BillingEntry {
8
+ duration: number
9
+ description: string
10
+ date: string
11
+ }
12
+
13
+ interface BillingTicket {
14
+ id: number
15
+ ticketNumber: string
16
+ subject: string
17
+ entries: BillingEntry[]
18
+ totalMinutes: number
19
+ billedAmount: number | null
20
+ }
21
+
22
+ interface BillingGroup {
23
+ project: { id: number; name: string } | null
24
+ client: { company: string } | null
25
+ tickets: BillingTicket[]
26
+ totalMinutes: number
27
+ totalBilledAmount: number
28
+ }
29
+
30
+ interface BillingData {
31
+ groups: BillingGroup[]
32
+ grandTotalMinutes: number
33
+ grandTotalBilledAmount: number
34
+ }
35
+
36
+ interface ProjectOption {
37
+ id: number
38
+ name: string
39
+ }
40
+
41
+ function formatDuration(minutes: number): string {
42
+ const h = Math.floor(minutes / 60)
43
+ const m = minutes % 60
44
+ if (h === 0) return `${m}min`
45
+ if (m === 0) return `${h}h`
46
+ return `${h}h ${m}min`
47
+ }
48
+
49
+ function formatAmount(minutes: number, rate: number): string {
50
+ const hours = minutes / 60
51
+ return (hours * rate).toFixed(2)
52
+ }
53
+
54
+ function getMonthRange(offset: number): { from: string; to: string } {
55
+ const now = new Date()
56
+ const year = now.getFullYear()
57
+ const month = now.getMonth() + offset
58
+ const start = new Date(year, month, 1)
59
+ const end = new Date(year, month + 1, 0)
60
+ return {
61
+ from: start.toISOString().split('T')[0],
62
+ to: end.toISOString().split('T')[0],
63
+ }
64
+ }
12
65
 
13
- 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}min` }
14
- function formatAmount(minutes: number, rate: number): string { return ((minutes / 60) * rate).toFixed(2) }
15
- function getMonthRange(offset: number): { from: string; to: string } { const now = new Date(); const start = new Date(now.getFullYear(), now.getMonth() + offset, 1); const end = new Date(now.getFullYear(), now.getMonth() + offset + 1, 0); return { from: start.toISOString().split('T')[0], to: end.toISOString().split('T')[0] } }
16
- function getQuarterRange(offset: number): { from: string; to: string } { const now = new Date(); const q = Math.floor(now.getMonth() / 3) + offset; const start = new Date(now.getFullYear(), q * 3, 1); const end = new Date(now.getFullYear(), q * 3 + 3, 0); return { from: start.toISOString().split('T')[0], to: end.toISOString().split('T')[0] } }
66
+ function getQuarterRange(offset: number): { from: string; to: string } {
67
+ const now = new Date()
68
+ const currentQuarter = Math.floor(now.getMonth() / 3)
69
+ const quarter = currentQuarter + offset
70
+ const year = now.getFullYear()
71
+ const startMonth = quarter * 3
72
+ const start = new Date(year, startMonth, 1)
73
+ const end = new Date(year, startMonth + 3, 0)
74
+ return {
75
+ from: start.toISOString().split('T')[0],
76
+ to: end.toISOString().split('T')[0],
77
+ }
78
+ }
17
79
 
18
80
  export const BillingClient: React.FC = () => {
19
81
  const { t } = useTranslation()
@@ -26,107 +88,338 @@ export const BillingClient: React.FC = () => {
26
88
  const [projects, setProjects] = useState<ProjectOption[]>([])
27
89
  const [projectsLoaded, setProjectsLoaded] = useState(false)
28
90
  const [copied, setCopied] = useState(false)
91
+ const [billedTickets, setBilledTickets] = useState<Set<number>>(() => {
92
+ if (typeof window === 'undefined') return new Set()
93
+ try {
94
+ const saved = localStorage.getItem('billing-checked-tickets')
95
+ return saved ? new Set(JSON.parse(saved)) : new Set()
96
+ } catch { return new Set() }
97
+ })
98
+
99
+ const toggleBilled = useCallback((ticketId: number) => {
100
+ setBilledTickets((prev) => {
101
+ const next = new Set(prev)
102
+ if (next.has(ticketId)) next.delete(ticketId)
103
+ else next.add(ticketId)
104
+ localStorage.setItem('billing-checked-tickets', JSON.stringify([...next]))
105
+ return next
106
+ })
107
+ }, [])
108
+
109
+ const allTicketIds = data?.groups.flatMap((g) => g.tickets.map((t) => t.id)) || []
110
+ const allBilled = allTicketIds.length > 0 && allTicketIds.every((id) => billedTickets.has(id))
111
+ const toggleAll = useCallback(() => {
112
+ setBilledTickets((prev) => {
113
+ const ids = data?.groups.flatMap((g) => g.tickets.map((t) => t.id)) || []
114
+ const next = ids.every((id) => prev.has(id)) ? new Set<number>() : new Set(ids)
115
+ localStorage.setItem('billing-checked-tickets', JSON.stringify([...next]))
116
+ return next
117
+ })
118
+ }, [data])
29
119
 
30
120
  const loadProjects = useCallback(async () => {
31
121
  if (projectsLoaded) return
32
- try { const res = await fetch('/api/projects?limit=100&depth=0&sort=name'); if (res.ok) { const json = await res.json(); setProjects(json.docs?.map((p: { id: number; name: string }) => ({ id: p.id, name: p.name })) || []) } } catch (err) { console.warn('[support] loadProjects error:', err) }
122
+ try {
123
+ const res = await fetch('/api/projects?limit=100&depth=0&sort=name')
124
+ if (res.ok) {
125
+ const json = await res.json()
126
+ setProjects(json.docs?.map((p: { id: number; name: string }) => ({ id: p.id, name: p.name })) || [])
127
+ }
128
+ } catch { /* ignore */ }
33
129
  setProjectsLoaded(true)
34
130
  }, [projectsLoaded])
35
131
 
132
+ // Load projects on mount
36
133
  React.useEffect(() => { loadProjects() }, [loadProjects])
37
134
 
38
135
  const fetchBilling = useCallback(async () => {
39
136
  setLoading(true)
40
- try { const params = new URLSearchParams({ from, to }); if (projectId) params.set('projectId', projectId); const res = await fetch(`/api/support/billing?${params}`); if (res.ok) setData(await res.json()) } catch (err) { console.warn('[support] fetchBilling error:', err) }
137
+ try {
138
+ const params = new URLSearchParams({ from, to })
139
+ if (projectId) params.set('projectId', projectId)
140
+ const res = await fetch(`/api/support/billing?${params}`)
141
+ if (res.ok) {
142
+ setData(await res.json())
143
+ }
144
+ } catch (err) {
145
+ console.error('[billing] Fetch error:', err)
146
+ }
41
147
  setLoading(false)
42
148
  }, [from, to, projectId])
43
149
 
44
- const setPeriod = (range: { from: string; to: string }) => { setFrom(range.from); setTo(range.to) }
150
+ const setPeriod = (range: { from: string; to: string }) => {
151
+ setFrom(range.from)
152
+ setTo(range.to)
153
+ }
45
154
 
46
155
  const copyRecap = useCallback(() => {
47
156
  if (!data) return
48
- const lines: string[] = [`PRE-FACTURATION -- Du ${from} au ${to}`, `Taux horaire : ${rate} EUR/h`, '='.repeat(50)]
157
+
158
+ const lines: string[] = []
159
+ lines.push(`PRE-FACTURATION — Du ${from} au ${to}`)
160
+ lines.push(`Taux horaire : ${rate} EUR/h`)
161
+ lines.push('='.repeat(50))
162
+
49
163
  for (const group of data.groups) {
50
- lines.push('', `PROJET : ${group.project?.name || 'Sans projet'}`)
164
+ lines.push('')
165
+ lines.push(`PROJET : ${group.project?.name || 'Sans projet'}`)
51
166
  if (group.client?.company) lines.push(`Client : ${group.client.company}`)
167
+ lines.push('-'.repeat(40))
168
+
52
169
  for (const ticket of group.tickets) {
53
- lines.push(` ${ticket.ticketNumber} -- ${ticket.subject}`)
54
- for (const entry of ticket.entries) lines.push(` ${entry.date} | ${formatDuration(entry.duration)} | ${entry.description || '-'}`)
55
- lines.push(` Sous-total : ${formatDuration(ticket.totalMinutes)} = ${formatAmount(ticket.totalMinutes, rate)} EUR`)
170
+ lines.push(` ${ticket.ticketNumber} ${ticket.subject}`)
171
+ for (const entry of ticket.entries) {
172
+ lines.push(` ${entry.date} | ${formatDuration(entry.duration)} | ${entry.description || '-'}`)
173
+ }
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)' : ''}`)
56
176
  }
177
+ const groupAmount = group.totalBilledAmount > 0
178
+ ? group.totalBilledAmount
179
+ : Number(formatAmount(group.totalMinutes, rate))
180
+ lines.push(` Total projet : ${formatDuration(group.totalMinutes)} = ${groupAmount.toFixed(2)} EUR`)
57
181
  }
58
- lines.push('', '='.repeat(50), `TOTAL : ${formatDuration(data.grandTotalMinutes)} = ${formatAmount(data.grandTotalMinutes, rate)} EUR`)
182
+
183
+ lines.push('')
184
+ lines.push('='.repeat(50))
185
+ lines.push(`TOTAL GENERAL : ${formatDuration(data.grandTotalMinutes)} = ${formatAmount(data.grandTotalMinutes, rate)} EUR`)
186
+
59
187
  navigator.clipboard.writeText(lines.join('\n'))
60
- setCopied(true); setTimeout(() => setCopied(false), 2000)
188
+ setCopied(true)
189
+ setTimeout(() => setCopied(false), 2000)
61
190
  }, [data, from, to, rate])
62
191
 
63
- const S: Record<string, React.CSSProperties> = {
64
- page: { padding: '20px 30px', maxWidth: 1100, margin: '0 auto' },
65
- filters: { marginBottom: 20 },
66
- quickPeriod: { display: 'flex', gap: 6, marginBottom: 8 },
67
- btn: { padding: '6px 12px', borderRadius: 6, border: '1px solid var(--theme-elevation-200)', fontSize: 12, cursor: 'pointer', background: 'var(--theme-elevation-0)', color: 'var(--theme-text)' },
68
- btnPrimary: { padding: '6px 12px', borderRadius: 6, border: 'none', fontSize: 12, cursor: 'pointer', background: '#2563eb', color: '#fff', fontWeight: 600 },
69
- filterRow: { display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' as const },
70
- fieldGroup: { display: 'flex', flexDirection: 'column' as const, gap: 4 },
71
- label: { fontSize: 11, fontWeight: 600, color: 'var(--theme-elevation-500)' },
72
- input: { padding: '6px 10px', borderRadius: 6, border: '1px solid var(--theme-elevation-200)', fontSize: 12, color: 'var(--theme-text)', background: 'var(--theme-elevation-0)' },
73
- select: { padding: '6px 10px', borderRadius: 6, border: '1px solid var(--theme-elevation-200)', fontSize: 12, color: 'var(--theme-text)', background: 'var(--theme-elevation-0)' },
74
- groupCard: { marginBottom: 16, borderRadius: 10, border: '1px solid var(--theme-elevation-150)', overflow: 'hidden' },
75
- groupHeader: { display: 'flex', justifyContent: 'space-between', padding: '12px 16px', background: 'var(--theme-elevation-50)', borderBottom: '1px solid var(--theme-elevation-150)' },
76
- table: { width: '100%', borderCollapse: 'collapse' as const, fontSize: 12 },
77
- th: { textAlign: 'left' as const, padding: '6px 8px', borderBottom: '1px solid var(--theme-elevation-200)', fontSize: 11, color: 'var(--theme-elevation-500)' },
78
- td: { padding: '6px 8px', borderBottom: '1px solid var(--theme-elevation-100)' },
79
- grandTotal: { padding: 16, borderRadius: 10, border: '2px solid #2563eb', display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 16 },
80
- }
192
+ const totalTickets = data?.groups.reduce((sum, g) => sum + g.tickets.length, 0) || 0
81
193
 
82
194
  return (
83
- <div style={S.page}>
84
- <div style={{ marginBottom: 16 }}>
85
- <h1 style={{ fontSize: 22, fontWeight: 700, margin: 0 }}>{t('billing.title')}</h1>
86
- <p style={{ fontSize: 13, color: 'var(--theme-elevation-500)', margin: '4px 0 0' }}>{t('billing.subtitle')}</p>
195
+ <div className={styles.page}>
196
+ {/* Header */}
197
+ <div className={styles.header}>
198
+ <div>
199
+ <h1 className={styles.title}>{t('billing.title')}</h1>
200
+ <p className={styles.subtitle}>{t('billing.subtitle')}</p>
201
+ </div>
87
202
  </div>
88
203
 
89
- <div style={S.filters}>
90
- <div style={S.quickPeriod}>
91
- <button style={S.btnPrimary} onClick={() => setPeriod(getMonthRange(0))}>{t('billing.filters.thisMonth')}</button>
92
- <button style={S.btn} onClick={() => setPeriod(getMonthRange(-1))}>{t('billing.filters.lastMonth')}</button>
93
- <button style={S.btn} onClick={() => setPeriod(getQuarterRange(0))}>{t('billing.filters.thisQuarter')}</button>
204
+ {/* Filters */}
205
+ <div className={styles.filters}>
206
+ <div className={styles.quickPeriod}>
207
+ <button className={styles.btnPrimary} onClick={() => setPeriod(getMonthRange(0))}>{t('billing.filters.thisMonth')}</button>
208
+ <button className={styles.btnSecondary} onClick={() => setPeriod(getMonthRange(-1))}>{t('billing.filters.lastMonth')}</button>
209
+ <button className={styles.btnAmber} onClick={() => setPeriod(getQuarterRange(0))}>{t('billing.filters.thisQuarter')}</button>
210
+ <button className={styles.btnMuted} onClick={() => setPeriod(getQuarterRange(-1))}>{t('billing.filters.lastQuarter')}</button>
94
211
  </div>
95
- <div style={S.filterRow}>
96
- <div style={S.fieldGroup}><label style={S.label}>{t('billing.filters.from')}</label><input type="date" value={from} onChange={(e) => setFrom(e.target.value)} style={S.input} /></div>
97
- <div style={S.fieldGroup}><label style={S.label}>{t('billing.filters.to')}</label><input type="date" value={to} onChange={(e) => setTo(e.target.value)} style={S.input} /></div>
98
- <div style={S.fieldGroup}><label style={S.label}>{t('billing.filters.project')}</label><select value={projectId} onChange={(e) => setProjectId(e.target.value)} style={S.select}><option value="">{t('common.all')}</option>{projects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</select></div>
99
- <div style={S.fieldGroup}><label style={S.label}>{t('billing.filters.hourlyRate')}</label><div style={{ display: 'flex', gap: 4, alignItems: 'center' }}><input type="number" value={rate} onChange={(e) => setRate(Number(e.target.value))} style={{ ...S.input, width: 70 }} min={0} /><span style={{ fontSize: 11 }}>{t('billing.filters.rateUnit')}</span></div></div>
100
- <button style={S.btnPrimary} onClick={fetchBilling} disabled={loading}>{loading ? t('billing.filters.loading') : t('billing.filters.load')}</button>
212
+
213
+ <div className={styles.filterRow}>
214
+ <div className={styles.fieldGroup}>
215
+ <label className={styles.label}>{t('billing.filters.from')}</label>
216
+ <input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className={styles.input} />
217
+ </div>
218
+ <div className={styles.fieldGroup}>
219
+ <label className={styles.label}>{t('billing.filters.to')}</label>
220
+ <input type="date" value={to} onChange={(e) => setTo(e.target.value)} className={styles.input} />
221
+ </div>
222
+ <div className={styles.fieldGroup}>
223
+ <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
+ >
229
+ <option value="">{t('ticket.allProjects')}</option>
230
+ {projects.map((p) => (
231
+ <option key={p.id} value={p.id}>{p.name}</option>
232
+ ))}
233
+ </select>
234
+ </div>
235
+ <div className={styles.fieldGroup}>
236
+ <label className={styles.label}>{t('billing.filters.hourlyRate')}</label>
237
+ <div className={styles.rateRow}>
238
+ <input
239
+ type="number"
240
+ value={rate}
241
+ onChange={(e) => setRate(Number(e.target.value))}
242
+ className={styles.rateInput}
243
+ min={0}
244
+ />
245
+ <span className={styles.rateUnit}>{t('billing.filters.rateUnit')}</span>
246
+ </div>
247
+ </div>
248
+ <button
249
+ className={styles.btnPrimary}
250
+ onClick={fetchBilling}
251
+ disabled={loading}
252
+ >
253
+ {loading ? t('billing.filters.loading') : t('billing.filters.load')}
254
+ </button>
101
255
  </div>
102
256
  </div>
103
257
 
258
+ {/* Results */}
104
259
  {data && (
105
260
  <>
106
- {data.groups.length === 0 ? <div style={{ padding: 40, textAlign: 'center', color: '#94a3b8' }}>{t('billing.empty')}</div> : (
261
+ {data.groups.length === 0 ? (
262
+ <div className={styles.empty}>
263
+ {t('billing.empty')}
264
+ </div>
265
+ ) : (
107
266
  <>
267
+ {/* Project groups */}
108
268
  {data.groups.map((group, gi) => (
109
- <div key={gi} style={S.groupCard}>
110
- <div style={S.groupHeader}>
111
- <div><span style={{ fontWeight: 700 }}>{group.project?.name || 'Sans projet'}</span>{group.client?.company && <span style={{ color: 'var(--theme-elevation-500)' }}> -- {group.client.company}</span>}</div>
112
- <div style={{ textAlign: 'right' }}><div style={{ fontWeight: 700 }}>{formatDuration(group.totalMinutes)}</div><div style={{ fontSize: 12, color: '#2563eb', fontWeight: 600 }}>{formatAmount(group.totalMinutes, rate)} EUR</div></div>
269
+ <div key={gi} className={styles.groupCard}>
270
+ {/* Project header */}
271
+ <div className={styles.groupHeader}>
272
+ <div>
273
+ <span className={styles.groupName}>
274
+ {group.project?.name || 'Sans projet'}
275
+ </span>
276
+ {group.client?.company && (
277
+ <span className={styles.groupClient}>
278
+ — {group.client.company}
279
+ </span>
280
+ )}
281
+ </div>
282
+ <div className={styles.groupTotals}>
283
+ <div className={styles.groupDuration}>
284
+ {formatDuration(group.totalMinutes)}
285
+ </div>
286
+ {group.totalBilledAmount > 0 ? (
287
+ <>
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>
294
+ </>
295
+ ) : (
296
+ <div className={styles.groupAmount}>
297
+ {formatAmount(group.totalMinutes, rate)} EUR
298
+ </div>
299
+ )}
300
+ </div>
113
301
  </div>
114
- <table style={S.table}>
115
- <thead><tr><th style={S.th}>{t('billing.table.ticketNumber')}</th><th style={S.th}>{t('billing.table.subject')}</th><th style={S.th}>{t('billing.table.date')}</th><th style={S.th}>{t('billing.table.duration')}</th><th style={S.th}>{t('billing.table.description')}</th><th style={{ ...S.th, textAlign: 'right' }}>{t('billing.table.amount')}</th></tr></thead>
302
+
303
+ {/* Tickets table */}
304
+ <table className={styles.table}>
305
+ <thead>
306
+ <tr>
307
+ <th className={styles.thCheckbox}>
308
+ <input
309
+ type="checkbox"
310
+ checked={group.tickets.every((t) => billedTickets.has(t.id))}
311
+ onChange={() => {
312
+ const ids = group.tickets.map((t) => t.id)
313
+ const allChecked = ids.every((id) => billedTickets.has(id))
314
+ setBilledTickets((prev) => {
315
+ const next = new Set(prev)
316
+ ids.forEach((id) => allChecked ? next.delete(id) : next.add(id))
317
+ localStorage.setItem('billing-checked-tickets', JSON.stringify([...next]))
318
+ return next
319
+ })
320
+ }}
321
+ className={styles.checkbox}
322
+ title="Tout cocher/decocher"
323
+ />
324
+ </th>
325
+ <th className={styles.th}>N° Ticket</th>
326
+ <th className={styles.thLeft}>Sujet</th>
327
+ <th className={styles.th}>Date</th>
328
+ <th className={styles.th}>Duree</th>
329
+ <th className={styles.thLeft}>Description</th>
330
+ <th className={styles.th}>Montant</th>
331
+ <th className={styles.th}>Facture</th>
332
+ </tr>
333
+ </thead>
116
334
  <tbody>
117
- {group.tickets.map((ticket) => ticket.entries.map((entry, ei) => (
118
- <tr key={`${ticket.id}-${ei}`}>
119
- {ei === 0 && <><td style={{ ...S.td, fontWeight: 600 }} rowSpan={ticket.entries.length}><a href={`/admin/support/ticket?id=${ticket.id}`} style={{ color: '#2563eb', textDecoration: 'none' }}>{ticket.ticketNumber}</a></td><td style={S.td} rowSpan={ticket.entries.length}>{ticket.subject}</td></>}
120
- <td style={S.td}>{entry.date}</td><td style={S.td}>{formatDuration(entry.duration)}</td><td style={S.td}>{entry.description || '-'}</td><td style={{ ...S.td, textAlign: 'right', fontWeight: 600 }}>{formatAmount(entry.duration, rate)} EUR</td>
121
- </tr>
122
- )))}
335
+ {group.tickets.map((ticket) => {
336
+ 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 || '-'}
370
+ </td>
371
+ <td className={`${styles.td} ${styles.bold}`}>
372
+ {formatAmount(entry.duration, rate)} EUR
373
+ </td>
374
+ {ei === 0 ? (
375
+ <td
376
+ className={`${styles.td} ${ticket.billedAmount ? styles.billedAmount : styles.secondary}`}
377
+ rowSpan={ticket.entries.length}
378
+ >
379
+ {ticket.billedAmount ? `${ticket.billedAmount.toFixed(2)} EUR` : '-'}
380
+ </td>
381
+ ) : null}
382
+ </tr>
383
+ ))
384
+ })}
123
385
  </tbody>
124
386
  </table>
125
387
  </div>
126
388
  ))}
127
- <div style={S.grandTotal}>
128
- <div><div style={{ fontSize: 12, color: 'var(--theme-elevation-500)' }}>{t('billing.totals.billableTicketsPlural', { count: String(data.groups.reduce((s, g) => s + g.tickets.length, 0)) })}</div><div style={{ fontSize: 18, fontWeight: 700 }}>{t('billing.totals.total')} : {formatDuration(data.grandTotalMinutes)} = {formatAmount(data.grandTotalMinutes, rate)} EUR</div></div>
129
- <button style={S.btnPrimary} onClick={copyRecap}>{copied ? t('billing.totals.copiedRecap') : t('billing.totals.copyRecap')}</button>
389
+
390
+ {/* Grand total */}
391
+ <div className={styles.grandTotal}>
392
+ <div>
393
+ <div className={styles.totalMeta}>
394
+ {totalTickets} ticket{totalTickets > 1 ? 's' : ''} facturable{totalTickets > 1 ? 's' : ''}
395
+ {billedTickets.size > 0 && (
396
+ <span className={styles.totalChecked}>
397
+ ({allTicketIds.filter((id) => billedTickets.has(id)).length} coche{allTicketIds.filter((id) => billedTickets.has(id)).length > 1 ? 's' : ''})
398
+ </span>
399
+ )}
400
+ </div>
401
+ <div className={styles.totalAmount}>
402
+ Total : {formatDuration(data.grandTotalMinutes)} ={' '}
403
+ {data.grandTotalBilledAmount > 0
404
+ ? `${data.grandTotalBilledAmount.toFixed(2)} EUR`
405
+ : `${formatAmount(data.grandTotalMinutes, rate)} EUR`
406
+ }
407
+ </div>
408
+ </div>
409
+ <div className={styles.totalActions}>
410
+ <button
411
+ className={allBilled ? styles.btnSecondary : styles.btnGreen}
412
+ onClick={toggleAll}
413
+ >
414
+ {allBilled ? t('billing.totals.uncheckAll') : t('billing.totals.checkAll')}
415
+ </button>
416
+ <button
417
+ className={copied ? styles.btnSuccess : styles.btnAmber}
418
+ onClick={copyRecap}
419
+ >
420
+ {copied ? t('billing.totals.copiedRecap') : t('billing.totals.copyRecap')}
421
+ </button>
422
+ </div>
130
423
  </div>
131
424
  </>
132
425
  )}