@consilioweb/payload-support 0.15.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/TicketConversation/components/ActionPanels.cjs +72 -0
- package/dist/components/TicketConversation/components/ActionPanels.js +73 -2
- package/dist/components/TicketConversation/components/QuickActions.cjs +22 -1
- package/dist/components/TicketConversation/components/QuickActions.js +22 -1
- package/dist/components/TicketConversation/hooks/useTicketActions.cjs +59 -0
- package/dist/components/TicketConversation/hooks/useTicketActions.js +59 -0
- package/dist/components/TicketConversation/index.cjs +32 -2
- package/dist/components/TicketConversation/index.js +33 -3
- package/dist/index.cjs +213 -52
- package/dist/index.js +213 -52
- package/package.json +1 -1
- package/src/collections/TicketMessages.ts +8 -0
- package/src/collections/Tickets.ts +1 -0
- package/src/components/TicketConversation/components/ActionPanels.tsx +82 -1
- package/src/components/TicketConversation/components/QuickActions.tsx +25 -0
- package/src/components/TicketConversation/hooks/useTicketActions.ts +51 -0
- package/src/components/TicketConversation/index.tsx +25 -5
- package/src/endpoints/auto-close.ts +31 -7
- package/src/endpoints/index.ts +6 -1
- package/src/endpoints/send-reminder.ts +189 -0
|
@@ -30,8 +30,16 @@ function createAutoUpdateStatus(slugs: CollectionSlugs): CollectionAfterChangeHo
|
|
|
30
30
|
if (!doc.isInternal) {
|
|
31
31
|
if (doc.authorType === 'admin') {
|
|
32
32
|
updateData.status = 'waiting_client'
|
|
33
|
+
// A fresh public reply starts a new waiting window: drop any pending
|
|
34
|
+
// auto-close arming (manual deadline + day-based reminder anchor) so
|
|
35
|
+
// reminders re-arm cleanly from here.
|
|
36
|
+
updateData.autoCloseScheduledAt = null
|
|
37
|
+
updateData.autoCloseRemindedAt = null
|
|
33
38
|
} else if (doc.authorType === 'client' || doc.authorType === 'email') {
|
|
34
39
|
updateData.lastClientMessageAt = new Date().toISOString()
|
|
40
|
+
// The client answered — cancel any pending automatic closure.
|
|
41
|
+
updateData.autoCloseScheduledAt = null
|
|
42
|
+
updateData.autoCloseRemindedAt = null
|
|
35
43
|
if (ticket.status && ['waiting_client', 'resolved'].includes(ticket.status as string)) {
|
|
36
44
|
updateData.status = 'open'
|
|
37
45
|
}
|
|
@@ -739,6 +739,7 @@ export function createTicketsCollection(slugs: CollectionSlugs, options?: {
|
|
|
739
739
|
},
|
|
740
740
|
{ name: 'mergedInto', type: 'relationship', relationTo: slugs.tickets, label: 'Fusionne dans', admin: { readOnly: true } },
|
|
741
741
|
{ name: 'autoCloseRemindedAt', type: 'date', label: 'Rappel auto-close envoye', admin: { readOnly: true, date: { displayFormat: 'dd/MM/yyyy HH:mm' } } },
|
|
742
|
+
{ name: 'autoCloseScheduledAt', type: 'date', label: 'Fermeture auto programmee', admin: { readOnly: true, date: { displayFormat: 'dd/MM/yyyy HH:mm' }, description: 'Echeance ferme posee par une relance manuelle. Le ticket se ferme apres cette date sans reponse client.' } },
|
|
742
743
|
],
|
|
743
744
|
},
|
|
744
745
|
// Sidebar
|
|
@@ -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'é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'a pas d'adresse email : impossible d'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 && (
|
|
@@ -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
|
}
|
|
@@ -31,7 +31,7 @@ import { TicketHeader } from './components/TicketHeader'
|
|
|
31
31
|
import { ClientBar } from './components/ClientBar'
|
|
32
32
|
import { QuickActions } from './components/QuickActions'
|
|
33
33
|
import { AISummaryPanel } from './components/AISummaryPanel'
|
|
34
|
-
import { MergePanel, ExtMessagePanel, SnoozePanel } from './components/ActionPanels'
|
|
34
|
+
import { MergePanel, ExtMessagePanel, SnoozePanel, ReminderPanel } from './components/ActionPanels'
|
|
35
35
|
import { ActivityLog } from './components/ActivityLog'
|
|
36
36
|
import { ClientHistory } from './components/ClientHistory'
|
|
37
37
|
import { TimeTrackingPanel } from './components/TimeTrackingPanel'
|
|
@@ -201,6 +201,7 @@ const TicketConversation: React.FC = () => {
|
|
|
201
201
|
const [ticketSubject, setTicketSubject] = useState<string>('')
|
|
202
202
|
const [ticketSource, setTicketSource] = useState<string>('')
|
|
203
203
|
const [chatSession, setChatSession] = useState<string>('')
|
|
204
|
+
const [autoCloseScheduledAt, setAutoCloseScheduledAt] = useState<string | null>(null)
|
|
204
205
|
// Client history
|
|
205
206
|
const [clientTickets, setClientTickets] = useState<Array<{ id: number; ticketNumber: string; subject: string; status: string; createdAt: string }>>([])
|
|
206
207
|
const [clientProjects, setClientProjects] = useState<Array<{ id: number; name: string; status: string }>>([])
|
|
@@ -236,6 +237,7 @@ const TicketConversation: React.FC = () => {
|
|
|
236
237
|
setClient(d.client)
|
|
237
238
|
}
|
|
238
239
|
setSnoozeUntil(d.snoozeUntil || null)
|
|
240
|
+
setAutoCloseScheduledAt(d.autoCloseScheduledAt || null)
|
|
239
241
|
setLastClientReadAt(d.lastClientReadAt || null)
|
|
240
242
|
setCurrentStatus(d.status || '')
|
|
241
243
|
setTicketNumber(d.ticketNumber || '')
|
|
@@ -384,7 +386,7 @@ const TicketConversation: React.FC = () => {
|
|
|
384
386
|
|
|
385
387
|
// Ticket actions (status, merge, snooze, ext msg, next ticket)
|
|
386
388
|
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
|
|
389
|
+
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
390
|
|
|
389
391
|
// Reply composer
|
|
390
392
|
const replyEditorRef = useRef<RichTextEditorHandle>(null)
|
|
@@ -419,6 +421,7 @@ const TicketConversation: React.FC = () => {
|
|
|
419
421
|
const d = await ticketRes.json()
|
|
420
422
|
setCurrentStatus(d.status || '')
|
|
421
423
|
setSnoozeUntil(d.snoozeUntil || null)
|
|
424
|
+
setAutoCloseScheduledAt(d.autoCloseScheduledAt || null)
|
|
422
425
|
setLastClientReadAt(d.lastClientReadAt || null)
|
|
423
426
|
}
|
|
424
427
|
if (activityRes.ok) {
|
|
@@ -1094,10 +1097,16 @@ const TicketConversation: React.FC = () => {
|
|
|
1094
1097
|
showMerge={showMerge}
|
|
1095
1098
|
showExtMsg={showExtMsg}
|
|
1096
1099
|
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) }}
|
|
1100
|
+
onToggleMerge={() => { setShowMerge(!showMerge); setShowExtMsg(false); setShowSnooze(false); setShowReminder(false) }}
|
|
1101
|
+
onToggleExtMsg={() => { setShowExtMsg(!showExtMsg); setShowMerge(false); setShowSnooze(false); setShowReminder(false) }}
|
|
1102
|
+
onToggleSnooze={() => { setShowSnooze(!showSnooze); setShowMerge(false); setShowExtMsg(false); setShowReminder(false) }}
|
|
1100
1103
|
onNextTicket={handleNextTicket}
|
|
1104
|
+
showReminderButton={features.autoClose}
|
|
1105
|
+
onToggleReminder={() => { setShowReminder(!showReminder); setReminderError(''); setShowMerge(false); setShowExtMsg(false); setShowSnooze(false) }}
|
|
1106
|
+
autoCloseScheduledAt={autoCloseScheduledAt}
|
|
1107
|
+
onCancelScheduledClose={handleCancelScheduledClose}
|
|
1108
|
+
cancelingClose={cancelingClose}
|
|
1109
|
+
reminderSuccess={reminderSuccess}
|
|
1101
1110
|
showNextTicket={showNextTicket}
|
|
1102
1111
|
nextTicketId={nextTicketId}
|
|
1103
1112
|
nextTicketInfo={nextTicketInfo}
|
|
@@ -1139,6 +1148,17 @@ const TicketConversation: React.FC = () => {
|
|
|
1139
1148
|
{features.snooze && showSnooze && (
|
|
1140
1149
|
<SnoozePanel snoozeSaving={snoozeSaving} handleSnooze={handleSnooze} />
|
|
1141
1150
|
)}
|
|
1151
|
+
{features.autoClose && showReminder && (
|
|
1152
|
+
<ReminderPanel
|
|
1153
|
+
clientFirstName={client?.firstName || ''}
|
|
1154
|
+
clientEmail={client?.email || ''}
|
|
1155
|
+
reminderHours={reminderHours}
|
|
1156
|
+
setReminderHours={setReminderHours}
|
|
1157
|
+
reminderSending={reminderSending}
|
|
1158
|
+
reminderError={reminderError}
|
|
1159
|
+
handleSendReminder={handleSendReminder}
|
|
1160
|
+
/>
|
|
1161
|
+
)}
|
|
1142
1162
|
|
|
1143
1163
|
|
|
1144
1164
|
</div>
|
|
@@ -90,7 +90,13 @@ export function createAutoCloseEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
-
// Step 2: Close tickets that
|
|
93
|
+
// Step 2: Close tickets that are due. Two independent paths:
|
|
94
|
+
// - day-based: reminded > CLOSE_AFTER_REMIND_DAYS ago and the client
|
|
95
|
+
// stayed silent (the legacy automatic flow).
|
|
96
|
+
// - explicit deadline: a manual reminder armed `autoCloseScheduledAt`
|
|
97
|
+
// (e.g. "close in 24h"). When the client replies, that field is
|
|
98
|
+
// cleared and the status flips to `open`, so a still-set deadline in
|
|
99
|
+
// the past means no reply was received.
|
|
94
100
|
const closeCutoff = new Date(now.getTime() - CLOSE_AFTER_REMIND_DAYS * 24 * 60 * 60 * 1000)
|
|
95
101
|
|
|
96
102
|
const ticketsToClose = await payload.find({
|
|
@@ -98,11 +104,20 @@ export function createAutoCloseEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
98
104
|
where: {
|
|
99
105
|
and: [
|
|
100
106
|
{ status: { equals: 'waiting_client' } },
|
|
101
|
-
{ autoCloseRemindedAt: { less_than: closeCutoff.toISOString() } },
|
|
102
107
|
{
|
|
103
108
|
or: [
|
|
104
|
-
{
|
|
105
|
-
|
|
109
|
+
{
|
|
110
|
+
and: [
|
|
111
|
+
{ autoCloseRemindedAt: { less_than: closeCutoff.toISOString() } },
|
|
112
|
+
{
|
|
113
|
+
or: [
|
|
114
|
+
{ lastClientMessageAt: { exists: false } },
|
|
115
|
+
{ lastClientMessageAt: { less_than_equal: closeCutoff.toISOString() } },
|
|
116
|
+
],
|
|
117
|
+
},
|
|
118
|
+
],
|
|
119
|
+
},
|
|
120
|
+
{ autoCloseScheduledAt: { less_than_equal: now.toISOString() } },
|
|
106
121
|
],
|
|
107
122
|
},
|
|
108
123
|
],
|
|
@@ -119,12 +134,19 @@ export function createAutoCloseEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
119
134
|
const ticketNumber = t.ticketNumber || 'TK-????'
|
|
120
135
|
const subject = t.subject || 'Support'
|
|
121
136
|
const closeTotalDays = REMIND_AFTER_DAYS + CLOSE_AFTER_REMIND_DAYS
|
|
137
|
+
// Was this ticket closed via a manual reminder's explicit deadline,
|
|
138
|
+
// or via the legacy day-based flow? Drives the wording below.
|
|
139
|
+
const viaSchedule = !!t.autoCloseScheduledAt
|
|
140
|
+
&& new Date(t.autoCloseScheduledAt).getTime() <= now.getTime()
|
|
141
|
+
const noteBody = viaSchedule
|
|
142
|
+
? 'Ticket résolu automatiquement — relance manuelle restée sans réponse du client'
|
|
143
|
+
: `Ticket résolu automatiquement — sans réponse client depuis ${closeTotalDays} jours`
|
|
122
144
|
|
|
123
145
|
await payload.create({
|
|
124
146
|
collection: slugs.ticketMessages as any,
|
|
125
147
|
data: {
|
|
126
148
|
ticket: t.id,
|
|
127
|
-
body:
|
|
149
|
+
body: noteBody,
|
|
128
150
|
authorType: 'admin',
|
|
129
151
|
isInternal: true,
|
|
130
152
|
skipNotification: true,
|
|
@@ -135,7 +157,9 @@ export function createAutoCloseEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
135
157
|
await payload.update({
|
|
136
158
|
collection: slugs.tickets as any,
|
|
137
159
|
id: t.id,
|
|
138
|
-
|
|
160
|
+
// Clear the armed deadline so a later manual reopen can't be
|
|
161
|
+
// closed instantly by a stale timestamp.
|
|
162
|
+
data: { status: 'resolved', autoCloseScheduledAt: null },
|
|
139
163
|
overrideAccess: true,
|
|
140
164
|
})
|
|
141
165
|
|
|
@@ -147,7 +171,7 @@ export function createAutoCloseEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
|
147
171
|
subject: `[${ticketNumber}] Ticket résolu — ${subject}`,
|
|
148
172
|
html: `<div style="font-family: system-ui, sans-serif; max-width: 600px; margin: 0 auto;">
|
|
149
173
|
<p>Bonjour <strong>${escapeHtml(client.firstName || '')}</strong>,</p>
|
|
150
|
-
<p>Votre ticket <strong>${escapeHtml(ticketNumber)}</strong> — <em>${escapeHtml(subject)}</em> — a été résolu automatiquement après ${closeTotalDays} jours sans réponse.</p>
|
|
174
|
+
<p>Votre ticket <strong>${escapeHtml(ticketNumber)}</strong> — <em>${escapeHtml(subject)}</em> — a été résolu automatiquement ${viaSchedule ? 'faute de réponse de votre part' : `après ${closeTotalDays} jours sans réponse`}.</p>
|
|
151
175
|
<p>Si vous avez encore besoin d'aide, n'hésitez pas à rouvrir ce ticket ou à en créer un nouveau.</p>
|
|
152
176
|
<p><a href="${portalUrl}">Consulter le ticket</a></p>
|
|
153
177
|
</div>`,
|
package/src/endpoints/index.ts
CHANGED
|
@@ -16,6 +16,7 @@ import { createSignatureGetEndpoint, createSignaturePostEndpoint } from './signa
|
|
|
16
16
|
import { createRoundRobinConfigGetEndpoint, createRoundRobinConfigPostEndpoint } from './round-robin-config'
|
|
17
17
|
import { createSlaCheckEndpoint } from './sla-check'
|
|
18
18
|
import { createAutoCloseEndpoint } from './auto-close'
|
|
19
|
+
import { createSendReminderEndpoint } from './send-reminder'
|
|
19
20
|
import { createStatusesEndpoint } from './statuses'
|
|
20
21
|
import { createApplyMacroEndpoint } from './apply-macro'
|
|
21
22
|
import { createPurgeLogsEndpoint } from './purge-logs'
|
|
@@ -62,6 +63,7 @@ export { createSignatureGetEndpoint, createSignaturePostEndpoint } from './signa
|
|
|
62
63
|
export { createRoundRobinConfigGetEndpoint, createRoundRobinConfigPostEndpoint } from './round-robin-config'
|
|
63
64
|
export { createSlaCheckEndpoint } from './sla-check'
|
|
64
65
|
export { createAutoCloseEndpoint } from './auto-close'
|
|
66
|
+
export { createSendReminderEndpoint } from './send-reminder'
|
|
65
67
|
export { createStatusesEndpoint } from './statuses'
|
|
66
68
|
export { createApplyMacroEndpoint } from './apply-macro'
|
|
67
69
|
export { createPurgeLogsEndpoint } from './purge-logs'
|
|
@@ -151,7 +153,10 @@ export function createSupportEndpoints(slugs: CollectionSlugs, options?: Support
|
|
|
151
153
|
endpoints.push(createSignatureGetEndpoint(slugs), createSignaturePostEndpoint(slugs))
|
|
152
154
|
}
|
|
153
155
|
if (!f || f.sla !== false) endpoints.push(createSlaCheckEndpoint(slugs))
|
|
154
|
-
if (!f || f.autoClose !== false)
|
|
156
|
+
if (!f || f.autoClose !== false) {
|
|
157
|
+
endpoints.push(createAutoCloseEndpoint(slugs))
|
|
158
|
+
endpoints.push(createSendReminderEndpoint(slugs))
|
|
159
|
+
}
|
|
155
160
|
if (!f || f.customStatuses !== false) endpoints.push(createStatusesEndpoint(slugs))
|
|
156
161
|
if (!f || f.macros !== false) endpoints.push(createApplyMacroEndpoint(slugs))
|
|
157
162
|
if (!f || f.roundRobin !== false) {
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import type { Endpoint } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import { requireAdmin, handleAuthError } from '../utils/auth'
|
|
4
|
+
import { RateLimiter } from '../utils/rateLimiter'
|
|
5
|
+
import { escapeHtml, emailWrapper, emailParagraph, emailButton } from '../utils/emailTemplate'
|
|
6
|
+
import { readSupportSettings } from '../utils/readSettings'
|
|
7
|
+
|
|
8
|
+
// Manual reminders are a deliberate admin action, so the cap is generous —
|
|
9
|
+
// it only exists to stop a runaway script, not normal usage.
|
|
10
|
+
const reminderLimiter = new RateLimiter(60 * 60 * 1000, 30) // 30 per hour per admin
|
|
11
|
+
|
|
12
|
+
const MIN_HOURS = 1
|
|
13
|
+
const MAX_HOURS = 24 * 30 // 30 days
|
|
14
|
+
|
|
15
|
+
function formatFr(date: Date, withTime: boolean): string {
|
|
16
|
+
return date.toLocaleString('fr-FR', {
|
|
17
|
+
day: 'numeric',
|
|
18
|
+
month: 'long',
|
|
19
|
+
year: 'numeric',
|
|
20
|
+
...(withTime ? { hour: '2-digit', minute: '2-digit' } : {}),
|
|
21
|
+
timeZone: 'Europe/Paris',
|
|
22
|
+
})
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* POST /api/support/send-reminder
|
|
27
|
+
*
|
|
28
|
+
* Manually nudge a client whose ticket is awaiting their reply: sends a generic
|
|
29
|
+
* "your ticket will be closed if you don't respond" email, drops an internal
|
|
30
|
+
* note in the thread, and arms the ticket for automatic closure.
|
|
31
|
+
*
|
|
32
|
+
* Arming = set `status: waiting_client`, `autoCloseRemindedAt: now` (so the
|
|
33
|
+
* day-based cron reminder skips it) and `autoCloseScheduledAt: now + hours`.
|
|
34
|
+
* The existing `/api/support/auto-close` cron then resolves the ticket once the
|
|
35
|
+
* deadline passes — UNLESS the client replies first, which flips the status back
|
|
36
|
+
* to `open` and clears `autoCloseScheduledAt` (see TicketMessages auto-update hook).
|
|
37
|
+
*
|
|
38
|
+
* Admin-only, rate-limited.
|
|
39
|
+
*/
|
|
40
|
+
export function createSendReminderEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
41
|
+
return {
|
|
42
|
+
path: '/support/send-reminder',
|
|
43
|
+
method: 'post',
|
|
44
|
+
handler: async (req) => {
|
|
45
|
+
try {
|
|
46
|
+
const payload = req.payload
|
|
47
|
+
|
|
48
|
+
requireAdmin(req, slugs)
|
|
49
|
+
|
|
50
|
+
if (reminderLimiter.check(String(req.user.id))) {
|
|
51
|
+
return Response.json(
|
|
52
|
+
{ error: 'Trop de relances. Réessayez dans une heure.' },
|
|
53
|
+
{ status: 429 },
|
|
54
|
+
)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let body: { ticketId?: string | number; hours?: number }
|
|
58
|
+
try {
|
|
59
|
+
body = await req.json!()
|
|
60
|
+
} catch {
|
|
61
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const { ticketId } = body
|
|
65
|
+
if (!ticketId) {
|
|
66
|
+
return Response.json({ error: 'ticketId requis' }, { status: 400 })
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const rawHours = Number(body.hours)
|
|
70
|
+
const hours = Number.isFinite(rawHours)
|
|
71
|
+
? Math.min(MAX_HOURS, Math.max(MIN_HOURS, Math.round(rawHours)))
|
|
72
|
+
: 24
|
|
73
|
+
|
|
74
|
+
const ticket = await payload.findByID({
|
|
75
|
+
collection: slugs.tickets as any,
|
|
76
|
+
id: ticketId,
|
|
77
|
+
depth: 1,
|
|
78
|
+
overrideAccess: true,
|
|
79
|
+
}) as any
|
|
80
|
+
|
|
81
|
+
if (!ticket) {
|
|
82
|
+
return Response.json({ error: 'Ticket introuvable' }, { status: 404 })
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const client = typeof ticket.client === 'object' ? ticket.client : null
|
|
86
|
+
if (!client?.email) {
|
|
87
|
+
return Response.json({ error: 'Client sans email' }, { status: 400 })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const now = new Date()
|
|
91
|
+
const deadline = new Date(now.getTime() + hours * 60 * 60 * 1000)
|
|
92
|
+
|
|
93
|
+
const settings = await readSupportSettings(payload)
|
|
94
|
+
const ticketNumber = ticket.ticketNumber || 'TK-????'
|
|
95
|
+
const subject = ticket.subject || 'Support'
|
|
96
|
+
const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || ''
|
|
97
|
+
const portalUrl = `${baseUrl}/support/tickets/${ticketId}`
|
|
98
|
+
const replyTo = settings.email.replyToAddress || process.env.SUPPORT_REPLY_TO || ''
|
|
99
|
+
|
|
100
|
+
// "En attente depuis le …" — anchored on our last public reply (that's
|
|
101
|
+
// what the client owes a response to), falling back to the first
|
|
102
|
+
// response then to ticket creation.
|
|
103
|
+
let waitingSince: string | undefined = ticket.firstResponseAt || ticket.createdAt
|
|
104
|
+
try {
|
|
105
|
+
const lastAdminMsg = await payload.find({
|
|
106
|
+
collection: slugs.ticketMessages as any,
|
|
107
|
+
where: {
|
|
108
|
+
and: [
|
|
109
|
+
{ ticket: { equals: ticketId } },
|
|
110
|
+
{ authorType: { equals: 'admin' } },
|
|
111
|
+
{ isInternal: { equals: false } },
|
|
112
|
+
],
|
|
113
|
+
},
|
|
114
|
+
sort: '-createdAt',
|
|
115
|
+
limit: 1,
|
|
116
|
+
depth: 0,
|
|
117
|
+
overrideAccess: true,
|
|
118
|
+
})
|
|
119
|
+
if (lastAdminMsg.docs.length > 0 && lastAdminMsg.docs[0].createdAt) {
|
|
120
|
+
waitingSince = lastAdminMsg.docs[0].createdAt as string
|
|
121
|
+
}
|
|
122
|
+
} catch { /* fallback already set */ }
|
|
123
|
+
|
|
124
|
+
const sinceLabel = waitingSince ? formatFr(new Date(waitingSince), false) : null
|
|
125
|
+
const deadlineLabel = formatFr(deadline, true)
|
|
126
|
+
|
|
127
|
+
await payload.sendEmail({
|
|
128
|
+
to: client.email,
|
|
129
|
+
...(replyTo ? { replyTo } : {}),
|
|
130
|
+
subject: `Rappel : [${ticketNumber}] ${subject} — votre réponse est attendue`,
|
|
131
|
+
html: emailWrapper(`Votre ticket attend votre réponse`, [
|
|
132
|
+
emailParagraph(`Bonjour <strong>${escapeHtml(client.firstName || '')}</strong>,`),
|
|
133
|
+
emailParagraph(
|
|
134
|
+
`Votre ticket <strong>${escapeHtml(ticketNumber)}</strong> — <em>${escapeHtml(String(subject))}</em> — est en attente de votre réponse${sinceLabel ? ` depuis le ${escapeHtml(sinceLabel)}` : ''}.`,
|
|
135
|
+
),
|
|
136
|
+
emailParagraph(
|
|
137
|
+
`<strong>Sans retour de votre part, ce ticket sera automatiquement clôturé le ${escapeHtml(deadlineLabel)}.</strong>`,
|
|
138
|
+
),
|
|
139
|
+
emailParagraph(
|
|
140
|
+
`Si vous avez encore besoin d'assistance, il vous suffit de répondre à ce message — votre réponse maintiendra le ticket ouvert.`,
|
|
141
|
+
),
|
|
142
|
+
emailButton('Répondre au ticket', portalUrl, 'primary'),
|
|
143
|
+
].join(''), {
|
|
144
|
+
kind: 'alert',
|
|
145
|
+
preheader: `Sans réponse de votre part, votre ticket ${ticketNumber} sera clôturé le ${deadlineLabel}.`,
|
|
146
|
+
}),
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
// Internal note for the conversation timeline (never sent to the client).
|
|
150
|
+
await payload.create({
|
|
151
|
+
collection: slugs.ticketMessages as any,
|
|
152
|
+
data: {
|
|
153
|
+
ticket: ticketId,
|
|
154
|
+
body: `Relance envoyée à ${client.email}. Fermeture automatique programmée le ${deadlineLabel} sans réponse du client.`,
|
|
155
|
+
authorType: 'admin',
|
|
156
|
+
isInternal: true,
|
|
157
|
+
skipNotification: true,
|
|
158
|
+
},
|
|
159
|
+
overrideAccess: true,
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
// Arm the ticket. `autoCloseRemindedAt: now` keeps the day-based cron
|
|
163
|
+
// reminder from firing a second time; `autoCloseScheduledAt` is the hard
|
|
164
|
+
// 24h-style deadline the cron close step honours.
|
|
165
|
+
await payload.update({
|
|
166
|
+
collection: slugs.tickets as any,
|
|
167
|
+
id: ticketId,
|
|
168
|
+
data: {
|
|
169
|
+
status: 'waiting_client',
|
|
170
|
+
autoCloseRemindedAt: now.toISOString(),
|
|
171
|
+
autoCloseScheduledAt: deadline.toISOString(),
|
|
172
|
+
},
|
|
173
|
+
overrideAccess: true,
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
return Response.json({
|
|
177
|
+
success: true,
|
|
178
|
+
sentTo: client.email,
|
|
179
|
+
scheduledCloseAt: deadline.toISOString(),
|
|
180
|
+
})
|
|
181
|
+
} catch (error) {
|
|
182
|
+
const authResponse = handleAuthError(error)
|
|
183
|
+
if (authResponse) return authResponse
|
|
184
|
+
console.error('[send-reminder] Error:', error)
|
|
185
|
+
return Response.json({ error: 'Erreur interne' }, { status: 500 })
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
}
|
|
189
|
+
}
|