@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.
Files changed (42) 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 +294 -0
  8. package/dist/views/TicketDetailView/client.cjs +455 -249
  9. package/dist/views/TicketDetailView/client.js +456 -250
  10. package/dist/views/TicketInboxView/client.cjs +10 -9
  11. package/dist/views/TicketInboxView/client.js +2 -1
  12. package/package.json +1 -1
  13. package/src/collections/ClientSummaries.ts +16 -0
  14. package/src/collections/TicketCollaborators.ts +93 -0
  15. package/src/collections/TicketFeedback.ts +116 -0
  16. package/src/collections/TicketMessages.ts +9 -0
  17. package/src/collections/Tickets.ts +31 -1
  18. package/src/collections/index.ts +2 -0
  19. package/src/components/TicketConversation/locales/en.json +25 -1
  20. package/src/components/TicketConversation/locales/fr.json +25 -1
  21. package/src/endpoints/escalate.ts +44 -0
  22. package/src/endpoints/index.ts +15 -0
  23. package/src/endpoints/invite-collaborator.ts +215 -0
  24. package/src/endpoints/kb-search.ts +156 -0
  25. package/src/endpoints/ticket-feedback.ts +104 -0
  26. package/src/endpoints/transfer-ticket.ts +248 -0
  27. package/src/index.ts +1 -0
  28. package/src/plugin.ts +4 -0
  29. package/src/portal/auth/ChatWidget.tsx +11 -0
  30. package/src/portal/auth/dashboard/DashboardClient.tsx +85 -99
  31. package/src/portal/auth/dashboard/page.tsx +11 -2
  32. package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +402 -0
  33. package/src/portal/auth/tickets/detail/page.tsx +122 -23
  34. package/src/portal/auth/tickets/new/KbDeflection.tsx +128 -0
  35. package/src/portal/auth/tickets/new/page.tsx +203 -100
  36. package/src/portal/locales/en.json +5 -0
  37. package/src/portal/locales/fr.json +5 -0
  38. package/src/styles/TicketDetail.module.scss +294 -0
  39. package/src/types.ts +19 -0
  40. package/src/utils/slugs.ts +2 -0
  41. package/src/views/TicketDetailView/client.tsx +346 -89
  42. 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
- 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') => {
@@ -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 ? '' : `✨ ${t('detail.iaSuggestion')}`}
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
- {features.timeTracking && (
1360
+ {summaryLoading && (
1095
1361
  <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
- )}
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
- {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>
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
- {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>
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, computeSlaState, formatSlaRemaining } from '../shared'
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 {