@consilioweb/payload-support 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/dist/components/TicketConversation/locales/en.json +25 -1
  2. package/dist/components/TicketConversation/locales/fr.json +25 -1
  3. package/dist/index.cjs +799 -4
  4. package/dist/index.d.cts +15 -1
  5. package/dist/index.d.ts +15 -1
  6. package/dist/index.js +800 -6
  7. package/dist/styles/TicketDetail.module.scss +294 -0
  8. package/dist/views/TicketDetailView/client.cjs +455 -249
  9. package/dist/views/TicketDetailView/client.js +456 -250
  10. package/dist/views/TicketInboxView/client.cjs +10 -9
  11. package/dist/views/TicketInboxView/client.js +2 -1
  12. package/package.json +1 -1
  13. package/src/collections/ClientSummaries.ts +16 -0
  14. package/src/collections/TicketCollaborators.ts +93 -0
  15. package/src/collections/TicketFeedback.ts +116 -0
  16. package/src/collections/TicketMessages.ts +9 -0
  17. package/src/collections/Tickets.ts +31 -1
  18. package/src/collections/index.ts +2 -0
  19. package/src/components/TicketConversation/locales/en.json +25 -1
  20. package/src/components/TicketConversation/locales/fr.json +25 -1
  21. package/src/endpoints/escalate.ts +44 -0
  22. package/src/endpoints/index.ts +15 -0
  23. package/src/endpoints/invite-collaborator.ts +215 -0
  24. package/src/endpoints/kb-search.ts +156 -0
  25. package/src/endpoints/ticket-feedback.ts +104 -0
  26. package/src/endpoints/transfer-ticket.ts +248 -0
  27. package/src/index.ts +1 -0
  28. package/src/plugin.ts +4 -0
  29. package/src/portal/auth/ChatWidget.tsx +11 -0
  30. package/src/portal/auth/dashboard/DashboardClient.tsx +85 -99
  31. package/src/portal/auth/dashboard/page.tsx +11 -2
  32. package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +402 -0
  33. package/src/portal/auth/tickets/detail/page.tsx +122 -23
  34. package/src/portal/auth/tickets/new/KbDeflection.tsx +128 -0
  35. package/src/portal/auth/tickets/new/page.tsx +203 -100
  36. package/src/portal/locales/en.json +5 -0
  37. package/src/portal/locales/fr.json +5 -0
  38. package/src/styles/TicketDetail.module.scss +294 -0
  39. package/src/types.ts +19 -0
  40. package/src/utils/slugs.ts +2 -0
  41. package/src/views/TicketDetailView/client.tsx +346 -89
  42. package/src/views/TicketInboxView/client.tsx +2 -1
@@ -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 ? ` &middot; ${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
@@ -50,4 +50,5 @@ export {
50
50
  createSlaPoliciesCollection,
51
51
  createMacrosCollection,
52
52
  createTicketStatusesCollection,
53
+ createTicketCollaboratorsCollection,
53
54
  } from './collections'
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
- {/* Page header */}
183
- <div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
184
- <div>
185
- <h1 className="text-2xl font-bold tracking-tight text-slate-900 dark:text-white">Mes tickets</h1>
186
- <p className="mt-1 text-sm text-slate-500 dark:text-slate-400">
187
- Suivez et gerez vos demandes de support
188
- </p>
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
- </div>
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
- <div className="rounded-xl border border-slate-200 bg-white p-4 dark:border-slate-800 dark:bg-slate-900">
253
- <div className="flex items-center gap-2">
254
- <div className="flex h-8 w-8 items-center justify-center rounded-lg bg-slate-100 dark:bg-slate-800">
255
- <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-400 dark:text-slate-500">
256
- <polyline points="20 6 9 17 4 12" />
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
- <span className={`inline-flex items-center rounded-md px-2 py-0.5 text-[11px] font-medium ring-1 ring-inset ${status.bg}`}>
544
- {status.label}
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
- {ticket.hasNewMessage && (
547
- <span className="inline-flex items-center rounded-md bg-blue-50 px-2 py-0.5 text-[11px] font-medium text-blue-700 ring-1 ring-inset ring-blue-600/20 dark:bg-blue-950/40 dark:text-blue-400 dark:ring-blue-400/20">
548
- Nouveau message
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<string | number, { hasNew: boolean; count: number; lastMessagePreview: string }> = {}
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] = { hasNew: isUnread, count: 1, lastMessagePreview: preview }
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