@consilioweb/payload-support 0.16.0 → 1.0.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 (193) hide show
  1. package/README.md +347 -435
  2. package/dist/components/TicketConversation/RewriteDropdown.cjs +98 -0
  3. package/dist/components/TicketConversation/RewriteDropdown.js +93 -0
  4. package/dist/components/TicketConversation/SkeletonText.cjs +21 -0
  5. package/dist/components/TicketConversation/SkeletonText.js +20 -0
  6. package/dist/components/TicketConversation/constants.cjs +27 -0
  7. package/dist/components/TicketConversation/constants.js +26 -1
  8. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +20 -0
  9. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.js +19 -0
  10. package/dist/components/TicketConversation/index.cjs +10 -147
  11. package/dist/components/TicketConversation/index.js +4 -141
  12. package/dist/index.cjs +1604 -671
  13. package/dist/index.d.cts +3 -0
  14. package/dist/index.d.ts +3 -0
  15. package/dist/index.js +1602 -671
  16. package/dist/views/BillingView/client.cjs +40 -54
  17. package/dist/views/BillingView/client.js +40 -54
  18. package/dist/views/ChatView/client.cjs +4 -3
  19. package/dist/views/ChatView/client.js +4 -3
  20. package/dist/views/CrmView/client.cjs +7 -7
  21. package/dist/views/CrmView/client.js +7 -7
  22. package/dist/views/EmailTrackingView/client.cjs +6 -5
  23. package/dist/views/EmailTrackingView/client.js +6 -5
  24. package/dist/views/ImportConversationView/client.cjs +4 -4
  25. package/dist/views/ImportConversationView/client.js +4 -4
  26. package/dist/views/LogsView/client.cjs +3 -2
  27. package/dist/views/LogsView/client.js +3 -2
  28. package/dist/views/NewTicketView/client.cjs +1 -1
  29. package/dist/views/NewTicketView/client.js +1 -1
  30. package/dist/views/PendingEmailsView/client.cjs +7 -6
  31. package/dist/views/PendingEmailsView/client.js +7 -6
  32. package/dist/views/SupportDashboardView/client.cjs +19 -22
  33. package/dist/views/SupportDashboardView/client.js +19 -22
  34. package/dist/views/TicketDetailView/NextActionItem.cjs +40 -0
  35. package/dist/views/TicketDetailView/NextActionItem.js +34 -0
  36. package/dist/views/TicketDetailView/RewriteDropdown.cjs +80 -0
  37. package/dist/views/TicketDetailView/RewriteDropdown.js +78 -0
  38. package/dist/views/TicketDetailView/client.cjs +59 -190
  39. package/dist/views/TicketDetailView/client.js +52 -183
  40. package/dist/views/TicketDetailView/constants.cjs +20 -0
  41. package/dist/views/TicketDetailView/constants.js +16 -0
  42. package/dist/views/TicketDetailView/helpers.cjs +23 -0
  43. package/dist/views/TicketDetailView/helpers.js +20 -0
  44. package/dist/views/TicketDetailView/types.cjs +2 -0
  45. package/dist/views/TicketDetailView/types.js +1 -0
  46. package/dist/views/TicketInboxView/client.cjs +14 -10
  47. package/dist/views/TicketInboxView/client.js +14 -10
  48. package/dist/views/TicketingSettingsView/client.cjs +101 -96
  49. package/dist/views/TicketingSettingsView/client.js +92 -88
  50. package/dist/views/TimeDashboardView/client.cjs +2 -2
  51. package/dist/views/TimeDashboardView/client.js +2 -2
  52. package/dist/views/shared/StatusPill.cjs +23 -12
  53. package/dist/views/shared/StatusPill.js +23 -12
  54. package/dist/views/shared/dateLocale.cjs +5 -0
  55. package/dist/views/shared/dateLocale.js +3 -0
  56. package/package.json +23 -15
  57. package/src/__tests__/generateTrackingToken.test.ts +53 -0
  58. package/src/__tests__/integration/ai-agent.test.ts +46 -0
  59. package/src/__tests__/integration/auth.test.ts +43 -0
  60. package/src/__tests__/integration/automation-rules.test.ts +68 -0
  61. package/src/__tests__/integration/buildTestPayload.ts +52 -0
  62. package/src/__tests__/integration/channels.test.ts +52 -0
  63. package/src/__tests__/integration/core-behaviors.test.ts +58 -0
  64. package/src/__tests__/integration/dashboard-volume.test.ts +31 -0
  65. package/src/__tests__/integration/digest.test.ts +48 -0
  66. package/src/__tests__/integration/invoice-pdf.test.ts +30 -0
  67. package/src/__tests__/integration/isolation.test.ts +81 -0
  68. package/src/__tests__/integration/mentions-invoice.test.ts +50 -0
  69. package/src/__tests__/integration/nps.test.ts +37 -0
  70. package/src/__tests__/integration/sanitization.test.ts +35 -0
  71. package/src/__tests__/integration/sla-pause.test.ts +39 -0
  72. package/src/__tests__/integration/smoke.test.ts +26 -0
  73. package/src/__tests__/integration/snooze.test.ts +68 -0
  74. package/src/__tests__/integration/teams.test.ts +38 -0
  75. package/src/__tests__/rateLimiter.test.ts +89 -0
  76. package/src/__tests__/sanitizeHtml.test.ts +165 -0
  77. package/src/__tests__/sla.test.ts +258 -0
  78. package/src/collections/AutomationRules.ts +66 -0
  79. package/src/collections/NotificationQueue.ts +30 -0
  80. package/src/collections/SatisfactionSurveys.ts +11 -2
  81. package/src/collections/SupportClients.ts +58 -1
  82. package/src/collections/SupportTeam.ts +27 -0
  83. package/src/collections/TicketActivityLog.ts +9 -4
  84. package/src/collections/TicketMessages.ts +129 -31
  85. package/src/collections/Tickets.ts +31 -73
  86. package/src/collections/index.ts +3 -0
  87. package/src/components/TicketConversation/RewriteDropdown.tsx +86 -0
  88. package/src/components/TicketConversation/SkeletonText.tsx +23 -0
  89. package/src/components/TicketConversation/constants.ts +29 -0
  90. package/src/components/TicketConversation/hooks/useDocumentIdFromUrl.ts +21 -0
  91. package/src/components/TicketConversation/index.tsx +4 -152
  92. package/src/endpoints/admin-chat-stream.ts +3 -4
  93. package/src/endpoints/admin-chat.ts +11 -20
  94. package/src/endpoints/admin-stats.ts +70 -32
  95. package/src/endpoints/ai-agent.ts +36 -0
  96. package/src/endpoints/apply-macro.ts +8 -14
  97. package/src/endpoints/auth-2fa.ts +12 -12
  98. package/src/endpoints/auto-close.ts +6 -10
  99. package/src/endpoints/billing.ts +17 -4
  100. package/src/endpoints/bulk-action.ts +7 -12
  101. package/src/endpoints/channels.ts +89 -0
  102. package/src/endpoints/chat-stream.ts +3 -4
  103. package/src/endpoints/chat.ts +7 -12
  104. package/src/endpoints/chatbot.ts +2 -2
  105. package/src/endpoints/client-intelligence.ts +4 -4
  106. package/src/endpoints/delete-account.ts +9 -16
  107. package/src/endpoints/email-stats.ts +2 -2
  108. package/src/endpoints/escalate.ts +2 -2
  109. package/src/endpoints/export-csv.ts +2 -2
  110. package/src/endpoints/export-data.ts +5 -8
  111. package/src/endpoints/import-conversation.ts +6 -10
  112. package/src/endpoints/index.ts +18 -1
  113. package/src/endpoints/invite-collaborator.ts +30 -15
  114. package/src/endpoints/invoice.ts +127 -0
  115. package/src/endpoints/kb-search.ts +2 -2
  116. package/src/endpoints/login.ts +12 -4
  117. package/src/endpoints/merge-clients.ts +14 -19
  118. package/src/endpoints/merge-tickets.ts +11 -18
  119. package/src/endpoints/oauth-google.ts +86 -39
  120. package/src/endpoints/pending-emails-process.ts +15 -25
  121. package/src/endpoints/process-digests.ts +93 -0
  122. package/src/endpoints/process-scheduled.ts +6 -10
  123. package/src/endpoints/process-snooze.ts +78 -0
  124. package/src/endpoints/purge-logs.ts +2 -2
  125. package/src/endpoints/resend-notification.ts +3 -4
  126. package/src/endpoints/round-robin-config.ts +3 -4
  127. package/src/endpoints/satisfaction.ts +21 -14
  128. package/src/endpoints/search.ts +5 -8
  129. package/src/endpoints/seed-kb.ts +3 -4
  130. package/src/endpoints/send-reminder.ts +6 -9
  131. package/src/endpoints/settings.ts +5 -2
  132. package/src/endpoints/signature.ts +3 -4
  133. package/src/endpoints/sla-check.ts +2 -2
  134. package/src/endpoints/split-ticket.ts +7 -12
  135. package/src/endpoints/statuses.ts +2 -2
  136. package/src/endpoints/ticket-feedback.ts +4 -6
  137. package/src/endpoints/ticket-synthesis.ts +2 -2
  138. package/src/endpoints/track-open.ts +16 -28
  139. package/src/endpoints/transfer-ticket.ts +21 -9
  140. package/src/endpoints/user-prefs.ts +3 -4
  141. package/src/hooks/automationRules.ts +84 -0
  142. package/src/hooks/checkSLA.ts +48 -16
  143. package/src/plugin.ts +6 -0
  144. package/src/portal/LiveChat.tsx +2 -63
  145. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +10 -0
  146. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +14 -2
  147. package/src/portal/auth/tickets/detail/MessageActions.tsx +37 -18
  148. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +10 -0
  149. package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +3 -2
  150. package/src/portal/auth/tickets/detail/page.tsx +2 -2
  151. package/src/portal/helpers.ts +46 -0
  152. package/src/portal/login/page.tsx +3 -8
  153. package/src/portal/types.ts +15 -0
  154. package/src/types/lucide-react.d.ts +34 -0
  155. package/src/types/payload-config.d.ts +10 -0
  156. package/src/utils/adminNotification.ts +2 -2
  157. package/src/utils/aiAgent.ts +147 -0
  158. package/src/utils/auth.ts +5 -4
  159. package/src/utils/channels.ts +36 -0
  160. package/src/utils/db.ts +73 -0
  161. package/src/utils/emailTemplate.ts +8 -1
  162. package/src/utils/fireWebhooks.ts +4 -6
  163. package/src/utils/generateTicketSynthesis.ts +7 -12
  164. package/src/utils/invoicePdf.ts +88 -0
  165. package/src/utils/notificationQueue.ts +36 -0
  166. package/src/utils/readSettings.ts +22 -6
  167. package/src/utils/sanitizeHtml.ts +57 -0
  168. package/src/utils/slugs.ts +6 -0
  169. package/src/utils/teamAccess.ts +27 -0
  170. package/src/utils/ticketAccess.ts +53 -0
  171. package/src/utils/webhookDispatcher.ts +4 -6
  172. package/src/views/BillingView/client.tsx +49 -37
  173. package/src/views/ChatView/client.tsx +4 -3
  174. package/src/views/CrmView/client.tsx +7 -7
  175. package/src/views/EmailTrackingView/client.tsx +7 -6
  176. package/src/views/ImportConversationView/client.tsx +4 -4
  177. package/src/views/LogsView/client.tsx +3 -2
  178. package/src/views/NewTicketView/client.tsx +1 -1
  179. package/src/views/PendingEmailsView/client.tsx +7 -6
  180. package/src/views/SupportDashboardView/client.tsx +21 -21
  181. package/src/views/TicketDetailView/NextActionItem.tsx +30 -0
  182. package/src/views/TicketDetailView/RewriteDropdown.tsx +63 -0
  183. package/src/views/TicketDetailView/client.tsx +54 -179
  184. package/src/views/TicketDetailView/constants.ts +23 -0
  185. package/src/views/TicketDetailView/helpers.ts +17 -0
  186. package/src/views/TicketDetailView/types.ts +10 -0
  187. package/src/views/TicketInboxView/client.tsx +16 -10
  188. package/src/views/TicketingSettingsView/client.tsx +90 -87
  189. package/src/views/TimeDashboardView/client.tsx +2 -2
  190. package/src/views/shared/StatusPill.tsx +24 -12
  191. package/src/views/shared/dateLocale.ts +5 -0
  192. package/src/views/shared/locales/en.json +708 -0
  193. package/src/views/shared/locales/fr.json +708 -0
@@ -9,138 +9,13 @@ import { CodeBlockInserter } from '../../components/TicketConversation/component
9
9
  import { getFeatures } from '../shared/config'
10
10
  import { computeSlaState, formatSlaRemaining } from '../shared/sla'
11
11
  import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
12
+ import { DATE_LOCALE } from '../shared/dateLocale'
12
13
  import s from '../../styles/TicketDetail.module.scss'
13
-
14
- interface Message {
15
- id: string | number; body: string; bodyHtml?: string; authorType: 'client' | 'admin' | 'email'
16
- isInternal?: boolean; isSolution?: boolean; createdAt: string; fromChat?: boolean
17
- fromAlias?: string
18
- attachments?: Array<{ file: { id: number; url?: string; filename?: string; mimeType?: string } | number }>
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
- ]
25
- interface ClientInfo { id: number; company: string; firstName: string; lastName: string; email: string; phone?: string }
26
- interface TimeEntry { id: string | number; duration: number; description?: string; date: string }
27
- interface ActivityEntry { id: string | number; action: string; detail?: string; actorType?: string; createdAt: string }
28
-
29
- const STATUS_STYLE: Record<string, { bg: string; color: string }> = {
30
- open: { bg: '#dbeafe', color: '#1e40af' },
31
- waiting_client: { bg: '#fef3c7', color: '#92400e' },
32
- resolved: { bg: '#dcfce7', color: '#166534' },
33
- }
34
-
35
- function timeAgo(d: string): string {
36
- const date = new Date(d), now = new Date()
37
- if (date.toDateString() === now.toDateString()) return date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
38
- const y = new Date(now); y.setDate(y.getDate() - 1)
39
- if (date.toDateString() === y.toDateString()) return `Hier, ${date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`
40
- return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
41
- }
42
-
43
- function dateLabel(d: string): string {
44
- const date = new Date(d), now = new Date()
45
- if (date.toDateString() === now.toDateString()) return "Aujourd'hui"
46
- const y = new Date(now); y.setDate(y.getDate() - 1)
47
- if (date.toDateString() === y.toDateString()) return 'Hier'
48
- return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' })
49
- }
50
-
51
- // ── Rewrite style dropdown ──────────────────────────────────────────────
52
- const REWRITE_STYLES = [
53
- { id: 'auto', label: '✏️ Auto', desc: 'Garde le ton actuel' },
54
- { id: 'tutoyer', label: '👋 Tutoyer', desc: 'Passe en tu' },
55
- { id: 'vouvoyer', label: '🎩 Vouvoyer', desc: 'Passe en vous' },
56
- { id: 'formel', label: '💼 Formel', desc: 'Ton professionnel' },
57
- { id: 'amical', label: '😊 Amical', desc: 'Ton chaleureux' },
58
- { id: 'court', label: '⚡ Court', desc: 'Version concise' },
59
- ]
60
-
61
- const RewriteDropdown: React.FC<{
62
- disabled: boolean
63
- loading: boolean
64
- onSelect: (style: string) => void
65
- toolbarBtnClass?: string
66
- }> = ({ disabled, loading, onSelect, toolbarBtnClass }) => {
67
- const [open, setOpen] = useState(false)
68
- const ref = useRef<HTMLDivElement>(null)
69
-
70
- useEffect(() => {
71
- if (!open) return
72
- const close = (e: MouseEvent) => {
73
- if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
74
- }
75
- document.addEventListener('mousedown', close)
76
- return () => document.removeEventListener('mousedown', close)
77
- }, [open])
78
-
79
- return (
80
- <div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
81
- <button
82
- type="button"
83
- className={toolbarBtnClass}
84
- onClick={() => setOpen(!open)}
85
- disabled={disabled}
86
- style={{ fontSize: 11, fontWeight: 700, padding: '4px 10px', width: 'auto', display: 'flex', alignItems: 'center', gap: 4 }}
87
- >
88
- {loading ? '...' : '✏️ Reformuler'}
89
- {!loading && <span style={{ fontSize: 9, opacity: 0.6 }}>▼</span>}
90
- </button>
91
- {open && !disabled && !loading && (
92
- <div style={{
93
- position: 'absolute', bottom: '100%', left: 0, marginBottom: 4,
94
- background: '#fff', border: '1px solid #e5e7eb', borderRadius: 8,
95
- boxShadow: '0 4px 16px rgba(0,0,0,0.12)', zIndex: 100, minWidth: 180, overflow: 'hidden',
96
- }}>
97
- {REWRITE_STYLES.map((style) => (
98
- <button
99
- key={style.id}
100
- type="button"
101
- onClick={() => { setOpen(false); onSelect(style.id) }}
102
- style={{
103
- display: 'flex', flexDirection: 'column', width: '100%', padding: '8px 12px',
104
- border: 'none', background: 'transparent', cursor: 'pointer', textAlign: 'left',
105
- borderBottom: '1px solid #f3f4f6',
106
- }}
107
- onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = '#f9fafb' }}
108
- onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = 'transparent' }}
109
- >
110
- <span style={{ fontSize: 12, fontWeight: 600 }}>{style.label}</span>
111
- <span style={{ fontSize: 10, color: '#9ca3af' }}>{style.desc}</span>
112
- </button>
113
- ))}
114
- </div>
115
- )}
116
- </div>
117
- )
118
- }
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
- }
14
+ import type { Message, ClientInfo, TimeEntry, ActivityEntry } from './types'
15
+ import { ALIASES, STATUS_STYLE } from './constants'
16
+ import { timeAgo, dateLabel } from './helpers'
17
+ import { RewriteDropdown } from './RewriteDropdown'
18
+ import { NextActionItem } from './NextActionItem'
144
19
 
145
20
  export const TicketDetailClient: React.FC = () => {
146
21
  const { t } = useTranslation()
@@ -663,7 +538,7 @@ const [clientTyping, setClientTyping] = useState(false)
663
538
  <div className={s.page}>
664
539
  {/* TOP BAR — detail__header maquette : back-btn + (id+meta + title) + quick-actions */}
665
540
  <div className={s.topBar}>
666
- <Link href="/admin/support/inbox" className={s.backLink} aria-label="Retour à la boîte de réception">&larr;</Link>
541
+ <Link href="/admin/support/inbox" className={s.backLink} aria-label={t('detail.backToInbox')}>&larr;</Link>
667
542
  <div className={s.ticketMeta}>
668
543
  {/* detail__id : ticketNumber · category · source email */}
669
544
  <span className={s.ticketNumber}>
@@ -717,7 +592,7 @@ const [clientTyping, setClientTyping] = useState(false)
717
592
  })
718
593
  if (slaInfo.state !== 'breach') return null
719
594
  const duration = formatSlaRemaining(slaInfo.remainingMs).replace(/^−/, '')
720
- const target = slaInfo.due ? new Date(slaInfo.due).toLocaleString('fr-FR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }) : ''
595
+ const target = slaInfo.due ? new Date(slaInfo.due).toLocaleString(DATE_LOCALE, { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }) : ''
721
596
  return (
722
597
  <div className={s.slaBanner} role="alert">
723
598
  <span className={s.slaBannerIcon} aria-hidden>
@@ -787,17 +662,17 @@ const [clientTyping, setClientTyping] = useState(false)
787
662
  <div className={s.messageHeader}>
788
663
  <span className={s.messageAuthor}>{msg.fromAlias || (isAdmin ? 'Support' : msg.authorType === 'email' ? 'Email' : client?.firstName || 'Client')}</span>
789
664
  <span className={s.messageTime}>{timeAgo(msg.createdAt)}</span>
790
- {msg.isInternal && <span className={s.badge} style={{ background: '#fef3c7', color: '#92400e' }}>Interne</span>}
791
- {msg.isSolution && <span className={s.badge} style={{ background: '#dcfce7', color: '#166534' }}>Solution</span>}
665
+ {msg.isInternal && <span className={s.badge} style={{ background: '#fef3c7', color: '#92400e' }}>{t('detail.badgeInternal')}</span>}
666
+ {msg.isSolution && <span className={s.badge} style={{ background: '#dcfce7', color: '#166534' }}>{t('detail.badgeSolution')}</span>}
792
667
  <span className={s.messageMeta}>
793
668
  {isAdmin && !msg.isInternal && (() => {
794
669
  const ext = msg as unknown as { emailOpenedAt?: string; emailSentAt?: string }
795
670
  if (ext.emailOpenedAt) {
796
- const d = new Date(ext.emailOpenedAt).toLocaleString('fr-FR', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
671
+ const d = new Date(ext.emailOpenedAt).toLocaleString(DATE_LOCALE, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
797
672
  return <span style={{ color: '#16a34a', cursor: 'help' }} title={`Ouvert le ${d}`}>✓✓ Lu {d}</span>
798
673
  }
799
674
  if (ext.emailSentAt) {
800
- const d = new Date(ext.emailSentAt).toLocaleString('fr-FR', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
675
+ const d = new Date(ext.emailSentAt).toLocaleString(DATE_LOCALE, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
801
676
  return <span style={{ color: '#2563eb', cursor: 'help' }} title={`Envoyé le ${d}`}>✓ Envoyé {d}</span>
802
677
  }
803
678
  return <span style={{ color: '#94a3b8' }}>✓</span>
@@ -810,7 +685,7 @@ const [clientTyping, setClientTyping] = useState(false)
810
685
  ref={editEditorRef}
811
686
  initialValue={editingHtml}
812
687
  onChange={(html, text) => { setEditingHtml(html); setEditingBody(text) }}
813
- placeholder="Éditer le message..."
688
+ placeholder={t('detail.editPlaceholder')}
814
689
  minHeight={120}
815
690
  onFileUpload={async (file) => {
816
691
  try {
@@ -825,8 +700,8 @@ const [clientTyping, setClientTyping] = useState(false)
825
700
  }}
826
701
  />
827
702
  <div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
828
- <button onClick={cancelEditMessage} disabled={editSaving} style={{ padding: '5px 12px', fontSize: 12, borderRadius: 5, border: '1px solid var(--theme-elevation-200)', background: 'var(--theme-elevation-0)', color: 'var(--theme-text)', cursor: 'pointer' }}>Annuler</button>
829
- <button onClick={saveEditMessage} disabled={editSaving || !editingBody.trim()} style={{ padding: '5px 12px', fontSize: 12, fontWeight: 600, borderRadius: 5, border: 'none', background: '#2563eb', color: '#fff', cursor: editSaving ? 'wait' : 'pointer', opacity: editSaving ? 0.6 : 1 }}>{editSaving ? 'Sauvegarde…' : 'Enregistrer'}</button>
703
+ <button onClick={cancelEditMessage} disabled={editSaving} style={{ padding: '5px 12px', fontSize: 12, borderRadius: 5, border: '1px solid var(--theme-elevation-200)', background: 'var(--theme-elevation-0)', color: 'var(--theme-text)', cursor: 'pointer' }}>{t('common.cancel')}</button>
704
+ <button onClick={saveEditMessage} disabled={editSaving || !editingBody.trim()} style={{ padding: '5px 12px', fontSize: 12, fontWeight: 600, borderRadius: 5, border: 'none', background: '#2563eb', color: '#fff', cursor: editSaving ? 'wait' : 'pointer', opacity: editSaving ? 0.6 : 1 }}>{editSaving ? t('detail.editSaving') : t('detail.editSave')}</button>
830
705
  </div>
831
706
  </div>
832
707
  ) : (msg as unknown as { deletedAt?: string }).deletedAt ? (
@@ -855,7 +730,7 @@ const [clientTyping, setClientTyping] = useState(false)
855
730
  {/* Hover actions — icon buttons with aria-labels (#7) */}
856
731
  {editingMsgId !== msg.id && !(msg as unknown as { deletedAt?: string }).deletedAt && (
857
732
  <div className={s.messageActions}>
858
- <button className={s.actionIcon} title="Éditer" aria-label="Éditer le message" onClick={() => startEditMessage(msg)} style={{ fontSize: 11, width: 'auto', padding: '4px 8px' }}>Éditer</button>
733
+ <button className={s.actionIcon} title={t('detail.editMessage')} aria-label={t('detail.editMessageLabel')} onClick={() => startEditMessage(msg)} style={{ fontSize: 11, width: 'auto', padding: '4px 8px' }}>{t('detail.editMessage')}</button>
859
734
  {/* #2 — Undo toast instead of confirm() */}
860
735
  <button className={`${s.actionIcon} ${s.danger}`} title={t('actions.deleteMessage')} aria-label={t('actions.deleteMessage')} onClick={() => handleDeleteMessage(msg.id)} style={{ fontSize: 11, width: 'auto', padding: '4px 8px' }}>{t('actions.deleteMessage')}</button>
861
736
  {/* #2 — Split modal instead of prompt() */}
@@ -942,7 +817,7 @@ const [clientTyping, setClientTyping] = useState(false)
942
817
  onChange={(e) => { const id = Number(e.target.value); if (id) handleApplyMacro(id); e.target.value = '' }}
943
818
  disabled={applyingMacro}
944
819
  style={{ marginLeft: 0 }}
945
- aria-label="Appliquer une macro"
820
+ aria-label={t('detail.macros')}
946
821
  >
947
822
  <option value="">{applyingMacro ? t('detail.applyingMacro') : t('detail.macros')}</option>
948
823
  {macros.map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
@@ -951,7 +826,7 @@ const [clientTyping, setClientTyping] = useState(false)
951
826
  <span className={s.toolbarDivider} />
952
827
  {/* #9 — "Canned" -> "Réponses types" */}
953
828
  {features.canned && cannedResponses.length > 0 && (
954
- <select className={s.cannedSelect} aria-label="Réponses types" onChange={(e) => {
829
+ <select className={s.cannedSelect} aria-label={t('detail.cannedResponses')} onChange={(e) => {
955
830
  const cr = cannedResponses.find((c) => String(c.id) === e.target.value)
956
831
  if (cr) { let b = cr.body; if (client) { b = b.replace(/\{\{client\.firstName\}\}/g, client.firstName).replace(/\{\{client\.company\}\}/g, client.company) }; setReplyBody(b); setReplyHtml(b.replace(/\n/g, '<br/>')); editorRef.current?.setContent(b.replace(/\n/g, '<br/>')) }
957
832
  e.target.value = ''
@@ -986,8 +861,8 @@ const [clientTyping, setClientTyping] = useState(false)
986
861
  <div className={s.uploadPreview}>
987
862
  {pendingFiles.map((f, i) => (
988
863
  <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>
864
+ <span>{t('detail.uploadItem', { name: f.name })}</span>
865
+ <button className={s.uploadRemoveBtn} aria-label={t('detail.removeUpload', { name: f.name })} onClick={() => setPendingFiles((prev) => prev.filter((_, j) => j !== i))}>&times;</button>
991
866
  </div>
992
867
  ))}
993
868
  </div>
@@ -1004,8 +879,8 @@ const [clientTyping, setClientTyping] = useState(false)
1004
879
  }}
1005
880
  style={{ fontSize: '12px', padding: '4px 8px', fontWeight: 600, borderRadius: 6, border: '1px solid var(--theme-elevation-200)', background: 'var(--theme-elevation-0)', color: 'var(--theme-text)' }}
1006
881
  >
1007
- <option value="admin">En tant que : Support</option>
1008
- <option value="client">En tant que : Client</option>
882
+ <option value="admin">{t('detail.sendAsSupport')}</option>
883
+ <option value="client">{t('detail.sendAsClient')}</option>
1009
884
  </select>
1010
885
  {!sendAsClient && (
1011
886
  <select
@@ -1030,7 +905,7 @@ const [clientTyping, setClientTyping] = useState(false)
1030
905
  <kbd>⌘</kbd><kbd>↵</kbd>
1031
906
  </span>
1032
907
  <button className={`${s.sendBtn} ${isInternal ? s.sendBtnInternal : ''}`} onClick={handleSend} disabled={sending || !replyBody.trim()} data-action="send-reply">
1033
- {sending ? t('detail.sending') : sendAsClient ? 'Ajouter message client' : isInternal ? t('detail.sendNote') : t('detail.sendReply')}
908
+ {sending ? t('detail.sending') : sendAsClient ? t('detail.addClientMessage') : isInternal ? t('detail.sendNote') : t('detail.sendReply')}
1034
909
  </button>
1035
910
  </div>
1036
911
  </div>
@@ -1103,7 +978,7 @@ const [clientTyping, setClientTyping] = useState(false)
1103
978
  <div className={`${s.sideSection} ${s.aiCard}`}>
1104
979
  <div className={s.aiCardTitle}>
1105
980
  <span className={s.aiCardIcon} aria-hidden>✨</span>
1106
- Synthèse client
981
+ {t('detail.aiCard.title')}
1107
982
  </div>
1108
983
  <p className={s.aiCardLead}>{clientSummary.summary}</p>
1109
984
  {clientSummary.recurringTopics && clientSummary.recurringTopics.length > 0 && (
@@ -1200,12 +1075,12 @@ const [clientTyping, setClientTyping] = useState(false)
1200
1075
  {String(Math.floor(timerSeconds / 60)).padStart(2, '0')}:{String(timerSeconds % 60).padStart(2, '0')}
1201
1076
  </span>
1202
1077
  {!timerRunning ? (
1203
- <button className={s.timerBtn} onClick={() => setTimerRunning(true)} aria-label="Démarrer le timer">{timerSeconds > 0 ? '▶' : '▶ Go'}</button>
1078
+ <button className={s.timerBtn} onClick={() => setTimerRunning(true)} aria-label={t('detail.timerStart')}>{timerSeconds > 0 ? '▶' : '▶ Go'}</button>
1204
1079
  ) : (
1205
- <button className={`${s.timerBtn} ${s.timerBtnRunning}`} onClick={() => setTimerRunning(false)} aria-label="Mettre en pause le timer">⏸ Pause</button>
1080
+ <button className={`${s.timerBtn} ${s.timerBtnRunning}`} onClick={() => setTimerRunning(false)} aria-label={t('detail.timerPause')}>⏸ Pause</button>
1206
1081
  )}
1207
1082
  {timerSeconds >= 60 && !timerRunning && (
1208
- <button className={`${s.timerBtn} ${s.timerBtnGhost}`} onClick={() => { handleTimerSave(); localStorage.removeItem(`timer-sec-${ticketId}`); localStorage.removeItem(`timer-run-${ticketId}`) }} aria-label="Sauvegarder le temps">💾 {Math.round(timerSeconds / 60)}m</button>
1083
+ <button className={`${s.timerBtn} ${s.timerBtnGhost}`} onClick={() => { handleTimerSave(); localStorage.removeItem(`timer-sec-${ticketId}`); localStorage.removeItem(`timer-run-${ticketId}`) }} aria-label={t('detail.timerSave')}>💾 {Math.round(timerSeconds / 60)}m</button>
1209
1084
  )}
1210
1085
  </div>
1211
1086
  {/* Progress bar (tt__bar maquette) — full = 120 min reference */}
@@ -1224,12 +1099,12 @@ const [clientTyping, setClientTyping] = useState(false)
1224
1099
  if (input) input.value = ''
1225
1100
  fetchAll()
1226
1101
  } catch {}
1227
- }} style={{ fontSize: 11 }}>+ Ajouter</button>
1102
+ }} style={{ fontSize: 11 }}>{t('detail.addTime')}</button>
1228
1103
  </div>
1229
1104
  {/* Billing info */}
1230
1105
  <div style={{ marginTop: 8, fontSize: 11, color: 'var(--theme-elevation-500)' }}>
1231
1106
  <div style={{ display: 'flex', justifyContent: 'space-between', padding: '2px 0', alignItems: 'center' }}>
1232
- <span>Facturable</span>
1107
+ <span>{t('detail.billing.billable')}</span>
1233
1108
  <button
1234
1109
  onClick={async () => {
1235
1110
  const newVal = ticket.billable === false ? true : false
@@ -1237,12 +1112,12 @@ const [clientTyping, setClientTyping] = useState(false)
1237
1112
  }}
1238
1113
  style={{ fontWeight: 600, color: (ticket.billable !== false) ? '#16a34a' : '#dc2626', background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, textDecoration: 'underline' }}
1239
1114
  >
1240
- {(ticket.billable !== false) ? 'Oui' : 'Non'}
1115
+ {(ticket.billable !== false) ? t('detail.billing.yes') : t('detail.billing.no')}
1241
1116
  </button>
1242
1117
  </div>
1243
1118
  {totalMin > 0 && (
1244
1119
  <div style={{ display: 'flex', justifyContent: 'space-between', padding: '2px 0' }}>
1245
- <span>Montant estimé</span>
1120
+ <span>{t('detail.billing.estimatedAmount')}</span>
1246
1121
  <span style={{ fontWeight: 700, color: 'var(--theme-text)' }}>{((totalMin / 60) * 60).toFixed(0)}€</span>
1247
1122
  </div>
1248
1123
  )}
@@ -1252,7 +1127,7 @@ const [clientTyping, setClientTyping] = useState(false)
1252
1127
  <div style={{ marginTop: 8, fontSize: 11 }}>
1253
1128
  {timeEntries.slice(0, 6).map((e) => (
1254
1129
  <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>
1130
+ <span>{new Date(e.date).toLocaleDateString(DATE_LOCALE, { day: 'numeric', month: 'short' })}</span>
1256
1131
  <span title={e.description} style={{ fontWeight: 600, cursor: e.description ? 'help' : 'default' }}>{e.duration}min</span>
1257
1132
  </div>
1258
1133
  ))}
@@ -1268,7 +1143,7 @@ const [clientTyping, setClientTyping] = useState(false)
1268
1143
  {tags.map((tag) => (
1269
1144
  <span key={tag} className={s.tagChip}>
1270
1145
  {tag}
1271
- <button className={s.tagRemove} aria-label={`Retirer le tag ${tag}`} onClick={() => handleRemoveTag(tag)}>&times;</button>
1146
+ <button className={s.tagRemove} aria-label={t('detail.removeTag', { tag })} onClick={() => handleRemoveTag(tag)}>&times;</button>
1272
1147
  </span>
1273
1148
  ))}
1274
1149
  {addingTag ? (
@@ -1278,21 +1153,21 @@ const [clientTyping, setClientTyping] = useState(false)
1278
1153
  onChange={(e) => setNewTagValue(e.target.value)}
1279
1154
  onKeyDown={(e) => { if (e.key === 'Enter') handleAddTag(); if (e.key === 'Escape') { setAddingTag(false); setNewTagValue('') } }}
1280
1155
  onBlur={handleAddTag}
1281
- placeholder="Tag..."
1156
+ placeholder={t('detail.tagPlaceholder')}
1282
1157
  autoFocus
1283
1158
  />
1284
1159
  ) : (
1285
- <button className={s.tagAddBtn} onClick={() => setAddingTag(true)} aria-label="Ajouter un tag">+ Tag</button>
1160
+ <button className={s.tagAddBtn} onClick={() => setAddingTag(true)} aria-label={t('detail.addTag')}>{t('detail.addTagBtn')}</button>
1286
1161
  )}
1287
1162
  </div>
1288
1163
  </div>
1289
1164
 
1290
1165
  {/* ===== BILLING ===== */}
1291
1166
  <div className={s.sideSection}>
1292
- <div className={s.sideSectionTitle}>Facturation</div>
1167
+ <div className={s.sideSectionTitle}>{t('detail.billing.title')}</div>
1293
1168
  <div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
1294
1169
  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
1295
- <span style={{ fontSize: 12 }}>Type</span>
1170
+ <span style={{ fontSize: 12 }}>{t('detail.billing.type')}</span>
1296
1171
  <select
1297
1172
  value={(ticket as any)?.billingType || 'hourly'}
1298
1173
  onChange={async (e) => {
@@ -1307,13 +1182,13 @@ const [clientTyping, setClientTyping] = useState(false)
1307
1182
  }}
1308
1183
  style={{ fontSize: 12, padding: '4px 8px', borderRadius: 6, border: '1px solid #e5e7eb', background: '#fff' }}
1309
1184
  >
1310
- <option value="hourly">Au temps</option>
1311
- <option value="flat">Forfait</option>
1185
+ <option value="hourly">{t('detail.billing.hourly')}</option>
1186
+ <option value="flat">{t('detail.billing.flat')}</option>
1312
1187
  </select>
1313
1188
  </div>
1314
1189
  {(ticket as any)?.billingType === 'flat' && (
1315
1190
  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
1316
- <span style={{ fontSize: 12 }}>Montant forfait</span>
1191
+ <span style={{ fontSize: 12 }}>{t('detail.billing.flatAmount')}</span>
1317
1192
  <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
1318
1193
  <input
1319
1194
  type="number"
@@ -1337,7 +1212,7 @@ const [clientTyping, setClientTyping] = useState(false)
1337
1212
  </div>
1338
1213
  )}
1339
1214
  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
1340
- <span style={{ fontSize: 12 }}>Montant facturé</span>
1215
+ <span style={{ fontSize: 12 }}>{t('detail.billing.billedAmount')}</span>
1341
1216
  <div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
1342
1217
  <input
1343
1218
  type="number"
@@ -1360,7 +1235,7 @@ const [clientTyping, setClientTyping] = useState(false)
1360
1235
  </div>
1361
1236
  </div>
1362
1237
  <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
1363
- <span style={{ fontSize: 12 }}>Paiement</span>
1238
+ <span style={{ fontSize: 12 }}>{t('detail.billing.payment')}</span>
1364
1239
  <select
1365
1240
  value={(ticket as any)?.paymentStatus || 'unpaid'}
1366
1241
  onChange={async (e) => {
@@ -1375,9 +1250,9 @@ const [clientTyping, setClientTyping] = useState(false)
1375
1250
  }}
1376
1251
  style={{ fontSize: 12, padding: '4px 8px', borderRadius: 6, border: '1px solid #e5e7eb', background: '#fff' }}
1377
1252
  >
1378
- <option value="unpaid">Non payé</option>
1379
- <option value="partial">Partiel</option>
1380
- <option value="paid">Payé</option>
1253
+ <option value="unpaid">{t('detail.billing.unpaid')}</option>
1254
+ <option value="partial">{t('detail.billing.partial')}</option>
1255
+ <option value="paid">{t('detail.billing.paid')}</option>
1381
1256
  </select>
1382
1257
  </div>
1383
1258
  </div>
@@ -1385,7 +1260,7 @@ const [clientTyping, setClientTyping] = useState(false)
1385
1260
 
1386
1261
  {summaryLoading && (
1387
1262
  <div className={s.sideSection}>
1388
- <div style={{ fontSize: 11, color: 'var(--theme-elevation-400)', textAlign: 'center', padding: 8 }}>✨ Chargement de la synthèse…</div>
1263
+ <div style={{ fontSize: 11, color: 'var(--theme-elevation-400)', textAlign: 'center', padding: 8 }}>{t('detail.loadingSummary')}</div>
1389
1264
  </div>
1390
1265
  )}
1391
1266
  </div>
@@ -1426,7 +1301,7 @@ const [clientTyping, setClientTyping] = useState(false)
1426
1301
  <span className={s.previousTicketsSubject}>{pt.subject || `#${pt.id}`}</span>
1427
1302
  <span className={s.previousTicketsMeta}>
1428
1303
  {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>
1304
+ <span className={s.previousTicketsDate}>{new Date(pt.createdAt).toLocaleDateString(DATE_LOCALE, { day: 'numeric', month: 'short', year: 'numeric' })}</span>
1430
1305
  </span>
1431
1306
  </Link>
1432
1307
  </li>
@@ -1470,28 +1345,28 @@ const [clientTyping, setClientTyping] = useState(false)
1470
1345
  {/* #2 — Undo delete toast */}
1471
1346
  {undoToast && (
1472
1347
  <div className={s.undoToast} role="alert">
1473
- <span>Message supprimé</span>
1474
- <button className={s.undoBtn} onClick={handleUndoDelete}>Annuler</button>
1348
+ <span>{t('detail.messageDeletedToast')}</span>
1349
+ <button className={s.undoBtn} onClick={handleUndoDelete}>{t('common.cancel')}</button>
1475
1350
  </div>
1476
1351
  )}
1477
1352
 
1478
1353
  {/* #2 — Split ticket modal */}
1479
1354
  {splitModal && (
1480
1355
  <div className={s.splitOverlay} onClick={(e) => { if (e.target === e.currentTarget) { setSplitModal(null); setSplitSubject('') } }}>
1481
- <div className={s.splitModal} role="dialog" aria-label="Extraire dans un nouveau ticket">
1482
- <h3 className={s.splitTitle}>Extraire dans un nouveau ticket</h3>
1356
+ <div className={s.splitModal} role="dialog" aria-label={t('actions.extractToNewTicket')}>
1357
+ <h3 className={s.splitTitle}>{t('actions.extractToNewTicket')}</h3>
1483
1358
  <div className={s.splitPreview}>{splitModal.preview}</div>
1484
1359
  <input
1485
1360
  className={s.splitInput}
1486
1361
  value={splitSubject}
1487
1362
  onChange={(e) => setSplitSubject(e.target.value)}
1488
1363
  onKeyDown={(e) => { if (e.key === 'Enter') handleSplitConfirm() }}
1489
- placeholder="Sujet du nouveau ticket..."
1364
+ placeholder={t('detail.split.subjectPlaceholder')}
1490
1365
  autoFocus
1491
1366
  />
1492
1367
  <div className={s.splitActions}>
1493
- <button className={s.splitCancelBtn} onClick={() => { setSplitModal(null); setSplitSubject('') }}>Annuler</button>
1494
- <button className={s.splitConfirmBtn} onClick={handleSplitConfirm} disabled={!splitSubject.trim()}>Créer le ticket</button>
1368
+ <button className={s.splitCancelBtn} onClick={() => { setSplitModal(null); setSplitSubject('') }}>{t('common.cancel')}</button>
1369
+ <button className={s.splitConfirmBtn} onClick={handleSplitConfirm} disabled={!splitSubject.trim()}>{t('detail.split.createBtn')}</button>
1495
1370
  </div>
1496
1371
  </div>
1497
1372
  </div>
@@ -0,0 +1,23 @@
1
+ // "Send as" aliases — host-configured via NEXT_PUBLIC_SUPPORT_SEND_ALIASES
2
+ // (comma-separated). Kept config-driven so the plugin carries no hardcoded
3
+ // brand identity. Empty by default: the composer then offers only "self".
4
+ export const ALIASES: Array<{ value: string; label: string }> = (process.env.NEXT_PUBLIC_SUPPORT_SEND_ALIASES || '')
5
+ .split(',')
6
+ .map((v) => v.trim())
7
+ .filter(Boolean)
8
+ .map((value) => ({ value, label: `En tant que : ${value}` }))
9
+
10
+ export const STATUS_STYLE: Record<string, { bg: string; color: string }> = {
11
+ open: { bg: '#dbeafe', color: '#1e40af' },
12
+ waiting_client: { bg: '#fef3c7', color: '#92400e' },
13
+ resolved: { bg: '#dcfce7', color: '#166534' },
14
+ }
15
+
16
+ export const REWRITE_STYLES = [
17
+ { id: 'auto', label: '✏️ Auto', desc: 'Garde le ton actuel' },
18
+ { id: 'tutoyer', label: '👋 Tutoyer', desc: 'Passe en tu' },
19
+ { id: 'vouvoyer', label: '🎩 Vouvoyer', desc: 'Passe en vous' },
20
+ { id: 'formel', label: '💼 Formel', desc: 'Ton professionnel' },
21
+ { id: 'amical', label: '😊 Amical', desc: 'Ton chaleureux' },
22
+ { id: 'court', label: '⚡ Court', desc: 'Version concise' },
23
+ ]
@@ -0,0 +1,17 @@
1
+ import { DATE_LOCALE } from '../shared/dateLocale'
2
+
3
+ export function timeAgo(d: string): string {
4
+ const date = new Date(d), now = new Date()
5
+ if (date.toDateString() === now.toDateString()) return date.toLocaleTimeString(DATE_LOCALE, { hour: '2-digit', minute: '2-digit' })
6
+ const y = new Date(now); y.setDate(y.getDate() - 1)
7
+ if (date.toDateString() === y.toDateString()) return `Hier, ${date.toLocaleTimeString(DATE_LOCALE, { hour: '2-digit', minute: '2-digit' })}`
8
+ return date.toLocaleDateString(DATE_LOCALE, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
9
+ }
10
+
11
+ export function dateLabel(d: string): string {
12
+ const date = new Date(d), now = new Date()
13
+ if (date.toDateString() === now.toDateString()) return "Aujourd'hui"
14
+ const y = new Date(now); y.setDate(y.getDate() - 1)
15
+ if (date.toDateString() === y.toDateString()) return 'Hier'
16
+ return date.toLocaleDateString(DATE_LOCALE, { day: 'numeric', month: 'long' })
17
+ }
@@ -0,0 +1,10 @@
1
+ export interface Message {
2
+ id: string | number; body: string; bodyHtml?: string; authorType: 'client' | 'admin' | 'email'
3
+ isInternal?: boolean; isSolution?: boolean; createdAt: string; fromChat?: boolean
4
+ fromAlias?: string
5
+ attachments?: Array<{ file: { id: number; url?: string; filename?: string; mimeType?: string } | number }>
6
+ }
7
+
8
+ export interface ClientInfo { id: number; company: string; firstName: string; lastName: string; email: string; phone?: string }
9
+ export interface TimeEntry { id: string | number; duration: number; description?: string; date: string }
10
+ export interface ActivityEntry { id: string | number; action: string; detail?: string; actorType?: string; createdAt: string }
@@ -4,6 +4,7 @@ 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 { DATE_LOCALE } from '../shared/dateLocale'
7
8
  import { StatusPill } from '../shared/StatusPill'
8
9
  import { computeSlaState, formatSlaRemaining } from '../shared/sla'
9
10
  import s from '../../styles/TicketInbox.module.scss'
@@ -45,16 +46,16 @@ const STATUS_VARIANT: Record<string, 'open' | 'pending' | 'resolved' | 'closed'>
45
46
  closed: 'closed',
46
47
  }
47
48
 
48
- function relativeTime(dateStr: string): string {
49
+ function relativeTime(dateStr: string, tFn: (k: string) => string): string {
49
50
  const diff = Date.now() - new Date(dateStr).getTime()
50
51
  const mins = Math.floor(diff / 60000)
51
- if (mins < 1) return 'maintenant'
52
+ if (mins < 1) return tFn('inbox.timeAgo.now')
52
53
  if (mins < 60) return `${mins}m`
53
54
  const hours = Math.floor(mins / 60)
54
55
  if (hours < 24) return `${hours}h`
55
56
  const days = Math.floor(hours / 24)
56
57
  if (days < 7) return `${days}j`
57
- return new Date(dateStr).toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
58
+ return new Date(dateStr).toLocaleDateString(DATE_LOCALE, { day: 'numeric', month: 'short' })
58
59
  }
59
60
 
60
61
  export const TicketInboxClient: React.FC = () => {
@@ -138,6 +139,9 @@ export const TicketInboxClient: React.FC = () => {
138
139
  params.push(`where[or][0][subject][contains]=${encodeURIComponent(search)}`)
139
140
  params.push(`where[or][1][ticketNumber][contains]=${encodeURIComponent(search)}`)
140
141
  }
142
+ // Hide snoozed tickets (snoozeUntil in the future); they resurface automatically once due.
143
+ params.push(`where[and][0][or][0][snoozeUntil][exists]=false`)
144
+ params.push(`where[and][0][or][1][snoozeUntil][less_than_equal]=${encodeURIComponent(new Date().toISOString())}`)
141
145
  try {
142
146
  const url = `/api/tickets?${params.join('&')}`
143
147
  const res = await fetch(url, { credentials: 'include' })
@@ -159,12 +163,14 @@ export const TicketInboxClient: React.FC = () => {
159
163
  useEffect(() => {
160
164
  const fetchCounts = async () => {
161
165
  try {
166
+ // Exclude snoozed tickets from the counts too (mirrors the list query).
167
+ const sn = `where[and][0][or][0][snoozeUntil][exists]=false&where[and][0][or][1][snoozeUntil][less_than_equal]=${encodeURIComponent(new Date().toISOString())}`
162
168
  const [all, openRes, waiting, resolved, breach] = await Promise.all([
163
- fetch('/api/tickets?limit=0&depth=0', { credentials: 'include' }),
164
- fetch('/api/tickets?limit=0&depth=0&where[status][equals]=open', { credentials: 'include' }),
165
- fetch('/api/tickets?limit=0&depth=0&where[status][equals]=waiting_client', { credentials: 'include' }),
166
- fetch('/api/tickets?limit=0&depth=0&where[status][equals]=resolved', { credentials: 'include' }),
167
- fetch('/api/tickets?limit=0&depth=0&where[or][0][slaFirstResponseBreached][equals]=true&where[or][1][slaResolutionBreached][equals]=true', { credentials: 'include' }),
169
+ fetch(`/api/tickets?limit=0&depth=0&${sn}`, { credentials: 'include' }),
170
+ fetch(`/api/tickets?limit=0&depth=0&where[status][equals]=open&${sn}`, { credentials: 'include' }),
171
+ fetch(`/api/tickets?limit=0&depth=0&where[status][equals]=waiting_client&${sn}`, { credentials: 'include' }),
172
+ fetch(`/api/tickets?limit=0&depth=0&where[status][equals]=resolved&${sn}`, { credentials: 'include' }),
173
+ fetch(`/api/tickets?limit=0&depth=0&where[or][0][slaFirstResponseBreached][equals]=true&where[or][1][slaResolutionBreached][equals]=true&${sn}`, { credentials: 'include' }),
168
174
  ])
169
175
  const [a, o, w, r, b] = await Promise.all([all.json(), openRes.json(), waiting.json(), resolved.json(), breach.json()])
170
176
  setCounts({
@@ -339,11 +345,11 @@ export const TicketInboxClient: React.FC = () => {
339
345
  sla.state === 'warn' ? s.slaWarn : '',
340
346
  sla.state === 'ok' ? s.slaOk : '',
341
347
  ].filter(Boolean).join(' ')}
342
- title={sla.due ? new Date(sla.due).toLocaleString('fr-FR') : ''}
348
+ title={sla.due ? new Date(sla.due).toLocaleString(DATE_LOCALE) : ''}
343
349
  >
344
350
  {formatSlaRemaining(sla.remainingMs)}
345
351
  </span>
346
- <span className={s.timeAgo}>{relativeTime(tk.updatedAt)}</span>
352
+ <span className={s.timeAgo}>{relativeTime(tk.updatedAt, t)}</span>
347
353
  {isUnread ? <div className={s.unreadDot} /> : <span />}
348
354
  </a>
349
355
  )