@consilioweb/payload-support 0.8.2 → 0.9.4
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/components/RichTextEditor/index.cjs +233 -0
- package/dist/components/RichTextEditor/index.js +232 -0
- package/dist/components/TicketConversation/components/CodeBlock.cjs +24 -7
- package/dist/components/TicketConversation/components/CodeBlock.js +23 -9
- package/dist/index.cjs +19 -1
- package/dist/index.js +19 -1
- package/dist/views/BillingView/client.cjs +260 -103
- package/dist/views/BillingView/client.js +259 -103
- package/dist/views/ChatView/client.cjs +184 -137
- package/dist/views/ChatView/client.js +180 -137
- package/dist/views/CrmView/client.cjs +270 -122
- package/dist/views/CrmView/client.js +266 -122
- package/dist/views/EmailTrackingView/client.cjs +80 -69
- package/dist/views/EmailTrackingView/client.js +80 -70
- package/dist/views/ImportConversationView/client.cjs +127 -94
- package/dist/views/ImportConversationView/client.js +123 -94
- package/dist/views/LogsView/client.cjs +56 -58
- package/dist/views/LogsView/client.js +52 -58
- package/dist/views/NewTicketView/client.cjs +39 -55
- package/dist/views/NewTicketView/client.js +38 -55
- package/dist/views/PendingEmailsView/client.cjs +399 -102
- package/dist/views/PendingEmailsView/client.js +396 -103
- package/dist/views/SupportDashboardView/client.cjs +276 -137
- package/dist/views/SupportDashboardView/client.js +275 -137
- package/dist/views/TicketDetailView/client.cjs +487 -204
- package/dist/views/TicketDetailView/client.js +486 -204
- package/dist/views/TicketInboxView/client.cjs +62 -65
- package/dist/views/TicketInboxView/client.js +62 -66
- package/dist/views/TicketingSettingsView/client.cjs +10 -8
- package/dist/views/TicketingSettingsView/client.js +10 -8
- package/dist/views/TimeDashboardView/client.cjs +70 -59
- package/dist/views/TimeDashboardView/client.js +69 -59
- package/package.json +6 -2
- package/src/components/RichTextEditor/index.tsx +261 -0
- package/src/components/TicketConversation/components/CodeBlock.tsx +53 -14
- package/src/plugin.ts +2 -0
- package/src/utils/emailTemplate.ts +37 -0
- package/src/views/BillingView/client.tsx +362 -69
- package/src/views/ChatView/client.tsx +225 -140
- package/src/views/CrmView/client.tsx +447 -189
- package/src/views/EmailTrackingView/client.tsx +111 -71
- package/src/views/ImportConversationView/client.tsx +255 -70
- package/src/views/LogsView/client.tsx +85 -50
- package/src/views/NewTicketView/client.tsx +37 -53
- package/src/views/PendingEmailsView/client.tsx +512 -92
- package/src/views/SupportDashboardView/client.tsx +294 -134
- package/src/views/TicketDetailView/client.tsx +486 -213
- package/src/views/TicketInboxView/client.tsx +52 -61
- package/src/views/TicketingSettingsView/client.tsx +10 -9
- 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
|
|
5
|
+
import styles from '../../styles/BillingView.module.scss'
|
|
6
6
|
|
|
7
|
-
interface BillingEntry {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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 {
|
|
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 {
|
|
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 }) => {
|
|
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
|
-
|
|
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(''
|
|
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}
|
|
54
|
-
for (const entry of ticket.entries)
|
|
55
|
-
|
|
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
|
-
|
|
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)
|
|
188
|
+
setCopied(true)
|
|
189
|
+
setTimeout(() => setCopied(false), 2000)
|
|
61
190
|
}, [data, from, to, rate])
|
|
62
191
|
|
|
63
|
-
const
|
|
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
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
<
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
<button
|
|
93
|
-
<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
|
-
|
|
96
|
-
|
|
97
|
-
<div
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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 ?
|
|
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}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
<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
|
-
|
|
115
|
-
|
|
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) =>
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
<
|
|
121
|
-
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
)}
|