@consilioweb/payload-support 0.10.1 → 0.12.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 +899 -353
- package/dist/views/TicketDetailView/client.cjs +504 -282
- package/dist/views/TicketDetailView/client.js +505 -283
- 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 +899 -353
- package/src/types.ts +19 -0
- package/src/utils/slugs.ts +2 -0
- package/src/views/TicketDetailView/client.tsx +404 -121
|
@@ -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') => {
|
|
@@ -574,12 +661,26 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
574
661
|
|
|
575
662
|
return (
|
|
576
663
|
<div className={s.page}>
|
|
577
|
-
{/* TOP BAR */}
|
|
664
|
+
{/* TOP BAR — detail__header maquette : back-btn + (id+meta + title) + quick-actions */}
|
|
578
665
|
<div className={s.topBar}>
|
|
579
666
|
<Link href="/admin/support/inbox" className={s.backLink} aria-label="Retour à la boîte de réception">←</Link>
|
|
580
667
|
<div className={s.ticketMeta}>
|
|
581
|
-
|
|
582
|
-
<span className={s.
|
|
668
|
+
{/* detail__id : ticketNumber · category · source email */}
|
|
669
|
+
<span className={s.ticketNumber}>
|
|
670
|
+
<span>{ticket.ticketNumber as string}</span>
|
|
671
|
+
{ticket.category ? (
|
|
672
|
+
<>
|
|
673
|
+
<span className={s.ticketNumberSep}>·</span>
|
|
674
|
+
<span className={s.ticketNumberCat}>{ticket.category as string}</span>
|
|
675
|
+
</>
|
|
676
|
+
) : null}
|
|
677
|
+
<span className={s.ticketNumberSep}>·</span>
|
|
678
|
+
<span className={s.ticketNumberSource} aria-label={t('detail.source')}>
|
|
679
|
+
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden><rect x="3" y="5" width="18" height="14" rx="2" /><path d="m3 7 9 6 9-6" /></svg>
|
|
680
|
+
{(ticket.source as string) === 'email' ? 'Email' : (ticket.source as string) || t('ticket.source.portal')}
|
|
681
|
+
</span>
|
|
682
|
+
</span>
|
|
683
|
+
<h1 className={s.ticketSubject}>{ticket.subject as string}</h1>
|
|
583
684
|
</div>
|
|
584
685
|
<div className={s.topBarRight}>
|
|
585
686
|
<select className={s.statusChip} style={{ background: st.bg, color: st.color }} value={(ticket.status as string) || 'open'} onChange={(e) => handleStatusChange(e.target.value)} disabled={statusUpdating} aria-label={t('ticket.status.label')}>
|
|
@@ -684,7 +785,7 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
684
785
|
</div>
|
|
685
786
|
<div className={s.messageContent}>
|
|
686
787
|
<div className={s.messageHeader}>
|
|
687
|
-
<span className={s.messageAuthor}>{isAdmin ? 'Support' : msg.authorType === 'email' ? 'Email' : client?.firstName || 'Client'}</span>
|
|
788
|
+
<span className={s.messageAuthor}>{msg.fromAlias || (isAdmin ? 'Support' : msg.authorType === 'email' ? 'Email' : client?.firstName || 'Client')}</span>
|
|
688
789
|
<span className={s.messageTime}>{timeAgo(msg.createdAt)}</span>
|
|
689
790
|
{msg.isInternal && <span className={s.badge} style={{ background: '#fef3c7', color: '#92400e' }}>Interne</span>}
|
|
690
791
|
{msg.isSolution && <span className={s.badge} style={{ background: '#dcfce7', color: '#166534' }}>Solution</span>}
|
|
@@ -776,6 +877,31 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
776
877
|
</div>
|
|
777
878
|
)}
|
|
778
879
|
|
|
880
|
+
{/* AI suggestion preview banner — appears above composer, preserves user's draft */}
|
|
881
|
+
{aiSuggestion && (
|
|
882
|
+
<div className={s.aiSuggestionPreview} role="region" aria-label={t('composer.aiSuggestion.label')}>
|
|
883
|
+
<div className={s.aiSuggestionPreviewHeader}>
|
|
884
|
+
<span className={s.aiSuggestionIcon} aria-hidden>✨</span>
|
|
885
|
+
<span className={s.aiSuggestionLabel}>{t('composer.aiSuggestion.label')}</span>
|
|
886
|
+
<button
|
|
887
|
+
type="button"
|
|
888
|
+
className={s.aiSuggestionDismiss}
|
|
889
|
+
onClick={() => setAiSuggestion(null)}
|
|
890
|
+
aria-label={t('composer.aiSuggestion.ignore')}
|
|
891
|
+
>×</button>
|
|
892
|
+
</div>
|
|
893
|
+
<div className={s.aiSuggestionBody}>{aiSuggestion}</div>
|
|
894
|
+
<div className={s.aiSuggestionActions}>
|
|
895
|
+
<button type="button" className={s.aiSuggestionIgnore} onClick={() => setAiSuggestion(null)}>
|
|
896
|
+
{t('composer.aiSuggestion.ignore')}
|
|
897
|
+
</button>
|
|
898
|
+
<button type="button" className={s.aiSuggestionInsert} onClick={handleAiSuggestionInsert}>
|
|
899
|
+
{t('composer.aiSuggestion.insert')}
|
|
900
|
+
</button>
|
|
901
|
+
</div>
|
|
902
|
+
</div>
|
|
903
|
+
)}
|
|
904
|
+
|
|
779
905
|
{/* COMPOSER */}
|
|
780
906
|
<div
|
|
781
907
|
className={`${s.composer} ${isInternal ? s.composerInternal : ''} ${composerDragOver ? s.composerDragOver : ''}`}
|
|
@@ -793,7 +919,7 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
793
919
|
onClick={handleAiSuggest}
|
|
794
920
|
disabled={aiReplying || messages.length === 0}
|
|
795
921
|
>
|
|
796
|
-
{aiReplying ? '
|
|
922
|
+
{aiReplying ? `⏳ ${t('composer.aiSuggestion.loading')}` : `✨ ${t('detail.iaSuggestion')}`}
|
|
797
923
|
</button>
|
|
798
924
|
<RewriteDropdown disabled={aiRewriting || !replyBody.trim()} loading={aiRewriting} onSelect={(style) => handleAiRewrite(style)} toolbarBtnClass={s.toolbarBtn} />
|
|
799
925
|
</>
|
|
@@ -835,35 +961,38 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
835
961
|
</select>
|
|
836
962
|
)}
|
|
837
963
|
</div>
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
<
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
964
|
+
{/* composerBox (composer__box maquette) : wrap the Lexical RTE with maquette box style */}
|
|
965
|
+
<div className={s.composerBox}>
|
|
966
|
+
<RichTextEditor
|
|
967
|
+
ref={editorRef}
|
|
968
|
+
onChange={(html, text) => { setReplyHtml(html); setReplyBody(text) }}
|
|
969
|
+
placeholder={isInternal ? t('composer.placeholderInternal') : t('composer.placeholderReplyTo', { name: client?.firstName || 'client' })}
|
|
970
|
+
minHeight={100}
|
|
971
|
+
borderColor="transparent"
|
|
972
|
+
onFileUpload={async (file) => {
|
|
973
|
+
try {
|
|
974
|
+
const formData = new FormData()
|
|
975
|
+
formData.append('file', file)
|
|
976
|
+
formData.append('_payload', JSON.stringify({ alt: file.name }))
|
|
977
|
+
const ur = await fetch('/api/media', { method: 'POST', credentials: 'include', body: formData })
|
|
978
|
+
if (!ur.ok) return null
|
|
979
|
+
const ud = await ur.json()
|
|
980
|
+
return ud.doc?.url || null
|
|
981
|
+
} catch { return null }
|
|
982
|
+
}}
|
|
983
|
+
/>
|
|
984
|
+
{/* #5 — File upload preview */}
|
|
985
|
+
{pendingFiles.length > 0 && (
|
|
986
|
+
<div className={s.uploadPreview}>
|
|
987
|
+
{pendingFiles.map((f, i) => (
|
|
988
|
+
<div key={i} className={s.uploadPreviewItem}>
|
|
989
|
+
<span>PJ {f.name}</span>
|
|
990
|
+
<button className={s.uploadRemoveBtn} aria-label={`Retirer ${f.name}`} onClick={() => setPendingFiles((prev) => prev.filter((_, j) => j !== i))}>×</button>
|
|
991
|
+
</div>
|
|
992
|
+
))}
|
|
993
|
+
</div>
|
|
994
|
+
)}
|
|
995
|
+
</div>
|
|
867
996
|
<div className={s.composerFooter}>
|
|
868
997
|
<div className={s.composerOptions}>
|
|
869
998
|
<select
|
|
@@ -878,6 +1007,18 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
878
1007
|
<option value="admin">En tant que : Support</option>
|
|
879
1008
|
<option value="client">En tant que : Client</option>
|
|
880
1009
|
</select>
|
|
1010
|
+
{!sendAsClient && (
|
|
1011
|
+
<select
|
|
1012
|
+
className={s.composerAliasSelect}
|
|
1013
|
+
value={fromAlias}
|
|
1014
|
+
onChange={(e) => setFromAlias(e.target.value)}
|
|
1015
|
+
aria-label={t('composer.aliases.label')}
|
|
1016
|
+
title={t('composer.aliases.tooltip')}
|
|
1017
|
+
>
|
|
1018
|
+
<option value="">{t('composer.aliases.self')}</option>
|
|
1019
|
+
{ALIASES.map(a => <option key={a.value} value={a.value}>{a.label}</option>)}
|
|
1020
|
+
</select>
|
|
1021
|
+
)}
|
|
881
1022
|
{!sendAsClient && (
|
|
882
1023
|
<>
|
|
883
1024
|
<label><input type="checkbox" checked={isInternal} onChange={(e) => setIsInternal(e.target.checked)} /> {t('detail.internalNote')}</label>
|
|
@@ -897,6 +1038,49 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
897
1038
|
|
|
898
1039
|
{/* RIGHT: Sidebar */}
|
|
899
1040
|
<div className={s.sidebar}>
|
|
1041
|
+
{/* Sticky tabs: Overview / Client / Activity */}
|
|
1042
|
+
<nav role="tablist" aria-label={t('detail.sidebar.tabs.overview')} className={s.sidebarTabs}>
|
|
1043
|
+
<button
|
|
1044
|
+
type="button"
|
|
1045
|
+
role="tab"
|
|
1046
|
+
id="sidebar-tab-overview"
|
|
1047
|
+
aria-selected={sidebarTab === 'overview'}
|
|
1048
|
+
aria-controls="sidebar-panel-overview"
|
|
1049
|
+
tabIndex={sidebarTab === 'overview' ? 0 : -1}
|
|
1050
|
+
className={`${s.sidebarTab} ${sidebarTab === 'overview' ? s.sidebarTabActive : ''}`}
|
|
1051
|
+
onClick={() => setSidebarTab('overview')}
|
|
1052
|
+
>
|
|
1053
|
+
{t('detail.sidebar.tabs.overview')}
|
|
1054
|
+
</button>
|
|
1055
|
+
<button
|
|
1056
|
+
type="button"
|
|
1057
|
+
role="tab"
|
|
1058
|
+
id="sidebar-tab-client"
|
|
1059
|
+
aria-selected={sidebarTab === 'client'}
|
|
1060
|
+
aria-controls="sidebar-panel-client"
|
|
1061
|
+
tabIndex={sidebarTab === 'client' ? 0 : -1}
|
|
1062
|
+
className={`${s.sidebarTab} ${sidebarTab === 'client' ? s.sidebarTabActive : ''}`}
|
|
1063
|
+
onClick={() => setSidebarTab('client')}
|
|
1064
|
+
>
|
|
1065
|
+
{t('detail.sidebar.tabs.client')}
|
|
1066
|
+
</button>
|
|
1067
|
+
<button
|
|
1068
|
+
type="button"
|
|
1069
|
+
role="tab"
|
|
1070
|
+
id="sidebar-tab-activity"
|
|
1071
|
+
aria-selected={sidebarTab === 'activity'}
|
|
1072
|
+
aria-controls="sidebar-panel-activity"
|
|
1073
|
+
tabIndex={sidebarTab === 'activity' ? 0 : -1}
|
|
1074
|
+
className={`${s.sidebarTab} ${sidebarTab === 'activity' ? s.sidebarTabActive : ''}`}
|
|
1075
|
+
onClick={() => setSidebarTab('activity')}
|
|
1076
|
+
>
|
|
1077
|
+
{t('detail.sidebar.tabs.activity')}
|
|
1078
|
+
</button>
|
|
1079
|
+
</nav>
|
|
1080
|
+
|
|
1081
|
+
{/* ===== TAB: OVERVIEW ===== */}
|
|
1082
|
+
{sidebarTab === 'overview' && (
|
|
1083
|
+
<div role="tabpanel" id="sidebar-panel-overview" aria-labelledby="sidebar-tab-overview">
|
|
900
1084
|
{client && (
|
|
901
1085
|
<div className={s.sideSection}>
|
|
902
1086
|
<div className={s.clientCard}>
|
|
@@ -939,6 +1123,39 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
939
1123
|
))}
|
|
940
1124
|
</ul>
|
|
941
1125
|
)}
|
|
1126
|
+
{clientSummary?.nextActions && clientSummary.nextActions.length > 0 && (
|
|
1127
|
+
<>
|
|
1128
|
+
<h5 className={s.aiCardNextTitle}>{t('detail.aiCard.nextActions.title')}</h5>
|
|
1129
|
+
<ul className={s.aiCardNextActions}>
|
|
1130
|
+
{clientSummary.nextActions.map((action: any, i: number) => (
|
|
1131
|
+
<NextActionItem
|
|
1132
|
+
key={action.id || `na-${i}`}
|
|
1133
|
+
action={action}
|
|
1134
|
+
index={i}
|
|
1135
|
+
onToggle={async (done) => {
|
|
1136
|
+
const updatedActions = (clientSummary.nextActions || []).map((a: any, j: number) =>
|
|
1137
|
+
j === i ? { ...a, done } : a,
|
|
1138
|
+
)
|
|
1139
|
+
// PATCH client-summaries to persist
|
|
1140
|
+
if (clientSummary.id != null) {
|
|
1141
|
+
try {
|
|
1142
|
+
await fetch(`/api/client-summaries/${clientSummary.id}`, {
|
|
1143
|
+
method: 'PATCH',
|
|
1144
|
+
credentials: 'include',
|
|
1145
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1146
|
+
body: JSON.stringify({ nextActions: updatedActions }),
|
|
1147
|
+
})
|
|
1148
|
+
} catch {
|
|
1149
|
+
/* keep local state updated even if persist fails */
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
setClientSummary({ ...clientSummary, nextActions: updatedActions })
|
|
1153
|
+
}}
|
|
1154
|
+
/>
|
|
1155
|
+
))}
|
|
1156
|
+
</ul>
|
|
1157
|
+
</>
|
|
1158
|
+
)}
|
|
942
1159
|
</div>
|
|
943
1160
|
)}
|
|
944
1161
|
|
|
@@ -969,6 +1186,81 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
969
1186
|
<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
1187
|
</div>
|
|
971
1188
|
|
|
1189
|
+
{features.timeTracking && (
|
|
1190
|
+
<div className={s.sideSection}>
|
|
1191
|
+
<div className={s.sideSectionTitle}>
|
|
1192
|
+
<span>{t('detail.time')}</span>
|
|
1193
|
+
<span style={{ color: 'var(--theme-text)', fontWeight: 600, textTransform: 'none', letterSpacing: 0 }}>
|
|
1194
|
+
{totalMin > 0 ? `${Math.floor(totalMin / 60)}h${String(totalMin % 60).padStart(2, '0')} ${t('detail.total')}` : '0min'}
|
|
1195
|
+
</span>
|
|
1196
|
+
</div>
|
|
1197
|
+
{/* Timer */}
|
|
1198
|
+
<div className={s.timer}>
|
|
1199
|
+
<span className={`${s.timerDisplay} ${timerRunning ? s.timerActive : ''}`}>
|
|
1200
|
+
{String(Math.floor(timerSeconds / 60)).padStart(2, '0')}:{String(timerSeconds % 60).padStart(2, '0')}
|
|
1201
|
+
</span>
|
|
1202
|
+
{!timerRunning ? (
|
|
1203
|
+
<button className={s.timerBtn} onClick={() => setTimerRunning(true)} aria-label="Démarrer le timer">{timerSeconds > 0 ? '▶' : '▶ Go'}</button>
|
|
1204
|
+
) : (
|
|
1205
|
+
<button className={s.timerBtn} onClick={() => setTimerRunning(false)} aria-label="Mettre en pause le timer">⏸ Pause</button>
|
|
1206
|
+
)}
|
|
1207
|
+
{timerSeconds >= 60 && !timerRunning && (
|
|
1208
|
+
<button className={s.timerBtn} onClick={() => { handleTimerSave(); localStorage.removeItem(`timer-sec-${ticketId}`); localStorage.removeItem(`timer-run-${ticketId}`) }} aria-label="Sauvegarder le temps">💾 {Math.round(timerSeconds / 60)}m</button>
|
|
1209
|
+
)}
|
|
1210
|
+
</div>
|
|
1211
|
+
{/* Progress bar (tt__bar maquette) — full = 120 min reference */}
|
|
1212
|
+
<div className={s.timerBar} aria-hidden>
|
|
1213
|
+
<div className={s.timerBarFill} style={{ width: `${Math.min(100, ((totalMin + Math.floor(timerSeconds / 60)) / 120) * 100)}%` }} />
|
|
1214
|
+
</div>
|
|
1215
|
+
{/* Manual time entry */}
|
|
1216
|
+
<div style={{ display: 'flex', gap: 6, marginTop: 8, alignItems: 'center' }}>
|
|
1217
|
+
<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" />
|
|
1218
|
+
<button className={s.timerBtn} onClick={async () => {
|
|
1219
|
+
const input = document.getElementById('manual-time-input') as HTMLInputElement
|
|
1220
|
+
const mins = Number(input?.value)
|
|
1221
|
+
if (!mins || mins < 1 || !ticketId) return
|
|
1222
|
+
try {
|
|
1223
|
+
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' }) })
|
|
1224
|
+
if (input) input.value = ''
|
|
1225
|
+
fetchAll()
|
|
1226
|
+
} catch {}
|
|
1227
|
+
}} style={{ fontSize: 11 }}>+ Ajouter</button>
|
|
1228
|
+
</div>
|
|
1229
|
+
{/* Billing info */}
|
|
1230
|
+
<div style={{ marginTop: 8, fontSize: 11, color: 'var(--theme-elevation-500)' }}>
|
|
1231
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '2px 0', alignItems: 'center' }}>
|
|
1232
|
+
<span>Facturable</span>
|
|
1233
|
+
<button
|
|
1234
|
+
onClick={async () => {
|
|
1235
|
+
const newVal = ticket.billable === false ? true : false
|
|
1236
|
+
try { await fetch(`/api/tickets/${ticketId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ billable: newVal }) }); fetchAll() } catch {}
|
|
1237
|
+
}}
|
|
1238
|
+
style={{ fontWeight: 600, color: (ticket.billable !== false) ? '#16a34a' : '#dc2626', background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, textDecoration: 'underline' }}
|
|
1239
|
+
>
|
|
1240
|
+
{(ticket.billable !== false) ? 'Oui' : 'Non'}
|
|
1241
|
+
</button>
|
|
1242
|
+
</div>
|
|
1243
|
+
{totalMin > 0 && (
|
|
1244
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '2px 0' }}>
|
|
1245
|
+
<span>Montant estimé</span>
|
|
1246
|
+
<span style={{ fontWeight: 700, color: 'var(--theme-text)' }}>{((totalMin / 60) * 60).toFixed(0)}€</span>
|
|
1247
|
+
</div>
|
|
1248
|
+
)}
|
|
1249
|
+
</div>
|
|
1250
|
+
{/* Time entries */}
|
|
1251
|
+
{timeEntries.length > 0 && (
|
|
1252
|
+
<div style={{ marginTop: 8, fontSize: 11 }}>
|
|
1253
|
+
{timeEntries.slice(0, 6).map((e) => (
|
|
1254
|
+
<div key={e.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0', color: 'var(--theme-elevation-500)' }}>
|
|
1255
|
+
<span>{new Date(e.date).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })}</span>
|
|
1256
|
+
<span title={e.description} style={{ fontWeight: 600, cursor: e.description ? 'help' : 'default' }}>{e.duration}min</span>
|
|
1257
|
+
</div>
|
|
1258
|
+
))}
|
|
1259
|
+
</div>
|
|
1260
|
+
)}
|
|
1261
|
+
</div>
|
|
1262
|
+
)}
|
|
1263
|
+
|
|
972
1264
|
{/* #6 — Tags section */}
|
|
973
1265
|
<div className={s.sideSection}>
|
|
974
1266
|
<div className={s.sideSectionTitle}>{t('detail.tags')}</div>
|
|
@@ -1091,94 +1383,85 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
1091
1383
|
</div>
|
|
1092
1384
|
</div>
|
|
1093
1385
|
|
|
1094
|
-
{
|
|
1386
|
+
{summaryLoading && (
|
|
1095
1387
|
<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
|
-
)}
|
|
1388
|
+
<div style={{ fontSize: 11, color: 'var(--theme-elevation-400)', textAlign: 'center', padding: 8 }}>✨ Chargement de la synthèse…</div>
|
|
1157
1389
|
</div>
|
|
1158
1390
|
)}
|
|
1391
|
+
</div>
|
|
1392
|
+
)}
|
|
1159
1393
|
|
|
1160
|
-
{
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1394
|
+
{/* ===== TAB: CLIENT (extended) ===== */}
|
|
1395
|
+
{sidebarTab === 'client' && (
|
|
1396
|
+
<div role="tabpanel" id="sidebar-panel-client" aria-labelledby="sidebar-tab-client">
|
|
1397
|
+
{client && (
|
|
1398
|
+
<div className={s.sideSection}>
|
|
1399
|
+
<div className={s.clientCard}>
|
|
1400
|
+
<div className={s.clientAvatar}>{initials}</div>
|
|
1401
|
+
<div className={s.clientInfo}>
|
|
1402
|
+
<div className={s.clientName}>{client.firstName} {client.lastName}</div>
|
|
1403
|
+
<div className={s.clientCompany}>{client.company}</div>
|
|
1404
|
+
<a href={`mailto:${client.email}`} className={s.clientEmail}>{client.email}</a>
|
|
1405
|
+
{client.phone && (
|
|
1406
|
+
<a href={`tel:${client.phone}`} className={s.clientEmail}>{client.phone}</a>
|
|
1407
|
+
)}
|
|
1408
|
+
</div>
|
|
1409
|
+
</div>
|
|
1410
|
+
<div className={s.clientActions}>
|
|
1411
|
+
<Link href={`/admin/collections/support-clients/${client.id}`} className={s.smallBtn}>{t('client.clientSheet')}</Link>
|
|
1412
|
+
<button className={s.smallBtn} onClick={() => window.open(`/api/admin/impersonate?clientId=${client.id}`, '_blank')}>{t('client.clientPortal')}</button>
|
|
1173
1413
|
</div>
|
|
1174
1414
|
</div>
|
|
1175
|
-
)
|
|
1415
|
+
)}
|
|
1416
|
+
|
|
1417
|
+
<div className={s.sideSection}>
|
|
1418
|
+
<div className={s.sideSectionTitle}>{t('detail.sidebar.previousTickets')}</div>
|
|
1419
|
+
{previousTickets.length === 0 ? (
|
|
1420
|
+
<div className={s.previousTicketsEmpty}>—</div>
|
|
1421
|
+
) : (
|
|
1422
|
+
<ul className={s.previousTicketsList}>
|
|
1423
|
+
{previousTickets.map((pt) => (
|
|
1424
|
+
<li key={pt.id} className={s.previousTicketsItem}>
|
|
1425
|
+
<Link href={`/admin/collections/tickets/${pt.id}`} className={s.previousTicketsLink}>
|
|
1426
|
+
<span className={s.previousTicketsSubject}>{pt.subject || `#${pt.id}`}</span>
|
|
1427
|
+
<span className={s.previousTicketsMeta}>
|
|
1428
|
+
{pt.status ? <span className={s.previousTicketsStatus}>{pt.status}</span> : null}
|
|
1429
|
+
<span className={s.previousTicketsDate}>{new Date(pt.createdAt).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' })}</span>
|
|
1430
|
+
</span>
|
|
1431
|
+
</Link>
|
|
1432
|
+
</li>
|
|
1433
|
+
))}
|
|
1434
|
+
</ul>
|
|
1435
|
+
)}
|
|
1436
|
+
</div>
|
|
1176
1437
|
</div>
|
|
1177
1438
|
)}
|
|
1178
1439
|
|
|
1179
|
-
{
|
|
1180
|
-
|
|
1181
|
-
|
|
1440
|
+
{/* ===== TAB: ACTIVITY ===== */}
|
|
1441
|
+
{sidebarTab === 'activity' && (
|
|
1442
|
+
<div role="tabpanel" id="sidebar-panel-activity" aria-labelledby="sidebar-tab-activity">
|
|
1443
|
+
{features.activityLog ? (
|
|
1444
|
+
<div className={s.sideSection}>
|
|
1445
|
+
<div className={s.sideSectionTitle}>{t('detail.activity')}</div>
|
|
1446
|
+
{activityLog.length === 0 ? (
|
|
1447
|
+
<div className={s.previousTicketsEmpty}>—</div>
|
|
1448
|
+
) : (
|
|
1449
|
+
activityLog.slice(0, 30).map((a) => (
|
|
1450
|
+
<div key={a.id} className={s.activityItem}>
|
|
1451
|
+
<div className={s.activityDot} style={{ backgroundColor: a.actorType === 'admin' ? '#2563eb' : a.actorType === 'system' ? '#6b7280' : '#16a34a' }} />
|
|
1452
|
+
<div className={s.activityContent}>
|
|
1453
|
+
<div className={s.activityText}>{(a.detail || a.action).slice(0, 120)}</div>
|
|
1454
|
+
<div className={s.activityTime}>{timeAgo(a.createdAt)}</div>
|
|
1455
|
+
</div>
|
|
1456
|
+
</div>
|
|
1457
|
+
))
|
|
1458
|
+
)}
|
|
1459
|
+
</div>
|
|
1460
|
+
) : (
|
|
1461
|
+
<div className={s.sideSection}>
|
|
1462
|
+
<div className={s.previousTicketsEmpty}>—</div>
|
|
1463
|
+
</div>
|
|
1464
|
+
)}
|
|
1182
1465
|
</div>
|
|
1183
1466
|
)}
|
|
1184
1467
|
</div>
|