@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.
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 +6 -2
  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
@@ -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 60s
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 style={S.page}>
192
+ <div className={s.page}>
208
193
  {/* Header */}
209
- <div style={S.header}>
210
- <h1 style={S.title}>{t('inbox.title')}</h1>
211
- <div style={S.headerRight}>
212
- <div style={S.searchWrap}>
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
- style={S.searchInput}
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}>&#8984;K</span>
220
207
  </div>
221
- <Link href="/admin/support/new-ticket" style={S.newTicketBtn}>{t('inbox.newTicketBtn')}</Link>
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 style={S.tabsRow}>
227
- {tabs.map((tb) => (
213
+ <div className={s.tabs}>
214
+ {tabs.map((tk) => (
228
215
  <button
229
- key={tb.key}
230
- style={{ ...S.tab, ...(tab === tb.key ? S.tabActive : {}) }}
231
- onClick={() => { setTab(tb.key); setSelectedIdx(-1) }}
216
+ key={tk.key}
217
+ className={`${s.tab} ${tab === tk.key ? s.tabActive : ''}`}
218
+ onClick={() => { setTab(tk.key); setSelectedIdx(-1) }}
232
219
  >
233
- {tb.label} <span style={{ opacity: 0.6 }}>({tb.count})</span>
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 style={S.sortRow}>
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 style={S.sortSelect} onClick={() => handleBulkAction('close')} disabled={bulkProcessing}>{t('inbox.closeAction')}</button>
244
- <button style={S.sortSelect} onClick={() => handleBulkAction('reopen')} disabled={bulkProcessing}>{t('inbox.reopenAction')}</button>
245
- <select style={S.sortSelect} value={bulkAction} onChange={(e) => { if (e.target.value) handleBulkAction(e.target.value); setBulkAction('') }}>
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 style={{ ...S.sortSelect, marginLeft: 'auto' }} onClick={() => setCheckedIds(new Set())}>{t('inbox.deselect')}</button>
238
+ <button className={s.sortSelect} onClick={() => setCheckedIds(new Set())} style={{ marginLeft: 'auto' }}>{t('inbox.deselect')}</button>
251
239
  </div>
252
240
  ) : (
253
- <select style={S.sortSelect} value={sort} onChange={(e) => setSort(e.target.value)}>
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 style={S.loading}>{t('common.loading')}</div>
252
+ <div className={s.loading}>{t('common.loading')}</div>
265
253
  ) : tickets.length === 0 ? (
266
- <div style={S.empty}>{t('inbox.empty')}</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 style={S.list}>
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
- style={{ ...S.row, ...(idx === selectedIdx ? { background: 'var(--theme-elevation-100)' } : {}) }}
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={{ ...S.statusDot, backgroundColor: STATUS_DOTS[tk.status] || '#94a3b8' }} />
292
- <span style={{ fontSize: 11, color: 'var(--theme-elevation-500)' }}>{STATUS_LABEL_KEYS[tk.status] ? t(STATUS_LABEL_KEYS[tk.status]) : tk.status}</span>
293
- <span style={{ fontWeight: 600, fontSize: 12, color: 'var(--theme-elevation-400)' }}>{tk.ticketNumber}</span>
294
- <span style={{ fontWeight: 500 }}>{tk.subject}</span>
295
- <span style={{ fontSize: 12, color: 'var(--theme-elevation-500)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{displayClient}</span>
296
- {tk.category ? <span style={{ fontSize: 11, color: 'var(--theme-elevation-400)' }}>[{CATEGORY_LABEL_KEYS[tk.category] ? t(CATEGORY_LABEL_KEYS[tk.category]) : tk.category}]</span> : <span />}
297
- <div style={{ ...S.priorityBar, backgroundColor: priorityColor }} />
298
- <span style={{ fontSize: 11, color: 'var(--theme-elevation-400)', textAlign: 'right' }}>{relativeTime(tk.updatedAt)}</span>
299
- {isUnread ? <div style={S.unreadDot} /> : <span />}
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 style={S.keyboardHints}>
308
- <span><kbd>&#8593;</kbd><kbd>&#8595;</kbd> {t('inbox.keyboardNavigate')}</span>
309
- <span><kbd>&#8629;</kbd> {t('inbox.keyboardOpen')}</span>
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 ts from './TicketingSettings.module.scss'
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="Configuration du module Support"
383
- subtitle={`${enabledCount}/${totalCount} fonctionnalites activees`}
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
- Reinitialiser
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 ? '\u2713 Sauvegarde' : 'Sauvegarder'}
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
- Configurez le module de support : fonctionnalités, email, IA, SLA et fermeture automatique.
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="Fonctionnalités"
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
- Activez ou desactivez les fonctionnalites du module. Les fonctionnalites desactivees sont completement masquees de l&apos;interface.
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 s from '../../styles/TimeDashboard.module.scss'
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 { label: string; entries: TimeEntry[]; totalMinutes: number }
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({ limit: '500', depth: '2', sort: '-date' })
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) { const json = await res.json(); setEntries(json.docs || []) }
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(() => { fetchEntries() }, [fetchEntries])
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') { key = entry.date ? entry.date.split('T')[0] : 'Sans date' }
51
- else if (groupBy === 'week') { const d = new Date(entry.date); key = `Semaine ${getWeekNumber(d)} (${MONTHS_FR[d.getMonth()]} ${d.getFullYear()})` }
52
- else { const ticket = typeof entry.ticket === 'object' ? entry.ticket : null; const project = ticket && typeof ticket.project === 'object' ? ticket.project : null; key = project?.name || 'Sans projet' }
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
- return Array.from(map.entries()).map(([label, items]) => ({ label, entries: items, totalMinutes: items.reduce((sum, e) => sum + (e.duration || 0), 0) }))
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) { const day = entry.date ? entry.date.split('T')[0] : null; if (day) dayMap.set(day, (dayMap.get(day) || 0) + (entry.duration || 0)) }
62
- return Array.from(dayMap.entries()).sort(([a], [b]) => a.localeCompare(b)).slice(-30).map(([day, mins]) => ({ day, minutes: mins }))
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
- const setPeriod = (range: { from: string; to: string }) => { setFrom(range.from); setTo(range.to) }
67
-
68
- const S: Record<string, React.CSSProperties> = {
69
- page: { padding: '20px 30px', maxWidth: 1100, margin: '0 auto' },
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 style={S.page}>
82
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
83
- <div>
84
- <h1 style={{ fontSize: 22, fontWeight: 700, margin: 0 }}>{t('timeDashboard.title')}</h1>
85
- <p style={{ fontSize: 13, color: 'var(--theme-elevation-500)', margin: '4px 0 0' }}>{t('timeDashboard.subtitle')}</p>
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" style={{ ...S.btnPrimary, textDecoration: 'none', display: 'inline-block' }}>{t('timeDashboard.newEntry')}</a>
138
+ <a href="/admin/collections/time-entries/create" className={styles.newEntryBtn}>
139
+ {t('timeDashboard.newEntry')}
140
+ </a>
88
141
  </div>
89
142
 
90
- <div style={{ marginBottom: 16 }}>
91
- <div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
92
- <button style={S.btnPrimary} onClick={() => setPeriod(getMonthRange(0))}>{t('timeDashboard.filters.thisMonth')}</button>
93
- <button style={S.btn} onClick={() => setPeriod(getMonthRange(-1))}>{t('timeDashboard.filters.lastMonth')}</button>
94
- <button style={S.btn} onClick={() => setPeriod(getMonthRange(-2))}>{t('timeDashboard.filters.twoMonthsAgo')}</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 style={{ display: 'flex', gap: 12, alignItems: 'flex-end' }}>
97
- <div><label style={{ fontSize: 11, fontWeight: 600, display: 'block', marginBottom: 4, color: 'var(--theme-elevation-500)' }}>{t('timeDashboard.filters.from')}</label><input type="date" value={from} onChange={(e) => setFrom(e.target.value)} style={{ padding: '6px 10px', borderRadius: 6, border: '1px solid var(--theme-elevation-200)', fontSize: 12, color: 'var(--theme-text)', background: 'var(--theme-elevation-0)' }} /></div>
98
- <div><label style={{ fontSize: 11, fontWeight: 600, display: 'block', marginBottom: 4, color: 'var(--theme-elevation-500)' }}>{t('timeDashboard.filters.to')}</label><input type="date" value={to} onChange={(e) => setTo(e.target.value)} style={{ padding: '6px 10px', borderRadius: 6, border: '1px solid var(--theme-elevation-200)', fontSize: 12, color: 'var(--theme-text)', background: 'var(--theme-elevation-0)' }} /></div>
99
- <div><label style={{ fontSize: 11, fontWeight: 600, display: 'block', marginBottom: 4, color: 'var(--theme-elevation-500)' }}>{t('timeDashboard.filters.groupBy')}</label><select value={groupBy} onChange={(e) => setGroupBy(e.target.value as 'day' | 'week' | 'project')} style={{ padding: '6px 10px', borderRadius: 6, border: '1px solid var(--theme-elevation-200)', fontSize: 12, color: 'var(--theme-text)', background: 'var(--theme-elevation-0)' }}><option value="day">{t('timeDashboard.filters.day')}</option><option value="week">{t('timeDashboard.filters.week')}</option><option value="project">{t('timeDashboard.filters.project')}</option></select></div>
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 ? <div style={{ padding: 40, textAlign: 'center', color: '#94a3b8' }}>{t('common.loading')}</div> : (
170
+ {loading ? (
171
+ <div className={styles.loading}>
172
+ <SkeletonDashboard />
173
+ </div>
174
+ ) : (
104
175
  <>
105
- <div style={S.kpis}>
106
- <div style={S.kpiCard}><div style={{ fontSize: 11, color: 'var(--theme-elevation-500)' }}>{t('timeDashboard.kpis.totalTime')}</div><div style={{ fontSize: 24, fontWeight: 700, color: '#2563eb' }}>{formatDuration(totalMinutes)}</div></div>
107
- <div style={S.kpiCard}><div style={{ fontSize: 11, color: 'var(--theme-elevation-500)' }}>{t('timeDashboard.kpis.entries')}</div><div style={{ fontSize: 24, fontWeight: 700, color: '#d97706' }}>{entries.length}</div></div>
108
- <div style={S.kpiCard}><div style={{ fontSize: 11, color: 'var(--theme-elevation-500)' }}>{t('timeDashboard.kpis.dailyAverage')}</div><div style={{ fontSize: 24, fontWeight: 700, color: '#ea580c' }}>{dailyChart.length > 0 ? formatDuration(Math.round(totalMinutes / dailyChart.length)) : '-'}</div></div>
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 style={{ padding: 16, borderRadius: 10, border: '1px solid var(--theme-elevation-150)', marginBottom: 20 }}>
113
- <div style={{ fontSize: 13, fontWeight: 700, marginBottom: 8 }}>{t('timeDashboard.chart.title')}</div>
114
- <div style={{ display: 'flex', alignItems: 'flex-end', gap: 3, height: 80 }}>
115
- {dailyChart.map((d) => <div key={d.day} style={{ flex: 1, background: '#3b82f6', borderRadius: '3px 3px 0 0', height: `${Math.max((d.minutes / maxDailyMinutes) * 100, 4)}%` }} title={`${d.day}: ${formatDuration(d.minutes)}`} />)}
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
- {grouped.length === 0 ? <div style={{ padding: 40, textAlign: 'center', color: '#94a3b8' }}>{t('timeDashboard.empty')}</div> : grouped.map((group) => (
121
- <div key={group.label} style={S.groupCard}>
122
- <div style={S.groupHeader}><span style={{ fontWeight: 700 }}>{group.label}</span><span style={{ fontWeight: 700, color: '#2563eb' }}>{formatDuration(group.totalMinutes)}</span></div>
123
- <table style={S.table}>
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>