@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
@@ -3,9 +3,9 @@
3
3
  import React, { useState, useEffect, useCallback, useMemo } from 'react'
4
4
  import Link from 'next/link'
5
5
  import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
6
- import s from '../../styles/SupportDashboard.module.scss'
6
+ import styles from '../../styles/SupportDashboard.module.scss'
7
7
 
8
- // ---- Types ----
8
+ // ─── Types ──────────────────────────────────────────────────
9
9
 
10
10
  interface Stats {
11
11
  total: number
@@ -51,7 +51,7 @@ interface ActiveTicket {
51
51
  updatedAt: string
52
52
  }
53
53
 
54
- // ---- Helpers ----
54
+ // ─── Helpers ────────────────────────────────────────────────
55
55
 
56
56
  function formatResponseTime(hours: number | null): string {
57
57
  if (hours == null) return '--'
@@ -74,6 +74,7 @@ function timeAgo(dateStr: string): string {
74
74
  return `${Math.floor(days / 7)}sem`
75
75
  }
76
76
 
77
+ /** Compute trend percentage from two values */
77
78
  function computeTrend(current: number, previous: number): { pct: number; dir: 'up' | 'down' | 'neutral' } {
78
79
  if (previous === 0 && current === 0) return { pct: 0, dir: 'neutral' }
79
80
  if (previous === 0) return { pct: 100, dir: 'up' }
@@ -82,30 +83,106 @@ function computeTrend(current: number, previous: number): { pct: number; dir: 'u
82
83
  return { pct: Math.abs(pct), dir: pct > 0 ? 'up' : 'down' }
83
84
  }
84
85
 
85
- // ---- Sub-components ----
86
+ // ─── Sub-components ─────────────────────────────────────────
86
87
 
87
- function StatCard({ label, value, trend, accentColor }: {
88
+ function StatCard({ label, value, trend, accentColor, onClick }: {
88
89
  label: string
89
90
  value: string
90
91
  trend?: { pct: number; dir: 'up' | 'down' | 'neutral' }
91
92
  accentColor?: string
93
+ onClick?: () => void
92
94
  }) {
95
+ const style = accentColor ? { '--stat-accent': accentColor } as React.CSSProperties : undefined
96
+ const Tag = onClick ? 'button' : 'div'
97
+
93
98
  return (
94
- <div style={{ padding: '16px 20px', borderRadius: 10, border: '1px solid var(--theme-elevation-150)', background: 'var(--theme-elevation-0)', borderLeft: `3px solid ${accentColor || '#2563eb'}` }}>
95
- <div style={{ fontSize: 12, color: 'var(--theme-elevation-500)', marginBottom: 4 }}>{label}</div>
96
- <div style={{ fontSize: 24, fontWeight: 700, color: 'var(--theme-text)' }}>{value}</div>
99
+ <Tag
100
+ className={styles.statCard}
101
+ style={style}
102
+ onClick={onClick}
103
+ type={onClick ? 'button' : undefined}
104
+ >
105
+ <div className={styles.statLabel}>{label}</div>
106
+ <div className={styles.statValueRow}>
107
+ <span className={styles.statValue}>{value}</span>
108
+ </div>
97
109
  {trend && trend.dir !== 'neutral' && (
98
- <div style={{ fontSize: 11, color: trend.dir === 'up' ? '#dc2626' : '#16a34a', marginTop: 4 }}>
99
- {trend.dir === 'up' ? '↑' : '↓'} {trend.pct}%
110
+ <div className={`${styles.statTrend} ${styles[trend.dir]}`}>
111
+ <span className={styles.trendArrow}>{trend.dir === 'up' ? '↑' : '↓'}</span>
112
+ {trend.pct}%
100
113
  </div>
101
114
  )}
102
115
  {trend && trend.dir === 'neutral' && (
103
- <div style={{ fontSize: 11, color: '#6b7280', marginTop: 4 }}>-- stable</div>
116
+ <div className={`${styles.statTrend} ${styles.neutral}`}>
117
+ — stable
118
+ </div>
104
119
  )}
120
+ </Tag>
121
+ )
122
+ }
123
+
124
+ function VolumeChart({ data }: { data: { label: string; value: number }[] }) {
125
+ const max = Math.max(...data.map(d => d.value), 1)
126
+ return (
127
+ <div>
128
+ <div className={styles.volumeChart}>
129
+ {data.map((d, i) => (
130
+ <div
131
+ key={i}
132
+ className={styles.volumeBar}
133
+ style={{ height: `${Math.max((d.value / max) * 100, 5)}%` }}
134
+ title={`${d.label}: ${d.value}`}
135
+ />
136
+ ))}
137
+ </div>
138
+ <div className={styles.volumeLabels}>
139
+ <span>{data[0]?.label}</span>
140
+ <span>{data[data.length - 1]?.label}</span>
141
+ </div>
142
+ </div>
143
+ )
144
+ }
145
+
146
+ function CSATRing({ score, count }: { score: number; count: number }) {
147
+ // Ring progress: score is out of 5
148
+ const pct = score > 0 ? (score / 5) * 100 : 0
149
+ const radius = 42
150
+ const circumference = 2 * Math.PI * radius
151
+ const strokeDashoffset = circumference - (pct / 100) * circumference
152
+ const color = score >= 4 ? '#22c55e' : score >= 3 ? '#f59e0b' : score > 0 ? '#ef4444' : '#94a3b8'
153
+
154
+ return (
155
+ <div className={styles.csatContainer}>
156
+ <div className={styles.csatRing}>
157
+ <svg width="100" height="100" viewBox="0 0 100 100">
158
+ <circle cx="50" cy="50" r={radius} fill="none" stroke="var(--theme-elevation-150, #e2e8f0)" strokeWidth="6" />
159
+ <circle
160
+ cx="50" cy="50" r={radius} fill="none"
161
+ stroke={color} strokeWidth="6" strokeLinecap="round"
162
+ strokeDasharray={circumference}
163
+ strokeDashoffset={strokeDashoffset}
164
+ transform="rotate(-90 50 50)"
165
+ style={{ transition: 'stroke-dashoffset 600ms ease' }}
166
+ />
167
+ </svg>
168
+ <div className={styles.csatValue}>
169
+ {score > 0 ? score.toFixed(1) : '--'}
170
+ </div>
171
+ </div>
172
+ <div className={styles.csatMeta}>
173
+ <div className={styles.csatLabel}>
174
+ {score > 0 ? `${score.toFixed(1)} / 5` : 'Pas de données'}
175
+ </div>
176
+ <div className={styles.csatSub}>
177
+ {count > 0 ? `${count} avis recueillis` : 'Aucun avis'}
178
+ </div>
179
+ </div>
105
180
  </div>
106
181
  )
107
182
  }
108
183
 
184
+ // ─── SLA Section ────────────────────────────────────────────
185
+
109
186
  function formatSlaTime(minutes: number): string {
110
187
  const absMin = Math.abs(minutes)
111
188
  if (absMin < 60) return `${Math.round(absMin)}min`
@@ -118,7 +195,6 @@ function formatSlaTime(minutes: number): string {
118
195
  }
119
196
 
120
197
  function SlaSection() {
121
- const { t } = useTranslation()
122
198
  const [sla, setSla] = useState<SlaData | null>(null)
123
199
  const [slaLoading, setSlaLoading] = useState(true)
124
200
 
@@ -126,7 +202,9 @@ function SlaSection() {
126
202
  const fetchSla = async () => {
127
203
  try {
128
204
  const res = await fetch('/api/support/sla-check')
129
- if (res.ok) setSla(await res.json())
205
+ if (res.ok) {
206
+ setSla(await res.json())
207
+ }
130
208
  } catch { /* ignore */ }
131
209
  setSlaLoading(false)
132
210
  }
@@ -135,32 +213,62 @@ function SlaSection() {
135
213
  return () => clearInterval(interval)
136
214
  }, [])
137
215
 
138
- if (slaLoading) return <div style={{ padding: 16, color: '#94a3b8', fontSize: 13 }}>{t('sla.loading')}</div>
216
+ if (slaLoading) {
217
+ return (
218
+ <div className={styles.slaSection}>
219
+ <h2 className={styles.slaSectionTitle}>SLA</h2>
220
+ <div className={styles.slaLoading}>Chargement...</div>
221
+ </div>
222
+ )
223
+ }
224
+
139
225
  if (!sla) return null
140
226
 
227
+ const navigateToTicket = (id: string) => {
228
+ window.location.href = `/admin/support/ticket?id=${id}`
229
+ }
230
+
141
231
  return (
142
- <div style={{ marginTop: 24 }}>
143
- <h2 style={{ fontSize: 16, fontWeight: 700, marginBottom: 12, color: 'var(--theme-text)' }}>{t('sla.title')}</h2>
144
- <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 16 }}>
232
+ <div className={styles.slaSection}>
233
+ <h2 className={styles.slaSectionTitle}>SLA</h2>
234
+ <div className={styles.slaGrid}>
145
235
  {/* Breached */}
146
- <div style={{ padding: 16, borderRadius: 10, border: '1px solid #fecaca', background: '#fef2f2' }}>
147
- <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
148
- <h3 style={{ fontSize: 14, fontWeight: 700, color: '#dc2626', margin: 0 }}>{t('sla.breached')}</h3>
149
- <span style={{ padding: '2px 8px', borderRadius: 10, background: '#fecaca', color: '#dc2626', fontSize: 12, fontWeight: 700 }}>{sla.breached.length}</span>
236
+ <div className={`${styles.slaCard} ${styles.slaBreach}`}>
237
+ <div className={styles.slaCardHeader}>
238
+ <h3 className={`${styles.slaCardTitle} ${styles.slaCardTitleBreach}`}>
239
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
240
+ <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
241
+ <line x1="12" y1="9" x2="12" y2="13" />
242
+ <line x1="12" y1="17" x2="12.01" y2="17" />
243
+ </svg>
244
+ En breach
245
+ </h3>
246
+ <span className={`${styles.slaCount} ${styles.slaCountBreach}`}>
247
+ {sla.breached.length}
248
+ </span>
150
249
  </div>
151
250
  {sla.breached.length === 0 ? (
152
- <div style={{ fontSize: 13, color: '#6b7280' }}>{t('sla.noBreached')}</div>
251
+ <div className={styles.slaEmpty}>Aucun ticket en breach</div>
153
252
  ) : (
154
- <ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
253
+ <ul className={styles.slaList}>
155
254
  {sla.breached.map((ticket) => {
156
- const overdueMin = Math.round((Date.now() - new Date(ticket.createdAt).getTime()) / 60000)
255
+ // Compute overdue time from createdAt (approximate)
256
+ const now = new Date()
257
+ const created = new Date(ticket.createdAt)
258
+ const overdueMin = Math.round((now.getTime() - created.getTime()) / 60000)
157
259
  return (
158
- <li key={ticket.id} style={{ padding: '6px 0', borderBottom: '1px solid #fecaca', cursor: 'pointer', display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }} onClick={() => { window.location.href = `/admin/support/ticket?id=${ticket.id}` }}>
159
- <div>
160
- <span style={{ fontWeight: 600 }}>#{ticket.ticketNumber}</span>{' '}
161
- <span style={{ color: '#6b7280' }}>{ticket.subject}</span>
260
+ <li
261
+ key={ticket.id}
262
+ className={styles.slaItem}
263
+ onClick={() => navigateToTicket(ticket.id)}
264
+ >
265
+ <div className={styles.slaItemLeft}>
266
+ <span className={styles.slaItemNum}>#{ticket.ticketNumber}</span>
267
+ <span className={styles.slaItemSubject}>{ticket.subject}</span>
162
268
  </div>
163
- <span style={{ color: '#dc2626', fontWeight: 600, fontSize: 12 }}>+{formatSlaTime(overdueMin)}</span>
269
+ <span className={`${styles.slaItemTime} ${styles.slaTimeBreach}`}>
270
+ +{formatSlaTime(overdueMin)}
271
+ </span>
164
272
  </li>
165
273
  )
166
274
  })}
@@ -169,26 +277,44 @@ function SlaSection() {
169
277
  </div>
170
278
 
171
279
  {/* At Risk */}
172
- <div style={{ padding: 16, borderRadius: 10, border: '1px solid #fde68a', background: '#fefce8' }}>
173
- <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
174
- <h3 style={{ fontSize: 14, fontWeight: 700, color: '#d97706', margin: 0 }}>{t('sla.atRisk')}</h3>
175
- <span style={{ padding: '2px 8px', borderRadius: 10, background: '#fde68a', color: '#d97706', fontSize: 12, fontWeight: 700 }}>{sla.atRisk.length}</span>
280
+ <div className={`${styles.slaCard} ${styles.slaRisk}`}>
281
+ <div className={styles.slaCardHeader}>
282
+ <h3 className={`${styles.slaCardTitle} ${styles.slaCardTitleRisk}`}>
283
+ <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
284
+ <circle cx="12" cy="12" r="10" />
285
+ <polyline points="12 6 12 12 16 14" />
286
+ </svg>
287
+ À risque
288
+ </h3>
289
+ <span className={`${styles.slaCount} ${styles.slaCountRisk}`}>
290
+ {sla.atRisk.length}
291
+ </span>
176
292
  </div>
177
293
  {sla.atRisk.length === 0 ? (
178
- <div style={{ fontSize: 13, color: '#6b7280' }}>{t('sla.noAtRisk')}</div>
294
+ <div className={styles.slaEmpty}>Aucun ticket à risque</div>
179
295
  ) : (
180
- <ul style={{ listStyle: 'none', margin: 0, padding: 0 }}>
296
+ <ul className={styles.slaList}>
181
297
  {sla.atRisk.map((ticket) => {
182
- const elapsedMin = Math.round((Date.now() - new Date(ticket.createdAt).getTime()) / 60000)
298
+ // Approximate remaining time (from created, not exact SLA deadline)
299
+ const now = new Date()
300
+ const created = new Date(ticket.createdAt)
301
+ const elapsedMin = Math.round((now.getTime() - created.getTime()) / 60000)
302
+ // At risk means ~80%+ elapsed, so remaining is roughly 20% of elapsed/0.8
183
303
  const estimatedTotalMin = Math.round(elapsedMin / 0.8)
184
304
  const remainingMin = Math.max(estimatedTotalMin - elapsedMin, 0)
185
305
  return (
186
- <li key={ticket.id} style={{ padding: '6px 0', borderBottom: '1px solid #fde68a', cursor: 'pointer', display: 'flex', justifyContent: 'space-between', alignItems: 'center', fontSize: 13 }} onClick={() => { window.location.href = `/admin/support/ticket?id=${ticket.id}` }}>
187
- <div>
188
- <span style={{ fontWeight: 600 }}>#{ticket.ticketNumber}</span>{' '}
189
- <span style={{ color: '#6b7280' }}>{ticket.subject}</span>
306
+ <li
307
+ key={ticket.id}
308
+ className={styles.slaItem}
309
+ onClick={() => navigateToTicket(ticket.id)}
310
+ >
311
+ <div className={styles.slaItemLeft}>
312
+ <span className={styles.slaItemNum}>#{ticket.ticketNumber}</span>
313
+ <span className={styles.slaItemSubject}>{ticket.subject}</span>
190
314
  </div>
191
- <span style={{ color: '#d97706', fontWeight: 600, fontSize: 12 }}>{t('sla.remaining', { time: formatSlaTime(remainingMin) })}</span>
315
+ <span className={`${styles.slaItemTime} ${styles.slaTimeRisk}`}>
316
+ {formatSlaTime(remainingMin)} restant
317
+ </span>
192
318
  </li>
193
319
  )
194
320
  })}
@@ -200,7 +326,7 @@ function SlaSection() {
200
326
  )
201
327
  }
202
328
 
203
- // ---- Main Dashboard ----
329
+ // ─── Main Dashboard ─────────────────────────────────────────
204
330
 
205
331
  export const SupportDashboardClient: React.FC = () => {
206
332
  const { t } = useTranslation()
@@ -238,6 +364,7 @@ export const SupportDashboardClient: React.FC = () => {
238
364
  return () => clearInterval(interval)
239
365
  }, [fetchData, sessionExpired])
240
366
 
367
+ // Refresh on window focus
241
368
  useEffect(() => {
242
369
  if (sessionExpired) return
243
370
  const onFocus = () => fetchData()
@@ -245,43 +372,62 @@ export const SupportDashboardClient: React.FC = () => {
245
372
  return () => window.removeEventListener('focus', onFocus)
246
373
  }, [fetchData, sessionExpired])
247
374
 
375
+ // Generate fake daily volume from available stats (7 synthetic bars)
248
376
  const volumeData = useMemo(() => {
249
377
  if (!stats) return []
250
378
  const avg = stats.createdLast7Days
251
- const days = [t('dashboard.weekDays.mon'), t('dashboard.weekDays.tue'), t('dashboard.weekDays.wed'), t('dashboard.weekDays.thu'), t('dashboard.weekDays.fri'), t('dashboard.weekDays.sat'), t('dashboard.weekDays.sun')]
379
+ const days = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim']
380
+ // Distribute the 7-day total across days with some variation
252
381
  const base = Math.max(Math.floor(avg / 7), 0)
253
382
  return days.map((label, i) => ({
254
383
  label,
384
+ // Simple deterministic variation based on index
255
385
  value: Math.max(base + ((i * 3 + 1) % 5) - 2, 0),
256
386
  }))
257
- }, [stats, t])
387
+ }, [stats])
258
388
 
389
+ // ── Loading state ──
259
390
  if (loading) {
260
391
  return (
261
- <div style={{ padding: '20px 30px', maxWidth: 1100, margin: '0 auto' }}>
262
- <h1 style={{ fontSize: 24, fontWeight: 700 }}>{t('dashboard.title')}</h1>
263
- <p style={{ color: '#94a3b8' }}>{t('dashboard.loadingMetrics')}</p>
264
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginTop: 20 }}>
265
- {[0, 1, 2, 3].map(i => <div key={i} style={{ height: 80, borderRadius: 10, background: '#f1f5f9' }} />)}
392
+ <div className={styles.page}>
393
+ <div className={styles.header}>
394
+ <h1 className={styles.title}>{t('dashboard.title')}</h1>
395
+ <p className={styles.subtitle}>{t('dashboard.loadingMetrics')}</p>
396
+ </div>
397
+ <div className={styles.statsRow}>
398
+ {[0, 1, 2, 3].map(i => <div key={i} className={styles.skeletonCard} />)}
399
+ </div>
400
+ <div className={styles.middleGrid}>
401
+ <div className={styles.skeletonTable} />
402
+ <div className={styles.rightColumn}>
403
+ <div className={styles.skeletonChart} />
404
+ <div className={styles.skeletonChart} />
405
+ </div>
266
406
  </div>
267
407
  </div>
268
408
  )
269
409
  }
270
410
 
411
+ // ── Error / session expired ──
271
412
  if (!stats) {
272
413
  return (
273
- <div style={{ padding: '20px 30px', maxWidth: 1100, margin: '0 auto' }}>
274
- <div style={{ padding: 40, textAlign: 'center', color: '#dc2626' }}>
414
+ <div className={styles.page}>
415
+ <div className={styles.errorState}>
275
416
  <strong>{t('dashboard.loadError')}</strong>
276
- <p>{sessionExpired ? t('common.sessionExpired') : t('dashboard.cannotLoadStats')}</p>
417
+ {sessionExpired
418
+ ? t('common.sessionExpired')
419
+ : t('dashboard.cannotLoadStats')}
277
420
  </div>
278
421
  </div>
279
422
  )
280
423
  }
281
424
 
425
+ // ── Computed values ──
282
426
  const openCount = stats.byStatus.open || 0
283
427
  const waitingCount = stats.byStatus.waiting_client || 0
284
428
  const trendOpen = computeTrend(stats.createdLast7Days, Math.round(stats.createdLast30Days / 4))
429
+
430
+ // For "waiting client" trend, approximate: if waiting > 30% of open, trending up
285
431
  const waitingTrend: { pct: number; dir: 'up' | 'down' | 'neutral' } =
286
432
  waitingCount === 0
287
433
  ? { pct: 0, dir: 'neutral' }
@@ -295,67 +441,88 @@ export const SupportDashboardClient: React.FC = () => {
295
441
  return ticket.client.company || ticket.client.firstName || '--'
296
442
  }
297
443
 
298
- const statusDotColor = (status: string) => {
444
+ const getStatusDotClass = (status: string): string => {
299
445
  switch (status) {
300
- case 'open': return '#22c55e'
301
- case 'waiting_client': return '#eab308'
302
- case 'resolved': return '#94a3b8'
303
- default: return '#94a3b8'
446
+ case 'open': return styles.statusDotOpen
447
+ case 'waiting_client': return styles.statusDotWaiting
448
+ case 'resolved': return styles.statusDotResolved
449
+ default: return ''
304
450
  }
305
451
  }
306
452
 
307
- const maxVolume = Math.max(...volumeData.map(d => d.value), 1)
308
-
309
- // CSAT ring
310
- const csatScore = stats.satisfactionAvg
311
- const csatPct = csatScore > 0 ? (csatScore / 5) * 100 : 0
312
- const csatRadius = 42
313
- const csatCircumference = 2 * Math.PI * csatRadius
314
- const csatStrokeDashoffset = csatCircumference - (csatPct / 100) * csatCircumference
315
- const csatColor = csatScore >= 4 ? '#22c55e' : csatScore >= 3 ? '#f59e0b' : csatScore > 0 ? '#ef4444' : '#94a3b8'
316
-
317
453
  return (
318
- <div style={{ padding: '20px 30px', maxWidth: 1100, margin: '0 auto' }}>
319
- {/* Header */}
320
- <div style={{ marginBottom: 20 }}>
321
- <h1 style={{ fontSize: 24, fontWeight: 700, margin: 0, color: 'var(--theme-text)' }}>{t('dashboard.title')}</h1>
322
- <p style={{ color: 'var(--theme-elevation-500)', margin: '4px 0 0', fontSize: 14 }}>{t('dashboard.subtitle')}</p>
454
+ <div className={styles.page}>
455
+ {/* ── Header ── */}
456
+ <div className={styles.header}>
457
+ <h1 className={styles.title}>{t('dashboard.title')}</h1>
458
+ <p className={styles.subtitle}>{t('dashboard.subtitle')}</p>
323
459
  </div>
324
460
 
325
- {/* Stat Cards */}
326
- <div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 16, marginBottom: 24 }}>
327
- <StatCard label={t('dashboard.openTickets')} value={String(openCount)} trend={trendOpen} accentColor="#3b82f6" />
328
- <StatCard label={t('dashboard.waitingClient')} value={String(waitingCount)} trend={waitingTrend} accentColor="#f59e0b" />
329
- <StatCard label={t('dashboard.responseTime')} value={formatResponseTime(stats.avgResponseTimeHours)} accentColor={stats.avgResponseTimeHours != null && stats.avgResponseTimeHours > 24 ? '#ef4444' : '#22c55e'} />
330
- <StatCard label={t('dashboard.satisfaction')} value={stats.satisfactionAvg > 0 ? `${stats.satisfactionAvg}/5` : '--'} accentColor={stats.satisfactionAvg >= 4 ? '#22c55e' : stats.satisfactionAvg >= 3 ? '#f59e0b' : '#94a3b8'} />
461
+ {/* ── Stat Cards ── */}
462
+ <div className={styles.statsRow}>
463
+ <StatCard
464
+ label={t('dashboard.openTickets')}
465
+ value={String(openCount)}
466
+ trend={trendOpen}
467
+ accentColor="#3b82f6"
468
+ />
469
+ <StatCard
470
+ label={t('dashboard.waitingClient')}
471
+ value={String(waitingCount)}
472
+ trend={waitingTrend}
473
+ accentColor="#f59e0b"
474
+ />
475
+ <StatCard
476
+ label={t('dashboard.responseTime')}
477
+ value={formatResponseTime(stats.avgResponseTimeHours)}
478
+ accentColor={
479
+ stats.avgResponseTimeHours != null && stats.avgResponseTimeHours > 24
480
+ ? '#ef4444'
481
+ : '#22c55e'
482
+ }
483
+ />
484
+ <StatCard
485
+ label={t('dashboard.satisfaction')}
486
+ value={stats.satisfactionAvg > 0 ? `${stats.satisfactionAvg}/5` : '--'}
487
+ accentColor={
488
+ stats.satisfactionAvg >= 4 ? '#22c55e' : stats.satisfactionAvg >= 3 ? '#f59e0b' : '#94a3b8'
489
+ }
490
+ />
331
491
  </div>
332
492
 
333
- {/* Middle Grid */}
334
- <div style={{ display: 'grid', gridTemplateColumns: '1fr 320px', gap: 20, marginBottom: 24 }}>
493
+ {/* ── Middle Grid ── */}
494
+ <div className={styles.middleGrid}>
335
495
  {/* Left: Active Tickets */}
336
- <div style={{ padding: 16, borderRadius: 10, border: '1px solid var(--theme-elevation-150)' }}>
337
- <h2 style={{ fontSize: 15, fontWeight: 700, margin: '0 0 12px', color: 'var(--theme-text)' }}>{t('dashboard.activeTickets')}</h2>
496
+ <div className={styles.panel}>
497
+ <h2 className={styles.panelTitle}>{t('dashboard.activeTickets')}</h2>
338
498
  {tickets.length === 0 ? (
339
- <div style={{ padding: 20, textAlign: 'center', color: '#94a3b8' }}>{t('dashboard.noActiveTickets')}</div>
499
+ <div className={styles.emptyTable}>{t('dashboard.noActiveTickets')}</div>
340
500
  ) : (
341
- <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
501
+ <table className={styles.ticketTable}>
342
502
  <thead>
343
503
  <tr>
344
- <th style={{ textAlign: 'left', padding: '6px 8px', borderBottom: '1px solid var(--theme-elevation-200)', fontSize: 11, color: 'var(--theme-elevation-500)' }}>{t('dashboard.tableHeaders.status')}</th>
345
- <th style={{ textAlign: 'left', padding: '6px 8px', borderBottom: '1px solid var(--theme-elevation-200)', fontSize: 11, color: 'var(--theme-elevation-500)' }}>{t('dashboard.tableHeaders.number')}</th>
346
- <th style={{ textAlign: 'left', padding: '6px 8px', borderBottom: '1px solid var(--theme-elevation-200)', fontSize: 11, color: 'var(--theme-elevation-500)' }}>{t('dashboard.tableHeaders.subject')}</th>
347
- <th style={{ textAlign: 'left', padding: '6px 8px', borderBottom: '1px solid var(--theme-elevation-200)', fontSize: 11, color: 'var(--theme-elevation-500)' }}>{t('dashboard.tableHeaders.client')}</th>
348
- <th style={{ textAlign: 'left', padding: '6px 8px', borderBottom: '1px solid var(--theme-elevation-200)', fontSize: 11, color: 'var(--theme-elevation-500)' }}>{t('dashboard.tableHeaders.modified')}</th>
504
+ <th>{t('dashboard.tableHeaders.status')}</th>
505
+ <th>{t('dashboard.tableHeaders.number')}</th>
506
+ <th>{t('dashboard.tableHeaders.subject')}</th>
507
+ <th>{t('dashboard.tableHeaders.client')}</th>
508
+ <th>{t('dashboard.tableHeaders.modified')}</th>
509
+ <th></th>
349
510
  </tr>
350
511
  </thead>
351
512
  <tbody>
352
513
  {tickets.map((tk) => (
353
- <tr key={tk.id} style={{ cursor: 'pointer' }} onClick={() => { window.location.href = `/admin/support/ticket?id=${tk.id}` }}>
354
- <td style={{ padding: '8px' }}><span style={{ width: 8, height: 8, borderRadius: '50%', display: 'inline-block', backgroundColor: statusDotColor(tk.status) }} /></td>
355
- <td style={{ padding: '8px', fontWeight: 600, fontSize: 12 }}>#{tk.ticketNumber}</td>
356
- <td style={{ padding: '8px', maxWidth: 250, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{tk.subject}</td>
357
- <td style={{ padding: '8px', color: 'var(--theme-elevation-500)', fontSize: 12 }}>{getClientName(tk)}</td>
358
- <td style={{ padding: '8px', color: 'var(--theme-elevation-400)', fontSize: 12 }}>{timeAgo(tk.updatedAt)}</td>
514
+ <tr
515
+ key={tk.id}
516
+ onClick={() => { window.location.href = `/admin/support/ticket?id=${tk.id}` }}
517
+ >
518
+ <td>
519
+ <span className={`${styles.statusDot} ${getStatusDotClass(tk.status)}`} />
520
+ </td>
521
+ <td className={styles.ticketNum}>#{tk.ticketNumber}</td>
522
+ <td className={styles.ticketSubject}>{tk.subject}</td>
523
+ <td className={styles.ticketClient}>{getClientName(tk)}</td>
524
+ <td className={styles.ticketTime}>{timeAgo(tk.updatedAt)}</td>
525
+ <td className={styles.ticketArrow}>&rarr;</td>
359
526
  </tr>
360
527
  ))}
361
528
  </tbody>
@@ -364,60 +531,53 @@ export const SupportDashboardClient: React.FC = () => {
364
531
  </div>
365
532
 
366
533
  {/* Right column */}
367
- <div style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
534
+ <div className={styles.rightColumn}>
368
535
  {/* Volume chart */}
369
- <div style={{ padding: 16, borderRadius: 10, border: '1px solid var(--theme-elevation-150)' }}>
370
- <h2 style={{ fontSize: 15, fontWeight: 700, margin: '0 0 12px', color: 'var(--theme-text)' }}>{t('dashboard.volume7days')}</h2>
371
- <div style={{ display: 'flex', alignItems: 'flex-end', gap: 4, height: 80 }}>
372
- {volumeData.map((d, i) => (
373
- <div key={i} style={{ flex: 1, background: '#3b82f6', borderRadius: '3px 3px 0 0', height: `${Math.max((d.value / maxVolume) * 100, 5)}%` }} title={`${d.label}: ${d.value}`} />
374
- ))}
375
- </div>
376
- <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 10, color: 'var(--theme-elevation-400)', marginTop: 4 }}>
377
- <span>{volumeData[0]?.label}</span>
378
- <span>{volumeData[volumeData.length - 1]?.label}</span>
379
- </div>
536
+ <div className={styles.panel}>
537
+ <h2 className={styles.panelTitle}>{t('dashboard.volume7days')}</h2>
538
+ <VolumeChart data={volumeData} />
380
539
  </div>
381
540
 
382
541
  {/* CSAT ring */}
383
- <div style={{ padding: 16, borderRadius: 10, border: '1px solid var(--theme-elevation-150)' }}>
384
- <h2 style={{ fontSize: 15, fontWeight: 700, margin: '0 0 12px', color: 'var(--theme-text)' }}>{t('dashboard.csat')}</h2>
385
- <div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
386
- <div style={{ position: 'relative', width: 80, height: 80 }}>
387
- <svg width="80" height="80" viewBox="0 0 100 100">
388
- <circle cx="50" cy="50" r={csatRadius} fill="none" stroke="var(--theme-elevation-150, #e2e8f0)" strokeWidth="6" />
389
- <circle cx="50" cy="50" r={csatRadius} fill="none" stroke={csatColor} strokeWidth="6" strokeLinecap="round" strokeDasharray={csatCircumference} strokeDashoffset={csatStrokeDashoffset} transform="rotate(-90 50 50)" style={{ transition: 'stroke-dashoffset 600ms ease' }} />
390
- </svg>
391
- <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontWeight: 700, fontSize: 16 }}>
392
- {csatScore > 0 ? csatScore.toFixed(1) : '--'}
393
- </div>
394
- </div>
395
- <div>
396
- <div style={{ fontSize: 13, fontWeight: 600 }}>{csatScore > 0 ? `${csatScore.toFixed(1)} / 5` : t('dashboard.csatNoData')}</div>
397
- <div style={{ fontSize: 12, color: 'var(--theme-elevation-500)' }}>{stats.satisfactionCount > 0 ? t('dashboard.csatReviews', { count: String(stats.satisfactionCount) }) : t('dashboard.csatNoReviews')}</div>
398
- </div>
399
- </div>
542
+ <div className={styles.panel}>
543
+ <h2 className={styles.panelTitle}>{t('dashboard.csat')}</h2>
544
+ <CSATRing score={stats.satisfactionAvg} count={stats.satisfactionCount} />
400
545
  </div>
401
546
  </div>
402
547
  </div>
403
548
 
404
- {/* SLA */}
549
+ {/* ── SLA ── */}
405
550
  <SlaSection />
406
551
 
407
- {/* Quick Actions */}
408
- <div style={{ display: 'flex', gap: 10, marginTop: 24, flexWrap: 'wrap' }}>
409
- <Link href="/admin/support/new-ticket" style={{ padding: '8px 16px', borderRadius: 8, background: '#2563eb', color: '#fff', fontSize: 13, fontWeight: 600, textDecoration: 'none' }}>{t('dashboard.newTicketAction')}</Link>
410
- <Link href="/admin/support/emails" style={{ padding: '8px 16px', borderRadius: 8, border: '1px solid var(--theme-elevation-200)', fontSize: 13, fontWeight: 500, textDecoration: 'none', color: 'var(--theme-text)', display: 'flex', alignItems: 'center', gap: 6 }}>
552
+ {/* ── Quick Actions ── */}
553
+ <div className={styles.actionsRow}>
554
+ <Link href="/admin/support/new-ticket" className={styles.actionBtn}>
555
+ {t('dashboard.newTicketAction')}
556
+ </Link>
557
+ <Link href="/admin/support/emails" className={styles.actionBtn}>
411
558
  {t('dashboard.pendingEmails')}
412
559
  {stats.pendingEmailsCount > 0 ? (
413
- <span style={{ padding: '1px 6px', borderRadius: 10, background: '#fef2f2', color: '#dc2626', fontSize: 11, fontWeight: 700 }}>{stats.pendingEmailsCount}</span>
560
+ <span className={`${styles.badge} ${styles.badgeRed}`}>
561
+ {stats.pendingEmailsCount}
562
+ </span>
414
563
  ) : (
415
- <span style={{ padding: '1px 6px', borderRadius: 10, background: '#dcfce7', color: '#16a34a', fontSize: 11, fontWeight: 700 }}>0</span>
564
+ <span className={`${styles.badge} ${styles.badgeGreen}`}>0</span>
416
565
  )}
417
566
  </Link>
418
- <Link href="/admin/support/crm" style={{ padding: '8px 16px', borderRadius: 8, border: '1px solid var(--theme-elevation-200)', fontSize: 13, fontWeight: 500, textDecoration: 'none', color: 'var(--theme-text)' }}>{t('dashboard.crm')}</Link>
419
- <Link href="/admin/support/billing" style={{ padding: '8px 16px', borderRadius: 8, border: '1px solid var(--theme-elevation-200)', fontSize: 13, fontWeight: 500, textDecoration: 'none', color: 'var(--theme-text)' }}>{t('dashboard.preBilling')}</Link>
420
- <a href="/api/support/export-csv" style={{ padding: '8px 16px', borderRadius: 8, border: '1px solid var(--theme-elevation-200)', fontSize: 13, fontWeight: 500, textDecoration: 'none', color: 'var(--theme-text)' }} target="_blank" rel="noopener noreferrer">{t('dashboard.exportCsv')}</a>
567
+ <Link href="/admin/support/crm" className={styles.actionBtn}>
568
+ {t('dashboard.crm')}
569
+ </Link>
570
+ <Link href="/admin/support/billing" className={styles.actionBtn}>
571
+ {t('dashboard.preBilling')}
572
+ </Link>
573
+ <a
574
+ href="/api/support/export-csv"
575
+ className={styles.actionBtn}
576
+ target="_blank"
577
+ rel="noopener noreferrer"
578
+ >
579
+ {t('dashboard.exportCsv')}
580
+ </a>
421
581
  </div>
422
582
  </div>
423
583
  )