@consilioweb/payload-support 0.8.1 → 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 -2
- package/dist/index.js +19 -2
- 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/collections/ClientSummaries.ts +0 -1
- 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
|
@@ -71,6 +71,15 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
71
71
|
if (urlTab && ['all', 'open', 'waiting_client', 'resolved'].includes(urlTab)) return urlTab as Tab
|
|
72
72
|
return 'all'
|
|
73
73
|
})
|
|
74
|
+
// Sync tab with URL searchParams on navigation
|
|
75
|
+
useEffect(() => {
|
|
76
|
+
const urlTab = searchParams.get('tab')
|
|
77
|
+
if (urlTab && ['all', 'open', 'waiting_client', 'resolved'].includes(urlTab)) {
|
|
78
|
+
setTab(urlTab as Tab)
|
|
79
|
+
} else if (!urlTab) {
|
|
80
|
+
setTab('all')
|
|
81
|
+
}
|
|
82
|
+
}, [searchParams])
|
|
74
83
|
const [search, setSearch] = useState('')
|
|
75
84
|
const [sort, setSort] = useState('-updatedAt')
|
|
76
85
|
const [counts, setCounts] = useState({ all: 0, open: 0, waiting: 0, resolved: 0 })
|
|
@@ -152,7 +161,7 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
152
161
|
fetchCounts()
|
|
153
162
|
}, [])
|
|
154
163
|
|
|
155
|
-
// Auto-refresh
|
|
164
|
+
// Auto-refresh 30s
|
|
156
165
|
useEffect(() => {
|
|
157
166
|
const iv = setInterval(fetchTickets, 60000)
|
|
158
167
|
return () => clearInterval(iv)
|
|
@@ -179,78 +188,57 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
179
188
|
{ key: 'resolved', label: t('inbox.tabs.resolved'), count: counts.resolved },
|
|
180
189
|
]
|
|
181
190
|
|
|
182
|
-
const S: Record<string, React.CSSProperties> = {
|
|
183
|
-
page: { padding: '20px 30px', maxWidth: 1100, margin: '0 auto' },
|
|
184
|
-
header: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 },
|
|
185
|
-
title: { fontSize: 24, fontWeight: 700, margin: 0, color: 'var(--theme-text)' },
|
|
186
|
-
headerRight: { display: 'flex', gap: 10, alignItems: 'center' },
|
|
187
|
-
searchWrap: { position: 'relative' as const, display: 'flex', alignItems: 'center' },
|
|
188
|
-
searchInput: { padding: '6px 12px 6px 30px', borderRadius: 8, border: '1px solid var(--theme-elevation-200)', fontSize: 13, background: 'var(--theme-elevation-0)', color: 'var(--theme-text)', width: 200 },
|
|
189
|
-
newTicketBtn: { padding: '7px 14px', borderRadius: 8, background: '#2563eb', color: '#fff', fontSize: 13, fontWeight: 600, textDecoration: 'none', whiteSpace: 'nowrap' as const },
|
|
190
|
-
tabsRow: { display: 'flex', gap: 4, marginBottom: 12, borderBottom: '1px solid var(--theme-elevation-200)', paddingBottom: 8 },
|
|
191
|
-
tab: { padding: '6px 12px', borderRadius: 6, border: 'none', background: 'none', cursor: 'pointer', fontSize: 13, color: 'var(--theme-elevation-500)', fontWeight: 500 },
|
|
192
|
-
tabActive: { background: 'var(--theme-elevation-100)', color: 'var(--theme-text)', fontWeight: 700 },
|
|
193
|
-
sortRow: { display: 'flex', alignItems: 'center', marginBottom: 12 },
|
|
194
|
-
sortSelect: { padding: '4px 10px', borderRadius: 6, border: '1px solid var(--theme-elevation-200)', fontSize: 12, background: 'var(--theme-elevation-0)', color: 'var(--theme-text)', cursor: 'pointer' },
|
|
195
|
-
loading: { padding: 40, textAlign: 'center' as const, color: '#94a3b8' },
|
|
196
|
-
empty: { padding: 60, textAlign: 'center' as const, color: '#94a3b8' },
|
|
197
|
-
list: { display: 'flex', flexDirection: 'column' as const, gap: 1 },
|
|
198
|
-
row: { display: 'grid', gridTemplateColumns: '20px 10px 60px 70px 1fr 140px 80px 4px 50px 10px', gap: 8, alignItems: 'center', padding: '10px 12px', borderRadius: 8, textDecoration: 'none', color: 'var(--theme-text)', fontSize: 13, cursor: 'pointer', transition: 'background 100ms', background: 'var(--theme-elevation-0)' },
|
|
199
|
-
rowHover: { background: 'var(--theme-elevation-50)' },
|
|
200
|
-
statusDot: { width: 8, height: 8, borderRadius: '50%' },
|
|
201
|
-
priorityBar: { width: 4, height: 20, borderRadius: 2 },
|
|
202
|
-
unreadDot: { width: 8, height: 8, borderRadius: '50%', background: '#2563eb' },
|
|
203
|
-
keyboardHints: { display: 'flex', gap: 12, marginTop: 16, fontSize: 11, color: 'var(--theme-elevation-400)' },
|
|
204
|
-
}
|
|
205
|
-
|
|
206
191
|
return (
|
|
207
|
-
<div
|
|
192
|
+
<div className={s.page}>
|
|
208
193
|
{/* Header */}
|
|
209
|
-
<div
|
|
210
|
-
<h1
|
|
211
|
-
<div
|
|
212
|
-
<div
|
|
194
|
+
<div className={s.header}>
|
|
195
|
+
<h1 className={s.title}>{t('inbox.title')}</h1>
|
|
196
|
+
<div className={s.headerRight}>
|
|
197
|
+
<div className={s.searchWrap}>
|
|
198
|
+
<svg className={s.searchIcon} width="14" height="14" viewBox="0 0 16 16" fill="none"><circle cx="7" cy="7" r="5" stroke="currentColor" strokeWidth="1.5"/><path d="M11 11l3.5 3.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/></svg>
|
|
213
199
|
<input
|
|
214
200
|
type="text"
|
|
215
|
-
|
|
201
|
+
className={s.searchInput}
|
|
216
202
|
placeholder={t('inbox.searchPlaceholder')}
|
|
217
203
|
value={search}
|
|
218
204
|
onChange={(e) => setSearch(e.target.value)}
|
|
219
205
|
/>
|
|
206
|
+
<span className={s.searchHint}>⌘K</span>
|
|
220
207
|
</div>
|
|
221
|
-
<Link href="/admin/support/new-ticket"
|
|
208
|
+
<Link href="/admin/support/new-ticket" className={s.newTicketBtn}>{t('inbox.newTicketBtn')}</Link>
|
|
222
209
|
</div>
|
|
223
210
|
</div>
|
|
224
211
|
|
|
225
212
|
{/* Tabs */}
|
|
226
|
-
<div
|
|
227
|
-
{tabs.map((
|
|
213
|
+
<div className={s.tabs}>
|
|
214
|
+
{tabs.map((tk) => (
|
|
228
215
|
<button
|
|
229
|
-
key={
|
|
230
|
-
|
|
231
|
-
onClick={() => { setTab(
|
|
216
|
+
key={tk.key}
|
|
217
|
+
className={`${s.tab} ${tab === tk.key ? s.tabActive : ''}`}
|
|
218
|
+
onClick={() => { setTab(tk.key); setSelectedIdx(-1) }}
|
|
232
219
|
>
|
|
233
|
-
{
|
|
220
|
+
{tk.label}
|
|
221
|
+
<span className={s.tabCount}>({tk.count})</span>
|
|
234
222
|
</button>
|
|
235
223
|
))}
|
|
236
224
|
</div>
|
|
237
225
|
|
|
238
226
|
{/* Sort + Bulk */}
|
|
239
|
-
<div
|
|
227
|
+
<div className={s.sortRow}>
|
|
240
228
|
{checkedIds.size > 0 ? (
|
|
241
229
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flex: 1 }}>
|
|
242
230
|
<span style={{ fontSize: 13, fontWeight: 600, color: 'var(--theme-text)' }}>{checkedIds.size > 1 ? t('inbox.selectedPlural', { count: String(checkedIds.size) }) : t('inbox.selected', { count: String(checkedIds.size) })}</span>
|
|
243
|
-
<button
|
|
244
|
-
<button
|
|
245
|
-
<select
|
|
231
|
+
<button className={s.sortSelect} onClick={() => handleBulkAction('close')} disabled={bulkProcessing}>{t('inbox.closeAction')}</button>
|
|
232
|
+
<button className={s.sortSelect} onClick={() => handleBulkAction('reopen')} disabled={bulkProcessing}>{t('inbox.reopenAction')}</button>
|
|
233
|
+
<select className={s.sortSelect} value={bulkAction} onChange={(e) => { if (e.target.value) handleBulkAction(e.target.value); setBulkAction('') }}>
|
|
246
234
|
<option value="">{t('inbox.moreActions')}</option>
|
|
247
235
|
<option value="set_priority">{t('inbox.changePriority')}</option>
|
|
248
236
|
<option value="delete">{t('inbox.deleteAction')}</option>
|
|
249
237
|
</select>
|
|
250
|
-
<button
|
|
238
|
+
<button className={s.sortSelect} onClick={() => setCheckedIds(new Set())} style={{ marginLeft: 'auto' }}>{t('inbox.deselect')}</button>
|
|
251
239
|
</div>
|
|
252
240
|
) : (
|
|
253
|
-
<select
|
|
241
|
+
<select className={s.sortSelect} value={sort} onChange={(e) => setSort(e.target.value)}>
|
|
254
242
|
<option value="-updatedAt">{t('inbox.sort.newest')}</option>
|
|
255
243
|
<option value="updatedAt">{t('inbox.sort.oldest')}</option>
|
|
256
244
|
<option value="-createdAt">{t('inbox.sort.created')}</option>
|
|
@@ -261,16 +249,19 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
261
249
|
|
|
262
250
|
{/* List */}
|
|
263
251
|
{loading ? (
|
|
264
|
-
<div
|
|
252
|
+
<div className={s.loading}>{t('common.loading')}</div>
|
|
265
253
|
) : tickets.length === 0 ? (
|
|
266
|
-
<div
|
|
254
|
+
<div className={s.empty}>
|
|
255
|
+
<div className={s.emptyIcon}>--</div>
|
|
256
|
+
<div className={s.emptyText}>{t('inbox.empty')}</div>
|
|
257
|
+
</div>
|
|
267
258
|
) : (
|
|
268
|
-
<div
|
|
259
|
+
<div className={s.list}>
|
|
269
260
|
{tickets.map((tk, idx) => {
|
|
270
261
|
const clientObj = typeof tk.client === 'object' ? tk.client : null
|
|
271
262
|
const clientName = clientObj ? `${clientObj.firstName || ''} ${clientObj.lastName || ''}`.trim() : ''
|
|
272
263
|
const clientCompany = clientObj?.company || ''
|
|
273
|
-
const displayClient = clientName ? `${clientName}${clientCompany ? `, ${clientCompany}` : ''}` : '
|
|
264
|
+
const displayClient = clientName ? `${clientName}${clientCompany ? `, ${clientCompany}` : ''}` : '—'
|
|
274
265
|
const isUnread = tk.lastClientMessageAt && (!tk.lastAdminReadAt || new Date(tk.lastClientMessageAt) > new Date(tk.lastAdminReadAt))
|
|
275
266
|
const priorityColor = PRIORITY_COLORS[tk.priority] || 'transparent'
|
|
276
267
|
|
|
@@ -278,7 +269,7 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
278
269
|
<a
|
|
279
270
|
key={tk.id}
|
|
280
271
|
href={`/admin/support/ticket?id=${tk.id}`}
|
|
281
|
-
|
|
272
|
+
className={`${s.row} ${idx === selectedIdx ? s.rowSelected : ''}`}
|
|
282
273
|
onClick={(e) => { e.preventDefault(); window.location.href = `/admin/support/ticket?id=${tk.id}` }}
|
|
283
274
|
>
|
|
284
275
|
<input
|
|
@@ -288,15 +279,15 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
288
279
|
onChange={() => toggleCheck(tk.id)}
|
|
289
280
|
style={{ width: 14, height: 14, cursor: 'pointer', accentColor: '#2563eb' }}
|
|
290
281
|
/>
|
|
291
|
-
<div style={{
|
|
292
|
-
<span
|
|
293
|
-
<span
|
|
294
|
-
<span
|
|
295
|
-
<span
|
|
296
|
-
{tk.category ? <span
|
|
297
|
-
<div style={{
|
|
298
|
-
<span
|
|
299
|
-
{isUnread ? <div
|
|
282
|
+
<div className={s.statusDot} style={{ backgroundColor: STATUS_DOTS[tk.status] || '#94a3b8' }} />
|
|
283
|
+
<span className={s.statusText}>{t(STATUS_LABEL_KEYS[tk.status] || 'ticket.status.open')}</span>
|
|
284
|
+
<span className={s.ticketNum}>{tk.ticketNumber}</span>
|
|
285
|
+
<span className={s.subject}>{tk.subject}</span>
|
|
286
|
+
<span className={s.client}>{displayClient}</span>
|
|
287
|
+
{tk.category ? <span className={s.categoryChip}>[{t(CATEGORY_LABEL_KEYS[tk.category] || 'ticket.category.bug')}]</span> : <span />}
|
|
288
|
+
<div className={s.priorityBar} style={{ backgroundColor: priorityColor }} />
|
|
289
|
+
<span className={s.timeAgo}>{relativeTime(tk.updatedAt)}</span>
|
|
290
|
+
{isUnread ? <div className={s.unreadDot} /> : <span />}
|
|
300
291
|
</a>
|
|
301
292
|
)
|
|
302
293
|
})}
|
|
@@ -304,9 +295,9 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
304
295
|
)}
|
|
305
296
|
|
|
306
297
|
{/* Keyboard hints */}
|
|
307
|
-
<div
|
|
308
|
-
<span><kbd
|
|
309
|
-
<span><kbd
|
|
298
|
+
<div className={s.keyboardHints}>
|
|
299
|
+
<span><kbd>↑</kbd><kbd>↓</kbd> {t('inbox.keyboardNavigate')}</span>
|
|
300
|
+
<span><kbd>↵</kbd> {t('inbox.keyboardOpen')}</span>
|
|
310
301
|
</div>
|
|
311
302
|
</div>
|
|
312
303
|
)
|
|
@@ -5,7 +5,8 @@ import { Settings, Mail, Bot, Clock, Timer, Globe, FileSignature } from 'lucide-
|
|
|
5
5
|
import { V, btnStyle } from '../shared/adminTokens'
|
|
6
6
|
import { AdminViewHeader } from '../shared/AdminViewHeader'
|
|
7
7
|
import { getFeatures, saveFeatures, DEFAULT_FEATURES, type TicketingFeatures } from '../../components/TicketConversation/config'
|
|
8
|
-
import
|
|
8
|
+
import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
|
|
9
|
+
import ts from '../../styles/TicketingSettings.module.scss'
|
|
9
10
|
|
|
10
11
|
/* ============================================
|
|
11
12
|
* Types
|
|
@@ -293,6 +294,7 @@ const FieldRow: React.FC<{
|
|
|
293
294
|
* ============================================ */
|
|
294
295
|
|
|
295
296
|
export const TicketingSettingsClient: React.FC = () => {
|
|
297
|
+
const { t } = useTranslation()
|
|
296
298
|
const [features, setFeatures] = useState<TicketingFeatures>(() => getFeatures())
|
|
297
299
|
const [settings, setSettings] = useState<AllSettings>(DEFAULT_SETTINGS)
|
|
298
300
|
const [signature, setSignature] = useState('')
|
|
@@ -379,34 +381,33 @@ export const TicketingSettingsClient: React.FC = () => {
|
|
|
379
381
|
<div className={ts.page}>
|
|
380
382
|
<AdminViewHeader
|
|
381
383
|
icon={<Settings size={24} />}
|
|
382
|
-
title=
|
|
383
|
-
subtitle={
|
|
384
|
+
title={t('settingsView.configTitle')}
|
|
385
|
+
subtitle={t('settings.subtitle', { enabled: String(enabledCount), total: String(totalCount) })}
|
|
384
386
|
actions={
|
|
385
387
|
<div style={{ display: 'flex', gap: 8 }}>
|
|
386
388
|
<button onClick={handleReset} style={btnStyle('var(--theme-elevation-400)', { small: true })}>
|
|
387
|
-
|
|
389
|
+
{t('settingsView.reset')}
|
|
388
390
|
</button>
|
|
389
391
|
<button
|
|
390
392
|
onClick={handleSave}
|
|
391
393
|
disabled={saving}
|
|
392
394
|
style={btnStyle(saved ? V.green : V.blue, { small: true })}
|
|
393
395
|
>
|
|
394
|
-
{saving ? '...' : saved ? '
|
|
396
|
+
{saving ? '...' : saved ? t('settingsView.saved') : t('common.save')}
|
|
395
397
|
</button>
|
|
396
398
|
</div>
|
|
397
399
|
}
|
|
398
400
|
/>
|
|
399
401
|
|
|
400
402
|
<p className={ts.intro}>
|
|
401
|
-
|
|
402
|
-
Les changements prennent effet immédiatement après sauvegarde (rechargez la page du ticket).
|
|
403
|
+
{t('settingsView.intro')}
|
|
403
404
|
</p>
|
|
404
405
|
|
|
405
406
|
{/* ========================================
|
|
406
407
|
* SECTION 1 — Feature Flags
|
|
407
408
|
* ======================================== */}
|
|
408
409
|
<CollapsibleSection
|
|
409
|
-
title=
|
|
410
|
+
title={t('settingsView.features')}
|
|
410
411
|
icon={<Settings size={16} />}
|
|
411
412
|
color={V.blue}
|
|
412
413
|
badge={
|
|
@@ -416,7 +417,7 @@ export const TicketingSettingsClient: React.FC = () => {
|
|
|
416
417
|
}
|
|
417
418
|
>
|
|
418
419
|
<p className={ts.sectionDescription}>
|
|
419
|
-
|
|
420
|
+
{t('settingsView.featuresDescription')}
|
|
420
421
|
</p>
|
|
421
422
|
|
|
422
423
|
{CATEGORIES.map((cat) => {
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
+
/* eslint-disable @next/next/no-html-link-for-pages */
|
|
1
2
|
'use client'
|
|
2
3
|
|
|
3
4
|
import React, { useState, useEffect, useCallback } from 'react'
|
|
5
|
+
import { SkeletonDashboard } from '../shared/Skeleton'
|
|
4
6
|
import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
|
|
5
|
-
import
|
|
7
|
+
import styles from '../../styles/TimeDashboard.module.scss'
|
|
6
8
|
|
|
7
9
|
interface TimeEntry {
|
|
8
10
|
id: number
|
|
@@ -12,11 +14,32 @@ interface TimeEntry {
|
|
|
12
14
|
date: string
|
|
13
15
|
}
|
|
14
16
|
|
|
15
|
-
interface GroupedData {
|
|
17
|
+
interface GroupedData {
|
|
18
|
+
label: string
|
|
19
|
+
entries: TimeEntry[]
|
|
20
|
+
totalMinutes: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function formatDuration(minutes: number): string {
|
|
24
|
+
const h = Math.floor(minutes / 60)
|
|
25
|
+
const m = minutes % 60
|
|
26
|
+
if (h === 0) return `${m}min`
|
|
27
|
+
if (m === 0) return `${h}h`
|
|
28
|
+
return `${h}h${m}m`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function getWeekNumber(d: Date): number {
|
|
32
|
+
const oneJan = new Date(d.getFullYear(), 0, 1)
|
|
33
|
+
return Math.ceil(((d.getTime() - oneJan.getTime()) / 86400000 + oneJan.getDay() + 1) / 7)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getMonthRange(offset: number): { from: string; to: string } {
|
|
37
|
+
const now = new Date()
|
|
38
|
+
const start = new Date(now.getFullYear(), now.getMonth() + offset, 1)
|
|
39
|
+
const end = new Date(now.getFullYear(), now.getMonth() + offset + 1, 0)
|
|
40
|
+
return { from: start.toISOString().split('T')[0], to: end.toISOString().split('T')[0] }
|
|
41
|
+
}
|
|
16
42
|
|
|
17
|
-
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}m` }
|
|
18
|
-
function getWeekNumber(d: Date): number { const oneJan = new Date(d.getFullYear(), 0, 1); return Math.ceil(((d.getTime() - oneJan.getTime()) / 86400000 + oneJan.getDay() + 1) / 7) }
|
|
19
|
-
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] } }
|
|
20
43
|
const MONTHS_FR = ['Jan', 'Fev', 'Mar', 'Avr', 'Mai', 'Jun', 'Jul', 'Aou', 'Sep', 'Oct', 'Nov', 'Dec']
|
|
21
44
|
|
|
22
45
|
export const TimeDashboardClient: React.FC = () => {
|
|
@@ -30,113 +53,205 @@ export const TimeDashboardClient: React.FC = () => {
|
|
|
30
53
|
const fetchEntries = useCallback(async () => {
|
|
31
54
|
setLoading(true)
|
|
32
55
|
try {
|
|
33
|
-
const params = new URLSearchParams({
|
|
56
|
+
const params = new URLSearchParams({
|
|
57
|
+
limit: '500',
|
|
58
|
+
depth: '2',
|
|
59
|
+
sort: '-date',
|
|
60
|
+
})
|
|
34
61
|
if (from) params.set('where[date][greater_than_equal]', from)
|
|
35
62
|
if (to) params.set('where[date][less_than_equal]', to)
|
|
63
|
+
|
|
36
64
|
const res = await fetch(`/api/time-entries?${params}`)
|
|
37
|
-
if (res.ok) {
|
|
65
|
+
if (res.ok) {
|
|
66
|
+
const json = await res.json()
|
|
67
|
+
setEntries(json.docs || [])
|
|
68
|
+
}
|
|
38
69
|
} catch { /* silent */ }
|
|
39
70
|
setLoading(false)
|
|
40
71
|
}, [from, to])
|
|
41
72
|
|
|
42
|
-
useEffect(() => {
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
fetchEntries()
|
|
75
|
+
}, [fetchEntries])
|
|
43
76
|
|
|
44
77
|
const totalMinutes = entries.reduce((sum, e) => sum + (e.duration || 0), 0)
|
|
78
|
+
const totalEntries = entries.length
|
|
45
79
|
|
|
80
|
+
// Group entries
|
|
46
81
|
const grouped: GroupedData[] = React.useMemo(() => {
|
|
47
82
|
const map = new Map<string, TimeEntry[]>()
|
|
83
|
+
|
|
48
84
|
for (const entry of entries) {
|
|
49
85
|
let key: string
|
|
50
|
-
if (groupBy === 'day') {
|
|
51
|
-
|
|
52
|
-
else
|
|
86
|
+
if (groupBy === 'day') {
|
|
87
|
+
key = entry.date ? entry.date.split('T')[0] : 'Sans date'
|
|
88
|
+
} else if (groupBy === 'week') {
|
|
89
|
+
const d = new Date(entry.date)
|
|
90
|
+
key = `Semaine ${getWeekNumber(d)} (${MONTHS_FR[d.getMonth()]} ${d.getFullYear()})`
|
|
91
|
+
} else {
|
|
92
|
+
const ticket = typeof entry.ticket === 'object' ? entry.ticket : null
|
|
93
|
+
const project = ticket && typeof ticket.project === 'object' ? ticket.project : null
|
|
94
|
+
key = project?.name || 'Sans projet'
|
|
95
|
+
}
|
|
96
|
+
|
|
53
97
|
if (!map.has(key)) map.set(key, [])
|
|
54
98
|
map.get(key)!.push(entry)
|
|
55
99
|
}
|
|
56
|
-
|
|
100
|
+
|
|
101
|
+
return Array.from(map.entries()).map(([label, items]) => ({
|
|
102
|
+
label,
|
|
103
|
+
entries: items,
|
|
104
|
+
totalMinutes: items.reduce((sum, e) => sum + (e.duration || 0), 0),
|
|
105
|
+
}))
|
|
57
106
|
}, [entries, groupBy])
|
|
58
107
|
|
|
108
|
+
// Daily chart data (last 30 days)
|
|
59
109
|
const dailyChart = React.useMemo(() => {
|
|
60
110
|
const dayMap = new Map<string, number>()
|
|
61
|
-
for (const entry of entries) {
|
|
62
|
-
|
|
111
|
+
for (const entry of entries) {
|
|
112
|
+
const day = entry.date ? entry.date.split('T')[0] : null
|
|
113
|
+
if (day) {
|
|
114
|
+
dayMap.set(day, (dayMap.get(day) || 0) + (entry.duration || 0))
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return Array.from(dayMap.entries())
|
|
118
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
119
|
+
.slice(-30)
|
|
120
|
+
.map(([day, mins]) => ({ day, minutes: mins }))
|
|
63
121
|
}, [entries])
|
|
64
122
|
|
|
65
123
|
const maxDailyMinutes = Math.max(...dailyChart.map((d) => d.minutes), 1)
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
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)' },
|
|
71
|
-
btnPrimary: { padding: '6px 12px', borderRadius: 6, border: 'none', fontSize: 12, cursor: 'pointer', background: '#2563eb', color: '#fff', fontWeight: 600 },
|
|
72
|
-
kpis: { display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 12, marginBottom: 20 },
|
|
73
|
-
kpiCard: { padding: '16px 20px', borderRadius: 10, border: '1px solid var(--theme-elevation-150)' },
|
|
74
|
-
groupCard: { marginBottom: 12, borderRadius: 10, border: '1px solid var(--theme-elevation-150)', overflow: 'hidden' },
|
|
75
|
-
groupHeader: { display: 'flex', justifyContent: 'space-between', padding: '10px 16px', background: 'var(--theme-elevation-50)', borderBottom: '1px solid var(--theme-elevation-150)' },
|
|
76
|
-
table: { width: '100%', borderCollapse: 'collapse' as const, fontSize: 12 },
|
|
77
|
-
td: { padding: '6px 8px', borderBottom: '1px solid var(--theme-elevation-100)' },
|
|
124
|
+
|
|
125
|
+
const setPeriod = (range: { from: string; to: string }) => {
|
|
126
|
+
setFrom(range.from)
|
|
127
|
+
setTo(range.to)
|
|
78
128
|
}
|
|
79
129
|
|
|
80
130
|
return (
|
|
81
|
-
<div
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
<
|
|
131
|
+
<div className={styles.page}>
|
|
132
|
+
{/* Header */}
|
|
133
|
+
<div className={styles.header}>
|
|
134
|
+
<div className={styles.headerLeft}>
|
|
135
|
+
<h1 className={styles.title}>{t('timeDashboard.title')}</h1>
|
|
136
|
+
<p className={styles.subtitle}>{t('timeDashboard.subtitle')}</p>
|
|
86
137
|
</div>
|
|
87
|
-
<a href="/admin/collections/time-entries/create"
|
|
138
|
+
<a href="/admin/collections/time-entries/create" className={styles.newEntryBtn}>
|
|
139
|
+
{t('timeDashboard.newEntry')}
|
|
140
|
+
</a>
|
|
88
141
|
</div>
|
|
89
142
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
<button
|
|
94
|
-
<button
|
|
143
|
+
{/* Filters */}
|
|
144
|
+
<div className={styles.filters}>
|
|
145
|
+
<div className={styles.quickPeriod}>
|
|
146
|
+
<button className={styles.btnPrimary} onClick={() => setPeriod(getMonthRange(0))}>{t('timeDashboard.filters.thisMonth')}</button>
|
|
147
|
+
<button className={styles.btnSecondary} onClick={() => setPeriod(getMonthRange(-1))}>{t('timeDashboard.filters.lastMonth')}</button>
|
|
148
|
+
<button className={styles.btnAmber} onClick={() => setPeriod(getMonthRange(-2))}>{t('timeDashboard.filters.twoMonthsAgo')}</button>
|
|
95
149
|
</div>
|
|
96
|
-
<div
|
|
97
|
-
<div
|
|
98
|
-
|
|
99
|
-
|
|
150
|
+
<div className={styles.filterRow}>
|
|
151
|
+
<div className={styles.fieldGroup}>
|
|
152
|
+
<label className={styles.label}>{t('timeDashboard.filters.from')}</label>
|
|
153
|
+
<input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className={styles.input} />
|
|
154
|
+
</div>
|
|
155
|
+
<div className={styles.fieldGroup}>
|
|
156
|
+
<label className={styles.label}>{t('timeDashboard.filters.to')}</label>
|
|
157
|
+
<input type="date" value={to} onChange={(e) => setTo(e.target.value)} className={styles.input} />
|
|
158
|
+
</div>
|
|
159
|
+
<div className={styles.fieldGroup}>
|
|
160
|
+
<label className={styles.label}>{t('timeDashboard.filters.groupBy')}</label>
|
|
161
|
+
<select value={groupBy} onChange={(e) => setGroupBy(e.target.value as 'day' | 'week' | 'project')} className={styles.select}>
|
|
162
|
+
<option value="day">{t('timeDashboard.filters.day')}</option>
|
|
163
|
+
<option value="week">{t('timeDashboard.filters.week')}</option>
|
|
164
|
+
<option value="project">{t('timeDashboard.filters.project')}</option>
|
|
165
|
+
</select>
|
|
166
|
+
</div>
|
|
100
167
|
</div>
|
|
101
168
|
</div>
|
|
102
169
|
|
|
103
|
-
{loading ?
|
|
170
|
+
{loading ? (
|
|
171
|
+
<div className={styles.loading}>
|
|
172
|
+
<SkeletonDashboard />
|
|
173
|
+
</div>
|
|
174
|
+
) : (
|
|
104
175
|
<>
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
<div
|
|
108
|
-
|
|
176
|
+
{/* KPIs */}
|
|
177
|
+
<div className={styles.kpis}>
|
|
178
|
+
<div className={styles.kpiCardPrimary}>
|
|
179
|
+
<div className={styles.kpiLabel}>{t('timeDashboard.kpis.totalTime')}</div>
|
|
180
|
+
<div className={styles.kpiPrimary}>{formatDuration(totalMinutes)}</div>
|
|
181
|
+
</div>
|
|
182
|
+
<div className={styles.kpiCardAmber}>
|
|
183
|
+
<div className={styles.kpiLabel}>{t('timeDashboard.kpis.entries')}</div>
|
|
184
|
+
<div className={styles.kpiAmber}>{totalEntries}</div>
|
|
185
|
+
</div>
|
|
186
|
+
<div className={styles.kpiCardOrange}>
|
|
187
|
+
<div className={styles.kpiLabel}>{t('timeDashboard.kpis.dailyAverage')}</div>
|
|
188
|
+
<div className={styles.kpiOrange}>
|
|
189
|
+
{dailyChart.length > 0 ? formatDuration(Math.round(totalMinutes / dailyChart.length)) : '-'}
|
|
190
|
+
</div>
|
|
191
|
+
</div>
|
|
109
192
|
</div>
|
|
110
193
|
|
|
194
|
+
{/* Chart */}
|
|
111
195
|
{dailyChart.length > 0 && (
|
|
112
|
-
<div
|
|
113
|
-
<div
|
|
114
|
-
|
|
115
|
-
|
|
196
|
+
<div className={styles.chartWrap}>
|
|
197
|
+
<div className={styles.chartHeader}>
|
|
198
|
+
{t('timeDashboard.chart.title')}
|
|
199
|
+
</div>
|
|
200
|
+
<div className={styles.chartBars}>
|
|
201
|
+
{dailyChart.map((d) => (
|
|
202
|
+
<div
|
|
203
|
+
key={d.day}
|
|
204
|
+
className={styles.chartBar}
|
|
205
|
+
style={{ height: `${Math.max((d.minutes / maxDailyMinutes) * 100, 4)}%` }}
|
|
206
|
+
title={`${d.day}: ${formatDuration(d.minutes)}`}
|
|
207
|
+
/>
|
|
208
|
+
))}
|
|
116
209
|
</div>
|
|
117
210
|
</div>
|
|
118
211
|
)}
|
|
119
212
|
|
|
120
|
-
{
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
<tbody>
|
|
125
|
-
{group.entries.map((entry) => {
|
|
126
|
-
const ticket = typeof entry.ticket === 'object' ? entry.ticket : null
|
|
127
|
-
return (
|
|
128
|
-
<tr key={entry.id}>
|
|
129
|
-
<td style={S.td}>{ticket ? <a href={`/admin/collections/tickets/${ticket.id}`} style={{ color: '#2563eb', textDecoration: 'none', fontWeight: 600 }}>{ticket.ticketNumber || `#${ticket.id}`}</a> : '-'}</td>
|
|
130
|
-
<td style={S.td}>{ticket?.subject || ''}</td>
|
|
131
|
-
<td style={{ ...S.td, color: 'var(--theme-elevation-500)' }}>{entry.description || '-'}</td>
|
|
132
|
-
<td style={{ ...S.td, textAlign: 'right', fontWeight: 600 }}>{formatDuration(entry.duration)}</td>
|
|
133
|
-
</tr>
|
|
134
|
-
)
|
|
135
|
-
})}
|
|
136
|
-
</tbody>
|
|
137
|
-
</table>
|
|
213
|
+
{/* Grouped entries */}
|
|
214
|
+
{grouped.length === 0 ? (
|
|
215
|
+
<div className={styles.empty}>
|
|
216
|
+
{t('timeDashboard.empty')}
|
|
138
217
|
</div>
|
|
139
|
-
)
|
|
218
|
+
) : (
|
|
219
|
+
grouped.map((group) => (
|
|
220
|
+
<div key={group.label} className={styles.groupCard}>
|
|
221
|
+
<div className={styles.groupHeader}>
|
|
222
|
+
<span className={styles.groupLabel}>{group.label}</span>
|
|
223
|
+
<span className={styles.groupTotal}>{formatDuration(group.totalMinutes)}</span>
|
|
224
|
+
</div>
|
|
225
|
+
<table className={styles.table}>
|
|
226
|
+
<tbody>
|
|
227
|
+
{group.entries.map((entry) => {
|
|
228
|
+
const ticket = typeof entry.ticket === 'object' ? entry.ticket : null
|
|
229
|
+
return (
|
|
230
|
+
<tr key={entry.id} className={styles.entryRow}>
|
|
231
|
+
<td className={styles.tdTicket}>
|
|
232
|
+
{ticket ? (
|
|
233
|
+
<a href={`/admin/collections/tickets/${ticket.id}`} className={styles.ticketLink}>
|
|
234
|
+
{ticket.ticketNumber || `#${ticket.id}`}
|
|
235
|
+
</a>
|
|
236
|
+
) : '-'}
|
|
237
|
+
</td>
|
|
238
|
+
<td className={styles.tdSubject}>
|
|
239
|
+
{ticket?.subject || ''}
|
|
240
|
+
</td>
|
|
241
|
+
<td className={styles.tdDescription}>
|
|
242
|
+
{entry.description || '-'}
|
|
243
|
+
</td>
|
|
244
|
+
<td className={styles.tdDuration}>
|
|
245
|
+
{formatDuration(entry.duration)}
|
|
246
|
+
</td>
|
|
247
|
+
</tr>
|
|
248
|
+
)
|
|
249
|
+
})}
|
|
250
|
+
</tbody>
|
|
251
|
+
</table>
|
|
252
|
+
</div>
|
|
253
|
+
))
|
|
254
|
+
)}
|
|
140
255
|
</>
|
|
141
256
|
)}
|
|
142
257
|
</div>
|