@consilioweb/payload-support 0.10.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/TicketConversation/locales/en.json +25 -1
- package/dist/components/TicketConversation/locales/fr.json +25 -1
- package/dist/index.cjs +799 -4
- package/dist/index.d.cts +15 -1
- package/dist/index.d.ts +15 -1
- package/dist/index.js +800 -6
- package/dist/styles/TicketDetail.module.scss +294 -0
- package/dist/views/TicketDetailView/client.cjs +455 -249
- package/dist/views/TicketDetailView/client.js +456 -250
- package/dist/views/TicketInboxView/client.cjs +10 -9
- package/dist/views/TicketInboxView/client.js +2 -1
- package/package.json +1 -1
- package/src/collections/ClientSummaries.ts +16 -0
- package/src/collections/TicketCollaborators.ts +93 -0
- package/src/collections/TicketFeedback.ts +116 -0
- package/src/collections/TicketMessages.ts +9 -0
- package/src/collections/Tickets.ts +31 -1
- package/src/collections/index.ts +2 -0
- package/src/components/TicketConversation/locales/en.json +25 -1
- package/src/components/TicketConversation/locales/fr.json +25 -1
- package/src/endpoints/escalate.ts +44 -0
- package/src/endpoints/index.ts +15 -0
- package/src/endpoints/invite-collaborator.ts +215 -0
- package/src/endpoints/kb-search.ts +156 -0
- package/src/endpoints/ticket-feedback.ts +104 -0
- package/src/endpoints/transfer-ticket.ts +248 -0
- package/src/index.ts +1 -0
- package/src/plugin.ts +4 -0
- package/src/portal/auth/ChatWidget.tsx +11 -0
- package/src/portal/auth/dashboard/DashboardClient.tsx +85 -99
- package/src/portal/auth/dashboard/page.tsx +11 -2
- package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +402 -0
- package/src/portal/auth/tickets/detail/page.tsx +122 -23
- package/src/portal/auth/tickets/new/KbDeflection.tsx +128 -0
- package/src/portal/auth/tickets/new/page.tsx +203 -100
- package/src/portal/locales/en.json +5 -0
- package/src/portal/locales/fr.json +5 -0
- package/src/styles/TicketDetail.module.scss +294 -0
- package/src/types.ts +19 -0
- package/src/utils/slugs.ts +2 -0
- package/src/views/TicketDetailView/client.tsx +346 -89
- package/src/views/TicketInboxView/client.tsx +2 -1
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import type { Endpoint } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from '../utils/slugs'
|
|
3
|
+
import { handleAuthError, AuthError } from '../utils/auth'
|
|
4
|
+
import { escapeHtml, emailWrapper, emailButton, emailParagraph } from '../utils/emailTemplate'
|
|
5
|
+
import { readSupportSettings } from '../utils/readSettings'
|
|
6
|
+
|
|
7
|
+
// Simple RFC-5322 style sanity check — same level of strictness as the
|
|
8
|
+
// portal forms used elsewhere in the plugin. Server-side validation only;
|
|
9
|
+
// the client is expected to validate too.
|
|
10
|
+
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
|
11
|
+
|
|
12
|
+
const MAX_TRANSFERS_PER_DAY = 5
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* POST /api/support/tickets/:id/transfer
|
|
16
|
+
*
|
|
17
|
+
* Authenticated endpoint — accepts admin OR the ticket's owner (support-clients).
|
|
18
|
+
* Sends a recap of the ticket conversation to an external email address.
|
|
19
|
+
*
|
|
20
|
+
* Body: { email: string, includeAttachments?: boolean, message?: string }
|
|
21
|
+
*
|
|
22
|
+
* Behaviour:
|
|
23
|
+
* - Verifies access (ownership or admin)
|
|
24
|
+
* - Renders an HTML recap : subject, status, timeline (no internal notes), attachments as links
|
|
25
|
+
* - Sends via payload.sendEmail
|
|
26
|
+
* - Logs the action in `email-logs` (action='transfer') when that collection exists
|
|
27
|
+
* - Rate-limited to 5 transfers per ticket per 24h
|
|
28
|
+
*/
|
|
29
|
+
export function createTransferTicketEndpoint(slugs: CollectionSlugs): Endpoint {
|
|
30
|
+
return {
|
|
31
|
+
path: '/support/tickets/:id/transfer',
|
|
32
|
+
method: 'post',
|
|
33
|
+
handler: async (req) => {
|
|
34
|
+
try {
|
|
35
|
+
if (!req.user) throw new AuthError('Authentication required', 401)
|
|
36
|
+
|
|
37
|
+
const idRaw = req.routeParams?.id as string | undefined
|
|
38
|
+
if (!idRaw) return Response.json({ error: 'missing-id' }, { status: 400 })
|
|
39
|
+
const ticketId = Number(idRaw)
|
|
40
|
+
if (Number.isNaN(ticketId)) return Response.json({ error: 'invalid-id' }, { status: 400 })
|
|
41
|
+
|
|
42
|
+
let body: { email?: string; includeAttachments?: boolean; message?: string }
|
|
43
|
+
try {
|
|
44
|
+
body = await req.json!()
|
|
45
|
+
} catch {
|
|
46
|
+
return Response.json({ error: 'Invalid JSON body' }, { status: 400 })
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const email = (body.email || '').trim().toLowerCase()
|
|
50
|
+
if (!email || !EMAIL_RE.test(email) || email.length > 254) {
|
|
51
|
+
return Response.json({ error: 'Email invalide' }, { status: 400 })
|
|
52
|
+
}
|
|
53
|
+
const includeAttachments = body.includeAttachments !== false
|
|
54
|
+
const customMessage = typeof body.message === 'string' ? body.message.slice(0, 1000) : ''
|
|
55
|
+
|
|
56
|
+
const payload = req.payload
|
|
57
|
+
|
|
58
|
+
// Load ticket (ownership check happens here too)
|
|
59
|
+
const ticket = await payload.findByID({
|
|
60
|
+
collection: slugs.tickets as any,
|
|
61
|
+
id: ticketId,
|
|
62
|
+
depth: 1,
|
|
63
|
+
overrideAccess: true,
|
|
64
|
+
}) as any
|
|
65
|
+
if (!ticket) return Response.json({ error: 'Ticket introuvable' }, { status: 404 })
|
|
66
|
+
|
|
67
|
+
const isAdmin = req.user.collection === slugs.users
|
|
68
|
+
const isOwner =
|
|
69
|
+
req.user.collection === slugs.supportClients &&
|
|
70
|
+
(typeof ticket.client === 'object'
|
|
71
|
+
? ticket.client?.id === req.user.id
|
|
72
|
+
: ticket.client === req.user.id)
|
|
73
|
+
if (!isAdmin && !isOwner) {
|
|
74
|
+
return Response.json({ error: 'Forbidden' }, { status: 403 })
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Rate limit: count transfers in last 24h for this ticket
|
|
78
|
+
try {
|
|
79
|
+
const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()
|
|
80
|
+
const existing = await payload.count({
|
|
81
|
+
collection: slugs.emailLogs as any,
|
|
82
|
+
where: {
|
|
83
|
+
and: [
|
|
84
|
+
{ action: { equals: 'transfer' } },
|
|
85
|
+
{ ticketNumber: { equals: ticket.ticketNumber || `#${ticketId}` } },
|
|
86
|
+
{ createdAt: { greater_than: since } },
|
|
87
|
+
],
|
|
88
|
+
},
|
|
89
|
+
overrideAccess: true,
|
|
90
|
+
})
|
|
91
|
+
if (existing.totalDocs >= MAX_TRANSFERS_PER_DAY) {
|
|
92
|
+
return Response.json(
|
|
93
|
+
{ error: `Limite atteinte (${MAX_TRANSFERS_PER_DAY} transferts par 24h sur ce ticket)` },
|
|
94
|
+
{ status: 429 },
|
|
95
|
+
)
|
|
96
|
+
}
|
|
97
|
+
} catch {
|
|
98
|
+
// emailLogs collection may not exist (feature disabled). Skip silently.
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Fetch non-internal messages, ordered chronologically
|
|
102
|
+
const messages = await payload.find({
|
|
103
|
+
collection: slugs.ticketMessages as any,
|
|
104
|
+
where: {
|
|
105
|
+
and: [
|
|
106
|
+
{ ticket: { equals: ticketId } },
|
|
107
|
+
{ isInternal: { equals: false } },
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
sort: 'createdAt',
|
|
111
|
+
limit: 200,
|
|
112
|
+
depth: 1,
|
|
113
|
+
overrideAccess: true,
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const baseUrl = process.env.NEXT_PUBLIC_SERVER_URL || ''
|
|
117
|
+
const ticketNumber = (ticket.ticketNumber as string) || `#${ticketId}`
|
|
118
|
+
const subject = (ticket.subject as string) || 'Ticket'
|
|
119
|
+
const statusLabel = statusToLabel(ticket.status)
|
|
120
|
+
|
|
121
|
+
// Build timeline HTML
|
|
122
|
+
const timelineHtml = messages.docs.map((m: any) => {
|
|
123
|
+
const date = m.createdAt
|
|
124
|
+
? new Date(m.createdAt).toLocaleString('fr-FR', { timeZone: 'Europe/Paris' })
|
|
125
|
+
: ''
|
|
126
|
+
const authorLabel = m.authorType === 'admin'
|
|
127
|
+
? 'Équipe support'
|
|
128
|
+
: (m.authorClient && typeof m.authorClient === 'object'
|
|
129
|
+
? `${m.authorClient.firstName || ''} ${m.authorClient.lastName || ''}`.trim() || 'Client'
|
|
130
|
+
: 'Client')
|
|
131
|
+
|
|
132
|
+
// Plain-text body; we escape HTML for safety in external recaps
|
|
133
|
+
const bodyText = (m.body as string) || ''
|
|
134
|
+
const bodyHtml = escapeHtml(bodyText).replace(/\n/g, '<br/>')
|
|
135
|
+
|
|
136
|
+
let attachmentsHtml = ''
|
|
137
|
+
if (includeAttachments && Array.isArray(m.attachments) && m.attachments.length > 0) {
|
|
138
|
+
const items = m.attachments
|
|
139
|
+
.map((a: any) => {
|
|
140
|
+
const f = a?.file
|
|
141
|
+
if (!f || typeof f !== 'object') return ''
|
|
142
|
+
const url = f.url ? (f.url.startsWith('http') ? f.url : `${baseUrl}${f.url}`) : ''
|
|
143
|
+
const name = f.filename || f.title || 'pièce jointe'
|
|
144
|
+
if (!url) return `<li>${escapeHtml(name)} (lien indisponible)</li>`
|
|
145
|
+
return `<li><a href="${escapeHtml(url)}">${escapeHtml(name)}</a></li>`
|
|
146
|
+
})
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
.join('')
|
|
149
|
+
if (items) {
|
|
150
|
+
attachmentsHtml = `<ul style="margin: 8px 0 0 0; padding-left: 20px; font-size: 13px; color: #475569;">${items}</ul>`
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return `
|
|
155
|
+
<div style="margin: 0 0 16px 0; padding: 14px 16px; border-left: 3px solid #e2e8f0; background: #f8fafc; border-radius: 0 8px 8px 0;">
|
|
156
|
+
<div style="margin-bottom: 8px; font-size: 12px; color: #64748b;">
|
|
157
|
+
<strong style="color: #0f172a;">${escapeHtml(authorLabel)}</strong>
|
|
158
|
+
${date ? ` · ${escapeHtml(date)}` : ''}
|
|
159
|
+
</div>
|
|
160
|
+
<div style="font-size: 14px; line-height: 1.6; color: #1e293b; white-space: pre-wrap;">${bodyHtml || '<em>(message vide)</em>'}</div>
|
|
161
|
+
${attachmentsHtml}
|
|
162
|
+
</div>
|
|
163
|
+
`
|
|
164
|
+
}).join('')
|
|
165
|
+
|
|
166
|
+
const customMessageHtml = customMessage
|
|
167
|
+
? `<div style="margin: 0 0 24px 0; padding: 16px; background: #fef9c3; border: 1px solid #fde047; border-radius: 8px;">
|
|
168
|
+
<div style="font-size: 12px; font-weight: 700; text-transform: uppercase; color: #854d0e; margin-bottom: 6px;">Message du transmetteur</div>
|
|
169
|
+
<div style="font-size: 14px; color: #422006; white-space: pre-wrap;">${escapeHtml(customMessage)}</div>
|
|
170
|
+
</div>`
|
|
171
|
+
: ''
|
|
172
|
+
|
|
173
|
+
const settings = await readSupportSettings(payload).catch(() => null)
|
|
174
|
+
const replyTo = settings?.email?.replyToAddress || process.env.SUPPORT_REPLY_TO || ''
|
|
175
|
+
|
|
176
|
+
const html = emailWrapper(
|
|
177
|
+
`Récapitulatif ticket ${ticketNumber}`,
|
|
178
|
+
[
|
|
179
|
+
emailParagraph(`Bonjour,`),
|
|
180
|
+
emailParagraph(
|
|
181
|
+
`Voici le récapitulatif du ticket <strong>${escapeHtml(ticketNumber)}</strong> — <em>${escapeHtml(subject)}</em>.`,
|
|
182
|
+
),
|
|
183
|
+
customMessageHtml,
|
|
184
|
+
`<table cellpadding="0" cellspacing="0" border="0" style="margin: 0 0 24px 0;">
|
|
185
|
+
<tr><td style="padding: 4px 12px 4px 0; font-size: 12px; text-transform: uppercase; color: #64748b; font-weight: 700;">Sujet</td><td style="padding: 4px 0; font-size: 14px; color: #0f172a;">${escapeHtml(subject)}</td></tr>
|
|
186
|
+
<tr><td style="padding: 4px 12px 4px 0; font-size: 12px; text-transform: uppercase; color: #64748b; font-weight: 700;">Statut</td><td style="padding: 4px 0; font-size: 14px; color: #0f172a;">${escapeHtml(statusLabel)}</td></tr>
|
|
187
|
+
<tr><td style="padding: 4px 12px 4px 0; font-size: 12px; text-transform: uppercase; color: #64748b; font-weight: 700;">Numéro</td><td style="padding: 4px 0; font-size: 14px; color: #0f172a;">${escapeHtml(ticketNumber)}</td></tr>
|
|
188
|
+
</table>`,
|
|
189
|
+
`<h2 style="margin: 24px 0 12px 0; font-size: 16px; color: #0f172a;">Conversation</h2>`,
|
|
190
|
+
timelineHtml || emailParagraph('<em>Aucun échange à afficher.</em>'),
|
|
191
|
+
baseUrl
|
|
192
|
+
? emailButton('Voir le ticket', `${baseUrl}/support/tickets/${ticketId}`)
|
|
193
|
+
: '',
|
|
194
|
+
emailParagraph(
|
|
195
|
+
`<span style="font-size: 12px; color: #94a3b8;">Ce récapitulatif a été transmis depuis l'espace support. Ne répondez pas à cet email — utilisez le lien ci-dessus pour échanger.</span>`,
|
|
196
|
+
),
|
|
197
|
+
].join(''),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
await payload.sendEmail({
|
|
201
|
+
to: email,
|
|
202
|
+
...(replyTo ? { replyTo } : {}),
|
|
203
|
+
subject: `Récapitulatif support — ${ticketNumber} ${subject}`,
|
|
204
|
+
html,
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
// Log the transfer (best-effort)
|
|
208
|
+
try {
|
|
209
|
+
await payload.create({
|
|
210
|
+
collection: slugs.emailLogs as any,
|
|
211
|
+
data: {
|
|
212
|
+
status: 'success',
|
|
213
|
+
action: 'transfer',
|
|
214
|
+
senderEmail:
|
|
215
|
+
req.user.collection === slugs.supportClients
|
|
216
|
+
? (req.user as any).email
|
|
217
|
+
: (req.user as any).email,
|
|
218
|
+
recipientEmail: email,
|
|
219
|
+
subject: `Transfert ${ticketNumber}`,
|
|
220
|
+
ticketNumber,
|
|
221
|
+
},
|
|
222
|
+
overrideAccess: true,
|
|
223
|
+
})
|
|
224
|
+
} catch {
|
|
225
|
+
// emailLogs may not be enabled — silently skip
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return Response.json({ ok: true, sentTo: email })
|
|
229
|
+
} catch (err) {
|
|
230
|
+
const authResponse = handleAuthError(err)
|
|
231
|
+
if (authResponse) return authResponse
|
|
232
|
+
console.error('[support/transfer-ticket] Error:', err)
|
|
233
|
+
return Response.json({ error: 'Internal server error' }, { status: 500 })
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function statusToLabel(status: unknown): string {
|
|
240
|
+
switch (status) {
|
|
241
|
+
case 'open': return 'Ouvert'
|
|
242
|
+
case 'waiting_client': return 'En attente du client'
|
|
243
|
+
case 'resolved': return 'Résolu'
|
|
244
|
+
case 'closed': return 'Clôturé'
|
|
245
|
+
case 'escalated': return 'Escaladé'
|
|
246
|
+
default: return String(status || '—')
|
|
247
|
+
}
|
|
248
|
+
}
|
package/src/index.ts
CHANGED
package/src/plugin.ts
CHANGED
|
@@ -21,6 +21,8 @@ import {
|
|
|
21
21
|
createMacrosCollection,
|
|
22
22
|
createTicketStatusesCollection,
|
|
23
23
|
createClientSummariesCollection,
|
|
24
|
+
createTicketFeedbackCollection,
|
|
25
|
+
createTicketCollaboratorsCollection,
|
|
24
26
|
} from './collections'
|
|
25
27
|
|
|
26
28
|
function viewConfig(component: string, path: string): AdminViewConfig {
|
|
@@ -86,6 +88,8 @@ export function supportPlugin(config?: SupportPluginConfig): Plugin {
|
|
|
86
88
|
createTicketActivityLogCollection(slugs),
|
|
87
89
|
createSatisfactionSurveysCollection(slugs),
|
|
88
90
|
createKnowledgeBaseCollection(slugs),
|
|
91
|
+
createTicketFeedbackCollection(slugs, { notificationSlug: config?.notificationSlug }),
|
|
92
|
+
createTicketCollaboratorsCollection(slugs),
|
|
89
93
|
]
|
|
90
94
|
|
|
91
95
|
// Auth logs (conditional)
|
|
@@ -10,7 +10,18 @@ interface ChatMessage {
|
|
|
10
10
|
agent?: { firstName?: string; lastName?: string } | null
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
// The legacy live-chat FAB is disabled by default in the refonte (May 2026).
|
|
14
|
+
// To re-enable it, set NEXT_PUBLIC_ENABLE_CHAT_WIDGET=true in the host app env.
|
|
15
|
+
const CHAT_WIDGET_ENABLED = process.env.NEXT_PUBLIC_ENABLE_CHAT_WIDGET === 'true'
|
|
16
|
+
|
|
17
|
+
// Wrapper: returns null synchronously when disabled, so we never mount the inner
|
|
18
|
+
// component (and its hooks/effects/polling) in production by default.
|
|
13
19
|
export function ChatWidget() {
|
|
20
|
+
if (!CHAT_WIDGET_ENABLED) return null
|
|
21
|
+
return <ChatWidgetInner />
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function ChatWidgetInner() {
|
|
14
25
|
const [isOpen, setIsOpen] = useState(false)
|
|
15
26
|
const [session, setSession] = useState<string | null>(null)
|
|
16
27
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
|
@@ -17,6 +17,40 @@ interface TicketData {
|
|
|
17
17
|
messageCount: number
|
|
18
18
|
totalTimeMinutes: number | null | undefined
|
|
19
19
|
lastMessagePreview: string | null | undefined
|
|
20
|
+
lastMessageAuthorType?: string | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type NextStep = 'you' | 'us' | 'done'
|
|
24
|
+
|
|
25
|
+
// Compute the "next-step" badge for a ticket:
|
|
26
|
+
// - resolved → done
|
|
27
|
+
// - last message from client (or hasNewMessage = false but status non resolved) → us
|
|
28
|
+
// - hasNewMessage (admin replied, client hasn't read) → you
|
|
29
|
+
function computeNextStep(t: TicketData): NextStep {
|
|
30
|
+
if (t.status === 'resolved') return 'done'
|
|
31
|
+
if (t.hasNewMessage) return 'you'
|
|
32
|
+
// If the last message is from the client (or email), we're waiting on support
|
|
33
|
+
if (t.lastMessageAuthorType === 'client' || t.lastMessageAuthorType === 'email') return 'us'
|
|
34
|
+
// Default: ball is in client's court (e.g. ticket just created with admin reply already read)
|
|
35
|
+
return 'you'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const nextStepConfig: Record<NextStep, { label: string; cls: string; icon: 'arrow' | 'clock' | 'check' }> = {
|
|
39
|
+
you: {
|
|
40
|
+
label: 'A vous de jouer',
|
|
41
|
+
cls: 'bg-blue-50 text-blue-700 ring-blue-600/20 dark:bg-blue-950/40 dark:text-blue-300 dark:ring-blue-400/20',
|
|
42
|
+
icon: 'arrow',
|
|
43
|
+
},
|
|
44
|
+
us: {
|
|
45
|
+
label: 'On revient vers vous',
|
|
46
|
+
cls: 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-950/40 dark:text-amber-300 dark:ring-amber-400/20',
|
|
47
|
+
icon: 'clock',
|
|
48
|
+
},
|
|
49
|
+
done: {
|
|
50
|
+
label: 'Resolu',
|
|
51
|
+
cls: 'bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-950/40 dark:text-emerald-300 dark:ring-emerald-400/20',
|
|
52
|
+
icon: 'check',
|
|
53
|
+
},
|
|
20
54
|
}
|
|
21
55
|
|
|
22
56
|
const statusConfig: Record<string, { label: string; dot: string; bg: string }> = {
|
|
@@ -165,31 +199,32 @@ export function DashboardClient({ tickets }: { tickets: TicketData[] }) {
|
|
|
165
199
|
|
|
166
200
|
const resetPage = () => setCurrentPage(1)
|
|
167
201
|
|
|
168
|
-
// Stats
|
|
169
|
-
const stats = useMemo(() => {
|
|
170
|
-
return {
|
|
171
|
-
total: tickets.length,
|
|
172
|
-
active: activeTickets.length,
|
|
173
|
-
archived: archivedTickets.length,
|
|
174
|
-
newMessages: tickets.filter((t) => t.hasNewMessage).length,
|
|
175
|
-
}
|
|
176
|
-
}, [tickets, activeTickets, archivedTickets])
|
|
177
|
-
|
|
178
202
|
const activeFilterCount = [filterStatus, filterCategory, filterProject].filter(Boolean).length
|
|
179
203
|
|
|
180
204
|
return (
|
|
181
205
|
<div className="space-y-6">
|
|
182
|
-
{/*
|
|
183
|
-
<
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
206
|
+
{/* Promoted SLA hero with single CTA "Nouveau ticket" */}
|
|
207
|
+
<section
|
|
208
|
+
aria-label="Engagement de service"
|
|
209
|
+
className="flex flex-col gap-4 rounded-2xl border border-blue-100 bg-gradient-to-br from-blue-50 via-blue-50/60 to-white px-5 py-5 dark:border-blue-900/60 dark:from-blue-950/40 dark:via-blue-950/20 dark:to-slate-900 sm:flex-row sm:items-center sm:justify-between sm:px-6 sm:py-6"
|
|
210
|
+
>
|
|
211
|
+
<div className="flex items-start gap-4">
|
|
212
|
+
<div className="flex h-12 w-12 flex-shrink-0 items-center justify-center rounded-2xl bg-blue-600/10 text-2xl dark:bg-blue-500/20">
|
|
213
|
+
<span aria-hidden>✨</span>
|
|
214
|
+
</div>
|
|
215
|
+
<div className="min-w-0">
|
|
216
|
+
<h1 className="text-xl font-bold tracking-tight text-slate-900 dark:text-white sm:text-2xl">
|
|
217
|
+
Reponse en moins de 2h
|
|
218
|
+
</h1>
|
|
219
|
+
<p className="mt-1 text-sm text-slate-600 dark:text-slate-300">
|
|
220
|
+
Notre engagement : une premiere reponse rapide a chacune de vos demandes,{' '}
|
|
221
|
+
<span className="text-slate-500 dark:text-slate-400">en jours ouvres</span>.
|
|
222
|
+
</p>
|
|
223
|
+
</div>
|
|
189
224
|
</div>
|
|
190
225
|
<Link
|
|
191
226
|
href="/support/tickets/new"
|
|
192
|
-
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-blue-700 hover:shadow-md active:scale-[0.98]"
|
|
227
|
+
className="inline-flex flex-shrink-0 items-center justify-center gap-2 rounded-lg bg-blue-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all hover:bg-blue-700 hover:shadow-md active:scale-[0.98]"
|
|
193
228
|
>
|
|
194
229
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4">
|
|
195
230
|
<line x1="12" y1="5" x2="12" y2="19" />
|
|
@@ -197,83 +232,13 @@ export function DashboardClient({ tickets }: { tickets: TicketData[] }) {
|
|
|
197
232
|
</svg>
|
|
198
233
|
Nouveau ticket
|
|
199
234
|
</Link>
|
|
200
|
-
</
|
|
201
|
-
|
|
202
|
-
{/* Stats row */}
|
|
203
|
-
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
|
204
|
-
<div className="rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
|
|
205
|
-
<div className="flex items-center gap-2">
|
|
206
|
-
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-50 dark:bg-blue-950/40">
|
|
207
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-4 w-4 text-blue-600 dark:text-blue-400">
|
|
208
|
-
<polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
|
|
209
|
-
<path d="M5.45 5.11L2 12v6a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2v-6l-3.45-6.89A2 2 0 0 0 16.76 4H7.24a2 2 0 0 0-1.79 1.11z" />
|
|
210
|
-
</svg>
|
|
211
|
-
</div>
|
|
212
|
-
<div>
|
|
213
|
-
<p className="text-xl font-bold text-slate-900 dark:text-white">{stats.active}</p>
|
|
214
|
-
<p className="text-xs text-slate-500 dark:text-slate-400">Actifs</p>
|
|
215
|
-
</div>
|
|
216
|
-
</div>
|
|
217
|
-
</div>
|
|
218
|
-
|
|
219
|
-
{stats.newMessages > 0 && (
|
|
220
|
-
<div className="rounded-xl border border-blue-200 bg-blue-50/50 p-4 dark:border-blue-800 dark:bg-blue-950/30">
|
|
221
|
-
<div className="flex items-center gap-2">
|
|
222
|
-
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 dark:bg-blue-900/50">
|
|
223
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-4 w-4 text-blue-600 dark:text-blue-400">
|
|
224
|
-
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
|
|
225
|
-
</svg>
|
|
226
|
-
</div>
|
|
227
|
-
<div>
|
|
228
|
-
<p className="text-xl font-bold text-blue-700 dark:text-blue-300">{stats.newMessages}</p>
|
|
229
|
-
<p className="text-xs text-blue-600 dark:text-blue-400">Non lus</p>
|
|
230
|
-
</div>
|
|
231
|
-
</div>
|
|
232
|
-
</div>
|
|
233
|
-
)}
|
|
234
|
-
|
|
235
|
-
<div className="rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
|
|
236
|
-
<div className="flex items-center gap-2">
|
|
237
|
-
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-100 dark:bg-slate-800">
|
|
238
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" className="h-4 w-4 text-slate-500 dark:text-slate-400">
|
|
239
|
-
<rect x="3" y="3" width="7" height="7" />
|
|
240
|
-
<rect x="14" y="3" width="7" height="7" />
|
|
241
|
-
<rect x="14" y="14" width="7" height="7" />
|
|
242
|
-
<rect x="3" y="14" width="7" height="7" />
|
|
243
|
-
</svg>
|
|
244
|
-
</div>
|
|
245
|
-
<div>
|
|
246
|
-
<p className="text-xl font-bold text-slate-900 dark:text-white">{stats.total}</p>
|
|
247
|
-
<p className="text-xs text-slate-500 dark:text-slate-400">Total</p>
|
|
248
|
-
</div>
|
|
249
|
-
</div>
|
|
250
|
-
</div>
|
|
235
|
+
</section>
|
|
251
236
|
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
</svg>
|
|
258
|
-
</div>
|
|
259
|
-
<div>
|
|
260
|
-
<p className="text-xl font-bold text-slate-900 dark:text-white">{stats.archived}</p>
|
|
261
|
-
<p className="text-xs text-slate-500 dark:text-slate-400">Archives</p>
|
|
262
|
-
</div>
|
|
263
|
-
</div>
|
|
264
|
-
</div>
|
|
265
|
-
</div>
|
|
266
|
-
|
|
267
|
-
{/* Response time banner */}
|
|
268
|
-
<div className="flex items-center gap-3 rounded-lg border border-blue-100 bg-blue-50/50 px-4 py-3 dark:border-blue-900/50 dark:bg-blue-950/20">
|
|
269
|
-
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/50">
|
|
270
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="h-4 w-4 text-blue-600 dark:text-blue-400">
|
|
271
|
-
<circle cx="12" cy="12" r="10" />
|
|
272
|
-
<polyline points="12 6 12 12 16 14" />
|
|
273
|
-
</svg>
|
|
274
|
-
</div>
|
|
275
|
-
<p className="text-sm text-blue-700 dark:text-blue-300">
|
|
276
|
-
Notre temps de reponse moyen est de <strong>moins de 2h</strong> en jours ouvres.
|
|
237
|
+
{/* Page title */}
|
|
238
|
+
<div>
|
|
239
|
+
<h2 className="text-lg font-semibold text-slate-900 dark:text-white">Mes tickets</h2>
|
|
240
|
+
<p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
|
|
241
|
+
Suivez et gerez vos demandes de support
|
|
277
242
|
</p>
|
|
278
243
|
</div>
|
|
279
244
|
|
|
@@ -498,6 +463,8 @@ export function DashboardClient({ tickets }: { tickets: TicketData[] }) {
|
|
|
498
463
|
const status = statusConfig[ticket.status || 'open'] || statusConfig.open
|
|
499
464
|
const priority = priorityConfig[ticket.priority || 'normal']
|
|
500
465
|
const category = ticket.category ? categoryLabels[ticket.category] : null
|
|
466
|
+
const nextStep = computeNextStep(ticket)
|
|
467
|
+
const nextStepCfg = nextStepConfig[nextStep]
|
|
501
468
|
|
|
502
469
|
return (
|
|
503
470
|
<Link
|
|
@@ -540,12 +507,31 @@ export function DashboardClient({ tickets }: { tickets: TicketData[] }) {
|
|
|
540
507
|
|
|
541
508
|
{/* Tags row (mobile friendly) */}
|
|
542
509
|
<div className="mt-2 flex flex-wrap items-center gap-1.5">
|
|
543
|
-
|
|
544
|
-
|
|
510
|
+
{/* Next-step badge replaces the raw status — orients the user */}
|
|
511
|
+
<span className={`inline-flex items-center gap-1 rounded-md px-2 py-0.5 text-[11px] font-semibold ring-1 ring-inset ${nextStepCfg.cls}`}>
|
|
512
|
+
{nextStepCfg.icon === 'arrow' && (
|
|
513
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="h-3 w-3" aria-hidden>
|
|
514
|
+
<line x1="12" y1="19" x2="12" y2="5" />
|
|
515
|
+
<polyline points="5 12 12 5 19 12" />
|
|
516
|
+
</svg>
|
|
517
|
+
)}
|
|
518
|
+
{nextStepCfg.icon === 'clock' && (
|
|
519
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="h-3 w-3" aria-hidden>
|
|
520
|
+
<circle cx="12" cy="12" r="10" />
|
|
521
|
+
<polyline points="12 6 12 12 16 14" />
|
|
522
|
+
</svg>
|
|
523
|
+
)}
|
|
524
|
+
{nextStepCfg.icon === 'check' && (
|
|
525
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="h-3 w-3" aria-hidden>
|
|
526
|
+
<polyline points="20 6 9 17 4 12" />
|
|
527
|
+
</svg>
|
|
528
|
+
)}
|
|
529
|
+
{nextStepCfg.label}
|
|
545
530
|
</span>
|
|
546
|
-
{
|
|
547
|
-
|
|
548
|
-
|
|
531
|
+
{/* Raw status only shown when not resolved (next-step "done" already conveys resolved) */}
|
|
532
|
+
{nextStep !== 'done' && (
|
|
533
|
+
<span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ring-1 ring-inset ${status.bg}`}>
|
|
534
|
+
{status.label}
|
|
549
535
|
</span>
|
|
550
536
|
)}
|
|
551
537
|
{category && (
|
|
@@ -46,14 +46,22 @@ export default async function SupportDashboardPage() {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
// Build message meta per ticket (allMessages sorted by -createdAt, so first entry per ticket is the latest)
|
|
49
|
-
const ticketMeta: Record<
|
|
49
|
+
const ticketMeta: Record<
|
|
50
|
+
string | number,
|
|
51
|
+
{ hasNew: boolean; count: number; lastMessagePreview: string; lastMessageAuthorType: string | null }
|
|
52
|
+
> = {}
|
|
50
53
|
for (const msg of allMessages.docs) {
|
|
51
54
|
const tid = typeof msg.ticket === 'object' ? msg.ticket.id : msg.ticket
|
|
52
55
|
if (!ticketMeta[tid]) {
|
|
53
56
|
const lastRead = readAtMap[tid]
|
|
54
57
|
const isUnread = msg.authorType === 'admin' && (!lastRead || new Date(msg.createdAt) > new Date(lastRead))
|
|
55
58
|
const preview = (msg.body || '').replace(/\n/g, ' ').slice(0, 80)
|
|
56
|
-
ticketMeta[tid] = {
|
|
59
|
+
ticketMeta[tid] = {
|
|
60
|
+
hasNew: isUnread,
|
|
61
|
+
count: 1,
|
|
62
|
+
lastMessagePreview: preview,
|
|
63
|
+
lastMessageAuthorType: msg.authorType || null,
|
|
64
|
+
}
|
|
57
65
|
} else {
|
|
58
66
|
ticketMeta[tid].count++
|
|
59
67
|
}
|
|
@@ -77,6 +85,7 @@ export default async function SupportDashboardPage() {
|
|
|
77
85
|
messageCount: meta?.count || 0,
|
|
78
86
|
totalTimeMinutes: ticket.totalTimeMinutes,
|
|
79
87
|
lastMessagePreview: meta?.lastMessagePreview || null,
|
|
88
|
+
lastMessageAuthorType: meta?.lastMessageAuthorType || null,
|
|
80
89
|
}
|
|
81
90
|
})
|
|
82
91
|
|