@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.
Files changed (39) hide show
  1. package/dist/components/TicketConversation/locales/en.json +25 -1
  2. package/dist/components/TicketConversation/locales/fr.json +25 -1
  3. package/dist/index.cjs +799 -4
  4. package/dist/index.d.cts +15 -1
  5. package/dist/index.d.ts +15 -1
  6. package/dist/index.js +800 -6
  7. package/dist/styles/TicketDetail.module.scss +899 -353
  8. package/dist/views/TicketDetailView/client.cjs +504 -282
  9. package/dist/views/TicketDetailView/client.js +505 -283
  10. package/package.json +1 -1
  11. package/src/collections/ClientSummaries.ts +16 -0
  12. package/src/collections/TicketCollaborators.ts +93 -0
  13. package/src/collections/TicketFeedback.ts +116 -0
  14. package/src/collections/TicketMessages.ts +9 -0
  15. package/src/collections/Tickets.ts +31 -1
  16. package/src/collections/index.ts +2 -0
  17. package/src/components/TicketConversation/locales/en.json +25 -1
  18. package/src/components/TicketConversation/locales/fr.json +25 -1
  19. package/src/endpoints/escalate.ts +44 -0
  20. package/src/endpoints/index.ts +15 -0
  21. package/src/endpoints/invite-collaborator.ts +215 -0
  22. package/src/endpoints/kb-search.ts +156 -0
  23. package/src/endpoints/ticket-feedback.ts +104 -0
  24. package/src/endpoints/transfer-ticket.ts +248 -0
  25. package/src/index.ts +1 -0
  26. package/src/plugin.ts +4 -0
  27. package/src/portal/auth/ChatWidget.tsx +11 -0
  28. package/src/portal/auth/dashboard/DashboardClient.tsx +85 -99
  29. package/src/portal/auth/dashboard/page.tsx +11 -2
  30. package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +402 -0
  31. package/src/portal/auth/tickets/detail/page.tsx +122 -23
  32. package/src/portal/auth/tickets/new/KbDeflection.tsx +128 -0
  33. package/src/portal/auth/tickets/new/page.tsx +203 -100
  34. package/src/portal/locales/en.json +5 -0
  35. package/src/portal/locales/fr.json +5 -0
  36. package/src/styles/TicketDetail.module.scss +899 -353
  37. package/src/types.ts +19 -0
  38. package/src/utils/slugs.ts +2 -0
  39. 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
- const [showActivity, setShowActivity] = useState(false)
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; setAiReplying(true)
560
+ if (messages.length === 0) return
561
+ setAiReplying(true)
500
562
  try {
501
- const r = await fetch('/api/support/ai', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
502
- body: JSON.stringify({ action: 'suggest_reply', messages: messages.slice(-10).map((m) => ({ authorType: m.authorType, body: m.body })), clientName: `${client?.firstName || ''} ${client?.lastName || ''}`.trim(), clientCompany: client?.company }) })
503
- if (r.ok) { const d = await r.json(); if (d.reply) { setReplyBody(d.reply); setReplyHtml(d.reply.replace(/\n/g, '<br/>')); editorRef.current?.setContent(d.reply.replace(/\n/g, '<br/>')) } }
504
- } catch {} finally { setAiReplying(false) }
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">&larr;</Link>
580
667
  <div className={s.ticketMeta}>
581
- <span className={s.ticketNumber}>{ticket.ticketNumber as string}</span>
582
- <span className={s.ticketSubject}>{ticket.subject as string}</span>
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 ? '' : `✨ ${t('detail.iaSuggestion')}`}
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
- <RichTextEditor
839
- ref={editorRef}
840
- onChange={(html, text) => { setReplyHtml(html); setReplyBody(text) }}
841
- placeholder={isInternal ? t('composer.placeholderInternal') : t('composer.placeholderReplyTo', { name: client?.firstName || 'client' })}
842
- minHeight={100}
843
- borderColor="transparent"
844
- onFileUpload={async (file) => {
845
- try {
846
- const formData = new FormData()
847
- formData.append('file', file)
848
- formData.append('_payload', JSON.stringify({ alt: file.name }))
849
- const ur = await fetch('/api/media', { method: 'POST', credentials: 'include', body: formData })
850
- if (!ur.ok) return null
851
- const ud = await ur.json()
852
- return ud.doc?.url || null
853
- } catch { return null }
854
- }}
855
- />
856
- {/* #5 — File upload preview */}
857
- {pendingFiles.length > 0 && (
858
- <div className={s.uploadPreview}>
859
- {pendingFiles.map((f, i) => (
860
- <div key={i} className={s.uploadPreviewItem}>
861
- <span>PJ {f.name}</span>
862
- <button className={s.uploadRemoveBtn} aria-label={`Retirer ${f.name}`} onClick={() => setPendingFiles((prev) => prev.filter((_, j) => j !== i))}>&times;</button>
863
- </div>
864
- ))}
865
- </div>
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))}>&times;</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
- {features.timeTracking && (
1386
+ {summaryLoading && (
1095
1387
  <div className={s.sideSection}>
1096
- <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>
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
- {features.activityLog && (
1161
- <div className={s.sideSection}>
1162
- <div className={s.sideSectionTitle}>
1163
- <button className={s.collapseBtn} onClick={() => setShowActivity(!showActivity)} aria-label={showActivity ? 'Masquer le journal' : 'Afficher le journal'}>
1164
- Activité {showActivity ? '▾' : '▸'}
1165
- </button>
1166
- </div>
1167
- {showActivity && activityLog.slice(0, 8).map((a) => (
1168
- <div key={a.id} className={s.activityItem}>
1169
- <div className={s.activityDot} style={{ backgroundColor: a.actorType === 'admin' ? '#2563eb' : a.actorType === 'system' ? '#6b7280' : '#16a34a' }} />
1170
- <div className={s.activityContent}>
1171
- <div className={s.activityText}>{(a.detail || a.action).slice(0, 60)}</div>
1172
- <div className={s.activityTime}>{timeAgo(a.createdAt)}</div>
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
- {summaryLoading && (
1180
- <div className={s.sideSection}>
1181
- <div style={{ fontSize: 11, color: 'var(--theme-elevation-400)', textAlign: 'center', padding: 8 }}>✨ Chargement de la synthèse…</div>
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>