@consilioweb/payload-support 0.15.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 (202) 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/components/ActionPanels.cjs +72 -0
  7. package/dist/components/TicketConversation/components/ActionPanels.js +73 -2
  8. package/dist/components/TicketConversation/components/QuickActions.cjs +22 -1
  9. package/dist/components/TicketConversation/components/QuickActions.js +22 -1
  10. package/dist/components/TicketConversation/constants.cjs +27 -0
  11. package/dist/components/TicketConversation/constants.js +26 -1
  12. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +20 -0
  13. package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.js +19 -0
  14. package/dist/components/TicketConversation/hooks/useTicketActions.cjs +59 -0
  15. package/dist/components/TicketConversation/hooks/useTicketActions.js +59 -0
  16. package/dist/components/TicketConversation/index.cjs +42 -149
  17. package/dist/components/TicketConversation/index.js +37 -144
  18. package/dist/index.cjs +1835 -741
  19. package/dist/index.d.cts +3 -0
  20. package/dist/index.d.ts +3 -0
  21. package/dist/index.js +1833 -741
  22. package/dist/views/BillingView/client.cjs +40 -54
  23. package/dist/views/BillingView/client.js +40 -54
  24. package/dist/views/ChatView/client.cjs +4 -3
  25. package/dist/views/ChatView/client.js +4 -3
  26. package/dist/views/CrmView/client.cjs +7 -7
  27. package/dist/views/CrmView/client.js +7 -7
  28. package/dist/views/EmailTrackingView/client.cjs +6 -5
  29. package/dist/views/EmailTrackingView/client.js +6 -5
  30. package/dist/views/ImportConversationView/client.cjs +4 -4
  31. package/dist/views/ImportConversationView/client.js +4 -4
  32. package/dist/views/LogsView/client.cjs +3 -2
  33. package/dist/views/LogsView/client.js +3 -2
  34. package/dist/views/NewTicketView/client.cjs +1 -1
  35. package/dist/views/NewTicketView/client.js +1 -1
  36. package/dist/views/PendingEmailsView/client.cjs +7 -6
  37. package/dist/views/PendingEmailsView/client.js +7 -6
  38. package/dist/views/SupportDashboardView/client.cjs +19 -22
  39. package/dist/views/SupportDashboardView/client.js +19 -22
  40. package/dist/views/TicketDetailView/NextActionItem.cjs +40 -0
  41. package/dist/views/TicketDetailView/NextActionItem.js +34 -0
  42. package/dist/views/TicketDetailView/RewriteDropdown.cjs +80 -0
  43. package/dist/views/TicketDetailView/RewriteDropdown.js +78 -0
  44. package/dist/views/TicketDetailView/client.cjs +59 -190
  45. package/dist/views/TicketDetailView/client.js +52 -183
  46. package/dist/views/TicketDetailView/constants.cjs +20 -0
  47. package/dist/views/TicketDetailView/constants.js +16 -0
  48. package/dist/views/TicketDetailView/helpers.cjs +23 -0
  49. package/dist/views/TicketDetailView/helpers.js +20 -0
  50. package/dist/views/TicketDetailView/types.cjs +2 -0
  51. package/dist/views/TicketDetailView/types.js +1 -0
  52. package/dist/views/TicketInboxView/client.cjs +14 -10
  53. package/dist/views/TicketInboxView/client.js +14 -10
  54. package/dist/views/TicketingSettingsView/client.cjs +101 -96
  55. package/dist/views/TicketingSettingsView/client.js +92 -88
  56. package/dist/views/TimeDashboardView/client.cjs +2 -2
  57. package/dist/views/TimeDashboardView/client.js +2 -2
  58. package/dist/views/shared/StatusPill.cjs +23 -12
  59. package/dist/views/shared/StatusPill.js +23 -12
  60. package/dist/views/shared/dateLocale.cjs +5 -0
  61. package/dist/views/shared/dateLocale.js +3 -0
  62. package/package.json +23 -15
  63. package/src/__tests__/generateTrackingToken.test.ts +53 -0
  64. package/src/__tests__/integration/ai-agent.test.ts +46 -0
  65. package/src/__tests__/integration/auth.test.ts +43 -0
  66. package/src/__tests__/integration/automation-rules.test.ts +68 -0
  67. package/src/__tests__/integration/buildTestPayload.ts +52 -0
  68. package/src/__tests__/integration/channels.test.ts +52 -0
  69. package/src/__tests__/integration/core-behaviors.test.ts +58 -0
  70. package/src/__tests__/integration/dashboard-volume.test.ts +31 -0
  71. package/src/__tests__/integration/digest.test.ts +48 -0
  72. package/src/__tests__/integration/invoice-pdf.test.ts +30 -0
  73. package/src/__tests__/integration/isolation.test.ts +81 -0
  74. package/src/__tests__/integration/mentions-invoice.test.ts +50 -0
  75. package/src/__tests__/integration/nps.test.ts +37 -0
  76. package/src/__tests__/integration/sanitization.test.ts +35 -0
  77. package/src/__tests__/integration/sla-pause.test.ts +39 -0
  78. package/src/__tests__/integration/smoke.test.ts +26 -0
  79. package/src/__tests__/integration/snooze.test.ts +68 -0
  80. package/src/__tests__/integration/teams.test.ts +38 -0
  81. package/src/__tests__/rateLimiter.test.ts +89 -0
  82. package/src/__tests__/sanitizeHtml.test.ts +165 -0
  83. package/src/__tests__/sla.test.ts +258 -0
  84. package/src/collections/AutomationRules.ts +66 -0
  85. package/src/collections/NotificationQueue.ts +30 -0
  86. package/src/collections/SatisfactionSurveys.ts +11 -2
  87. package/src/collections/SupportClients.ts +58 -1
  88. package/src/collections/SupportTeam.ts +27 -0
  89. package/src/collections/TicketActivityLog.ts +9 -4
  90. package/src/collections/TicketMessages.ts +137 -31
  91. package/src/collections/Tickets.ts +31 -72
  92. package/src/collections/index.ts +3 -0
  93. package/src/components/TicketConversation/RewriteDropdown.tsx +86 -0
  94. package/src/components/TicketConversation/SkeletonText.tsx +23 -0
  95. package/src/components/TicketConversation/components/ActionPanels.tsx +82 -1
  96. package/src/components/TicketConversation/components/QuickActions.tsx +25 -0
  97. package/src/components/TicketConversation/constants.ts +29 -0
  98. package/src/components/TicketConversation/hooks/useDocumentIdFromUrl.ts +21 -0
  99. package/src/components/TicketConversation/hooks/useTicketActions.ts +51 -0
  100. package/src/components/TicketConversation/index.tsx +29 -157
  101. package/src/endpoints/admin-chat-stream.ts +3 -4
  102. package/src/endpoints/admin-chat.ts +11 -20
  103. package/src/endpoints/admin-stats.ts +70 -32
  104. package/src/endpoints/ai-agent.ts +36 -0
  105. package/src/endpoints/apply-macro.ts +8 -14
  106. package/src/endpoints/auth-2fa.ts +12 -12
  107. package/src/endpoints/auto-close.ts +38 -18
  108. package/src/endpoints/billing.ts +17 -4
  109. package/src/endpoints/bulk-action.ts +7 -12
  110. package/src/endpoints/channels.ts +89 -0
  111. package/src/endpoints/chat-stream.ts +3 -4
  112. package/src/endpoints/chat.ts +7 -12
  113. package/src/endpoints/chatbot.ts +2 -2
  114. package/src/endpoints/client-intelligence.ts +4 -4
  115. package/src/endpoints/delete-account.ts +9 -16
  116. package/src/endpoints/email-stats.ts +2 -2
  117. package/src/endpoints/escalate.ts +2 -2
  118. package/src/endpoints/export-csv.ts +2 -2
  119. package/src/endpoints/export-data.ts +5 -8
  120. package/src/endpoints/import-conversation.ts +6 -10
  121. package/src/endpoints/index.ts +24 -2
  122. package/src/endpoints/invite-collaborator.ts +30 -15
  123. package/src/endpoints/invoice.ts +127 -0
  124. package/src/endpoints/kb-search.ts +2 -2
  125. package/src/endpoints/login.ts +12 -4
  126. package/src/endpoints/merge-clients.ts +14 -19
  127. package/src/endpoints/merge-tickets.ts +11 -18
  128. package/src/endpoints/oauth-google.ts +86 -39
  129. package/src/endpoints/pending-emails-process.ts +15 -25
  130. package/src/endpoints/process-digests.ts +93 -0
  131. package/src/endpoints/process-scheduled.ts +6 -10
  132. package/src/endpoints/process-snooze.ts +78 -0
  133. package/src/endpoints/purge-logs.ts +2 -2
  134. package/src/endpoints/resend-notification.ts +3 -4
  135. package/src/endpoints/round-robin-config.ts +3 -4
  136. package/src/endpoints/satisfaction.ts +21 -14
  137. package/src/endpoints/search.ts +5 -8
  138. package/src/endpoints/seed-kb.ts +3 -4
  139. package/src/endpoints/send-reminder.ts +186 -0
  140. package/src/endpoints/settings.ts +5 -2
  141. package/src/endpoints/signature.ts +3 -4
  142. package/src/endpoints/sla-check.ts +2 -2
  143. package/src/endpoints/split-ticket.ts +7 -12
  144. package/src/endpoints/statuses.ts +2 -2
  145. package/src/endpoints/ticket-feedback.ts +4 -6
  146. package/src/endpoints/ticket-synthesis.ts +2 -2
  147. package/src/endpoints/track-open.ts +16 -28
  148. package/src/endpoints/transfer-ticket.ts +21 -9
  149. package/src/endpoints/user-prefs.ts +3 -4
  150. package/src/hooks/automationRules.ts +84 -0
  151. package/src/hooks/checkSLA.ts +48 -16
  152. package/src/plugin.ts +6 -0
  153. package/src/portal/LiveChat.tsx +2 -63
  154. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +10 -0
  155. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +14 -2
  156. package/src/portal/auth/tickets/detail/MessageActions.tsx +37 -18
  157. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +10 -0
  158. package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +3 -2
  159. package/src/portal/auth/tickets/detail/page.tsx +2 -2
  160. package/src/portal/helpers.ts +46 -0
  161. package/src/portal/login/page.tsx +3 -8
  162. package/src/portal/types.ts +15 -0
  163. package/src/types/lucide-react.d.ts +34 -0
  164. package/src/types/payload-config.d.ts +10 -0
  165. package/src/utils/adminNotification.ts +2 -2
  166. package/src/utils/aiAgent.ts +147 -0
  167. package/src/utils/auth.ts +5 -4
  168. package/src/utils/channels.ts +36 -0
  169. package/src/utils/db.ts +73 -0
  170. package/src/utils/emailTemplate.ts +8 -1
  171. package/src/utils/fireWebhooks.ts +4 -6
  172. package/src/utils/generateTicketSynthesis.ts +7 -12
  173. package/src/utils/invoicePdf.ts +88 -0
  174. package/src/utils/notificationQueue.ts +36 -0
  175. package/src/utils/readSettings.ts +22 -6
  176. package/src/utils/sanitizeHtml.ts +57 -0
  177. package/src/utils/slugs.ts +6 -0
  178. package/src/utils/teamAccess.ts +27 -0
  179. package/src/utils/ticketAccess.ts +53 -0
  180. package/src/utils/webhookDispatcher.ts +4 -6
  181. package/src/views/BillingView/client.tsx +49 -37
  182. package/src/views/ChatView/client.tsx +4 -3
  183. package/src/views/CrmView/client.tsx +7 -7
  184. package/src/views/EmailTrackingView/client.tsx +7 -6
  185. package/src/views/ImportConversationView/client.tsx +4 -4
  186. package/src/views/LogsView/client.tsx +3 -2
  187. package/src/views/NewTicketView/client.tsx +1 -1
  188. package/src/views/PendingEmailsView/client.tsx +7 -6
  189. package/src/views/SupportDashboardView/client.tsx +21 -21
  190. package/src/views/TicketDetailView/NextActionItem.tsx +30 -0
  191. package/src/views/TicketDetailView/RewriteDropdown.tsx +63 -0
  192. package/src/views/TicketDetailView/client.tsx +54 -179
  193. package/src/views/TicketDetailView/constants.ts +23 -0
  194. package/src/views/TicketDetailView/helpers.ts +17 -0
  195. package/src/views/TicketDetailView/types.ts +10 -0
  196. package/src/views/TicketInboxView/client.tsx +16 -10
  197. package/src/views/TicketingSettingsView/client.tsx +90 -87
  198. package/src/views/TimeDashboardView/client.tsx +2 -2
  199. package/src/views/shared/StatusPill.tsx +24 -12
  200. package/src/views/shared/dateLocale.ts +5 -0
  201. package/src/views/shared/locales/en.json +708 -0
  202. package/src/views/shared/locales/fr.json +708 -0
@@ -1,7 +1,7 @@
1
1
  'use client'
2
2
 
3
3
  import React, { useRef } from 'react'
4
- import { s } from '../constants'
4
+ import { C, s } from '../constants'
5
5
 
6
6
  // ========== MERGE PANEL ==========
7
7
  interface MergePanelProps {
@@ -115,6 +115,87 @@ export function ExtMessagePanel({
115
115
  )
116
116
  }
117
117
 
118
+ // ========== REMINDER PANEL ==========
119
+ interface ReminderPanelProps {
120
+ clientFirstName: string
121
+ clientEmail: string
122
+ reminderHours: number
123
+ setReminderHours: (v: number) => void
124
+ reminderSending: boolean
125
+ reminderError: string
126
+ handleSendReminder: () => void
127
+ }
128
+
129
+ const REMINDER_DELAYS = [
130
+ { hours: 24, label: '24 h' },
131
+ { hours: 48, label: '48 h' },
132
+ { hours: 72, label: '72 h' },
133
+ ]
134
+
135
+ export function ReminderPanel({
136
+ clientFirstName, clientEmail, reminderHours, setReminderHours,
137
+ reminderSending, reminderError, handleSendReminder,
138
+ }: ReminderPanelProps) {
139
+ const deadline = new Date(Date.now() + reminderHours * 60 * 60 * 1000)
140
+ const deadlineLabel = deadline.toLocaleString('fr-FR', {
141
+ weekday: 'long', day: 'numeric', month: 'long', hour: '2-digit', minute: '2-digit',
142
+ })
143
+ const name = clientFirstName?.trim() || 'le client'
144
+
145
+ return (
146
+ <div style={{ padding: '14px 18px', borderRadius: '8px', backgroundColor: '#fff7ed', border: '1px solid #fed7aa', marginBottom: '14px' }}>
147
+ <h4 style={{ fontSize: '13px', fontWeight: 600, marginBottom: '8px', color: '#9a3412' }}>
148
+ Relancer {name} — fermeture automatique sans réponse
149
+ </h4>
150
+ {clientEmail ? (
151
+ <p style={{ fontSize: '12px', color: '#7c2d12', margin: '0 0 12px 0', lineHeight: 1.5 }}>
152
+ Un email de relance générique sera envoyé à <strong>{clientEmail}</strong>. Sans retour de sa part avant l&apos;échéance, le ticket sera automatiquement résolu (via le cron auto-close).
153
+ </p>
154
+ ) : (
155
+ <p style={{ fontSize: '12px', color: '#b91c1c', margin: '0 0 12px 0', fontWeight: 600 }}>
156
+ Ce client n&apos;a pas d&apos;adresse email : impossible d&apos;envoyer une relance.
157
+ </p>
158
+ )}
159
+
160
+ <div style={{ display: 'flex', gap: '6px', alignItems: 'center', flexWrap: 'wrap', marginBottom: '12px' }}>
161
+ <span style={{ fontSize: '12px', fontWeight: 600, color: '#9a3412' }}>Fermer dans :</span>
162
+ {REMINDER_DELAYS.map((d) => {
163
+ const active = reminderHours === d.hours
164
+ return (
165
+ <button
166
+ key={d.hours}
167
+ type="button"
168
+ onClick={() => setReminderHours(d.hours)}
169
+ style={{
170
+ ...(active ? s.btn(C.orange) : s.outlineBtn('#ea580c')),
171
+ fontSize: '12px', padding: '6px 14px',
172
+ }}
173
+ >
174
+ {d.label}
175
+ </button>
176
+ )
177
+ })}
178
+ </div>
179
+
180
+ <div style={{ fontSize: '12px', color: '#7c2d12', marginBottom: '12px' }}>
181
+ Fermeture automatique prévue le <strong>{deadlineLabel}</strong> sans réponse.
182
+ </div>
183
+
184
+ {reminderError && (
185
+ <div style={{ fontSize: '12px', color: '#b91c1c', fontWeight: 600, marginBottom: '10px' }}>{reminderError}</div>
186
+ )}
187
+
188
+ <button
189
+ onClick={handleSendReminder}
190
+ disabled={reminderSending || !clientEmail}
191
+ style={{ ...s.btn(C.orange, reminderSending || !clientEmail), color: '#fff', fontSize: '13px', padding: '8px 18px' }}
192
+ >
193
+ {reminderSending ? 'Envoi...' : 'Confirmer la relance'}
194
+ </button>
195
+ </div>
196
+ )
197
+ }
198
+
118
199
  // ========== SNOOZE PANEL ==========
119
200
  interface SnoozePanelProps {
120
201
  snoozeSaving: boolean
@@ -18,6 +18,13 @@ interface QuickActionsProps {
18
18
  onToggleExtMsg: () => void
19
19
  onToggleSnooze: () => void
20
20
  onNextTicket: () => void
21
+ // Reminder (relance + auto-close)
22
+ showReminderButton: boolean
23
+ onToggleReminder: () => void
24
+ autoCloseScheduledAt: string | null
25
+ onCancelScheduledClose: () => void
26
+ cancelingClose: boolean
27
+ reminderSuccess: boolean
21
28
  // Next ticket banner
22
29
  showNextTicket: boolean
23
30
  nextTicketId: number | null
@@ -30,8 +37,12 @@ export function QuickActions({
30
37
  snoozeUntil, snoozeSaving, onCancelSnooze,
31
38
  showMerge: _showMerge, showExtMsg: _showExtMsg, showSnooze: _showSnooze,
32
39
  onToggleMerge, onToggleExtMsg, onToggleSnooze, onNextTicket,
40
+ showReminderButton, onToggleReminder, autoCloseScheduledAt, onCancelScheduledClose, cancelingClose, reminderSuccess,
33
41
  showNextTicket, nextTicketId, nextTicketInfo, onCloseNextTicket,
34
42
  }: QuickActionsProps) {
43
+ const scheduledClose = autoCloseScheduledAt && new Date(autoCloseScheduledAt) > new Date()
44
+ ? new Date(autoCloseScheduledAt).toLocaleString('fr-FR', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
45
+ : null
35
46
  return (
36
47
  <>
37
48
  <div style={{ display: 'flex', gap: '8px', flexWrap: 'wrap', marginBottom: '16px', alignItems: 'center' }}>
@@ -54,7 +65,21 @@ export function QuickActions({
54
65
  <button onClick={onToggleMerge} style={s.ghostBtn('#be185d')}>Fusionner</button>
55
66
  <button onClick={onToggleExtMsg} style={s.ghostBtn('#4f46e5')}>+ Message reçu</button>
56
67
  <button onClick={onToggleSnooze} style={s.ghostBtn('#7c3aed')}>Snooze</button>
68
+ {showReminderButton && (
69
+ <button onClick={onToggleReminder} style={s.ghostBtn('#ea580c')}>{'⏰'} Relancer</button>
70
+ )}
57
71
  <button onClick={onNextTicket} style={s.ghostBtn('#16a34a')}>Ticket suivant</button>
72
+ {scheduledClose && (
73
+ <span style={{ display: 'inline-flex', alignItems: 'center', gap: '8px', padding: '5px 10px', borderRadius: '6px', backgroundColor: '#fff7ed', border: '1px solid #fed7aa', fontSize: '12px', color: '#9a3412', fontWeight: 600 }}>
74
+ {'⏰'} Fermeture auto le {scheduledClose}
75
+ <button onClick={onCancelScheduledClose} disabled={cancelingClose} style={{ border: 'none', background: 'none', color: '#b91c1c', cursor: cancelingClose ? 'not-allowed' : 'pointer', fontWeight: 700, fontSize: '12px', textDecoration: 'underline', padding: 0, opacity: cancelingClose ? 0.5 : 1 }}>
76
+ {cancelingClose ? '...' : 'Annuler'}
77
+ </button>
78
+ </span>
79
+ )}
80
+ {reminderSuccess && (
81
+ <span style={{ ...s.badge('#f0fdf4', '#16a34a'), fontSize: '12px', padding: '5px 10px' }}>Relance envoyée {'✓'}</span>
82
+ )}
58
83
  </div>
59
84
 
60
85
  {showNextTicket && (
@@ -1,5 +1,34 @@
1
1
  import type React from 'react'
2
2
 
3
+ // ── Rewrite style dropdown options ──────────────────────────────────────
4
+ export const REWRITE_STYLES = [
5
+ { id: 'auto', label: '✏️ Auto', desc: 'Garde le ton actuel' },
6
+ { id: 'tutoyer', label: '👋 Tutoyer', desc: 'Passe en tu' },
7
+ { id: 'vouvoyer', label: '🎩 Vouvoyer', desc: 'Passe en vous' },
8
+ { id: 'formel', label: '💼 Formel', desc: 'Ton professionnel' },
9
+ { id: 'amical', label: '😊 Amical', desc: 'Ton chaleureux' },
10
+ { id: 'court', label: '⚡ Court', desc: 'Version concise' },
11
+ ]
12
+
13
+ // Inline layout styles
14
+ export const layoutStyles = {
15
+ root: { padding: '12px 0' } as React.CSSProperties,
16
+ twoColumns: {
17
+ display: 'grid',
18
+ gridTemplateColumns: '1fr 320px',
19
+ gap: '16px',
20
+ alignItems: 'start',
21
+ } as React.CSSProperties,
22
+ mainColumn: { minWidth: 0 } as React.CSSProperties,
23
+ sideColumn: {
24
+ display: 'flex',
25
+ flexDirection: 'column' as const,
26
+ gap: '12px',
27
+ position: 'sticky' as const,
28
+ top: '80px',
29
+ } as React.CSSProperties,
30
+ }
31
+
3
32
  export const statusLabels: Record<string, { label: string; bg: string; color: string }> = {
4
33
  open: { label: 'Ouvert', bg: '#dbeafe', color: '#1e40af' },
5
34
  waiting_client: { label: 'Attente', bg: '#fef3c7', color: '#92400e' },
@@ -0,0 +1,21 @@
1
+ import { useState, useEffect } from 'react'
2
+
3
+ // Replacement for @payloadcms/ui's useDocumentInfo — avoids importing
4
+ // @payloadcms/ui (which has chunk splits that break webpack resolution
5
+ // when this bundle is consumed from node_modules).
6
+ // Extracts the document id from the admin URL pattern:
7
+ // /admin/collections/:slug/:id or /admin/globals/:slug
8
+ export function useDocumentIdFromUrl(): { id: string | number | undefined } {
9
+ const [id, setId] = useState<string | number | undefined>(undefined)
10
+ useEffect(() => {
11
+ const match = window.location.pathname.match(
12
+ /\/admin\/collections\/[^/]+\/([^/?#]+)/
13
+ )
14
+ if (match && match[1] !== 'create') {
15
+ const raw = match[1]
16
+ const num = Number(raw)
17
+ setId(Number.isFinite(num) && String(num) === raw ? num : raw)
18
+ }
19
+ }, [])
20
+ return { id }
21
+ }
@@ -23,6 +23,13 @@ export function useTicketActions(
23
23
  const [showSnooze, setShowSnooze] = useState(false)
24
24
  const [snoozeUntil, setSnoozeUntil] = useState<string | null>(null)
25
25
  const [snoozeSaving, setSnoozeSaving] = useState(false)
26
+ // Reminder (relance + auto-close arming)
27
+ const [showReminder, setShowReminder] = useState(false)
28
+ const [reminderHours, setReminderHours] = useState(24)
29
+ const [reminderSending, setReminderSending] = useState(false)
30
+ const [reminderError, setReminderError] = useState('')
31
+ const [reminderSuccess, setReminderSuccess] = useState(false)
32
+ const [cancelingClose, setCancelingClose] = useState(false)
26
33
  // Next ticket
27
34
  const [showNextTicket, setShowNextTicket] = useState(false)
28
35
  const [nextTicketId, setNextTicketId] = useState<number | null>(null)
@@ -178,6 +185,49 @@ export function useTicketActions(
178
185
  }
179
186
  }
180
187
 
188
+ const handleSendReminder = async () => {
189
+ if (!id) return
190
+ setReminderSending(true)
191
+ setReminderError('')
192
+ try {
193
+ const res = await fetch('/api/support/send-reminder', {
194
+ method: 'POST',
195
+ headers: { 'Content-Type': 'application/json' },
196
+ credentials: 'include',
197
+ body: JSON.stringify({ ticketId: id, hours: reminderHours }),
198
+ })
199
+ const d = await res.json().catch(() => ({}))
200
+ if (res.ok) {
201
+ setShowReminder(false)
202
+ setReminderSuccess(true)
203
+ setTimeout(() => setReminderSuccess(false), 5000)
204
+ fetchAll()
205
+ } else {
206
+ setReminderError(d.error || 'Erreur lors de la relance')
207
+ }
208
+ } catch {
209
+ setReminderError('Erreur réseau')
210
+ } finally {
211
+ setReminderSending(false)
212
+ }
213
+ }
214
+
215
+ const handleCancelScheduledClose = async () => {
216
+ if (!id) return
217
+ setCancelingClose(true)
218
+ try {
219
+ await fetch(`/api/tickets/${id}`, {
220
+ method: 'PATCH',
221
+ headers: { 'Content-Type': 'application/json' },
222
+ credentials: 'include',
223
+ body: JSON.stringify({ autoCloseScheduledAt: null, autoCloseRemindedAt: null }),
224
+ })
225
+ fetchAll()
226
+ } catch { /* ignore */ } finally {
227
+ setCancelingClose(false)
228
+ }
229
+ }
230
+
181
231
  const handleNextTicket = async () => {
182
232
  try {
183
233
  const res = await fetch('/api/tickets?where[status][equals]=open&sort=updatedAt&limit=1&depth=0', { credentials: 'include' })
@@ -200,6 +250,7 @@ export function useTicketActions(
200
250
  showMerge, setShowMerge, mergeTarget, setMergeTarget, mergeTargetInfo, setMergeTargetInfo, mergeError, setMergeError, merging, handleMergeLookup, handleMerge,
201
251
  showExtMsg, setShowExtMsg, extMsgBody, setExtMsgBody, extMsgAuthor, setExtMsgAuthor, extMsgDate, setExtMsgDate, extMsgFiles, setExtMsgFiles, sendingExtMsg, handleExtFileChange, handleSendExtMsg,
202
252
  showSnooze, setShowSnooze, snoozeUntil, setSnoozeUntil, snoozeSaving, handleSnooze,
253
+ showReminder, setShowReminder, reminderHours, setReminderHours, reminderSending, reminderError, setReminderError, reminderSuccess, handleSendReminder, cancelingClose, handleCancelScheduledClose,
203
254
  showNextTicket, setShowNextTicket, nextTicketId, nextTicketInfo, handleNextTicket,
204
255
  }
205
256
  }
@@ -1,29 +1,9 @@
1
1
  'use client'
2
2
 
3
3
  import React, { useState, useEffect, useCallback, useRef } from 'react'
4
-
5
- // Replacement for @payloadcms/ui's useDocumentInfo — avoids importing
6
- // @payloadcms/ui (which has chunk splits that break webpack resolution
7
- // when this bundle is consumed from node_modules).
8
- // Extracts the document id from the admin URL pattern:
9
- // /admin/collections/:slug/:id or /admin/globals/:slug
10
- function useDocumentIdFromUrl(): { id: string | number | undefined } {
11
- const [id, setId] = useState<string | number | undefined>(undefined)
12
- useEffect(() => {
13
- const match = window.location.pathname.match(
14
- /\/admin\/collections\/[^/]+\/([^/?#]+)/
15
- )
16
- if (match && match[1] !== 'create') {
17
- const raw = match[1]
18
- const num = Number(raw)
19
- setId(Number.isFinite(num) && String(num) === raw ? num : raw)
20
- }
21
- }, [])
22
- return { id }
23
- }
24
4
  import type { Message, TimeEntry, ClientInfo, CannedResponse, ActivityEntry, SatisfactionSurvey } from './types'
25
5
  import type { RichTextEditorHandle } from './context'
26
- import { C, s } from './constants'
6
+ import { C, s, layoutStyles } from './constants'
27
7
  import { getDateLabel, formatMessageDate } from './utils'
28
8
  import { CodeBlockRenderer, CodeBlockRendererHtml } from './components/CodeBlock'
29
9
  import { CodeBlockInserter } from './components/CodeBlockInserter'
@@ -31,7 +11,7 @@ import { TicketHeader } from './components/TicketHeader'
31
11
  import { ClientBar } from './components/ClientBar'
32
12
  import { QuickActions } from './components/QuickActions'
33
13
  import { AISummaryPanel } from './components/AISummaryPanel'
34
- import { MergePanel, ExtMessagePanel, SnoozePanel } from './components/ActionPanels'
14
+ import { MergePanel, ExtMessagePanel, SnoozePanel, ReminderPanel } from './components/ActionPanels'
35
15
  import { ActivityLog } from './components/ActivityLog'
36
16
  import { ClientHistory } from './components/ClientHistory'
37
17
  import { TimeTrackingPanel } from './components/TimeTrackingPanel'
@@ -40,140 +20,12 @@ import { useMessageActions } from './hooks/useMessageActions'
40
20
  import { useTicketActions } from './hooks/useTicketActions'
41
21
  import { useReply } from './hooks/useReply'
42
22
  import { useAI } from './hooks/useAI'
23
+ import { useDocumentIdFromUrl } from './hooks/useDocumentIdFromUrl'
43
24
  import { getFeatures, type TicketingFeatures } from './config'
25
+ import { SkeletonText } from './SkeletonText'
26
+ import { RewriteDropdown } from './RewriteDropdown'
44
27
  import '../../styles/theme.css'
45
28
 
46
- // Inline skeleton replacement (no external dependency)
47
- function SkeletonText({ lines = 3 }: { lines?: number }) {
48
- return (
49
- <div style={{ display: 'flex', flexDirection: 'column', gap: '8px', padding: '12px 0' }}>
50
- {Array.from({ length: lines }).map((_, i) => (
51
- <div
52
- key={i}
53
- style={{
54
- height: '14px',
55
- borderRadius: '4px',
56
- backgroundColor: '#e2e8f0',
57
- width: i === lines - 1 ? '60%' : '100%',
58
- animation: 'pulse 1.5s ease-in-out infinite',
59
- }}
60
- />
61
- ))}
62
- </div>
63
- )
64
- }
65
-
66
- // Inline layout styles (replacing Layout.module.scss)
67
- const layoutStyles = {
68
- root: { padding: '12px 0' } as React.CSSProperties,
69
- twoColumns: {
70
- display: 'grid',
71
- gridTemplateColumns: '1fr 320px',
72
- gap: '16px',
73
- alignItems: 'start',
74
- } as React.CSSProperties,
75
- mainColumn: { minWidth: 0 } as React.CSSProperties,
76
- sideColumn: {
77
- display: 'flex',
78
- flexDirection: 'column' as const,
79
- gap: '12px',
80
- position: 'sticky' as const,
81
- top: '80px',
82
- } as React.CSSProperties,
83
- }
84
-
85
- // ── Rewrite style dropdown ──────────────────────────────────────────────
86
- const REWRITE_STYLES = [
87
- { id: 'auto', label: '✏️ Auto', desc: 'Garde le ton actuel' },
88
- { id: 'tutoyer', label: '👋 Tutoyer', desc: 'Passe en tu' },
89
- { id: 'vouvoyer', label: '🎩 Vouvoyer', desc: 'Passe en vous' },
90
- { id: 'formel', label: '💼 Formel', desc: 'Ton professionnel' },
91
- { id: 'amical', label: '😊 Amical', desc: 'Ton chaleureux' },
92
- { id: 'court', label: '⚡ Court', desc: 'Version concise' },
93
- ]
94
-
95
- const RewriteDropdown: React.FC<{
96
- disabled: boolean
97
- loading: boolean
98
- onSelect: (style: string) => void
99
- }> = ({ disabled, loading, onSelect }) => {
100
- const [open, setOpen] = useState(false)
101
- const ref = React.useRef<HTMLDivElement>(null)
102
-
103
- React.useEffect(() => {
104
- if (!open) return
105
- const close = (e: MouseEvent) => {
106
- if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
107
- }
108
- document.addEventListener('mousedown', close)
109
- return () => document.removeEventListener('mousedown', close)
110
- }, [open])
111
-
112
- return (
113
- <div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
114
- <button
115
- type="button"
116
- onClick={() => setOpen(!open)}
117
- disabled={disabled}
118
- style={{
119
- ...s.outlineBtn('#0891b2', disabled),
120
- fontSize: '11px',
121
- padding: '3px 10px',
122
- borderRadius: '14px',
123
- display: 'flex',
124
- alignItems: 'center',
125
- gap: '4px',
126
- }}
127
- >
128
- {loading ? 'Reformulation...' : '✏️ Reformuler'}
129
- {!loading && <span style={{ fontSize: 9, opacity: 0.7 }}>▼</span>}
130
- </button>
131
- {open && !disabled && !loading && (
132
- <div
133
- style={{
134
- position: 'absolute',
135
- bottom: '100%',
136
- left: 0,
137
- marginBottom: 4,
138
- background: 'var(--theme-elevation-0, #fff)',
139
- border: '1px solid var(--theme-elevation-200, #e5e7eb)',
140
- borderRadius: 8,
141
- boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
142
- zIndex: 100,
143
- minWidth: 180,
144
- overflow: 'hidden',
145
- }}
146
- >
147
- {REWRITE_STYLES.map((style) => (
148
- <button
149
- key={style.id}
150
- type="button"
151
- onClick={() => { setOpen(false); onSelect(style.id) }}
152
- style={{
153
- display: 'flex',
154
- flexDirection: 'column',
155
- width: '100%',
156
- padding: '8px 12px',
157
- border: 'none',
158
- background: 'transparent',
159
- cursor: 'pointer',
160
- textAlign: 'left',
161
- borderBottom: '1px solid var(--theme-elevation-100, #f3f4f6)',
162
- transition: 'background 120ms',
163
- }}
164
- onMouseEnter={(e) => { (e.target as HTMLElement).style.background = 'var(--theme-elevation-50, #f9fafb)' }}
165
- onMouseLeave={(e) => { (e.target as HTMLElement).style.background = 'transparent' }}
166
- >
167
- <span style={{ fontSize: 12, fontWeight: 600, color: 'var(--theme-text, #111)' }}>{style.label}</span>
168
- <span style={{ fontSize: 10, color: 'var(--theme-elevation-500, #6b7280)' }}>{style.desc}</span>
169
- </button>
170
- ))}
171
- </div>
172
- )}
173
- </div>
174
- )
175
- }
176
-
177
29
  const TicketConversation: React.FC = () => {
178
30
  const { id } = useDocumentIdFromUrl()
179
31
  const [features] = useState<TicketingFeatures>(() => getFeatures())
@@ -201,6 +53,7 @@ const TicketConversation: React.FC = () => {
201
53
  const [ticketSubject, setTicketSubject] = useState<string>('')
202
54
  const [ticketSource, setTicketSource] = useState<string>('')
203
55
  const [chatSession, setChatSession] = useState<string>('')
56
+ const [autoCloseScheduledAt, setAutoCloseScheduledAt] = useState<string | null>(null)
204
57
  // Client history
205
58
  const [clientTickets, setClientTickets] = useState<Array<{ id: number; ticketNumber: string; subject: string; status: string; createdAt: string }>>([])
206
59
  const [clientProjects, setClientProjects] = useState<Array<{ id: number; name: string; status: string }>>([])
@@ -236,6 +89,7 @@ const TicketConversation: React.FC = () => {
236
89
  setClient(d.client)
237
90
  }
238
91
  setSnoozeUntil(d.snoozeUntil || null)
92
+ setAutoCloseScheduledAt(d.autoCloseScheduledAt || null)
239
93
  setLastClientReadAt(d.lastClientReadAt || null)
240
94
  setCurrentStatus(d.status || '')
241
95
  setTicketNumber(d.ticketNumber || '')
@@ -384,7 +238,7 @@ const TicketConversation: React.FC = () => {
384
238
 
385
239
  // Ticket actions (status, merge, snooze, ext msg, next ticket)
386
240
  const ta = useTicketActions(id, fetchAll)
387
- const { statusUpdating, handleStatusChange, showMerge, setShowMerge, mergeTarget, setMergeTarget, mergeTargetInfo, setMergeTargetInfo, mergeError, setMergeError, merging, handleMergeLookup, handleMerge, showExtMsg, setShowExtMsg, extMsgBody, setExtMsgBody, extMsgAuthor, setExtMsgAuthor, extMsgDate, setExtMsgDate, extMsgFiles, setExtMsgFiles, sendingExtMsg, handleExtFileChange, handleSendExtMsg, showSnooze, setShowSnooze, snoozeUntil, setSnoozeUntil, snoozeSaving, handleSnooze, showNextTicket, setShowNextTicket, nextTicketId, nextTicketInfo, handleNextTicket } = ta
241
+ const { statusUpdating, handleStatusChange, showMerge, setShowMerge, mergeTarget, setMergeTarget, mergeTargetInfo, setMergeTargetInfo, mergeError, setMergeError, merging, handleMergeLookup, handleMerge, showExtMsg, setShowExtMsg, extMsgBody, setExtMsgBody, extMsgAuthor, setExtMsgAuthor, extMsgDate, setExtMsgDate, extMsgFiles, setExtMsgFiles, sendingExtMsg, handleExtFileChange, handleSendExtMsg, showSnooze, setShowSnooze, snoozeUntil, setSnoozeUntil, snoozeSaving, handleSnooze, showReminder, setShowReminder, reminderHours, setReminderHours, reminderSending, reminderError, setReminderError, reminderSuccess, handleSendReminder, cancelingClose, handleCancelScheduledClose, showNextTicket, setShowNextTicket, nextTicketId, nextTicketInfo, handleNextTicket } = ta
388
242
 
389
243
  // Reply composer
390
244
  const replyEditorRef = useRef<RichTextEditorHandle>(null)
@@ -419,6 +273,7 @@ const TicketConversation: React.FC = () => {
419
273
  const d = await ticketRes.json()
420
274
  setCurrentStatus(d.status || '')
421
275
  setSnoozeUntil(d.snoozeUntil || null)
276
+ setAutoCloseScheduledAt(d.autoCloseScheduledAt || null)
422
277
  setLastClientReadAt(d.lastClientReadAt || null)
423
278
  }
424
279
  if (activityRes.ok) {
@@ -1094,10 +949,16 @@ const TicketConversation: React.FC = () => {
1094
949
  showMerge={showMerge}
1095
950
  showExtMsg={showExtMsg}
1096
951
  showSnooze={showSnooze}
1097
- onToggleMerge={() => { setShowMerge(!showMerge); setShowExtMsg(false); setShowSnooze(false) }}
1098
- onToggleExtMsg={() => { setShowExtMsg(!showExtMsg); setShowMerge(false); setShowSnooze(false) }}
1099
- onToggleSnooze={() => { setShowSnooze(!showSnooze); setShowMerge(false); setShowExtMsg(false) }}
952
+ onToggleMerge={() => { setShowMerge(!showMerge); setShowExtMsg(false); setShowSnooze(false); setShowReminder(false) }}
953
+ onToggleExtMsg={() => { setShowExtMsg(!showExtMsg); setShowMerge(false); setShowSnooze(false); setShowReminder(false) }}
954
+ onToggleSnooze={() => { setShowSnooze(!showSnooze); setShowMerge(false); setShowExtMsg(false); setShowReminder(false) }}
1100
955
  onNextTicket={handleNextTicket}
956
+ showReminderButton={features.autoClose}
957
+ onToggleReminder={() => { setShowReminder(!showReminder); setReminderError(''); setShowMerge(false); setShowExtMsg(false); setShowSnooze(false) }}
958
+ autoCloseScheduledAt={autoCloseScheduledAt}
959
+ onCancelScheduledClose={handleCancelScheduledClose}
960
+ cancelingClose={cancelingClose}
961
+ reminderSuccess={reminderSuccess}
1101
962
  showNextTicket={showNextTicket}
1102
963
  nextTicketId={nextTicketId}
1103
964
  nextTicketInfo={nextTicketInfo}
@@ -1139,6 +1000,17 @@ const TicketConversation: React.FC = () => {
1139
1000
  {features.snooze && showSnooze && (
1140
1001
  <SnoozePanel snoozeSaving={snoozeSaving} handleSnooze={handleSnooze} />
1141
1002
  )}
1003
+ {features.autoClose && showReminder && (
1004
+ <ReminderPanel
1005
+ clientFirstName={client?.firstName || ''}
1006
+ clientEmail={client?.email || ''}
1007
+ reminderHours={reminderHours}
1008
+ setReminderHours={setReminderHours}
1009
+ reminderSending={reminderSending}
1010
+ reminderError={reminderError}
1011
+ handleSendReminder={handleSendReminder}
1012
+ />
1013
+ )}
1142
1014
 
1143
1015
 
1144
1016
  </div>
@@ -1,6 +1,7 @@
1
1
  import type { Endpoint } from 'payload'
2
2
  import type { CollectionSlugs } from '../utils/slugs'
3
3
  import { requireAdmin, handleAuthError } from '../utils/auth'
4
+ import { dbFind } from '../utils/db'
4
5
 
5
6
  /**
6
7
  * GET /api/support/admin-chat-stream?session=xxx
@@ -58,8 +59,7 @@ function createSessionStream(
58
59
  // Poll DB every 2 seconds for new messages in this session
59
60
  const interval = setInterval(async () => {
60
61
  try {
61
- const messages = await req.payload.find({
62
- collection: slugs.chatMessages as any,
62
+ const messages = await dbFind(req.payload, slugs.chatMessages, {
63
63
  where: {
64
64
  session: { equals: sessionId },
65
65
  createdAt: { greater_than: lastCheck },
@@ -140,8 +140,7 @@ function createSessionListStream(req: any, slugs: CollectionSlugs): Response {
140
140
 
141
141
  const interval = setInterval(async () => {
142
142
  try {
143
- const recentMessages = await req.payload.find({
144
- collection: slugs.chatMessages as any,
143
+ const recentMessages = await dbFind(req.payload, slugs.chatMessages, {
145
144
  sort: '-createdAt',
146
145
  limit: 50,
147
146
  depth: 1,
@@ -3,6 +3,7 @@ import type { Where } from 'payload'
3
3
  import type { CollectionSlugs } from '../utils/slugs'
4
4
  import { RateLimiter } from '../utils/rateLimiter'
5
5
  import { requireAdmin, handleAuthError } from '../utils/auth'
6
+ import { dbFind, dbCreate, dbUpdate } from '../utils/db'
6
7
 
7
8
  const adminChatLimiter = new RateLimiter(60_000, 30) // 30 per minute
8
9
 
@@ -28,8 +29,7 @@ export function createAdminChatGetEndpoint(slugs: CollectionSlugs): Endpoint {
28
29
  const where: Where = { session: { equals: session } }
29
30
  if (after) where.createdAt = { greater_than: after }
30
31
 
31
- const messages = await payload.find({
32
- collection: slugs.chatMessages as any,
32
+ const messages = await dbFind(payload, slugs.chatMessages, {
33
33
  where,
34
34
  sort: 'createdAt',
35
35
  limit: 200,
@@ -46,8 +46,7 @@ export function createAdminChatGetEndpoint(slugs: CollectionSlugs): Endpoint {
46
46
  }
47
47
 
48
48
  // List recent sessions: fetch only recent messages, limit to 50
49
- const recentMessages = await payload.find({
50
- collection: slugs.chatMessages as any,
49
+ const recentMessages = await dbFind(payload, slugs.chatMessages, {
51
50
  sort: '-createdAt',
52
51
  limit: 50,
53
52
  depth: 1,
@@ -134,8 +133,7 @@ export function createAdminChatPostEndpoint(slugs: CollectionSlugs): Endpoint {
134
133
  return Response.json({ error: 'Session requise' }, { status: 400 })
135
134
  }
136
135
 
137
- const sessionMsg = await payload.find({
138
- collection: slugs.chatMessages as any,
136
+ const sessionMsg = await dbFind(payload, slugs.chatMessages, {
139
137
  where: { session: { equals: session } },
140
138
  limit: 1,
141
139
  depth: 0,
@@ -161,8 +159,7 @@ export function createAdminChatPostEndpoint(slugs: CollectionSlugs): Endpoint {
161
159
  return Response.json({ error: 'Message invalide (1-2000 caractères).' }, { status: 400 })
162
160
  }
163
161
 
164
- const newMsg = await payload.create({
165
- collection: slugs.chatMessages as any,
162
+ const newMsg = await dbCreate(payload, slugs.chatMessages, {
166
163
  data: {
167
164
  session,
168
165
  client: clientId,
@@ -176,8 +173,7 @@ export function createAdminChatPostEndpoint(slugs: CollectionSlugs): Endpoint {
176
173
 
177
174
  // Try to link to ticket
178
175
  try {
179
- const linkedTicket = await payload.find({
180
- collection: slugs.tickets as any,
176
+ const linkedTicket = await dbFind(payload, slugs.tickets, {
181
177
  where: { chatSession: { equals: session } },
182
178
  limit: 1,
183
179
  depth: 0,
@@ -185,8 +181,7 @@ export function createAdminChatPostEndpoint(slugs: CollectionSlugs): Endpoint {
185
181
  })
186
182
  if (linkedTicket.docs.length > 0) {
187
183
  const ticketId = linkedTicket.docs[0].id
188
- await payload.create({
189
- collection: slugs.ticketMessages as any,
184
+ await dbCreate(payload, slugs.ticketMessages, {
190
185
  data: {
191
186
  ticket: ticketId,
192
187
  body: trimmedMessage,
@@ -195,8 +190,7 @@ export function createAdminChatPostEndpoint(slugs: CollectionSlugs): Endpoint {
195
190
  },
196
191
  overrideAccess: true,
197
192
  })
198
- await payload.update({
199
- collection: slugs.chatMessages as any,
193
+ await dbUpdate(payload, slugs.chatMessages, {
200
194
  id: newMsg.id,
201
195
  data: { ticket: ticketId },
202
196
  overrideAccess: true,
@@ -211,8 +205,7 @@ export function createAdminChatPostEndpoint(slugs: CollectionSlugs): Endpoint {
211
205
 
212
206
  // Admin closes session
213
207
  if (action === 'close') {
214
- await payload.create({
215
- collection: slugs.chatMessages as any,
208
+ await dbCreate(payload, slugs.chatMessages, {
216
209
  data: {
217
210
  session,
218
211
  client: clientId,
@@ -224,16 +217,14 @@ export function createAdminChatPostEndpoint(slugs: CollectionSlugs): Endpoint {
224
217
  })
225
218
 
226
219
  try {
227
- const linkedTicket = await payload.find({
228
- collection: slugs.tickets as any,
220
+ const linkedTicket = await dbFind(payload, slugs.tickets, {
229
221
  where: { chatSession: { equals: session } },
230
222
  limit: 1,
231
223
  depth: 0,
232
224
  overrideAccess: true,
233
225
  })
234
226
  if (linkedTicket.docs.length > 0) {
235
- await payload.create({
236
- collection: slugs.ticketMessages as any,
227
+ await dbCreate(payload, slugs.ticketMessages, {
237
228
  data: {
238
229
  ticket: linkedTicket.docs[0].id,
239
230
  body: 'Session de chat terminée par un agent.',