@consilioweb/payload-support 0.8.2 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/RichTextEditor/index.cjs +233 -0
- package/dist/components/RichTextEditor/index.js +232 -0
- package/dist/components/TicketConversation/components/CodeBlock.cjs +24 -7
- package/dist/components/TicketConversation/components/CodeBlock.js +23 -9
- package/dist/index.cjs +19 -1
- package/dist/index.js +19 -1
- package/dist/views/BillingView/client.cjs +260 -103
- package/dist/views/BillingView/client.js +259 -103
- package/dist/views/ChatView/client.cjs +184 -137
- package/dist/views/ChatView/client.js +180 -137
- package/dist/views/CrmView/client.cjs +270 -122
- package/dist/views/CrmView/client.js +266 -122
- package/dist/views/EmailTrackingView/client.cjs +80 -69
- package/dist/views/EmailTrackingView/client.js +80 -70
- package/dist/views/ImportConversationView/client.cjs +127 -94
- package/dist/views/ImportConversationView/client.js +123 -94
- package/dist/views/LogsView/client.cjs +56 -58
- package/dist/views/LogsView/client.js +52 -58
- package/dist/views/NewTicketView/client.cjs +39 -55
- package/dist/views/NewTicketView/client.js +38 -55
- package/dist/views/PendingEmailsView/client.cjs +399 -102
- package/dist/views/PendingEmailsView/client.js +396 -103
- package/dist/views/SupportDashboardView/client.cjs +276 -137
- package/dist/views/SupportDashboardView/client.js +275 -137
- package/dist/views/TicketDetailView/client.cjs +487 -204
- package/dist/views/TicketDetailView/client.js +486 -204
- package/dist/views/TicketInboxView/client.cjs +62 -65
- package/dist/views/TicketInboxView/client.js +62 -66
- package/dist/views/TicketingSettingsView/client.cjs +10 -8
- package/dist/views/TicketingSettingsView/client.js +10 -8
- package/dist/views/TimeDashboardView/client.cjs +70 -59
- package/dist/views/TimeDashboardView/client.js +69 -59
- package/package.json +6 -2
- package/src/components/RichTextEditor/index.tsx +261 -0
- package/src/components/TicketConversation/components/CodeBlock.tsx +53 -14
- package/src/plugin.ts +2 -0
- package/src/utils/emailTemplate.ts +37 -0
- package/src/views/BillingView/client.tsx +362 -69
- package/src/views/ChatView/client.tsx +225 -140
- package/src/views/CrmView/client.tsx +447 -189
- package/src/views/EmailTrackingView/client.tsx +111 -71
- package/src/views/ImportConversationView/client.tsx +255 -70
- package/src/views/LogsView/client.tsx +85 -50
- package/src/views/NewTicketView/client.tsx +37 -53
- package/src/views/PendingEmailsView/client.tsx +512 -92
- package/src/views/SupportDashboardView/client.tsx +294 -134
- package/src/views/TicketDetailView/client.tsx +486 -213
- package/src/views/TicketInboxView/client.tsx +52 -61
- package/src/views/TicketingSettingsView/client.tsx +10 -9
- package/src/views/TimeDashboardView/client.tsx +184 -69
|
@@ -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
|
|
6
|
+
import styles from '../../styles/SupportDashboard.module.scss'
|
|
7
7
|
|
|
8
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
<
|
|
95
|
-
|
|
96
|
-
|
|
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
|
|
99
|
-
{trend.dir === 'up' ? '↑' : '↓'}
|
|
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
|
|
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)
|
|
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)
|
|
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
|
|
143
|
-
<h2
|
|
144
|
-
<div
|
|
232
|
+
<div className={styles.slaSection}>
|
|
233
|
+
<h2 className={styles.slaSectionTitle}>SLA</h2>
|
|
234
|
+
<div className={styles.slaGrid}>
|
|
145
235
|
{/* Breached */}
|
|
146
|
-
<div
|
|
147
|
-
<div
|
|
148
|
-
<h3
|
|
149
|
-
|
|
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
|
|
251
|
+
<div className={styles.slaEmpty}>Aucun ticket en breach</div>
|
|
153
252
|
) : (
|
|
154
|
-
<ul
|
|
253
|
+
<ul className={styles.slaList}>
|
|
155
254
|
{sla.breached.map((ticket) => {
|
|
156
|
-
|
|
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
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
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
|
|
173
|
-
<div
|
|
174
|
-
<h3
|
|
175
|
-
|
|
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
|
|
294
|
+
<div className={styles.slaEmpty}>Aucun ticket à risque</div>
|
|
179
295
|
) : (
|
|
180
|
-
<ul
|
|
296
|
+
<ul className={styles.slaList}>
|
|
181
297
|
{sla.atRisk.map((ticket) => {
|
|
182
|
-
|
|
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
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
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
|
-
//
|
|
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 = [
|
|
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
|
|
387
|
+
}, [stats])
|
|
258
388
|
|
|
389
|
+
// ── Loading state ──
|
|
259
390
|
if (loading) {
|
|
260
391
|
return (
|
|
261
|
-
<div
|
|
262
|
-
<
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
|
274
|
-
<div
|
|
414
|
+
<div className={styles.page}>
|
|
415
|
+
<div className={styles.errorState}>
|
|
275
416
|
<strong>{t('dashboard.loadError')}</strong>
|
|
276
|
-
|
|
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
|
|
444
|
+
const getStatusDotClass = (status: string): string => {
|
|
299
445
|
switch (status) {
|
|
300
|
-
case 'open': return
|
|
301
|
-
case 'waiting_client': return
|
|
302
|
-
case 'resolved': return
|
|
303
|
-
default: return '
|
|
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
|
|
319
|
-
{/* Header */}
|
|
320
|
-
<div
|
|
321
|
-
<h1
|
|
322
|
-
<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
|
|
327
|
-
<StatCard
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
|
493
|
+
{/* ── Middle Grid ── */}
|
|
494
|
+
<div className={styles.middleGrid}>
|
|
335
495
|
{/* Left: Active Tickets */}
|
|
336
|
-
<div
|
|
337
|
-
<h2
|
|
496
|
+
<div className={styles.panel}>
|
|
497
|
+
<h2 className={styles.panelTitle}>{t('dashboard.activeTickets')}</h2>
|
|
338
498
|
{tickets.length === 0 ? (
|
|
339
|
-
<div
|
|
499
|
+
<div className={styles.emptyTable}>{t('dashboard.noActiveTickets')}</div>
|
|
340
500
|
) : (
|
|
341
|
-
<table
|
|
501
|
+
<table className={styles.ticketTable}>
|
|
342
502
|
<thead>
|
|
343
503
|
<tr>
|
|
344
|
-
<th
|
|
345
|
-
<th
|
|
346
|
-
<th
|
|
347
|
-
<th
|
|
348
|
-
<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
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
<td
|
|
358
|
-
|
|
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}>→</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
|
|
534
|
+
<div className={styles.rightColumn}>
|
|
368
535
|
{/* Volume chart */}
|
|
369
|
-
<div
|
|
370
|
-
<h2
|
|
371
|
-
<
|
|
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
|
|
384
|
-
<h2
|
|
385
|
-
<
|
|
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
|
|
409
|
-
<Link href="/admin/support/new-ticket"
|
|
410
|
-
|
|
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
|
|
560
|
+
<span className={`${styles.badge} ${styles.badgeRed}`}>
|
|
561
|
+
{stats.pendingEmailsCount}
|
|
562
|
+
</span>
|
|
414
563
|
) : (
|
|
415
|
-
<span
|
|
564
|
+
<span className={`${styles.badge} ${styles.badgeGreen}`}>0</span>
|
|
416
565
|
)}
|
|
417
566
|
</Link>
|
|
418
|
-
<Link href="/admin/support/crm"
|
|
419
|
-
|
|
420
|
-
|
|
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
|
)
|