@consilioweb/payload-support 0.10.0 → 0.11.0
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 +25 -1
- package/dist/components/TicketConversation/locales/fr.json +25 -1
- package/dist/index.cjs +799 -4
- package/dist/index.d.cts +15 -1
- package/dist/index.d.ts +15 -1
- package/dist/index.js +800 -6
- package/dist/styles/TicketDetail.module.scss +294 -0
- package/dist/views/TicketDetailView/client.cjs +455 -249
- package/dist/views/TicketDetailView/client.js +456 -250
- package/dist/views/TicketInboxView/client.cjs +10 -9
- package/dist/views/TicketInboxView/client.js +2 -1
- package/package.json +1 -1
- package/src/collections/ClientSummaries.ts +16 -0
- package/src/collections/TicketCollaborators.ts +93 -0
- package/src/collections/TicketFeedback.ts +116 -0
- package/src/collections/TicketMessages.ts +9 -0
- package/src/collections/Tickets.ts +31 -1
- package/src/collections/index.ts +2 -0
- package/src/components/TicketConversation/locales/en.json +25 -1
- package/src/components/TicketConversation/locales/fr.json +25 -1
- package/src/endpoints/escalate.ts +44 -0
- package/src/endpoints/index.ts +15 -0
- package/src/endpoints/invite-collaborator.ts +215 -0
- package/src/endpoints/kb-search.ts +156 -0
- package/src/endpoints/ticket-feedback.ts +104 -0
- package/src/endpoints/transfer-ticket.ts +248 -0
- package/src/index.ts +1 -0
- package/src/plugin.ts +4 -0
- package/src/portal/auth/ChatWidget.tsx +11 -0
- package/src/portal/auth/dashboard/DashboardClient.tsx +85 -99
- package/src/portal/auth/dashboard/page.tsx +11 -2
- package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +402 -0
- package/src/portal/auth/tickets/detail/page.tsx +122 -23
- package/src/portal/auth/tickets/new/KbDeflection.tsx +128 -0
- package/src/portal/auth/tickets/new/page.tsx +203 -100
- package/src/portal/locales/en.json +5 -0
- package/src/portal/locales/fr.json +5 -0
- package/src/styles/TicketDetail.module.scss +294 -0
- package/src/types.ts +19 -0
- package/src/utils/slugs.ts +2 -0
- package/src/views/TicketDetailView/client.tsx +346 -89
- package/src/views/TicketInboxView/client.tsx +2 -1
|
@@ -14,8 +14,14 @@ import s from '../../styles/TicketDetail.module.scss'
|
|
|
14
14
|
interface Message {
|
|
15
15
|
id: string | number; body: string; bodyHtml?: string; authorType: 'client' | 'admin' | 'email'
|
|
16
16
|
isInternal?: boolean; isSolution?: boolean; createdAt: string; fromChat?: boolean
|
|
17
|
+
fromAlias?: string
|
|
17
18
|
attachments?: Array<{ file: { id: number; url?: string; filename?: string; mimeType?: string } | number }>
|
|
18
19
|
}
|
|
20
|
+
|
|
21
|
+
const ALIASES: Array<{ value: string; label: string }> = [
|
|
22
|
+
{ value: 'Support Consilioweb', label: 'En tant que : Support Consilioweb' },
|
|
23
|
+
{ value: 'contact@consilioweb.fr', label: 'En tant que : contact@consilioweb.fr' },
|
|
24
|
+
]
|
|
19
25
|
interface ClientInfo { id: number; company: string; firstName: string; lastName: string; email: string; phone?: string }
|
|
20
26
|
interface TimeEntry { id: string | number; duration: number; description?: string; date: string }
|
|
21
27
|
interface ActivityEntry { id: string | number; action: string; detail?: string; actorType?: string; createdAt: string }
|
|
@@ -111,6 +117,31 @@ const RewriteDropdown: React.FC<{
|
|
|
111
117
|
)
|
|
112
118
|
}
|
|
113
119
|
|
|
120
|
+
function NextActionItem({ action, index, onToggle }: { action: any; index: number; onToggle: (done: boolean) => Promise<void> }) {
|
|
121
|
+
const [pending, setPending] = useState(false)
|
|
122
|
+
const labelMap: Record<string, string> = { now: 'Maintenant', today: "Aujourd'hui", 'this-week': 'Cette semaine' }
|
|
123
|
+
const done = !!action.done
|
|
124
|
+
return (
|
|
125
|
+
<li className={s.aiCardNextAction}>
|
|
126
|
+
<button
|
|
127
|
+
type="button"
|
|
128
|
+
className={`${s.aiCardNextCheck} ${done ? s.aiCardNextCheckDone : ''}`}
|
|
129
|
+
onClick={async () => {
|
|
130
|
+
setPending(true)
|
|
131
|
+
try { await onToggle(!done) } finally { setPending(false) }
|
|
132
|
+
}}
|
|
133
|
+
aria-pressed={done}
|
|
134
|
+
aria-label={done ? 'Marquer non fait' : 'Marquer fait'}
|
|
135
|
+
disabled={pending}
|
|
136
|
+
>
|
|
137
|
+
{done && <span aria-hidden>✓</span>}
|
|
138
|
+
</button>
|
|
139
|
+
<span className={`${s.aiCardNextLabel} ${done ? s.aiCardNextLabelDone : ''}`}>{action.label}</span>
|
|
140
|
+
<span className={s.aiCardNextWhen}>{labelMap[action.priority] || action.priority}</span>
|
|
141
|
+
</li>
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
114
145
|
export const TicketDetailClient: React.FC = () => {
|
|
115
146
|
const { t } = useTranslation()
|
|
116
147
|
const searchParams = useSearchParams()
|
|
@@ -134,6 +165,7 @@ export const TicketDetailClient: React.FC = () => {
|
|
|
134
165
|
const [isInternal, setIsInternal] = useState(false)
|
|
135
166
|
const [notifyClient, setNotifyClient] = useState(true)
|
|
136
167
|
const [sendAsClient, setSendAsClient] = useState(false)
|
|
168
|
+
const [fromAlias, setFromAlias] = useState<string>('')
|
|
137
169
|
// Inline message edit
|
|
138
170
|
const [editingMsgId, setEditingMsgId] = useState<string | number | null>(null)
|
|
139
171
|
const [editingBody, setEditingBody] = useState('')
|
|
@@ -145,12 +177,25 @@ export const TicketDetailClient: React.FC = () => {
|
|
|
145
177
|
const [showMenu, setShowMenu] = useState(false)
|
|
146
178
|
const [clientTyping, setClientTyping] = useState(false)
|
|
147
179
|
const [aiReplying, setAiReplying] = useState(false)
|
|
180
|
+
const [aiSuggestion, setAiSuggestion] = useState<string | null>(null)
|
|
148
181
|
const [aiRewriting, setAiRewriting] = useState(false)
|
|
149
182
|
const [sentiment, setSentiment] = useState<{ emoji: string; label: string; color: string } | null>(null)
|
|
150
183
|
const [statusUpdating, setStatusUpdating] = useState(false)
|
|
151
|
-
|
|
184
|
+
// Sidebar tabs (overview | client | activity) — persisted in localStorage
|
|
185
|
+
type SidebarTab = 'overview' | 'client' | 'activity'
|
|
186
|
+
const [sidebarTab, setSidebarTab] = useState<SidebarTab>(() => {
|
|
187
|
+
if (typeof window === 'undefined') return 'overview'
|
|
188
|
+
const v = localStorage.getItem('support_sidebar_tab')
|
|
189
|
+
return v === 'client' || v === 'activity' ? v : 'overview'
|
|
190
|
+
})
|
|
191
|
+
useEffect(() => {
|
|
192
|
+
if (typeof window === 'undefined') return
|
|
193
|
+
localStorage.setItem('support_sidebar_tab', sidebarTab)
|
|
194
|
+
}, [sidebarTab])
|
|
195
|
+
// Previous tickets of the same client (loaded only for the "client" tab)
|
|
196
|
+
const [previousTickets, setPreviousTickets] = useState<Array<{ id: number; subject?: string; status?: string; createdAt: string }>>([])
|
|
152
197
|
// Client Intelligence
|
|
153
|
-
const [clientSummary, setClientSummary] = useState<{ summary: string; recurringTopics?: { topic: string; count: number }[]; keyFacts?: string[] } | null>(null)
|
|
198
|
+
const [clientSummary, setClientSummary] = useState<{ id?: string | number; summary: string; recurringTopics?: { topic: string; count: number }[]; keyFacts?: string[]; nextActions?: Array<{ id?: string; label: string; priority?: 'now' | 'today' | 'this-week'; done?: boolean }> } | null>(null)
|
|
154
199
|
const [summaryLoading, setSummaryLoading] = useState(false)
|
|
155
200
|
const [timerRunning, setTimerRunning] = useState(() => {
|
|
156
201
|
if (typeof window === 'undefined' || !ticketId) return false
|
|
@@ -231,6 +276,21 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
231
276
|
.catch(() => {})
|
|
232
277
|
.finally(() => setSummaryLoading(false))
|
|
233
278
|
}, [ticket?.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
279
|
+
// Fetch previous tickets of the same client when "Client" tab is active
|
|
280
|
+
useEffect(() => {
|
|
281
|
+
if (sidebarTab !== 'client' || !ticket || !ticketId) return
|
|
282
|
+
const clientId = typeof ticket.client === 'object' ? (ticket.client as { id?: number })?.id : ticket.client
|
|
283
|
+
if (!clientId) return
|
|
284
|
+
let aborted = false
|
|
285
|
+
fetch(
|
|
286
|
+
`/api/tickets?where[client][equals]=${clientId}&where[id][not_equals]=${ticketId}&limit=5&sort=-createdAt&depth=0`,
|
|
287
|
+
{ credentials: 'include' },
|
|
288
|
+
)
|
|
289
|
+
.then((r) => (r.ok ? r.json() : null))
|
|
290
|
+
.then((d) => { if (!aborted && d?.docs) setPreviousTickets(d.docs) })
|
|
291
|
+
.catch(() => {})
|
|
292
|
+
return () => { aborted = true }
|
|
293
|
+
}, [sidebarTab, ticket?.id, ticketId]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
234
294
|
useEffect(() => { // Poll 10s
|
|
235
295
|
if (!ticketId || loading) return
|
|
236
296
|
const iv = setInterval(async () => {
|
|
@@ -410,10 +470,11 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
410
470
|
...(finalHtml ? { bodyHtml: finalHtml } : {}),
|
|
411
471
|
authorType: sendAsClient ? 'client' : 'admin',
|
|
412
472
|
...(sendAsClient && client ? { authorClient: client.id } : {}),
|
|
473
|
+
...(fromAlias && !sendAsClient ? { fromAlias } : {}),
|
|
413
474
|
isInternal: sendAsClient ? false : isInternal,
|
|
414
475
|
skipNotification: sendAsClient || isInternal || !notifyClient,
|
|
415
476
|
}) })
|
|
416
|
-
if (res.ok) { setReplyBody(''); setReplyHtml(''); setIsInternal(false); setSendAsClient(false); setPendingFiles([]); editorRef.current?.clear(); fetchAll() }
|
|
477
|
+
if (res.ok) { setReplyBody(''); setReplyHtml(''); setIsInternal(false); setSendAsClient(false); setFromAlias(''); setPendingFiles([]); editorRef.current?.clear(); fetchAll() }
|
|
417
478
|
} catch {} finally { setSending(false) }
|
|
418
479
|
}
|
|
419
480
|
|
|
@@ -496,12 +557,38 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
496
557
|
}
|
|
497
558
|
|
|
498
559
|
const handleAiSuggest = async () => {
|
|
499
|
-
if (messages.length === 0) return
|
|
560
|
+
if (messages.length === 0) return
|
|
561
|
+
setAiReplying(true)
|
|
500
562
|
try {
|
|
501
|
-
const r = await fetch('/api/support/ai', {
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
563
|
+
const r = await fetch('/api/support/ai', {
|
|
564
|
+
method: 'POST',
|
|
565
|
+
headers: { 'Content-Type': 'application/json' },
|
|
566
|
+
credentials: 'include',
|
|
567
|
+
body: JSON.stringify({
|
|
568
|
+
action: 'suggest_reply',
|
|
569
|
+
messages: messages.slice(-10).map((m) => ({ authorType: m.authorType, body: m.body })),
|
|
570
|
+
clientName: `${client?.firstName || ''} ${client?.lastName || ''}`.trim(),
|
|
571
|
+
clientCompany: client?.company,
|
|
572
|
+
}),
|
|
573
|
+
})
|
|
574
|
+
if (r.ok) {
|
|
575
|
+
const d = await r.json()
|
|
576
|
+
// Show the suggestion in a preview banner instead of inserting directly into the editor
|
|
577
|
+
if (d.reply) setAiSuggestion(d.reply)
|
|
578
|
+
}
|
|
579
|
+
} catch {
|
|
580
|
+
/* silent */
|
|
581
|
+
} finally {
|
|
582
|
+
setAiReplying(false)
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const handleAiSuggestionInsert = () => {
|
|
587
|
+
if (!aiSuggestion) return
|
|
588
|
+
setReplyBody(aiSuggestion)
|
|
589
|
+
setReplyHtml(aiSuggestion.replace(/\n/g, '<br/>'))
|
|
590
|
+
editorRef.current?.setContent(aiSuggestion.replace(/\n/g, '<br/>'))
|
|
591
|
+
setAiSuggestion(null)
|
|
505
592
|
}
|
|
506
593
|
|
|
507
594
|
const handleAiRewrite = async (style: string = 'auto') => {
|
|
@@ -684,7 +771,7 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
684
771
|
</div>
|
|
685
772
|
<div className={s.messageContent}>
|
|
686
773
|
<div className={s.messageHeader}>
|
|
687
|
-
<span className={s.messageAuthor}>{isAdmin ? 'Support' : msg.authorType === 'email' ? 'Email' : client?.firstName || 'Client'}</span>
|
|
774
|
+
<span className={s.messageAuthor}>{msg.fromAlias || (isAdmin ? 'Support' : msg.authorType === 'email' ? 'Email' : client?.firstName || 'Client')}</span>
|
|
688
775
|
<span className={s.messageTime}>{timeAgo(msg.createdAt)}</span>
|
|
689
776
|
{msg.isInternal && <span className={s.badge} style={{ background: '#fef3c7', color: '#92400e' }}>Interne</span>}
|
|
690
777
|
{msg.isSolution && <span className={s.badge} style={{ background: '#dcfce7', color: '#166534' }}>Solution</span>}
|
|
@@ -776,6 +863,31 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
776
863
|
</div>
|
|
777
864
|
)}
|
|
778
865
|
|
|
866
|
+
{/* AI suggestion preview banner — appears above composer, preserves user's draft */}
|
|
867
|
+
{aiSuggestion && (
|
|
868
|
+
<div className={s.aiSuggestionPreview} role="region" aria-label={t('composer.aiSuggestion.label')}>
|
|
869
|
+
<div className={s.aiSuggestionPreviewHeader}>
|
|
870
|
+
<span className={s.aiSuggestionIcon} aria-hidden>✨</span>
|
|
871
|
+
<span className={s.aiSuggestionLabel}>{t('composer.aiSuggestion.label')}</span>
|
|
872
|
+
<button
|
|
873
|
+
type="button"
|
|
874
|
+
className={s.aiSuggestionDismiss}
|
|
875
|
+
onClick={() => setAiSuggestion(null)}
|
|
876
|
+
aria-label={t('composer.aiSuggestion.ignore')}
|
|
877
|
+
>×</button>
|
|
878
|
+
</div>
|
|
879
|
+
<div className={s.aiSuggestionBody}>{aiSuggestion}</div>
|
|
880
|
+
<div className={s.aiSuggestionActions}>
|
|
881
|
+
<button type="button" className={s.aiSuggestionIgnore} onClick={() => setAiSuggestion(null)}>
|
|
882
|
+
{t('composer.aiSuggestion.ignore')}
|
|
883
|
+
</button>
|
|
884
|
+
<button type="button" className={s.aiSuggestionInsert} onClick={handleAiSuggestionInsert}>
|
|
885
|
+
{t('composer.aiSuggestion.insert')}
|
|
886
|
+
</button>
|
|
887
|
+
</div>
|
|
888
|
+
</div>
|
|
889
|
+
)}
|
|
890
|
+
|
|
779
891
|
{/* COMPOSER */}
|
|
780
892
|
<div
|
|
781
893
|
className={`${s.composer} ${isInternal ? s.composerInternal : ''} ${composerDragOver ? s.composerDragOver : ''}`}
|
|
@@ -793,7 +905,7 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
793
905
|
onClick={handleAiSuggest}
|
|
794
906
|
disabled={aiReplying || messages.length === 0}
|
|
795
907
|
>
|
|
796
|
-
{aiReplying ? '
|
|
908
|
+
{aiReplying ? `⏳ ${t('composer.aiSuggestion.loading')}` : `✨ ${t('detail.iaSuggestion')}`}
|
|
797
909
|
</button>
|
|
798
910
|
<RewriteDropdown disabled={aiRewriting || !replyBody.trim()} loading={aiRewriting} onSelect={(style) => handleAiRewrite(style)} toolbarBtnClass={s.toolbarBtn} />
|
|
799
911
|
</>
|
|
@@ -878,6 +990,18 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
878
990
|
<option value="admin">En tant que : Support</option>
|
|
879
991
|
<option value="client">En tant que : Client</option>
|
|
880
992
|
</select>
|
|
993
|
+
{!sendAsClient && (
|
|
994
|
+
<select
|
|
995
|
+
className={s.composerAliasSelect}
|
|
996
|
+
value={fromAlias}
|
|
997
|
+
onChange={(e) => setFromAlias(e.target.value)}
|
|
998
|
+
aria-label={t('composer.aliases.label')}
|
|
999
|
+
title={t('composer.aliases.tooltip')}
|
|
1000
|
+
>
|
|
1001
|
+
<option value="">{t('composer.aliases.self')}</option>
|
|
1002
|
+
{ALIASES.map(a => <option key={a.value} value={a.value}>{a.label}</option>)}
|
|
1003
|
+
</select>
|
|
1004
|
+
)}
|
|
881
1005
|
{!sendAsClient && (
|
|
882
1006
|
<>
|
|
883
1007
|
<label><input type="checkbox" checked={isInternal} onChange={(e) => setIsInternal(e.target.checked)} /> {t('detail.internalNote')}</label>
|
|
@@ -897,6 +1021,49 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
897
1021
|
|
|
898
1022
|
{/* RIGHT: Sidebar */}
|
|
899
1023
|
<div className={s.sidebar}>
|
|
1024
|
+
{/* Sticky tabs: Overview / Client / Activity */}
|
|
1025
|
+
<nav role="tablist" aria-label={t('detail.sidebar.tabs.overview')} className={s.sidebarTabs}>
|
|
1026
|
+
<button
|
|
1027
|
+
type="button"
|
|
1028
|
+
role="tab"
|
|
1029
|
+
id="sidebar-tab-overview"
|
|
1030
|
+
aria-selected={sidebarTab === 'overview'}
|
|
1031
|
+
aria-controls="sidebar-panel-overview"
|
|
1032
|
+
tabIndex={sidebarTab === 'overview' ? 0 : -1}
|
|
1033
|
+
className={`${s.sidebarTab} ${sidebarTab === 'overview' ? s.sidebarTabActive : ''}`}
|
|
1034
|
+
onClick={() => setSidebarTab('overview')}
|
|
1035
|
+
>
|
|
1036
|
+
{t('detail.sidebar.tabs.overview')}
|
|
1037
|
+
</button>
|
|
1038
|
+
<button
|
|
1039
|
+
type="button"
|
|
1040
|
+
role="tab"
|
|
1041
|
+
id="sidebar-tab-client"
|
|
1042
|
+
aria-selected={sidebarTab === 'client'}
|
|
1043
|
+
aria-controls="sidebar-panel-client"
|
|
1044
|
+
tabIndex={sidebarTab === 'client' ? 0 : -1}
|
|
1045
|
+
className={`${s.sidebarTab} ${sidebarTab === 'client' ? s.sidebarTabActive : ''}`}
|
|
1046
|
+
onClick={() => setSidebarTab('client')}
|
|
1047
|
+
>
|
|
1048
|
+
{t('detail.sidebar.tabs.client')}
|
|
1049
|
+
</button>
|
|
1050
|
+
<button
|
|
1051
|
+
type="button"
|
|
1052
|
+
role="tab"
|
|
1053
|
+
id="sidebar-tab-activity"
|
|
1054
|
+
aria-selected={sidebarTab === 'activity'}
|
|
1055
|
+
aria-controls="sidebar-panel-activity"
|
|
1056
|
+
tabIndex={sidebarTab === 'activity' ? 0 : -1}
|
|
1057
|
+
className={`${s.sidebarTab} ${sidebarTab === 'activity' ? s.sidebarTabActive : ''}`}
|
|
1058
|
+
onClick={() => setSidebarTab('activity')}
|
|
1059
|
+
>
|
|
1060
|
+
{t('detail.sidebar.tabs.activity')}
|
|
1061
|
+
</button>
|
|
1062
|
+
</nav>
|
|
1063
|
+
|
|
1064
|
+
{/* ===== TAB: OVERVIEW ===== */}
|
|
1065
|
+
{sidebarTab === 'overview' && (
|
|
1066
|
+
<div role="tabpanel" id="sidebar-panel-overview" aria-labelledby="sidebar-tab-overview">
|
|
900
1067
|
{client && (
|
|
901
1068
|
<div className={s.sideSection}>
|
|
902
1069
|
<div className={s.clientCard}>
|
|
@@ -939,6 +1106,39 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
939
1106
|
))}
|
|
940
1107
|
</ul>
|
|
941
1108
|
)}
|
|
1109
|
+
{clientSummary?.nextActions && clientSummary.nextActions.length > 0 && (
|
|
1110
|
+
<>
|
|
1111
|
+
<h5 className={s.aiCardNextTitle}>{t('detail.aiCard.nextActions.title')}</h5>
|
|
1112
|
+
<ul className={s.aiCardNextActions}>
|
|
1113
|
+
{clientSummary.nextActions.map((action: any, i: number) => (
|
|
1114
|
+
<NextActionItem
|
|
1115
|
+
key={action.id || `na-${i}`}
|
|
1116
|
+
action={action}
|
|
1117
|
+
index={i}
|
|
1118
|
+
onToggle={async (done) => {
|
|
1119
|
+
const updatedActions = (clientSummary.nextActions || []).map((a: any, j: number) =>
|
|
1120
|
+
j === i ? { ...a, done } : a,
|
|
1121
|
+
)
|
|
1122
|
+
// PATCH client-summaries to persist
|
|
1123
|
+
if (clientSummary.id != null) {
|
|
1124
|
+
try {
|
|
1125
|
+
await fetch(`/api/client-summaries/${clientSummary.id}`, {
|
|
1126
|
+
method: 'PATCH',
|
|
1127
|
+
credentials: 'include',
|
|
1128
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1129
|
+
body: JSON.stringify({ nextActions: updatedActions }),
|
|
1130
|
+
})
|
|
1131
|
+
} catch {
|
|
1132
|
+
/* keep local state updated even if persist fails */
|
|
1133
|
+
}
|
|
1134
|
+
}
|
|
1135
|
+
setClientSummary({ ...clientSummary, nextActions: updatedActions })
|
|
1136
|
+
}}
|
|
1137
|
+
/>
|
|
1138
|
+
))}
|
|
1139
|
+
</ul>
|
|
1140
|
+
</>
|
|
1141
|
+
)}
|
|
942
1142
|
</div>
|
|
943
1143
|
)}
|
|
944
1144
|
|
|
@@ -969,6 +1169,72 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
969
1169
|
<div className={s.sideField}><span className={s.sideLabel}>{t('detail.assigned')}</span><span className={s.sideValue}>{typeof ticket.assignedTo === 'object' && ticket.assignedTo ? (ticket.assignedTo as { firstName?: string }).firstName || 'Admin' : '—'}</span></div>
|
|
970
1170
|
</div>
|
|
971
1171
|
|
|
1172
|
+
{features.timeTracking && (
|
|
1173
|
+
<div className={s.sideSection}>
|
|
1174
|
+
<div className={s.sideSectionTitle}>{t('detail.time')} <span style={{ fontWeight: 700, fontSize: 13, color: '#d97706' }}>{totalMin > 0 ? `${Math.floor(totalMin / 60)}h${String(totalMin % 60).padStart(2, '0')} ${t('detail.total')}` : '0min'}</span></div>
|
|
1175
|
+
{/* Timer */}
|
|
1176
|
+
<div className={s.timer}>
|
|
1177
|
+
<span className={`${s.timerDisplay} ${timerRunning ? s.timerActive : ''}`}>
|
|
1178
|
+
{String(Math.floor(timerSeconds / 60)).padStart(2, '0')}:{String(timerSeconds % 60).padStart(2, '0')}
|
|
1179
|
+
</span>
|
|
1180
|
+
{!timerRunning ? (
|
|
1181
|
+
<button className={s.timerBtn} onClick={() => setTimerRunning(true)} style={{ color: '#dc2626', borderColor: '#dc2626' }} aria-label="Démarrer le timer">{timerSeconds > 0 ? '▶' : '▶ Go'}</button>
|
|
1182
|
+
) : (
|
|
1183
|
+
<button className={s.timerBtn} onClick={() => setTimerRunning(false)} aria-label="Mettre en pause le timer">⏸</button>
|
|
1184
|
+
)}
|
|
1185
|
+
{timerSeconds >= 60 && !timerRunning && (
|
|
1186
|
+
<button className={s.timerBtn} onClick={() => { handleTimerSave(); localStorage.removeItem(`timer-sec-${ticketId}`); localStorage.removeItem(`timer-run-${ticketId}`) }} style={{ color: '#16a34a', borderColor: '#16a34a' }} aria-label="Sauvegarder le temps">💾 {Math.round(timerSeconds / 60)}m</button>
|
|
1187
|
+
)}
|
|
1188
|
+
</div>
|
|
1189
|
+
{/* Manual time entry */}
|
|
1190
|
+
<div style={{ display: 'flex', gap: 6, marginTop: 8, alignItems: 'center' }}>
|
|
1191
|
+
<input type="number" min="1" placeholder="min" style={{ width: 60, padding: '4px 8px', borderRadius: 6, border: '1px solid var(--theme-elevation-200)', fontSize: 12, color: 'var(--theme-text)', background: 'var(--theme-elevation-0)' }} id="manual-time-input" />
|
|
1192
|
+
<button className={s.timerBtn} onClick={async () => {
|
|
1193
|
+
const input = document.getElementById('manual-time-input') as HTMLInputElement
|
|
1194
|
+
const mins = Number(input?.value)
|
|
1195
|
+
if (!mins || mins < 1 || !ticketId) return
|
|
1196
|
+
try {
|
|
1197
|
+
await fetch('/api/time-entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ ticket: Number(ticketId), duration: mins, date: new Date().toISOString(), description: 'Saisie manuelle' }) })
|
|
1198
|
+
if (input) input.value = ''
|
|
1199
|
+
fetchAll()
|
|
1200
|
+
} catch {}
|
|
1201
|
+
}} style={{ fontSize: 11 }}>+ Ajouter</button>
|
|
1202
|
+
</div>
|
|
1203
|
+
{/* Billing info */}
|
|
1204
|
+
<div style={{ marginTop: 8, fontSize: 11, color: 'var(--theme-elevation-500)' }}>
|
|
1205
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '2px 0', alignItems: 'center' }}>
|
|
1206
|
+
<span>Facturable</span>
|
|
1207
|
+
<button
|
|
1208
|
+
onClick={async () => {
|
|
1209
|
+
const newVal = ticket.billable === false ? true : false
|
|
1210
|
+
try { await fetch(`/api/tickets/${ticketId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ billable: newVal }) }); fetchAll() } catch {}
|
|
1211
|
+
}}
|
|
1212
|
+
style={{ fontWeight: 600, color: (ticket.billable !== false) ? '#16a34a' : '#dc2626', background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, textDecoration: 'underline' }}
|
|
1213
|
+
>
|
|
1214
|
+
{(ticket.billable !== false) ? 'Oui' : 'Non'}
|
|
1215
|
+
</button>
|
|
1216
|
+
</div>
|
|
1217
|
+
{totalMin > 0 && (
|
|
1218
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '2px 0' }}>
|
|
1219
|
+
<span>Montant estimé</span>
|
|
1220
|
+
<span style={{ fontWeight: 700, color: 'var(--theme-text)' }}>{((totalMin / 60) * 60).toFixed(0)}€</span>
|
|
1221
|
+
</div>
|
|
1222
|
+
)}
|
|
1223
|
+
</div>
|
|
1224
|
+
{/* Time entries */}
|
|
1225
|
+
{timeEntries.length > 0 && (
|
|
1226
|
+
<div style={{ marginTop: 8, fontSize: 11 }}>
|
|
1227
|
+
{timeEntries.slice(0, 6).map((e) => (
|
|
1228
|
+
<div key={e.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0', color: 'var(--theme-elevation-500)' }}>
|
|
1229
|
+
<span>{new Date(e.date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })}</span>
|
|
1230
|
+
<span title={e.description} style={{ fontWeight: 600, cursor: e.description ? 'help' : 'default' }}>{e.duration}min</span>
|
|
1231
|
+
</div>
|
|
1232
|
+
))}
|
|
1233
|
+
</div>
|
|
1234
|
+
)}
|
|
1235
|
+
</div>
|
|
1236
|
+
)}
|
|
1237
|
+
|
|
972
1238
|
{/* #6 — Tags section */}
|
|
973
1239
|
<div className={s.sideSection}>
|
|
974
1240
|
<div className={s.sideSectionTitle}>{t('detail.tags')}</div>
|
|
@@ -1091,94 +1357,85 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
1091
1357
|
</div>
|
|
1092
1358
|
</div>
|
|
1093
1359
|
|
|
1094
|
-
{
|
|
1360
|
+
{summaryLoading && (
|
|
1095
1361
|
<div className={s.sideSection}>
|
|
1096
|
-
<div
|
|
1097
|
-
{/* Timer */}
|
|
1098
|
-
<div className={s.timer}>
|
|
1099
|
-
<span className={`${s.timerDisplay} ${timerRunning ? s.timerActive : ''}`}>
|
|
1100
|
-
{String(Math.floor(timerSeconds / 60)).padStart(2, '0')}:{String(timerSeconds % 60).padStart(2, '0')}
|
|
1101
|
-
</span>
|
|
1102
|
-
{!timerRunning ? (
|
|
1103
|
-
<button className={s.timerBtn} onClick={() => setTimerRunning(true)} style={{ color: '#dc2626', borderColor: '#dc2626' }} aria-label="Démarrer le timer">{timerSeconds > 0 ? '▶' : '▶ Go'}</button>
|
|
1104
|
-
) : (
|
|
1105
|
-
<button className={s.timerBtn} onClick={() => setTimerRunning(false)} aria-label="Mettre en pause le timer">⏸</button>
|
|
1106
|
-
)}
|
|
1107
|
-
{timerSeconds >= 60 && !timerRunning && (
|
|
1108
|
-
<button className={s.timerBtn} onClick={() => { handleTimerSave(); localStorage.removeItem(`timer-sec-${ticketId}`); localStorage.removeItem(`timer-run-${ticketId}`) }} style={{ color: '#16a34a', borderColor: '#16a34a' }} aria-label="Sauvegarder le temps">💾 {Math.round(timerSeconds / 60)}m</button>
|
|
1109
|
-
)}
|
|
1110
|
-
</div>
|
|
1111
|
-
{/* Manual time entry */}
|
|
1112
|
-
<div style={{ display: 'flex', gap: 6, marginTop: 8, alignItems: 'center' }}>
|
|
1113
|
-
<input type="number" min="1" placeholder="min" style={{ width: 60, padding: '4px 8px', borderRadius: 6, border: '1px solid var(--theme-elevation-200)', fontSize: 12, color: 'var(--theme-text)', background: 'var(--theme-elevation-0)' }} id="manual-time-input" />
|
|
1114
|
-
<button className={s.timerBtn} onClick={async () => {
|
|
1115
|
-
const input = document.getElementById('manual-time-input') as HTMLInputElement
|
|
1116
|
-
const mins = Number(input?.value)
|
|
1117
|
-
if (!mins || mins < 1 || !ticketId) return
|
|
1118
|
-
try {
|
|
1119
|
-
await fetch('/api/time-entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ ticket: Number(ticketId), duration: mins, date: new Date().toISOString(), description: 'Saisie manuelle' }) })
|
|
1120
|
-
if (input) input.value = ''
|
|
1121
|
-
fetchAll()
|
|
1122
|
-
} catch {}
|
|
1123
|
-
}} style={{ fontSize: 11 }}>+ Ajouter</button>
|
|
1124
|
-
</div>
|
|
1125
|
-
{/* Billing info */}
|
|
1126
|
-
<div style={{ marginTop: 8, fontSize: 11, color: 'var(--theme-elevation-500)' }}>
|
|
1127
|
-
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '2px 0', alignItems: 'center' }}>
|
|
1128
|
-
<span>Facturable</span>
|
|
1129
|
-
<button
|
|
1130
|
-
onClick={async () => {
|
|
1131
|
-
const newVal = ticket.billable === false ? true : false
|
|
1132
|
-
try { await fetch(`/api/tickets/${ticketId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ billable: newVal }) }); fetchAll() } catch {}
|
|
1133
|
-
}}
|
|
1134
|
-
style={{ fontWeight: 600, color: (ticket.billable !== false) ? '#16a34a' : '#dc2626', background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, textDecoration: 'underline' }}
|
|
1135
|
-
>
|
|
1136
|
-
{(ticket.billable !== false) ? 'Oui' : 'Non'}
|
|
1137
|
-
</button>
|
|
1138
|
-
</div>
|
|
1139
|
-
{totalMin > 0 && (
|
|
1140
|
-
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '2px 0' }}>
|
|
1141
|
-
<span>Montant estimé</span>
|
|
1142
|
-
<span style={{ fontWeight: 700, color: 'var(--theme-text)' }}>{((totalMin / 60) * 60).toFixed(0)}€</span>
|
|
1143
|
-
</div>
|
|
1144
|
-
)}
|
|
1145
|
-
</div>
|
|
1146
|
-
{/* Time entries */}
|
|
1147
|
-
{timeEntries.length > 0 && (
|
|
1148
|
-
<div style={{ marginTop: 8, fontSize: 11 }}>
|
|
1149
|
-
{timeEntries.slice(0, 6).map((e) => (
|
|
1150
|
-
<div key={e.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0', color: 'var(--theme-elevation-500)' }}>
|
|
1151
|
-
<span>{new Date(e.date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })}</span>
|
|
1152
|
-
<span title={e.description} style={{ fontWeight: 600, cursor: e.description ? 'help' : 'default' }}>{e.duration}min</span>
|
|
1153
|
-
</div>
|
|
1154
|
-
))}
|
|
1155
|
-
</div>
|
|
1156
|
-
)}
|
|
1362
|
+
<div style={{ fontSize: 11, color: 'var(--theme-elevation-400)', textAlign: 'center', padding: 8 }}>✨ Chargement de la synthèse…</div>
|
|
1157
1363
|
</div>
|
|
1158
1364
|
)}
|
|
1365
|
+
</div>
|
|
1366
|
+
)}
|
|
1159
1367
|
|
|
1160
|
-
{
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1368
|
+
{/* ===== TAB: CLIENT (extended) ===== */}
|
|
1369
|
+
{sidebarTab === 'client' && (
|
|
1370
|
+
<div role="tabpanel" id="sidebar-panel-client" aria-labelledby="sidebar-tab-client">
|
|
1371
|
+
{client && (
|
|
1372
|
+
<div className={s.sideSection}>
|
|
1373
|
+
<div className={s.clientCard}>
|
|
1374
|
+
<div className={s.clientAvatar}>{initials}</div>
|
|
1375
|
+
<div className={s.clientInfo}>
|
|
1376
|
+
<div className={s.clientName}>{client.firstName} {client.lastName}</div>
|
|
1377
|
+
<div className={s.clientCompany}>{client.company}</div>
|
|
1378
|
+
<a href={`mailto:${client.email}`} className={s.clientEmail}>{client.email}</a>
|
|
1379
|
+
{client.phone && (
|
|
1380
|
+
<a href={`tel:${client.phone}`} className={s.clientEmail}>{client.phone}</a>
|
|
1381
|
+
)}
|
|
1382
|
+
</div>
|
|
1383
|
+
</div>
|
|
1384
|
+
<div className={s.clientActions}>
|
|
1385
|
+
<Link href={`/admin/collections/support-clients/${client.id}`} className={s.smallBtn}>{t('client.clientSheet')}</Link>
|
|
1386
|
+
<button className={s.smallBtn} onClick={() => window.open(`/api/admin/impersonate?clientId=${client.id}`, '_blank')}>{t('client.clientPortal')}</button>
|
|
1173
1387
|
</div>
|
|
1174
1388
|
</div>
|
|
1175
|
-
)
|
|
1389
|
+
)}
|
|
1390
|
+
|
|
1391
|
+
<div className={s.sideSection}>
|
|
1392
|
+
<div className={s.sideSectionTitle}>{t('detail.sidebar.previousTickets')}</div>
|
|
1393
|
+
{previousTickets.length === 0 ? (
|
|
1394
|
+
<div className={s.previousTicketsEmpty}>—</div>
|
|
1395
|
+
) : (
|
|
1396
|
+
<ul className={s.previousTicketsList}>
|
|
1397
|
+
{previousTickets.map((pt) => (
|
|
1398
|
+
<li key={pt.id} className={s.previousTicketsItem}>
|
|
1399
|
+
<Link href={`/admin/collections/tickets/${pt.id}`} className={s.previousTicketsLink}>
|
|
1400
|
+
<span className={s.previousTicketsSubject}>{pt.subject || `#${pt.id}`}</span>
|
|
1401
|
+
<span className={s.previousTicketsMeta}>
|
|
1402
|
+
{pt.status ? <span className={s.previousTicketsStatus}>{pt.status}</span> : null}
|
|
1403
|
+
<span className={s.previousTicketsDate}>{new Date(pt.createdAt).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' })}</span>
|
|
1404
|
+
</span>
|
|
1405
|
+
</Link>
|
|
1406
|
+
</li>
|
|
1407
|
+
))}
|
|
1408
|
+
</ul>
|
|
1409
|
+
)}
|
|
1410
|
+
</div>
|
|
1176
1411
|
</div>
|
|
1177
1412
|
)}
|
|
1178
1413
|
|
|
1179
|
-
{
|
|
1180
|
-
|
|
1181
|
-
|
|
1414
|
+
{/* ===== TAB: ACTIVITY ===== */}
|
|
1415
|
+
{sidebarTab === 'activity' && (
|
|
1416
|
+
<div role="tabpanel" id="sidebar-panel-activity" aria-labelledby="sidebar-tab-activity">
|
|
1417
|
+
{features.activityLog ? (
|
|
1418
|
+
<div className={s.sideSection}>
|
|
1419
|
+
<div className={s.sideSectionTitle}>{t('detail.activity')}</div>
|
|
1420
|
+
{activityLog.length === 0 ? (
|
|
1421
|
+
<div className={s.previousTicketsEmpty}>—</div>
|
|
1422
|
+
) : (
|
|
1423
|
+
activityLog.slice(0, 30).map((a) => (
|
|
1424
|
+
<div key={a.id} className={s.activityItem}>
|
|
1425
|
+
<div className={s.activityDot} style={{ backgroundColor: a.actorType === 'admin' ? '#2563eb' : a.actorType === 'system' ? '#6b7280' : '#16a34a' }} />
|
|
1426
|
+
<div className={s.activityContent}>
|
|
1427
|
+
<div className={s.activityText}>{(a.detail || a.action).slice(0, 120)}</div>
|
|
1428
|
+
<div className={s.activityTime}>{timeAgo(a.createdAt)}</div>
|
|
1429
|
+
</div>
|
|
1430
|
+
</div>
|
|
1431
|
+
))
|
|
1432
|
+
)}
|
|
1433
|
+
</div>
|
|
1434
|
+
) : (
|
|
1435
|
+
<div className={s.sideSection}>
|
|
1436
|
+
<div className={s.previousTicketsEmpty}>—</div>
|
|
1437
|
+
</div>
|
|
1438
|
+
)}
|
|
1182
1439
|
</div>
|
|
1183
1440
|
)}
|
|
1184
1441
|
</div>
|
|
@@ -4,7 +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
|
|
7
|
+
import { StatusPill } from '../shared/StatusPill'
|
|
8
|
+
import { computeSlaState, formatSlaRemaining } from '../shared/sla'
|
|
8
9
|
import s from '../../styles/TicketInbox.module.scss'
|
|
9
10
|
|
|
10
11
|
interface Ticket {
|