@consilioweb/payload-support 0.9.13 → 0.10.1
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/TicketConversation/locales/en.json +8 -0
- package/dist/components/TicketConversation/locales/fr.json +9 -1
- package/dist/styles/TicketDetail.module.scss +270 -20
- package/dist/styles/TicketInbox.module.scss +112 -50
- package/dist/views/TicketDetailView/client.cjs +80 -13
- package/dist/views/TicketDetailView/client.js +80 -13
- package/dist/views/TicketInboxView/client.cjs +117 -58
- package/dist/views/TicketInboxView/client.js +117 -58
- package/dist/views/shared/StatusPill.cjs +36 -0
- package/dist/views/shared/StatusPill.js +30 -0
- package/dist/views/shared/StatusPill.module.scss +66 -0
- package/dist/views/shared/index.cjs +14 -0
- package/dist/views/shared/index.js +2 -0
- package/dist/views/shared/sla.cjs +37 -0
- package/dist/views/shared/sla.js +34 -0
- package/package.json +1 -1
- package/src/components/TicketConversation/locales/en.json +8 -0
- package/src/components/TicketConversation/locales/fr.json +9 -1
- package/src/styles/TicketDetail.module.scss +270 -20
- package/src/styles/TicketInbox.module.scss +112 -50
- package/src/views/TicketDetailView/client.tsx +88 -27
- package/src/views/TicketInboxView/client.tsx +107 -50
- package/src/views/shared/StatusPill.module.scss +66 -0
- package/src/views/shared/StatusPill.tsx +44 -0
- package/src/views/shared/index.ts +4 -0
- package/src/views/shared/sla.ts +56 -0
|
@@ -7,6 +7,7 @@ import { RichTextEditor, type RichTextEditorHandle } from '../../components/Rich
|
|
|
7
7
|
import { hasCodeBlocks, MessageWithCodeBlocks, CodeBlockRendererHtml } from '../../components/TicketConversation/components/CodeBlock'
|
|
8
8
|
import { CodeBlockInserter } from '../../components/TicketConversation/components/CodeBlockInserter'
|
|
9
9
|
import { getFeatures } from '../shared/config'
|
|
10
|
+
import { computeSlaState, formatSlaRemaining } from '../shared/sla'
|
|
10
11
|
import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
|
|
11
12
|
import s from '../../styles/TicketDetail.module.scss'
|
|
12
13
|
|
|
@@ -604,6 +605,41 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
604
605
|
</div>
|
|
605
606
|
</div>
|
|
606
607
|
|
|
608
|
+
{/* SLA BREACH BANNER */}
|
|
609
|
+
{(() => {
|
|
610
|
+
const slaInfo = computeSlaState({
|
|
611
|
+
slaFirstResponseDue: ticket.slaFirstResponseDue as string | null,
|
|
612
|
+
slaFirstResponseBreached: ticket.slaFirstResponseBreached as boolean | null,
|
|
613
|
+
slaResolutionDue: ticket.slaResolutionDue as string | null,
|
|
614
|
+
slaResolutionBreached: ticket.slaResolutionBreached as boolean | null,
|
|
615
|
+
firstResponseAt: ticket.firstResponseAt as string | null,
|
|
616
|
+
})
|
|
617
|
+
if (slaInfo.state !== 'breach') return null
|
|
618
|
+
const duration = formatSlaRemaining(slaInfo.remainingMs).replace(/^−/, '')
|
|
619
|
+
const target = slaInfo.due ? new Date(slaInfo.due).toLocaleString('fr-FR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }) : ''
|
|
620
|
+
return (
|
|
621
|
+
<div className={s.slaBanner} role="alert">
|
|
622
|
+
<span className={s.slaBannerIcon} aria-hidden>
|
|
623
|
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 9v4M12 17h.01" /><path d="M10.3 3.86 1.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" /></svg>
|
|
624
|
+
</span>
|
|
625
|
+
<div className={s.slaBannerBody}>
|
|
626
|
+
<strong>{t('inbox.slaBanner.breached')}</strong>
|
|
627
|
+
<span className={s.slaBannerSep}>—</span>
|
|
628
|
+
<span>{t('inbox.slaBanner.responseAwaitedFor', { duration })}</span>
|
|
629
|
+
{target && <span className={s.slaBannerTarget}>· {t('inbox.slaBanner.target', { target })}</span>}
|
|
630
|
+
</div>
|
|
631
|
+
<button
|
|
632
|
+
type="button"
|
|
633
|
+
className={s.slaBannerCta}
|
|
634
|
+
onClick={() => handleStatusChange('escalated' as any)}
|
|
635
|
+
aria-label={t('inbox.slaBanner.escalate')}
|
|
636
|
+
>
|
|
637
|
+
↑ {t('inbox.slaBanner.escalate')}
|
|
638
|
+
</button>
|
|
639
|
+
</div>
|
|
640
|
+
)
|
|
641
|
+
})()}
|
|
642
|
+
|
|
607
643
|
{/* PRESENCE BANNER */}
|
|
608
644
|
{otherViewers.length > 0 && (
|
|
609
645
|
<div style={{
|
|
@@ -632,9 +668,18 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
632
668
|
return (
|
|
633
669
|
<React.Fragment key={msg.id}>
|
|
634
670
|
{showDate && <div className={s.dateSeparator}><span className={s.dateSeparatorText}>{dateLabel(msg.createdAt)}</span></div>}
|
|
635
|
-
<div className={`${s.message} ${msg.isInternal ? s.messageInternal : ''}`} style={isPendingDelete ? { opacity: 0.3, pointerEvents: 'none' } : undefined}>
|
|
671
|
+
<div className={`${s.message} ${isAdmin ? s.messageAdmin : s.messageClient} ${msg.isInternal ? s.messageInternal : ''}`} style={isPendingDelete ? { opacity: 0.3, pointerEvents: 'none' } : undefined}>
|
|
636
672
|
{/* #10 — Client avatar purple */}
|
|
637
|
-
<div
|
|
673
|
+
<div
|
|
674
|
+
className={s.avatar}
|
|
675
|
+
style={{
|
|
676
|
+
backgroundColor: isAdmin
|
|
677
|
+
? 'var(--theme-info-500, #2a6bd8)'
|
|
678
|
+
: msg.authorType === 'email'
|
|
679
|
+
? 'var(--theme-warning-500, #c97a0a)'
|
|
680
|
+
: 'var(--theme-accent-500, #6b46e8)',
|
|
681
|
+
}}
|
|
682
|
+
>
|
|
638
683
|
{isAdmin ? 'CW' : initials}
|
|
639
684
|
</div>
|
|
640
685
|
<div className={s.messageContent}>
|
|
@@ -741,7 +786,15 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
741
786
|
<div className={s.composerToolbar}>
|
|
742
787
|
{features.ai && (
|
|
743
788
|
<>
|
|
744
|
-
<button
|
|
789
|
+
<button
|
|
790
|
+
className={`${s.toolbarBtn} ${s.toolbarBtnAi}`}
|
|
791
|
+
data-tooltip={t('detail.iaSuggestion')}
|
|
792
|
+
aria-label={t('detail.iaSuggestion')}
|
|
793
|
+
onClick={handleAiSuggest}
|
|
794
|
+
disabled={aiReplying || messages.length === 0}
|
|
795
|
+
>
|
|
796
|
+
{aiReplying ? '…' : `✨ ${t('detail.iaSuggestion')}`}
|
|
797
|
+
</button>
|
|
745
798
|
<RewriteDropdown disabled={aiRewriting || !replyBody.trim()} loading={aiRewriting} onSelect={(style) => handleAiRewrite(style)} toolbarBtnClass={s.toolbarBtn} />
|
|
746
799
|
</>
|
|
747
800
|
)}
|
|
@@ -832,6 +885,9 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
832
885
|
</>
|
|
833
886
|
)}
|
|
834
887
|
</div>
|
|
888
|
+
<span className={s.sendShortcut} aria-hidden>
|
|
889
|
+
<kbd>⌘</kbd><kbd>↵</kbd>
|
|
890
|
+
</span>
|
|
835
891
|
<button className={`${s.sendBtn} ${isInternal ? s.sendBtnInternal : ''}`} onClick={handleSend} disabled={sending || !replyBody.trim()} data-action="send-reply">
|
|
836
892
|
{sending ? t('detail.sending') : sendAsClient ? 'Ajouter message client' : isInternal ? t('detail.sendNote') : t('detail.sendReply')}
|
|
837
893
|
</button>
|
|
@@ -858,6 +914,34 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
858
914
|
</div>
|
|
859
915
|
)}
|
|
860
916
|
|
|
917
|
+
{/* AI client synthesis — promoted: highest-value briefing for the agent */}
|
|
918
|
+
{clientSummary && clientSummary.summary && (
|
|
919
|
+
<div className={`${s.sideSection} ${s.aiCard}`}>
|
|
920
|
+
<div className={s.aiCardTitle}>
|
|
921
|
+
<span className={s.aiCardIcon} aria-hidden>✨</span>
|
|
922
|
+
Synthèse client
|
|
923
|
+
</div>
|
|
924
|
+
<p className={s.aiCardLead}>{clientSummary.summary}</p>
|
|
925
|
+
{clientSummary.recurringTopics && clientSummary.recurringTopics.length > 0 && (
|
|
926
|
+
<div className={s.aiCardChips}>
|
|
927
|
+
{clientSummary.recurringTopics.slice(0, 5).map((tp, i) => (
|
|
928
|
+
<span key={`tp-${i}`} className={s.aiChip}>{tp.topic}</span>
|
|
929
|
+
))}
|
|
930
|
+
</div>
|
|
931
|
+
)}
|
|
932
|
+
{clientSummary.keyFacts && clientSummary.keyFacts.length > 0 && (
|
|
933
|
+
<ul className={s.aiCardFacts}>
|
|
934
|
+
{clientSummary.keyFacts.slice(0, 4).map((f, i) => (
|
|
935
|
+
<li key={`kf-${i}`} className={s.aiCardFact}>
|
|
936
|
+
<span className={s.aiCardDot} aria-hidden />
|
|
937
|
+
{f}
|
|
938
|
+
</li>
|
|
939
|
+
))}
|
|
940
|
+
</ul>
|
|
941
|
+
)}
|
|
942
|
+
</div>
|
|
943
|
+
)}
|
|
944
|
+
|
|
861
945
|
{/* #1 — Editable sidebar fields */}
|
|
862
946
|
<div className={s.sideSection}>
|
|
863
947
|
<div className={s.sideSectionTitle}>{t('detail.details')}</div>
|
|
@@ -1092,32 +1176,9 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
1092
1176
|
</div>
|
|
1093
1177
|
)}
|
|
1094
1178
|
|
|
1095
|
-
{/* Client Intelligence (compact) */}
|
|
1096
|
-
{clientSummary && clientSummary.summary && (
|
|
1097
|
-
<div className={s.sideSection} style={{ background: 'linear-gradient(135deg, rgba(37,99,235,0.03) 0%, rgba(139,92,246,0.03) 100%)' }}>
|
|
1098
|
-
<div className={s.sideSectionTitle} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
1099
|
-
<span style={{ fontSize: 13 }}>🧠</span> Résumé client
|
|
1100
|
-
</div>
|
|
1101
|
-
<p style={{ margin: '0 0 8px', fontSize: 11, lineHeight: 1.6, color: '#374151' }}>{clientSummary.summary}</p>
|
|
1102
|
-
{clientSummary.recurringTopics && clientSummary.recurringTopics.length > 0 && (
|
|
1103
|
-
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 6 }}>
|
|
1104
|
-
{clientSummary.recurringTopics.slice(0, 3).map((tp, i) => (
|
|
1105
|
-
<span key={i} style={{ padding: '2px 8px', borderRadius: 10, background: 'rgba(37,99,235,0.08)', color: '#2563eb', fontSize: 10, fontWeight: 600 }}>{tp.topic}</span>
|
|
1106
|
-
))}
|
|
1107
|
-
</div>
|
|
1108
|
-
)}
|
|
1109
|
-
{clientSummary.keyFacts && clientSummary.keyFacts.length > 0 && (
|
|
1110
|
-
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
|
1111
|
-
{clientSummary.keyFacts.slice(0, 3).map((f, i) => (
|
|
1112
|
-
<span key={i} style={{ padding: '2px 8px', borderRadius: 10, background: 'rgba(22,163,74,0.08)', color: '#16a34a', fontSize: 10, fontWeight: 600 }}>{f}</span>
|
|
1113
|
-
))}
|
|
1114
|
-
</div>
|
|
1115
|
-
)}
|
|
1116
|
-
</div>
|
|
1117
|
-
)}
|
|
1118
1179
|
{summaryLoading && (
|
|
1119
1180
|
<div className={s.sideSection}>
|
|
1120
|
-
<div style={{ fontSize: 11, color: '
|
|
1181
|
+
<div style={{ fontSize: 11, color: 'var(--theme-elevation-400)', textAlign: 'center', padding: 8 }}>✨ Chargement de la synthèse…</div>
|
|
1121
1182
|
</div>
|
|
1122
1183
|
)}
|
|
1123
1184
|
</div>
|
|
@@ -4,6 +4,8 @@ import React, { useState, useEffect, useCallback } from 'react'
|
|
|
4
4
|
import { useSearchParams } from 'next/navigation'
|
|
5
5
|
import Link from 'next/link'
|
|
6
6
|
import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
|
|
7
|
+
import { StatusPill } from '../shared/StatusPill'
|
|
8
|
+
import { computeSlaState, formatSlaRemaining } from '../shared/sla'
|
|
7
9
|
import s from '../../styles/TicketInbox.module.scss'
|
|
8
10
|
|
|
9
11
|
interface Ticket {
|
|
@@ -18,35 +20,29 @@ interface Ticket {
|
|
|
18
20
|
createdAt: string
|
|
19
21
|
lastClientMessageAt?: string
|
|
20
22
|
lastAdminReadAt?: string
|
|
23
|
+
// SLA fields (already declared on the Tickets collection)
|
|
24
|
+
slaFirstResponseDue?: string | null
|
|
25
|
+
slaFirstResponseBreached?: boolean | null
|
|
26
|
+
slaResolutionDue?: string | null
|
|
27
|
+
slaResolutionBreached?: boolean | null
|
|
28
|
+
firstResponseAt?: string | null
|
|
21
29
|
}
|
|
22
30
|
|
|
23
|
-
type Tab = 'all' | 'open' | 'waiting_client' | 'resolved'
|
|
24
|
-
|
|
25
|
-
const STATUS_DOTS: Record<string, string> = {
|
|
26
|
-
open: '#22c55e',
|
|
27
|
-
waiting_client: '#eab308',
|
|
28
|
-
resolved: '#94a3b8',
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const STATUS_LABEL_KEYS: Record<string, string> = {
|
|
32
|
-
open: 'inbox.tabs.open',
|
|
33
|
-
waiting_client: 'inbox.tabs.waiting',
|
|
34
|
-
resolved: 'inbox.tabs.resolved',
|
|
35
|
-
}
|
|
31
|
+
type Tab = 'all' | 'open' | 'waiting_client' | 'resolved' | 'sla_breach'
|
|
36
32
|
|
|
37
33
|
const PRIORITY_COLORS: Record<string, string> = {
|
|
38
|
-
urgent: '
|
|
39
|
-
high: '
|
|
40
|
-
normal: '
|
|
41
|
-
low: '
|
|
34
|
+
urgent: 'var(--theme-error-500)',
|
|
35
|
+
high: 'var(--theme-warning-500)',
|
|
36
|
+
normal: 'var(--theme-info-500, #2a6bd8)',
|
|
37
|
+
low: 'var(--theme-elevation-400)',
|
|
42
38
|
}
|
|
43
39
|
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
40
|
+
const STATUS_VARIANT: Record<string, 'open' | 'pending' | 'resolved' | 'closed'> = {
|
|
41
|
+
open: 'open',
|
|
42
|
+
waiting_client: 'pending',
|
|
43
|
+
pending: 'pending',
|
|
44
|
+
resolved: 'resolved',
|
|
45
|
+
closed: 'closed',
|
|
50
46
|
}
|
|
51
47
|
|
|
52
48
|
function relativeTime(dateStr: string): string {
|
|
@@ -68,13 +64,13 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
68
64
|
const [loading, setLoading] = useState(true)
|
|
69
65
|
const [tab, setTab] = useState<Tab>(() => {
|
|
70
66
|
const urlTab = searchParams.get('tab')
|
|
71
|
-
if (urlTab && ['all', 'open', 'waiting_client', 'resolved'].includes(urlTab)) return urlTab as Tab
|
|
67
|
+
if (urlTab && ['all', 'open', 'waiting_client', 'resolved', 'sla_breach'].includes(urlTab)) return urlTab as Tab
|
|
72
68
|
return 'all'
|
|
73
69
|
})
|
|
74
70
|
// Sync tab with URL searchParams on navigation
|
|
75
71
|
useEffect(() => {
|
|
76
72
|
const urlTab = searchParams.get('tab')
|
|
77
|
-
if (urlTab && ['all', 'open', 'waiting_client', 'resolved'].includes(urlTab)) {
|
|
73
|
+
if (urlTab && ['all', 'open', 'waiting_client', 'resolved', 'sla_breach'].includes(urlTab)) {
|
|
78
74
|
setTab(urlTab as Tab)
|
|
79
75
|
} else if (!urlTab) {
|
|
80
76
|
setTab('all')
|
|
@@ -82,7 +78,7 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
82
78
|
}, [searchParams])
|
|
83
79
|
const [search, setSearch] = useState('')
|
|
84
80
|
const [sort, setSort] = useState('-updatedAt')
|
|
85
|
-
const [counts, setCounts] = useState({ all: 0, open: 0, waiting: 0, resolved: 0 })
|
|
81
|
+
const [counts, setCounts] = useState({ all: 0, open: 0, waiting: 0, resolved: 0, breach: 0 })
|
|
86
82
|
const [selectedIdx, setSelectedIdx] = useState(-1)
|
|
87
83
|
const [checkedIds, setCheckedIds] = useState<Set<number>>(new Set())
|
|
88
84
|
const [bulkAction, setBulkAction] = useState('')
|
|
@@ -121,8 +117,23 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
121
117
|
}
|
|
122
118
|
|
|
123
119
|
const fetchTickets = useCallback(async () => {
|
|
124
|
-
const params = [
|
|
125
|
-
|
|
120
|
+
const params = [
|
|
121
|
+
`limit=30`, `sort=${sort}`, `depth=1`,
|
|
122
|
+
`select[id]=true`, `select[ticketNumber]=true`, `select[subject]=true`,
|
|
123
|
+
`select[status]=true`, `select[priority]=true`, `select[category]=true`,
|
|
124
|
+
`select[client]=true`, `select[updatedAt]=true`,
|
|
125
|
+
`select[lastClientMessageAt]=true`, `select[lastAdminReadAt]=true`,
|
|
126
|
+
`select[slaFirstResponseDue]=true`, `select[slaFirstResponseBreached]=true`,
|
|
127
|
+
`select[slaResolutionDue]=true`, `select[slaResolutionBreached]=true`,
|
|
128
|
+
`select[firstResponseAt]=true`,
|
|
129
|
+
]
|
|
130
|
+
if (tab === 'sla_breach') {
|
|
131
|
+
// Either first-response breached or resolution breached
|
|
132
|
+
params.push(`where[or][0][slaFirstResponseBreached][equals]=true`)
|
|
133
|
+
params.push(`where[or][1][slaResolutionBreached][equals]=true`)
|
|
134
|
+
} else if (tab !== 'all') {
|
|
135
|
+
params.push(`where[status][equals]=${tab}`)
|
|
136
|
+
}
|
|
126
137
|
if (search.trim()) {
|
|
127
138
|
params.push(`where[or][0][subject][contains]=${encodeURIComponent(search)}`)
|
|
128
139
|
params.push(`where[or][1][ticketNumber][contains]=${encodeURIComponent(search)}`)
|
|
@@ -148,14 +159,21 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
148
159
|
useEffect(() => {
|
|
149
160
|
const fetchCounts = async () => {
|
|
150
161
|
try {
|
|
151
|
-
const [all, openRes, waiting, resolved] = await Promise.all([
|
|
162
|
+
const [all, openRes, waiting, resolved, breach] = await Promise.all([
|
|
152
163
|
fetch('/api/tickets?limit=0&depth=0', { credentials: 'include' }),
|
|
153
164
|
fetch('/api/tickets?limit=0&depth=0&where[status][equals]=open', { credentials: 'include' }),
|
|
154
165
|
fetch('/api/tickets?limit=0&depth=0&where[status][equals]=waiting_client', { credentials: 'include' }),
|
|
155
166
|
fetch('/api/tickets?limit=0&depth=0&where[status][equals]=resolved', { credentials: 'include' }),
|
|
167
|
+
fetch('/api/tickets?limit=0&depth=0&where[or][0][slaFirstResponseBreached][equals]=true&where[or][1][slaResolutionBreached][equals]=true', { credentials: 'include' }),
|
|
156
168
|
])
|
|
157
|
-
const [a, o, w, r] = await Promise.all([all.json(), openRes.json(), waiting.json(), resolved.json()])
|
|
158
|
-
setCounts({
|
|
169
|
+
const [a, o, w, r, b] = await Promise.all([all.json(), openRes.json(), waiting.json(), resolved.json(), breach.json()])
|
|
170
|
+
setCounts({
|
|
171
|
+
all: a.totalDocs || 0,
|
|
172
|
+
open: o.totalDocs || 0,
|
|
173
|
+
waiting: w.totalDocs || 0,
|
|
174
|
+
resolved: r.totalDocs || 0,
|
|
175
|
+
breach: b.totalDocs || 0,
|
|
176
|
+
})
|
|
159
177
|
} catch { /* silent */ }
|
|
160
178
|
}
|
|
161
179
|
fetchCounts()
|
|
@@ -181,10 +199,11 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
181
199
|
return () => window.removeEventListener('keydown', handleKey)
|
|
182
200
|
}, [tickets, selectedIdx])
|
|
183
201
|
|
|
184
|
-
const tabs: Array<{ key: Tab; label: string; count: number }> = [
|
|
202
|
+
const tabs: Array<{ key: Tab; label: string; count: number; alert?: boolean }> = [
|
|
185
203
|
{ key: 'all', label: t('inbox.tabs.all'), count: counts.all },
|
|
186
204
|
{ key: 'open', label: t('inbox.tabs.open'), count: counts.open },
|
|
187
205
|
{ key: 'waiting_client', label: t('inbox.tabs.waiting'), count: counts.waiting },
|
|
206
|
+
{ key: 'sla_breach', label: t('inbox.tabs.slaBreach'), count: counts.breach, alert: true },
|
|
188
207
|
{ key: 'resolved', label: t('inbox.tabs.resolved'), count: counts.resolved },
|
|
189
208
|
]
|
|
190
209
|
|
|
@@ -192,7 +211,17 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
192
211
|
<div className={s.page}>
|
|
193
212
|
{/* Header */}
|
|
194
213
|
<div className={s.header}>
|
|
195
|
-
<
|
|
214
|
+
<div>
|
|
215
|
+
<h1 className={s.title}>{t('inbox.title')}</h1>
|
|
216
|
+
<p className={s.subtitle}>
|
|
217
|
+
{tickets.length > 0 && `${tickets.length} sur ${counts.all} · `}
|
|
218
|
+
{counts.breach > 0 ? (
|
|
219
|
+
<span className={s.subtitleBreach}>{counts.breach} SLA dépassé{counts.breach > 1 ? 's' : ''}</span>
|
|
220
|
+
) : (
|
|
221
|
+
<span>Aucun SLA en alerte</span>
|
|
222
|
+
)}
|
|
223
|
+
</p>
|
|
224
|
+
</div>
|
|
196
225
|
<div className={s.headerRight}>
|
|
197
226
|
<div className={s.searchWrap}>
|
|
198
227
|
<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>
|
|
@@ -210,17 +239,22 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
210
239
|
</div>
|
|
211
240
|
|
|
212
241
|
{/* Tabs */}
|
|
213
|
-
<div className={s.tabs}>
|
|
214
|
-
{tabs.map((tk) =>
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
242
|
+
<div className={s.tabs} role="tablist">
|
|
243
|
+
{tabs.map((tk) => {
|
|
244
|
+
const isAlert = tk.alert && tk.count > 0
|
|
245
|
+
return (
|
|
246
|
+
<button
|
|
247
|
+
key={tk.key}
|
|
248
|
+
role="tab"
|
|
249
|
+
aria-selected={tab === tk.key}
|
|
250
|
+
className={`${s.tab} ${tab === tk.key ? s.tabActive : ''} ${isAlert ? s.tabAlert : ''}`}
|
|
251
|
+
onClick={() => { setTab(tk.key); setSelectedIdx(-1) }}
|
|
252
|
+
>
|
|
253
|
+
{tk.label}
|
|
254
|
+
<span className={`${s.tabCount} ${isAlert ? s.tabCountAlert : ''}`}>{tk.count}</span>
|
|
255
|
+
</button>
|
|
256
|
+
)
|
|
257
|
+
})}
|
|
224
258
|
</div>
|
|
225
259
|
|
|
226
260
|
{/* Sort + Bulk */}
|
|
@@ -264,12 +298,20 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
264
298
|
const displayClient = clientName ? `${clientName}${clientCompany ? `, ${clientCompany}` : ''}` : '—'
|
|
265
299
|
const isUnread = tk.lastClientMessageAt && (!tk.lastAdminReadAt || new Date(tk.lastClientMessageAt) > new Date(tk.lastAdminReadAt))
|
|
266
300
|
const priorityColor = PRIORITY_COLORS[tk.priority] || 'transparent'
|
|
301
|
+
const sla = computeSlaState(tk)
|
|
302
|
+
const isBreach = sla.state === 'breach'
|
|
303
|
+
const statusVariant: 'open' | 'pending' | 'resolved' | 'closed' = STATUS_VARIANT[tk.status] || 'open'
|
|
267
304
|
|
|
268
305
|
return (
|
|
269
306
|
<a
|
|
270
307
|
key={tk.id}
|
|
271
308
|
href={`/admin/support/ticket?id=${tk.id}`}
|
|
272
|
-
className={
|
|
309
|
+
className={[
|
|
310
|
+
s.row,
|
|
311
|
+
idx === selectedIdx ? s.rowSelected : '',
|
|
312
|
+
isUnread ? s.rowUnread : '',
|
|
313
|
+
isBreach ? s.rowBreach : '',
|
|
314
|
+
].filter(Boolean).join(' ')}
|
|
273
315
|
onClick={(e) => { e.preventDefault(); window.location.href = `/admin/support/ticket?id=${tk.id}` }}
|
|
274
316
|
>
|
|
275
317
|
<input
|
|
@@ -277,15 +319,30 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
277
319
|
checked={checkedIds.has(tk.id)}
|
|
278
320
|
onClick={(e) => e.stopPropagation()}
|
|
279
321
|
onChange={() => toggleCheck(tk.id)}
|
|
280
|
-
|
|
322
|
+
className={s.rowCheck}
|
|
323
|
+
aria-label={`Sélectionner ${tk.ticketNumber}`}
|
|
324
|
+
/>
|
|
325
|
+
<span
|
|
326
|
+
className={s.priorityDot}
|
|
327
|
+
style={{ background: priorityColor }}
|
|
328
|
+
aria-label={`Priorité ${tk.priority}`}
|
|
329
|
+
title={`Priorité ${tk.priority}`}
|
|
281
330
|
/>
|
|
282
|
-
<
|
|
283
|
-
<span className={s.statusText}>{t(STATUS_LABEL_KEYS[tk.status] || 'ticket.status.open')}</span>
|
|
331
|
+
<StatusPill status={isBreach ? 'sla-breach' : statusVariant} />
|
|
284
332
|
<span className={s.ticketNum}>{tk.ticketNumber}</span>
|
|
285
333
|
<span className={s.subject}>{tk.subject}</span>
|
|
286
334
|
<span className={s.client}>{displayClient}</span>
|
|
287
|
-
|
|
288
|
-
|
|
335
|
+
<span
|
|
336
|
+
className={[
|
|
337
|
+
s.slaCell,
|
|
338
|
+
sla.state === 'breach' ? s.slaBreach : '',
|
|
339
|
+
sla.state === 'warn' ? s.slaWarn : '',
|
|
340
|
+
sla.state === 'ok' ? s.slaOk : '',
|
|
341
|
+
].filter(Boolean).join(' ')}
|
|
342
|
+
title={sla.due ? new Date(sla.due).toLocaleString('fr-FR') : ''}
|
|
343
|
+
>
|
|
344
|
+
{formatSlaRemaining(sla.remainingMs)}
|
|
345
|
+
</span>
|
|
289
346
|
<span className={s.timeAgo}>{relativeTime(tk.updatedAt)}</span>
|
|
290
347
|
{isUnread ? <div className={s.unreadDot} /> : <span />}
|
|
291
348
|
</a>
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// StatusPill — sémantique unique des statuts ticket
|
|
2
|
+
// Tous les fonds/couleurs sont mappés sur les --theme-* Payload natifs
|
|
3
|
+
// via color-mix() pour rester cohérent en light/dark.
|
|
4
|
+
|
|
5
|
+
.pill {
|
|
6
|
+
display: inline-flex;
|
|
7
|
+
align-items: center;
|
|
8
|
+
gap: 6px;
|
|
9
|
+
height: 22px;
|
|
10
|
+
padding: 0 8px;
|
|
11
|
+
border-radius: 999px;
|
|
12
|
+
font-size: 11px;
|
|
13
|
+
font-weight: 500;
|
|
14
|
+
letter-spacing: 0.01em;
|
|
15
|
+
white-space: nowrap;
|
|
16
|
+
border: 1px solid transparent;
|
|
17
|
+
line-height: 1;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
.dot {
|
|
21
|
+
width: 6px;
|
|
22
|
+
height: 6px;
|
|
23
|
+
border-radius: 50%;
|
|
24
|
+
background: currentColor;
|
|
25
|
+
flex-shrink: 0;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ---------- Variants ----------
|
|
29
|
+
// Each variant uses color-mix on Payload's theme tokens so dark/light just works.
|
|
30
|
+
|
|
31
|
+
.open {
|
|
32
|
+
background: color-mix(in srgb, var(--theme-success-500, #2dbf86) 12%, transparent);
|
|
33
|
+
color: var(--theme-success-500, #16a34a);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.pending {
|
|
37
|
+
background: color-mix(in srgb, var(--theme-warning-500, #e9a23a) 14%, transparent);
|
|
38
|
+
color: var(--theme-warning-500, #c97a0a);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
.resolved {
|
|
42
|
+
background: color-mix(in srgb, var(--theme-elevation-500, #7a7a7a) 14%, transparent);
|
|
43
|
+
color: var(--theme-elevation-600, #5e5e5e);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.neutral {
|
|
47
|
+
background: var(--theme-elevation-100, #f3f3f3);
|
|
48
|
+
color: var(--theme-elevation-600, #5e5e5e);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.escalated {
|
|
52
|
+
background: color-mix(in srgb, var(--theme-error-500, #c4392c) 14%, transparent);
|
|
53
|
+
color: var(--theme-error-500, #c4392c);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.breach {
|
|
57
|
+
background: color-mix(in srgb, var(--theme-error-500, #c4392c) 14%, transparent);
|
|
58
|
+
color: var(--theme-error-500, #c4392c);
|
|
59
|
+
border-color: color-mix(in srgb, var(--theme-error-500, #c4392c) 30%, transparent);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
[data-theme='dark'] {
|
|
63
|
+
.open { color: var(--theme-success-500, #2dbf86); }
|
|
64
|
+
.pending { color: var(--theme-warning-500, #e9a23a); }
|
|
65
|
+
.escalated, .breach { color: var(--theme-error-500, #ed5f51); }
|
|
66
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import React from 'react'
|
|
4
|
+
import s from './StatusPill.module.scss'
|
|
5
|
+
|
|
6
|
+
export type TicketStatusVariant =
|
|
7
|
+
| 'open'
|
|
8
|
+
| 'pending'
|
|
9
|
+
| 'waiting_client'
|
|
10
|
+
| 'resolved'
|
|
11
|
+
| 'closed'
|
|
12
|
+
| 'escalated'
|
|
13
|
+
| 'sla-breach'
|
|
14
|
+
|
|
15
|
+
export interface StatusPillProps {
|
|
16
|
+
status: TicketStatusVariant | string
|
|
17
|
+
label?: string
|
|
18
|
+
className?: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const VARIANT_MAP: Record<string, { cls: string; defaultLabel: string }> = {
|
|
22
|
+
open: { cls: s.open, defaultLabel: 'Ouvert' },
|
|
23
|
+
pending: { cls: s.pending, defaultLabel: 'En attente' },
|
|
24
|
+
waiting_client: { cls: s.pending, defaultLabel: 'Attente client' },
|
|
25
|
+
resolved: { cls: s.resolved, defaultLabel: 'Résolu' },
|
|
26
|
+
closed: { cls: s.neutral, defaultLabel: 'Fermé' },
|
|
27
|
+
escalated: { cls: s.escalated, defaultLabel: 'Escaladé' },
|
|
28
|
+
'sla-breach': { cls: s.breach, defaultLabel: 'SLA dépassé' },
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const StatusPill: React.FC<StatusPillProps> = ({ status, label, className }) => {
|
|
32
|
+
const v = VARIANT_MAP[status] || VARIANT_MAP.open
|
|
33
|
+
const text = label ?? v.defaultLabel
|
|
34
|
+
return (
|
|
35
|
+
<span
|
|
36
|
+
className={[s.pill, v.cls, className].filter(Boolean).join(' ')}
|
|
37
|
+
role="status"
|
|
38
|
+
aria-label={`Statut : ${text}`}
|
|
39
|
+
>
|
|
40
|
+
<span className={s.dot} aria-hidden />
|
|
41
|
+
{text}
|
|
42
|
+
</span>
|
|
43
|
+
)
|
|
44
|
+
}
|
|
@@ -4,3 +4,7 @@ export { AdminViewHeader } from './AdminViewHeader'
|
|
|
4
4
|
export { Skeleton, SkeletonText, SkeletonCard, SkeletonTable, SkeletonDashboard } from './Skeleton'
|
|
5
5
|
export { getFeatures, saveFeatures, DEFAULT_FEATURES } from './config'
|
|
6
6
|
export type { TicketingFeatures } from './config'
|
|
7
|
+
export { StatusPill } from './StatusPill'
|
|
8
|
+
export type { StatusPillProps, TicketStatusVariant } from './StatusPill'
|
|
9
|
+
export { computeSlaState, formatSlaRemaining } from './sla'
|
|
10
|
+
export type { SlaState } from './sla'
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// SLA helpers — derive remaining time + visual state from ticket's
|
|
2
|
+
// slaFirstResponseDue / slaFirstResponseBreached fields (already on Tickets).
|
|
3
|
+
|
|
4
|
+
export type SlaState = 'ok' | 'warn' | 'breach' | 'none' | 'met'
|
|
5
|
+
|
|
6
|
+
export interface SlaInput {
|
|
7
|
+
slaFirstResponseDue?: string | null
|
|
8
|
+
slaFirstResponseBreached?: boolean | null
|
|
9
|
+
firstResponseAt?: string | null
|
|
10
|
+
slaResolutionDue?: string | null
|
|
11
|
+
slaResolutionBreached?: boolean | null
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const HOUR_MS = 3_600_000
|
|
15
|
+
const MINUTE_MS = 60_000
|
|
16
|
+
|
|
17
|
+
export function computeSlaState(t: SlaInput, now: Date = new Date()): {
|
|
18
|
+
state: SlaState
|
|
19
|
+
remainingMs: number | null
|
|
20
|
+
due: string | null
|
|
21
|
+
} {
|
|
22
|
+
// Once first-response is met, fall back to resolution SLA.
|
|
23
|
+
const hasFirstResponse = !!t.firstResponseAt
|
|
24
|
+
const dueRaw = hasFirstResponse ? t.slaResolutionDue : t.slaFirstResponseDue
|
|
25
|
+
const breached = hasFirstResponse ? !!t.slaResolutionBreached : !!t.slaFirstResponseBreached
|
|
26
|
+
|
|
27
|
+
if (!dueRaw) return { state: 'none', remainingMs: null, due: null }
|
|
28
|
+
|
|
29
|
+
const dueDate = new Date(dueRaw)
|
|
30
|
+
if (Number.isNaN(dueDate.getTime())) return { state: 'none', remainingMs: null, due: null }
|
|
31
|
+
|
|
32
|
+
const remaining = dueDate.getTime() - now.getTime()
|
|
33
|
+
|
|
34
|
+
if (breached || remaining < 0) {
|
|
35
|
+
return { state: 'breach', remainingMs: remaining, due: dueRaw }
|
|
36
|
+
}
|
|
37
|
+
if (remaining < HOUR_MS) {
|
|
38
|
+
return { state: 'warn', remainingMs: remaining, due: dueRaw }
|
|
39
|
+
}
|
|
40
|
+
return { state: 'ok', remainingMs: remaining, due: dueRaw }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function formatSlaRemaining(remainingMs: number | null): string {
|
|
44
|
+
if (remainingMs == null) return '—'
|
|
45
|
+
const abs = Math.abs(remainingMs)
|
|
46
|
+
const sign = remainingMs < 0 ? '−' : ''
|
|
47
|
+
if (abs < HOUR_MS) {
|
|
48
|
+
const m = Math.floor(abs / MINUTE_MS)
|
|
49
|
+
return `${sign}${m}m`
|
|
50
|
+
}
|
|
51
|
+
const h = Math.floor(abs / HOUR_MS)
|
|
52
|
+
const m = Math.floor((abs % HOUR_MS) / MINUTE_MS)
|
|
53
|
+
if (h < 24) return `${sign}${h}h${m > 0 ? ` ${String(m).padStart(2, '0')}` : ''}`
|
|
54
|
+
const d = Math.floor(h / 24)
|
|
55
|
+
return `${sign}${d}j`
|
|
56
|
+
}
|