@consilioweb/payload-support 0.5.1
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/LICENSE +21 -0
- package/README.md +525 -0
- package/dist/client.cjs +7 -0
- package/dist/client.d.cts +3 -0
- package/dist/client.d.ts +3 -0
- package/dist/client.js +5 -0
- package/dist/index.cjs +7766 -0
- package/dist/index.d.cts +384 -0
- package/dist/index.d.ts +384 -0
- package/dist/index.js +7730 -0
- package/dist/views.d.cts +30 -0
- package/dist/views.d.ts +30 -0
- package/package.json +131 -0
- package/src/client.ts +1 -0
- package/src/collections/AuthLogs.ts +65 -0
- package/src/collections/CannedResponses.ts +69 -0
- package/src/collections/ChatMessages.ts +98 -0
- package/src/collections/EmailLogs.ts +94 -0
- package/src/collections/KnowledgeBase.ts +99 -0
- package/src/collections/Macros.ts +98 -0
- package/src/collections/PendingEmails.ts +122 -0
- package/src/collections/SatisfactionSurveys.ts +98 -0
- package/src/collections/SlaPolicies.ts +123 -0
- package/src/collections/SupportClients.ts +210 -0
- package/src/collections/TicketActivityLog.ts +81 -0
- package/src/collections/TicketMessages.ts +364 -0
- package/src/collections/TicketStatuses.ts +108 -0
- package/src/collections/Tickets.ts +704 -0
- package/src/collections/TimeEntries.ts +105 -0
- package/src/collections/WebhookEndpoints.ts +96 -0
- package/src/collections/index.ts +16 -0
- package/src/components/TicketConversation/components/AISummaryPanel.tsx +85 -0
- package/src/components/TicketConversation/components/ActionPanels.tsx +140 -0
- package/src/components/TicketConversation/components/ActivityLog.tsx +39 -0
- package/src/components/TicketConversation/components/ClientBar.tsx +37 -0
- package/src/components/TicketConversation/components/ClientHistory.tsx +117 -0
- package/src/components/TicketConversation/components/CodeBlock.tsx +186 -0
- package/src/components/TicketConversation/components/CodeBlockInserter.tsx +166 -0
- package/src/components/TicketConversation/components/QuickActions.tsx +82 -0
- package/src/components/TicketConversation/components/TicketHeader.tsx +91 -0
- package/src/components/TicketConversation/components/TimeTrackingPanel.tsx +161 -0
- package/src/components/TicketConversation/config.ts +82 -0
- package/src/components/TicketConversation/constants.ts +74 -0
- package/src/components/TicketConversation/context.ts +63 -0
- package/src/components/TicketConversation/hooks/useAI.ts +180 -0
- package/src/components/TicketConversation/hooks/useMessageActions.ts +131 -0
- package/src/components/TicketConversation/hooks/useReply.ts +190 -0
- package/src/components/TicketConversation/hooks/useTicketActions.ts +205 -0
- package/src/components/TicketConversation/hooks/useTimeTracking.ts +107 -0
- package/src/components/TicketConversation/hooks/useTranslation.ts +116 -0
- package/src/components/TicketConversation/index.tsx +1110 -0
- package/src/components/TicketConversation/locales/en.json +878 -0
- package/src/components/TicketConversation/locales/fr.json +878 -0
- package/src/components/TicketConversation/types.ts +54 -0
- package/src/components/TicketConversation/utils.ts +25 -0
- package/src/endpoints/admin-chat-stream.ts +238 -0
- package/src/endpoints/admin-chat.ts +263 -0
- package/src/endpoints/admin-stats.ts +200 -0
- package/src/endpoints/ai.ts +199 -0
- package/src/endpoints/apply-macro.ts +144 -0
- package/src/endpoints/auth-2fa.ts +163 -0
- package/src/endpoints/auto-close.ts +175 -0
- package/src/endpoints/billing.ts +167 -0
- package/src/endpoints/bulk-action.ts +103 -0
- package/src/endpoints/chat-stream.ts +127 -0
- package/src/endpoints/chat.ts +188 -0
- package/src/endpoints/chatbot.ts +113 -0
- package/src/endpoints/delete-account.ts +129 -0
- package/src/endpoints/email-stats.ts +109 -0
- package/src/endpoints/export-csv.ts +84 -0
- package/src/endpoints/export-data.ts +104 -0
- package/src/endpoints/import-conversation.ts +307 -0
- package/src/endpoints/index.ts +154 -0
- package/src/endpoints/login.ts +92 -0
- package/src/endpoints/merge-clients.ts +132 -0
- package/src/endpoints/merge-tickets.ts +137 -0
- package/src/endpoints/oauth-google.ts +179 -0
- package/src/endpoints/pending-emails-process.ts +224 -0
- package/src/endpoints/presence.ts +104 -0
- package/src/endpoints/process-scheduled.ts +144 -0
- package/src/endpoints/purge-logs.ts +58 -0
- package/src/endpoints/resend-notification.ts +99 -0
- package/src/endpoints/round-robin-config.ts +92 -0
- package/src/endpoints/satisfaction.ts +93 -0
- package/src/endpoints/search.ts +106 -0
- package/src/endpoints/seed-kb.ts +153 -0
- package/src/endpoints/settings.ts +144 -0
- package/src/endpoints/signature.ts +93 -0
- package/src/endpoints/sla-check.ts +124 -0
- package/src/endpoints/split-ticket.ts +131 -0
- package/src/endpoints/statuses.ts +45 -0
- package/src/endpoints/track-open.ts +154 -0
- package/src/endpoints/typing.ts +101 -0
- package/src/endpoints/user-prefs.ts +125 -0
- package/src/hooks/checkSLA.ts +414 -0
- package/src/hooks/ticketStatusEmail.ts +182 -0
- package/src/index.ts +51 -0
- package/src/plugin.ts +157 -0
- package/src/portal/LiveChat.tsx +1353 -0
- package/src/portal/auth/ChatWidget.tsx +350 -0
- package/src/portal/auth/ChatbotWidget.tsx +285 -0
- package/src/portal/auth/SupportHeader.tsx +409 -0
- package/src/portal/auth/dashboard/DashboardClient.tsx +650 -0
- package/src/portal/auth/dashboard/page.tsx +84 -0
- package/src/portal/auth/faq/FAQSearch.tsx +117 -0
- package/src/portal/auth/faq/page.tsx +199 -0
- package/src/portal/auth/layout.tsx +61 -0
- package/src/portal/auth/profile/page.tsx +705 -0
- package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +74 -0
- package/src/portal/auth/tickets/detail/CollapsibleMessages.tsx +46 -0
- package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +50 -0
- package/src/portal/auth/tickets/detail/MessageActions.tsx +158 -0
- package/src/portal/auth/tickets/detail/PrintButton.tsx +16 -0
- package/src/portal/auth/tickets/detail/ReadReceipt.tsx +34 -0
- package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +74 -0
- package/src/portal/auth/tickets/detail/SatisfactionForm.tsx +156 -0
- package/src/portal/auth/tickets/detail/TicketPolling.tsx +57 -0
- package/src/portal/auth/tickets/detail/TicketReplyForm.tsx +294 -0
- package/src/portal/auth/tickets/detail/TypingIndicator.tsx +58 -0
- package/src/portal/auth/tickets/detail/page.tsx +738 -0
- package/src/portal/auth/tickets/new/page.tsx +515 -0
- package/src/portal/forgot-password/page.tsx +114 -0
- package/src/portal/layout.tsx +26 -0
- package/src/portal/locales/en.json +374 -0
- package/src/portal/locales/fr.json +374 -0
- package/src/portal/login/page.tsx +351 -0
- package/src/portal/page.tsx +162 -0
- package/src/portal/register/page.tsx +281 -0
- package/src/portal/reset-password/page.tsx +152 -0
- package/src/styles/BillingView.module.scss +311 -0
- package/src/styles/ChatView.module.scss +438 -0
- package/src/styles/CommandPalette.module.scss +160 -0
- package/src/styles/CrmView.module.scss +554 -0
- package/src/styles/EmailTracking.module.scss +238 -0
- package/src/styles/ImportConversation.module.scss +267 -0
- package/src/styles/Layout.module.scss +55 -0
- package/src/styles/Logs.module.scss +164 -0
- package/src/styles/NewTicket.module.scss +143 -0
- package/src/styles/PendingEmails.module.scss +629 -0
- package/src/styles/SupportDashboard.module.scss +649 -0
- package/src/styles/TicketDetail.module.scss +1043 -0
- package/src/styles/TicketInbox.module.scss +296 -0
- package/src/styles/TicketingSettings.module.scss +358 -0
- package/src/styles/TimeDashboard.module.scss +287 -0
- package/src/styles/_tokens.scss +78 -0
- package/src/styles/theme.css +633 -0
- package/src/types.ts +255 -0
- package/src/utils/adminNotification.ts +38 -0
- package/src/utils/auth.ts +46 -0
- package/src/utils/emailTemplate.ts +343 -0
- package/src/utils/fireWebhooks.ts +84 -0
- package/src/utils/index.ts +22 -0
- package/src/utils/rateLimiter.ts +52 -0
- package/src/utils/readSettings.ts +67 -0
- package/src/utils/slugs.ts +54 -0
- package/src/utils/webhookDispatcher.ts +120 -0
- package/src/views/BillingView/client.tsx +137 -0
- package/src/views/BillingView/index.tsx +33 -0
- package/src/views/ChatView/client.tsx +294 -0
- package/src/views/ChatView/index.tsx +33 -0
- package/src/views/CrmView/client.tsx +206 -0
- package/src/views/CrmView/index.tsx +33 -0
- package/src/views/EmailTrackingView/client.tsx +124 -0
- package/src/views/EmailTrackingView/index.tsx +33 -0
- package/src/views/ImportConversationView/client.tsx +133 -0
- package/src/views/ImportConversationView/index.tsx +33 -0
- package/src/views/LogsView/client.tsx +151 -0
- package/src/views/LogsView/index.tsx +30 -0
- package/src/views/NewTicketView/client.tsx +227 -0
- package/src/views/NewTicketView/index.tsx +30 -0
- package/src/views/PendingEmailsView/client.tsx +177 -0
- package/src/views/PendingEmailsView/index.tsx +33 -0
- package/src/views/SupportDashboardView/client.tsx +424 -0
- package/src/views/SupportDashboardView/index.tsx +33 -0
- package/src/views/TicketDetailView/client.tsx +775 -0
- package/src/views/TicketDetailView/index.tsx +33 -0
- package/src/views/TicketInboxView/client.tsx +313 -0
- package/src/views/TicketInboxView/index.tsx +30 -0
- package/src/views/TicketingSettingsView/client.tsx +866 -0
- package/src/views/TicketingSettingsView/index.tsx +33 -0
- package/src/views/TimeDashboardView/client.tsx +144 -0
- package/src/views/TimeDashboardView/index.tsx +33 -0
- package/src/views/shared/AdminViewHeader.tsx +69 -0
- package/src/views/shared/ErrorBoundary.tsx +68 -0
- package/src/views/shared/Skeleton.tsx +125 -0
- package/src/views/shared/adminTokens.ts +37 -0
- package/src/views/shared/config.ts +82 -0
- package/src/views/shared/index.ts +6 -0
- package/src/views.ts +16 -0
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
import { headers as getHeaders } from 'next/headers'
|
|
3
|
+
import { getPayload } from 'payload'
|
|
4
|
+
import configPromise from '@payload-config'
|
|
5
|
+
import { notFound } from 'next/navigation'
|
|
6
|
+
import Link from 'next/link'
|
|
7
|
+
import { TicketReplyForm } from './TicketReplyForm'
|
|
8
|
+
import { CloseTicketButton } from './CloseTicketButton'
|
|
9
|
+
import { ReopenTicketButton } from './ReopenTicketButton'
|
|
10
|
+
import { SatisfactionForm } from './SatisfactionForm'
|
|
11
|
+
import { CollapsibleMessages } from './CollapsibleMessages'
|
|
12
|
+
import { TicketPolling } from './TicketPolling'
|
|
13
|
+
import { MarkSolutionButton } from './MarkSolutionButton'
|
|
14
|
+
import { PrintButton } from './PrintButton'
|
|
15
|
+
import { TypingIndicator } from './TypingIndicator'
|
|
16
|
+
import { MessageActions, EditedBadge, DeletedMessage } from './MessageActions'
|
|
17
|
+
import { ReadReceipt } from './ReadReceipt'
|
|
18
|
+
// Document type for ticket attachments
|
|
19
|
+
type PayloadDocument = { filename?: string; title?: string; url?: string }
|
|
20
|
+
|
|
21
|
+
const statusConfig: Record<string, { label: string; color: string; bg: string }> = {
|
|
22
|
+
open: { label: 'Ouvert', color: 'text-green-700', bg: 'bg-green-50 text-green-700' },
|
|
23
|
+
waiting_client: { label: 'En attente', color: 'text-amber-700', bg: 'bg-amber-50 text-amber-700' },
|
|
24
|
+
resolved: { label: 'Résolu', color: 'text-blue-700', bg: 'bg-blue-50 text-blue-700' },
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Date formatting helpers
|
|
28
|
+
const TZ = 'Europe/Paris'
|
|
29
|
+
|
|
30
|
+
function getDateLabel(dateStr: string): string {
|
|
31
|
+
const date = new Date(dateStr)
|
|
32
|
+
const today = new Date()
|
|
33
|
+
const todayParis = today.toLocaleDateString('fr-FR', { timeZone: TZ })
|
|
34
|
+
const dateParis = date.toLocaleDateString('fr-FR', { timeZone: TZ })
|
|
35
|
+
const yesterday = new Date(today)
|
|
36
|
+
yesterday.setDate(yesterday.getDate() - 1)
|
|
37
|
+
const yesterdayParis = yesterday.toLocaleDateString('fr-FR', { timeZone: TZ })
|
|
38
|
+
|
|
39
|
+
if (dateParis === todayParis) return "Aujourd'hui"
|
|
40
|
+
if (dateParis === yesterdayParis) return 'Hier'
|
|
41
|
+
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric', timeZone: TZ })
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatMessageDate(dateStr: string): string {
|
|
45
|
+
const date = new Date(dateStr)
|
|
46
|
+
const today = new Date()
|
|
47
|
+
const todayParis = today.toLocaleDateString('fr-FR', { timeZone: TZ })
|
|
48
|
+
const dateParis = date.toLocaleDateString('fr-FR', { timeZone: TZ })
|
|
49
|
+
const yesterday = new Date(today)
|
|
50
|
+
yesterday.setDate(yesterday.getDate() - 1)
|
|
51
|
+
const yesterdayParis = yesterday.toLocaleDateString('fr-FR', { timeZone: TZ })
|
|
52
|
+
const time = date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit', timeZone: TZ })
|
|
53
|
+
|
|
54
|
+
if (dateParis === todayParis) return time
|
|
55
|
+
if (dateParis === yesterdayParis) return `Hier, ${time}`
|
|
56
|
+
if (date.getFullYear() === today.getFullYear()) {
|
|
57
|
+
return `${date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', timeZone: TZ })}, ${time}`
|
|
58
|
+
}
|
|
59
|
+
return `${date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric', timeZone: TZ })}, ${time}`
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const priorityConfig: Record<string, { label: string; color: string; bg: string }> = {
|
|
63
|
+
low: { label: 'Basse', color: 'text-gray-600', bg: 'bg-gray-100 text-gray-600' },
|
|
64
|
+
normal: { label: 'Normale', color: 'text-blue-600', bg: 'bg-blue-50 text-blue-600' },
|
|
65
|
+
high: { label: 'Haute', color: 'text-orange-600', bg: 'bg-orange-50 text-orange-600' },
|
|
66
|
+
urgent: { label: 'Urgente', color: 'text-red-600', bg: 'bg-red-50 text-red-600' },
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const categoryLabels: Record<string, string> = {
|
|
70
|
+
bug: 'Bug / Dysfonctionnement',
|
|
71
|
+
content: 'Modification de contenu',
|
|
72
|
+
feature: 'Nouvelle fonctionnalité',
|
|
73
|
+
question: 'Question / Aide',
|
|
74
|
+
hosting: 'Hébergement / Domaine',
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export default async function TicketDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
|
78
|
+
const { id } = await params
|
|
79
|
+
const payload = await getPayload({ config: configPromise })
|
|
80
|
+
const headers = await getHeaders()
|
|
81
|
+
const { user } = await payload.auth({ headers })
|
|
82
|
+
|
|
83
|
+
if (!user) return null
|
|
84
|
+
|
|
85
|
+
// Fetch the ticket
|
|
86
|
+
let ticket
|
|
87
|
+
try {
|
|
88
|
+
ticket = await payload.findByID({
|
|
89
|
+
collection: 'tickets',
|
|
90
|
+
id,
|
|
91
|
+
depth: 1,
|
|
92
|
+
overrideAccess: false,
|
|
93
|
+
user,
|
|
94
|
+
})
|
|
95
|
+
} catch {
|
|
96
|
+
notFound()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!ticket) notFound()
|
|
100
|
+
|
|
101
|
+
// Mark as read by updating lastClientReadAt
|
|
102
|
+
if (user.collection === 'support-clients') {
|
|
103
|
+
payload.update({
|
|
104
|
+
collection: 'tickets',
|
|
105
|
+
id: ticket.id,
|
|
106
|
+
data: { lastClientReadAt: new Date().toISOString() },
|
|
107
|
+
overrideAccess: true,
|
|
108
|
+
}).catch(() => {})
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Fetch messages for this ticket
|
|
112
|
+
const messages = await payload.find({
|
|
113
|
+
collection: 'ticket-messages',
|
|
114
|
+
where: {
|
|
115
|
+
ticket: { equals: ticket.id },
|
|
116
|
+
},
|
|
117
|
+
sort: 'createdAt',
|
|
118
|
+
limit: 100,
|
|
119
|
+
depth: 1,
|
|
120
|
+
overrideAccess: false,
|
|
121
|
+
user,
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// Check if satisfaction survey already exists
|
|
125
|
+
let hasSurvey = false
|
|
126
|
+
if (ticket.status === 'resolved') {
|
|
127
|
+
const existingSurvey = await payload.find({
|
|
128
|
+
collection: 'satisfaction-surveys',
|
|
129
|
+
where: { ticket: { equals: ticket.id } },
|
|
130
|
+
limit: 1,
|
|
131
|
+
depth: 0,
|
|
132
|
+
overrideAccess: true,
|
|
133
|
+
})
|
|
134
|
+
hasSurvey = existingSurvey.docs.length > 0
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const status = statusConfig[ticket.status || 'open']
|
|
138
|
+
const priority = priorityConfig[ticket.priority || 'normal']
|
|
139
|
+
const isClosed = ticket.status === 'resolved'
|
|
140
|
+
|
|
141
|
+
// Compute client initials for avatar
|
|
142
|
+
const clientInitials = (() => {
|
|
143
|
+
const fn = (user as { firstName?: string }).firstName || ''
|
|
144
|
+
const ln = (user as { lastName?: string }).lastName || ''
|
|
145
|
+
return ((fn[0] || '') + (ln[0] || '')).toUpperCase() || '?'
|
|
146
|
+
})()
|
|
147
|
+
|
|
148
|
+
// SLA indicator
|
|
149
|
+
const slaInfo = (() => {
|
|
150
|
+
if (!ticket.createdAt) return null
|
|
151
|
+
const created = new Date(ticket.createdAt)
|
|
152
|
+
const now = new Date()
|
|
153
|
+
|
|
154
|
+
if (ticket.firstResponseAt) {
|
|
155
|
+
const firstResponse = new Date(ticket.firstResponseAt)
|
|
156
|
+
const responseTimeMs = firstResponse.getTime() - created.getTime()
|
|
157
|
+
const responseTimeH = Math.floor(responseTimeMs / (1000 * 60 * 60))
|
|
158
|
+
const responseTimeM = Math.floor((responseTimeMs % (1000 * 60 * 60)) / (1000 * 60))
|
|
159
|
+
return {
|
|
160
|
+
label: 'Temps de réponse',
|
|
161
|
+
value: responseTimeH > 0 ? `${responseTimeH}h${String(responseTimeM).padStart(2, '0')}` : `${responseTimeM}min`,
|
|
162
|
+
resolved: ticket.resolvedAt,
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Waiting for first response
|
|
167
|
+
const waitingMs = now.getTime() - created.getTime()
|
|
168
|
+
const waitingH = Math.floor(waitingMs / (1000 * 60 * 60))
|
|
169
|
+
return {
|
|
170
|
+
label: 'En attente depuis',
|
|
171
|
+
value: waitingH < 1 ? 'Moins d\'1h' : waitingH < 24 ? `${waitingH}h` : `${Math.floor(waitingH / 24)}j`,
|
|
172
|
+
resolved: null,
|
|
173
|
+
}
|
|
174
|
+
})()
|
|
175
|
+
|
|
176
|
+
// Sidebar content for XL 2-column layout (ticket metadata)
|
|
177
|
+
const ticketSidebar = (
|
|
178
|
+
<aside className="hidden xl:block">
|
|
179
|
+
<div className="sticky top-4 space-y-4">
|
|
180
|
+
{/* Status & Priority */}
|
|
181
|
+
<div className="rounded-2xl border border-slate-200/80 dark:border-slate-700/80 bg-white dark:bg-slate-800/90 p-4 shadow-sm">
|
|
182
|
+
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Détails</h2>
|
|
183
|
+
<div className="space-y-3">
|
|
184
|
+
<div className="flex items-center justify-between">
|
|
185
|
+
<span className="text-xs text-slate-500 dark:text-slate-400">Statut</span>
|
|
186
|
+
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${status.bg} dark:bg-opacity-20`}>
|
|
187
|
+
{status.label}
|
|
188
|
+
</span>
|
|
189
|
+
</div>
|
|
190
|
+
<div className="flex items-center justify-between">
|
|
191
|
+
<span className="text-xs text-slate-500 dark:text-slate-400">Priorité</span>
|
|
192
|
+
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${priority.bg} dark:bg-opacity-20`}>
|
|
193
|
+
{priority.label}
|
|
194
|
+
</span>
|
|
195
|
+
</div>
|
|
196
|
+
{ticket.category && (
|
|
197
|
+
<div className="flex items-center justify-between">
|
|
198
|
+
<span className="text-xs text-slate-500 dark:text-slate-400">Catégorie</span>
|
|
199
|
+
<span className="text-xs font-medium text-slate-700 dark:text-slate-300">{categoryLabels[ticket.category]}</span>
|
|
200
|
+
</div>
|
|
201
|
+
)}
|
|
202
|
+
{slaInfo && (
|
|
203
|
+
<div className="flex items-center justify-between">
|
|
204
|
+
<span className="text-xs text-slate-500 dark:text-slate-400">{slaInfo.label}</span>
|
|
205
|
+
<span className="text-xs font-mono font-medium text-slate-700 dark:text-slate-300">{slaInfo.value}</span>
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
208
|
+
{ticket.project && typeof ticket.project === 'object' && (
|
|
209
|
+
<div className="flex items-center justify-between">
|
|
210
|
+
<span className="text-xs text-slate-500 dark:text-slate-400">Projet</span>
|
|
211
|
+
<span className="inline-flex items-center rounded-full bg-cyan-50 dark:bg-cyan-900/30 px-2 py-0.5 text-xs font-medium text-cyan-700 dark:text-cyan-400">
|
|
212
|
+
{ticket.project.name}
|
|
213
|
+
</span>
|
|
214
|
+
</div>
|
|
215
|
+
)}
|
|
216
|
+
<div className="flex items-center justify-between">
|
|
217
|
+
<span className="text-xs text-slate-500 dark:text-slate-400">Créé le</span>
|
|
218
|
+
<span className="text-xs font-mono text-slate-700 dark:text-slate-300">
|
|
219
|
+
{ticket.createdAt
|
|
220
|
+
? new Date(ticket.createdAt).toLocaleDateString('fr-FR', {
|
|
221
|
+
day: 'numeric',
|
|
222
|
+
month: 'short',
|
|
223
|
+
year: 'numeric',
|
|
224
|
+
timeZone: TZ,
|
|
225
|
+
})
|
|
226
|
+
: ''}
|
|
227
|
+
</span>
|
|
228
|
+
</div>
|
|
229
|
+
</div>
|
|
230
|
+
</div>
|
|
231
|
+
|
|
232
|
+
{/* Documents sidebar */}
|
|
233
|
+
{(ticket.quote || ticket.invoice) && (() => {
|
|
234
|
+
const quoteDoc = typeof ticket.quote === 'object' ? ticket.quote as PayloadDocument : null
|
|
235
|
+
const invoiceDoc = typeof ticket.invoice === 'object' ? ticket.invoice as PayloadDocument : null
|
|
236
|
+
return (
|
|
237
|
+
<div className="rounded-2xl border border-slate-200/80 dark:border-slate-700/80 bg-white dark:bg-slate-800/90 p-4 shadow-sm">
|
|
238
|
+
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Documents</h2>
|
|
239
|
+
<div className="space-y-2">
|
|
240
|
+
{quoteDoc && (
|
|
241
|
+
<a
|
|
242
|
+
href={`${process.env.NEXT_PUBLIC_SERVER_URL || ''}/api/documents/file/${quoteDoc.filename}`}
|
|
243
|
+
target="_blank"
|
|
244
|
+
rel="noopener noreferrer"
|
|
245
|
+
className="flex items-center gap-2 rounded-xl border border-amber-200 dark:border-amber-800/50 bg-amber-50 dark:bg-amber-900/20 px-3 py-2 text-sm font-medium text-amber-800 dark:text-amber-300 transition-all hover:bg-amber-100 dark:hover:bg-amber-900/30"
|
|
246
|
+
>
|
|
247
|
+
<svg className="h-4 w-4 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
248
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
249
|
+
</svg>
|
|
250
|
+
<span className="text-xs">Devis</span>
|
|
251
|
+
</a>
|
|
252
|
+
)}
|
|
253
|
+
{invoiceDoc && (
|
|
254
|
+
<a
|
|
255
|
+
href={`${process.env.NEXT_PUBLIC_SERVER_URL || ''}/api/documents/file/${invoiceDoc.filename}`}
|
|
256
|
+
target="_blank"
|
|
257
|
+
rel="noopener noreferrer"
|
|
258
|
+
className="flex items-center gap-2 rounded-xl border border-emerald-200 dark:border-emerald-800/50 bg-emerald-50 dark:bg-emerald-900/20 px-3 py-2 text-sm font-medium text-emerald-800 dark:text-emerald-300 transition-all hover:bg-emerald-100 dark:hover:bg-emerald-900/30"
|
|
259
|
+
>
|
|
260
|
+
<svg className="h-4 w-4 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
261
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
262
|
+
</svg>
|
|
263
|
+
<span className="text-xs">Facture</span>
|
|
264
|
+
{(() => {
|
|
265
|
+
const ps = ticket.paymentStatus
|
|
266
|
+
if (ps === 'paid') return <span className="ml-auto rounded-full bg-green-100 dark:bg-green-900/30 px-2 py-0.5 text-xs font-medium text-green-700 dark:text-green-400">Payé</span>
|
|
267
|
+
if (ps === 'partial') return <span className="ml-auto rounded-full bg-orange-100 dark:bg-orange-900/30 px-2 py-0.5 text-xs font-medium text-orange-700 dark:text-orange-400">Partiel</span>
|
|
268
|
+
return <span className="ml-auto rounded-full bg-red-100 dark:bg-red-900/30 px-2 py-0.5 text-xs font-medium text-red-700 dark:text-red-400">Non payé</span>
|
|
269
|
+
})()}
|
|
270
|
+
</a>
|
|
271
|
+
)}
|
|
272
|
+
</div>
|
|
273
|
+
</div>
|
|
274
|
+
)
|
|
275
|
+
})()}
|
|
276
|
+
|
|
277
|
+
{/* Actions sidebar */}
|
|
278
|
+
<div className="rounded-2xl border border-slate-200/80 dark:border-slate-700/80 bg-white dark:bg-slate-800/90 p-4 shadow-sm">
|
|
279
|
+
<div className="flex flex-col gap-2">
|
|
280
|
+
<PrintButton />
|
|
281
|
+
{!isClosed && (
|
|
282
|
+
<CloseTicketButton ticketId={ticket.id} />
|
|
283
|
+
)}
|
|
284
|
+
</div>
|
|
285
|
+
</div>
|
|
286
|
+
</div>
|
|
287
|
+
</aside>
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
return (
|
|
291
|
+
<div className="mx-auto px-4 sm:px-6 lg:px-8" style={{ maxWidth: 1920, display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) 320px', gap: 32 }}>
|
|
292
|
+
{/* Print styles for PDF export */}
|
|
293
|
+
<style dangerouslySetInnerHTML={{ __html: `
|
|
294
|
+
@media print {
|
|
295
|
+
header, nav, .no-print, button, form, [role="link"] { display: none !important; }
|
|
296
|
+
.print-show { display: block !important; }
|
|
297
|
+
body { background: white !important; }
|
|
298
|
+
* { border-color: #e5e7eb !important; }
|
|
299
|
+
}
|
|
300
|
+
`}} />
|
|
301
|
+
|
|
302
|
+
{/* Main column */}
|
|
303
|
+
<div className="flex flex-col" style={{ height: 'calc(100dvh - 64px)' }}>
|
|
304
|
+
{/* Auto-refresh polling */}
|
|
305
|
+
<TicketPolling ticketId={ticket.id} messageCount={messages.docs.length} />
|
|
306
|
+
|
|
307
|
+
{/* Non-scrollable header area */}
|
|
308
|
+
<div className="flex-shrink-0">
|
|
309
|
+
{/* Breadcrumb */}
|
|
310
|
+
<div className="mb-5">
|
|
311
|
+
<Link
|
|
312
|
+
href="/support/dashboard"
|
|
313
|
+
className="group inline-flex items-center gap-1.5 text-sm font-medium text-slate-500 dark:text-slate-400 hover:text-blue-600 dark:hover:text-blue-400 transition-colors"
|
|
314
|
+
>
|
|
315
|
+
<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 transition-transform group-hover:-translate-x-0.5">
|
|
316
|
+
<polyline points="15 18 9 12 15 6" />
|
|
317
|
+
</svg>
|
|
318
|
+
Retour aux tickets
|
|
319
|
+
</Link>
|
|
320
|
+
</div>
|
|
321
|
+
|
|
322
|
+
{/* Ticket header card */}
|
|
323
|
+
<div className="mb-4 rounded-2xl border border-slate-200/80 dark:border-slate-700/80 bg-white dark:bg-slate-800/90 p-5 sm:p-6 shadow-sm backdrop-blur-sm">
|
|
324
|
+
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
|
325
|
+
<div className="min-w-0 flex-1">
|
|
326
|
+
{/* Ticket number + badges row */}
|
|
327
|
+
<div className="mb-2.5 flex flex-wrap items-center gap-2">
|
|
328
|
+
<span className="font-mono text-xs text-slate-500 dark:text-slate-400">{ticket.ticketNumber}</span>
|
|
329
|
+
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${status.bg} dark:bg-opacity-20`}>
|
|
330
|
+
{status.label}
|
|
331
|
+
</span>
|
|
332
|
+
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold xl:hidden ${priority.bg} dark:bg-opacity-20`}>
|
|
333
|
+
{priority.label}
|
|
334
|
+
</span>
|
|
335
|
+
{slaInfo && (
|
|
336
|
+
<span className="inline-flex items-center gap-1 rounded-full bg-slate-100 dark:bg-slate-700 px-2.5 py-0.5 text-xs font-medium text-slate-500 dark:text-slate-400 xl:hidden">
|
|
337
|
+
<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-3 w-3">
|
|
338
|
+
<circle cx="12" cy="12" r="10" />
|
|
339
|
+
<polyline points="12 6 12 12 16 14" />
|
|
340
|
+
</svg>
|
|
341
|
+
<span className="font-mono">{slaInfo.label} : {slaInfo.value}</span>
|
|
342
|
+
</span>
|
|
343
|
+
)}
|
|
344
|
+
</div>
|
|
345
|
+
{/* Title */}
|
|
346
|
+
<h1 className="text-lg font-bold text-slate-900 dark:text-white leading-snug sm:text-xl">{ticket.subject}</h1>
|
|
347
|
+
{/* Metadata -- hidden on XL (shown in sidebar) */}
|
|
348
|
+
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500 dark:text-slate-400 xl:hidden">
|
|
349
|
+
{ticket.category && (
|
|
350
|
+
<span className="inline-flex items-center gap-1">
|
|
351
|
+
<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-3 w-3">
|
|
352
|
+
<path d="M20.59 13.41l-7.17 7.17a2 2 0 01-2.83 0L2 12V2h10l8.59 8.59a2 2 0 010 2.82z" />
|
|
353
|
+
<line x1="7" y1="7" x2="7.01" y2="7" />
|
|
354
|
+
</svg>
|
|
355
|
+
{categoryLabels[ticket.category]}
|
|
356
|
+
</span>
|
|
357
|
+
)}
|
|
358
|
+
{ticket.project && typeof ticket.project === 'object' && (
|
|
359
|
+
<span className="inline-flex items-center gap-1 rounded-full bg-cyan-50 dark:bg-cyan-900/30 px-2 py-0.5 text-xs font-medium text-cyan-700 dark:text-cyan-400">
|
|
360
|
+
{ticket.project.name}
|
|
361
|
+
</span>
|
|
362
|
+
)}
|
|
363
|
+
<span className="font-mono">
|
|
364
|
+
Créé le{' '}
|
|
365
|
+
{ticket.createdAt
|
|
366
|
+
? new Date(ticket.createdAt).toLocaleDateString('fr-FR', {
|
|
367
|
+
day: 'numeric',
|
|
368
|
+
month: 'long',
|
|
369
|
+
year: 'numeric',
|
|
370
|
+
hour: '2-digit',
|
|
371
|
+
minute: '2-digit',
|
|
372
|
+
timeZone: TZ,
|
|
373
|
+
})
|
|
374
|
+
: ''}
|
|
375
|
+
</span>
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
{/* Actions -- hidden on XL (shown in sidebar) */}
|
|
379
|
+
<div className="flex flex-shrink-0 items-center gap-2 xl:hidden">
|
|
380
|
+
<PrintButton />
|
|
381
|
+
{!isClosed && (
|
|
382
|
+
<CloseTicketButton ticketId={ticket.id} />
|
|
383
|
+
)}
|
|
384
|
+
</div>
|
|
385
|
+
</div>
|
|
386
|
+
</div>
|
|
387
|
+
|
|
388
|
+
{/* Documents -- visible below XL only (on XL they are in the sidebar) */}
|
|
389
|
+
<div className="xl:hidden">
|
|
390
|
+
{(ticket.quote || ticket.invoice) && (() => {
|
|
391
|
+
const quoteDoc = typeof ticket.quote === 'object' ? ticket.quote as PayloadDocument : null
|
|
392
|
+
const invoiceDoc = typeof ticket.invoice === 'object' ? ticket.invoice as PayloadDocument : null
|
|
393
|
+
return (
|
|
394
|
+
<div className="mb-4 rounded-2xl border border-slate-200/80 dark:border-slate-700/80 bg-white dark:bg-slate-800/90 p-5 shadow-sm">
|
|
395
|
+
<h2 className="mb-3 text-xs font-semibold uppercase tracking-wider text-slate-500 dark:text-slate-400">Documents</h2>
|
|
396
|
+
<div className="flex flex-wrap gap-3">
|
|
397
|
+
{quoteDoc && (
|
|
398
|
+
<a
|
|
399
|
+
href={`${process.env.NEXT_PUBLIC_SERVER_URL || ''}/api/documents/file/${quoteDoc.filename}`}
|
|
400
|
+
target="_blank"
|
|
401
|
+
rel="noopener noreferrer"
|
|
402
|
+
className="flex items-center gap-3 rounded-xl border border-amber-200 dark:border-amber-800/50 bg-amber-50 dark:bg-amber-900/20 px-4 py-3 text-sm font-medium text-amber-800 dark:text-amber-300 transition-all hover:bg-amber-100 dark:hover:bg-amber-900/30 hover:shadow-sm"
|
|
403
|
+
>
|
|
404
|
+
<svg className="h-5 w-5 text-amber-600 dark:text-amber-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
405
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
406
|
+
</svg>
|
|
407
|
+
<div>
|
|
408
|
+
<div className="text-sm">Devis</div>
|
|
409
|
+
<div className="text-xs font-normal text-slate-500 dark:text-slate-400">{quoteDoc.title || 'PDF'}</div>
|
|
410
|
+
</div>
|
|
411
|
+
<svg className="ml-2 h-4 w-4 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
412
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
|
413
|
+
</svg>
|
|
414
|
+
</a>
|
|
415
|
+
)}
|
|
416
|
+
{invoiceDoc && (
|
|
417
|
+
<a
|
|
418
|
+
href={`${process.env.NEXT_PUBLIC_SERVER_URL || ''}/api/documents/file/${invoiceDoc.filename}`}
|
|
419
|
+
target="_blank"
|
|
420
|
+
rel="noopener noreferrer"
|
|
421
|
+
className="flex items-center gap-3 rounded-xl border border-emerald-200 dark:border-emerald-800/50 bg-emerald-50 dark:bg-emerald-900/20 px-4 py-3 text-sm font-medium text-emerald-800 dark:text-emerald-300 transition-all hover:bg-emerald-100 dark:hover:bg-emerald-900/30 hover:shadow-sm"
|
|
422
|
+
>
|
|
423
|
+
<svg className="h-5 w-5 text-emerald-600 dark:text-emerald-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
424
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
|
425
|
+
</svg>
|
|
426
|
+
<div>
|
|
427
|
+
<div className="text-sm">Facture</div>
|
|
428
|
+
<div className="text-xs font-normal text-slate-500 dark:text-slate-400">{invoiceDoc.title || 'PDF'}</div>
|
|
429
|
+
</div>
|
|
430
|
+
{(() => {
|
|
431
|
+
const ps = ticket.paymentStatus
|
|
432
|
+
if (ps === 'paid') return <span className="ml-2 rounded-full bg-green-100 dark:bg-green-900/30 px-2.5 py-0.5 text-xs font-medium text-green-700 dark:text-green-400">Payé</span>
|
|
433
|
+
if (ps === 'partial') return <span className="ml-2 rounded-full bg-orange-100 dark:bg-orange-900/30 px-2.5 py-0.5 text-xs font-medium text-orange-700 dark:text-orange-400">Partiel</span>
|
|
434
|
+
return <span className="ml-2 rounded-full bg-red-100 dark:bg-red-900/30 px-2.5 py-0.5 text-xs font-medium text-red-700 dark:text-red-400">Non payé</span>
|
|
435
|
+
})()}
|
|
436
|
+
<svg className="ml-1 h-4 w-4 text-slate-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
437
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
|
438
|
+
</svg>
|
|
439
|
+
</a>
|
|
440
|
+
)}
|
|
441
|
+
</div>
|
|
442
|
+
</div>
|
|
443
|
+
)
|
|
444
|
+
})()}
|
|
445
|
+
</div>
|
|
446
|
+
</div>
|
|
447
|
+
|
|
448
|
+
{/* Scrollable conversation area */}
|
|
449
|
+
<div className="flex-1 min-h-0 overflow-y-auto mb-0 rounded-t-2xl border border-b-0 border-slate-200/80 dark:border-slate-700/80 bg-gradient-to-b from-slate-50/50 to-white dark:from-slate-800/50 dark:to-slate-800/90 shadow-sm">
|
|
450
|
+
{/* Conversation header */}
|
|
451
|
+
<div className="sticky top-0 z-10 border-b border-slate-200/80 dark:border-slate-700/80 bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm px-5 py-3">
|
|
452
|
+
<div className="flex items-center justify-between">
|
|
453
|
+
<h2 className="text-sm font-semibold text-slate-700 dark:text-slate-300">Conversation</h2>
|
|
454
|
+
<span className="text-xs text-slate-500 dark:text-slate-400">{messages.docs.length} message{messages.docs.length !== 1 ? 's' : ''}</span>
|
|
455
|
+
</div>
|
|
456
|
+
</div>
|
|
457
|
+
|
|
458
|
+
{/* Messages area */}
|
|
459
|
+
<div className="px-4 py-5 sm:px-6 space-y-1">
|
|
460
|
+
{/* Automatic acknowledgement for new tickets */}
|
|
461
|
+
{messages.docs.length <= 1 && ticket.createdAt && (Date.now() - new Date(ticket.createdAt).getTime()) < 3600000 && (
|
|
462
|
+
<div className="flex justify-center mb-4">
|
|
463
|
+
<div className="inline-flex items-center gap-2 rounded-full bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800/50 px-4 py-2">
|
|
464
|
+
<div className="flex h-5 w-5 items-center justify-center rounded-full bg-green-500">
|
|
465
|
+
<svg className="h-3 w-3 text-white" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round">
|
|
466
|
+
<polyline points="20,6 9,17 4,12" />
|
|
467
|
+
</svg>
|
|
468
|
+
</div>
|
|
469
|
+
<div className="text-sm">
|
|
470
|
+
<span className="font-medium text-green-800 dark:text-green-300">Demande enregistrée</span>
|
|
471
|
+
<span className="text-green-600 dark:text-green-400"> — Réponse sous 2h en moyenne</span>
|
|
472
|
+
</div>
|
|
473
|
+
</div>
|
|
474
|
+
</div>
|
|
475
|
+
)}
|
|
476
|
+
|
|
477
|
+
{messages.docs.length === 0 ? (
|
|
478
|
+
<div className="flex flex-col items-center justify-center py-16 text-center">
|
|
479
|
+
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-full bg-slate-100 dark:bg-slate-700">
|
|
480
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" className="h-6 w-6 text-slate-400">
|
|
481
|
+
<path d="M21 15a2 2 0 01-2 2H7l-4 4V5a2 2 0 012-2h14a2 2 0 012 2z" />
|
|
482
|
+
</svg>
|
|
483
|
+
</div>
|
|
484
|
+
<p className="text-sm text-slate-500 dark:text-slate-400">Aucun message pour le moment</p>
|
|
485
|
+
</div>
|
|
486
|
+
) : (
|
|
487
|
+
<CollapsibleMessages>
|
|
488
|
+
{messages.docs.map((msg, idx) => {
|
|
489
|
+
const isClient = msg.authorType === 'client' || msg.authorType === 'email'
|
|
490
|
+
const authorName = isClient
|
|
491
|
+
? msg.authorType === 'email'
|
|
492
|
+
? 'Email'
|
|
493
|
+
: 'Vous'
|
|
494
|
+
: 'Support'
|
|
495
|
+
|
|
496
|
+
// Date separator: check if day changed from previous message
|
|
497
|
+
const prevMsg = idx > 0 ? messages.docs[idx - 1] : null
|
|
498
|
+
const showDateSeparator = msg.createdAt && (!prevMsg?.createdAt || new Date(msg.createdAt).toDateString() !== new Date(prevMsg.createdAt).toDateString())
|
|
499
|
+
|
|
500
|
+
return (
|
|
501
|
+
<React.Fragment key={msg.id}>
|
|
502
|
+
{showDateSeparator && (
|
|
503
|
+
<div className="flex items-center gap-3 py-3 my-1">
|
|
504
|
+
<div className="flex-1 border-t border-slate-200/60 dark:border-slate-700/60" />
|
|
505
|
+
<span className="rounded-full bg-white dark:bg-slate-800 border border-slate-200/80 dark:border-slate-700/80 px-3 py-1 text-xs font-medium font-mono text-slate-500 dark:text-slate-400 whitespace-nowrap shadow-sm">{getDateLabel(msg.createdAt!)}</span>
|
|
506
|
+
<div className="flex-1 border-t border-slate-200/60 dark:border-slate-700/60" />
|
|
507
|
+
</div>
|
|
508
|
+
)}
|
|
509
|
+
|
|
510
|
+
<div className={`flex items-end gap-2.5 mb-3 ${isClient ? 'flex-row-reverse' : 'flex-row'}`}>
|
|
511
|
+
{/* Avatar */}
|
|
512
|
+
{isClient ? (
|
|
513
|
+
<div className="flex-shrink-0 h-6 w-6 sm:h-7 sm:w-7 rounded-full bg-gradient-to-br from-slate-400 to-slate-500 flex items-center justify-center text-white text-xs font-bold shadow-sm">
|
|
514
|
+
{clientInitials}
|
|
515
|
+
</div>
|
|
516
|
+
) : (
|
|
517
|
+
<div className="flex-shrink-0 h-6 w-6 sm:h-7 sm:w-7 rounded-full bg-gradient-to-br from-blue-500 to-blue-700 flex items-center justify-center text-white text-xs font-bold shadow-sm">
|
|
518
|
+
CW
|
|
519
|
+
</div>
|
|
520
|
+
)}
|
|
521
|
+
|
|
522
|
+
{/* Message bubble */}
|
|
523
|
+
<div className={`group relative max-w-[88%] sm:max-w-[80%] md:max-w-[75%] ${isClient ? 'items-end' : 'items-start'}`}>
|
|
524
|
+
<div
|
|
525
|
+
className={`relative rounded-2xl px-4 py-2.5 ${
|
|
526
|
+
isClient
|
|
527
|
+
? 'rounded-br-md bg-slate-100 dark:bg-slate-700/80 text-slate-800 dark:text-slate-200'
|
|
528
|
+
: 'rounded-bl-md bg-blue-600 text-white'
|
|
529
|
+
}`}
|
|
530
|
+
>
|
|
531
|
+
{/* Author + time */}
|
|
532
|
+
<div className={`mb-1 flex items-center gap-2 ${isClient ? 'justify-end' : ''}`}>
|
|
533
|
+
<span className={`text-xs font-semibold ${isClient ? 'text-slate-500 dark:text-slate-400' : 'text-blue-100'}`}>
|
|
534
|
+
{authorName}
|
|
535
|
+
</span>
|
|
536
|
+
<span className={`text-xs font-mono ${isClient ? 'text-slate-500 dark:text-slate-400' : 'text-blue-200'}`}>
|
|
537
|
+
{msg.createdAt ? formatMessageDate(msg.createdAt) : ''}
|
|
538
|
+
<EditedBadge editedAt={(msg as unknown as { editedAt?: string }).editedAt} />
|
|
539
|
+
</span>
|
|
540
|
+
</div>
|
|
541
|
+
|
|
542
|
+
{/* Message content */}
|
|
543
|
+
{(msg as unknown as { deletedAt?: string }).deletedAt ? (
|
|
544
|
+
<DeletedMessage />
|
|
545
|
+
) : msg.bodyHtml ? (
|
|
546
|
+
<div
|
|
547
|
+
className={`text-sm leading-relaxed [&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:pl-3 [&_blockquote]:opacity-80 [&_img]:max-w-full [&_img]:h-auto [&_img]:rounded-lg [&_img]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-0.5 [&_p]:mb-1 last:[&_p]:mb-0 ${
|
|
548
|
+
isClient
|
|
549
|
+
? '[&_a]:text-blue-600 dark:[&_a]:text-blue-400 [&_a]:underline [&_blockquote]:border-slate-300 [&_blockquote]:bg-slate-50 dark:[&_blockquote]:bg-slate-600/30 [&_blockquote]:rounded-r-lg [&_blockquote]:py-1 [&_blockquote]:px-3'
|
|
550
|
+
: '[&_a]:text-blue-100 [&_a]:underline [&_blockquote]:border-blue-300 [&_blockquote]:rounded-r-lg [&_blockquote]:py-1 [&_blockquote]:px-3'
|
|
551
|
+
}`}
|
|
552
|
+
dangerouslySetInnerHTML={{ __html: msg.bodyHtml }}
|
|
553
|
+
/>
|
|
554
|
+
) : (
|
|
555
|
+
<div className="whitespace-pre-wrap text-sm leading-relaxed" dangerouslySetInnerHTML={{
|
|
556
|
+
__html: msg.body
|
|
557
|
+
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
558
|
+
.replace(/\[code:(\d+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="text-blue-600 underline font-semibold">🔗 Voir le code partagé</a>')
|
|
559
|
+
.replace(/\n/g, '<br/>')
|
|
560
|
+
}} />
|
|
561
|
+
)}
|
|
562
|
+
|
|
563
|
+
{/* Attachments */}
|
|
564
|
+
{Array.isArray(msg.attachments) && msg.attachments.length > 0 && (
|
|
565
|
+
<div className="mt-2.5 space-y-2">
|
|
566
|
+
{/* Inline image previews */}
|
|
567
|
+
{msg.attachments
|
|
568
|
+
.filter((att) => {
|
|
569
|
+
const file = typeof att.file === 'object' ? att.file : null
|
|
570
|
+
return file?.mimeType?.startsWith('image/')
|
|
571
|
+
})
|
|
572
|
+
.map((att, i) => {
|
|
573
|
+
const file = typeof att.file === 'object' ? att.file : null
|
|
574
|
+
if (!file) return null
|
|
575
|
+
const thumbnailUrl = file.sizes?.medium?.url || file.sizes?.small?.url || file.url
|
|
576
|
+
return (
|
|
577
|
+
<a
|
|
578
|
+
key={`img-${i}`}
|
|
579
|
+
href={file.url || '#'}
|
|
580
|
+
target="_blank"
|
|
581
|
+
rel="noopener noreferrer"
|
|
582
|
+
className="block overflow-hidden rounded-xl border border-white/20 transition-opacity hover:opacity-90"
|
|
583
|
+
>
|
|
584
|
+
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
585
|
+
<img
|
|
586
|
+
src={thumbnailUrl || file.url || ''}
|
|
587
|
+
alt={file.alt || file.filename || 'Image jointe'}
|
|
588
|
+
className="max-h-72 w-auto object-contain rounded-xl"
|
|
589
|
+
loading="lazy"
|
|
590
|
+
/>
|
|
591
|
+
</a>
|
|
592
|
+
)
|
|
593
|
+
})}
|
|
594
|
+
{/* Video previews */}
|
|
595
|
+
{msg.attachments
|
|
596
|
+
.filter((att) => {
|
|
597
|
+
const file = typeof att.file === 'object' ? att.file : null
|
|
598
|
+
return file?.mimeType?.startsWith('video/')
|
|
599
|
+
})
|
|
600
|
+
.map((att, i) => {
|
|
601
|
+
const file = typeof att.file === 'object' ? att.file : null
|
|
602
|
+
if (!file) return null
|
|
603
|
+
return (
|
|
604
|
+
<video
|
|
605
|
+
key={`vid-${i}`}
|
|
606
|
+
src={file.url || ''}
|
|
607
|
+
controls
|
|
608
|
+
preload="metadata"
|
|
609
|
+
className="max-h-52 max-w-sm rounded-xl bg-black"
|
|
610
|
+
/>
|
|
611
|
+
)
|
|
612
|
+
})}
|
|
613
|
+
{/* Non-media file links */}
|
|
614
|
+
<div className="flex flex-wrap gap-1.5">
|
|
615
|
+
{msg.attachments
|
|
616
|
+
.filter((att) => {
|
|
617
|
+
const file = typeof att.file === 'object' ? att.file : null
|
|
618
|
+
return file && !file.mimeType?.startsWith('image/') && !file.mimeType?.startsWith('video/')
|
|
619
|
+
})
|
|
620
|
+
.map((att, i) => {
|
|
621
|
+
const file = typeof att.file === 'object' ? att.file : null
|
|
622
|
+
if (!file) return null
|
|
623
|
+
return (
|
|
624
|
+
<a
|
|
625
|
+
key={`file-${i}`}
|
|
626
|
+
href={file.url || '#'}
|
|
627
|
+
target="_blank"
|
|
628
|
+
rel="noopener noreferrer"
|
|
629
|
+
className={`inline-flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors ${
|
|
630
|
+
isClient
|
|
631
|
+
? 'bg-slate-200 dark:bg-slate-600 text-slate-700 dark:text-slate-300 hover:bg-slate-300 dark:hover:bg-slate-500'
|
|
632
|
+
: 'bg-blue-500/30 text-blue-50 hover:bg-blue-500/50'
|
|
633
|
+
}`}
|
|
634
|
+
>
|
|
635
|
+
<svg className="h-3.5 w-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
636
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M15.172 7l-6.586 6.586a2 2 0 102.828 2.828l6.414-6.586a4 4 0 00-5.656-5.656l-6.415 6.585a6 6 0 108.486 8.486L20.5 13" />
|
|
637
|
+
</svg>
|
|
638
|
+
{file.filename || 'Fichier'}
|
|
639
|
+
</a>
|
|
640
|
+
)
|
|
641
|
+
})}
|
|
642
|
+
</div>
|
|
643
|
+
</div>
|
|
644
|
+
)}
|
|
645
|
+
</div>
|
|
646
|
+
|
|
647
|
+
{/* Actions below bubble */}
|
|
648
|
+
<div className={`mt-1 flex items-center gap-1.5 ${isClient ? 'justify-end' : 'justify-start'}`}>
|
|
649
|
+
{/* Edit/delete actions for client messages */}
|
|
650
|
+
{isClient && msg.authorType === 'client' && !(msg as unknown as { deletedAt?: string }).deletedAt && (
|
|
651
|
+
<MessageActions
|
|
652
|
+
messageId={msg.id}
|
|
653
|
+
body={msg.body}
|
|
654
|
+
createdAt={msg.createdAt}
|
|
655
|
+
/>
|
|
656
|
+
)}
|
|
657
|
+
{/* Read receipt on last client message */}
|
|
658
|
+
{isClient && idx === messages.docs.length - 1 && (
|
|
659
|
+
<ReadReceipt
|
|
660
|
+
lastAdminReadAt={ticket.lastAdminReadAt as string | undefined}
|
|
661
|
+
messageCreatedAt={msg.createdAt}
|
|
662
|
+
/>
|
|
663
|
+
)}
|
|
664
|
+
{/* Solution badge / mark button */}
|
|
665
|
+
{msg.isSolution && (
|
|
666
|
+
<div className="inline-flex items-center gap-1 rounded-full bg-emerald-100 dark:bg-emerald-900/40 px-2.5 py-1 text-xs font-semibold text-emerald-700 dark:text-emerald-400 shadow-sm border border-emerald-200 dark:border-emerald-800/50">
|
|
667
|
+
<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">
|
|
668
|
+
<polyline points="20,6 9,17 4,12" />
|
|
669
|
+
</svg>
|
|
670
|
+
Solution
|
|
671
|
+
</div>
|
|
672
|
+
)}
|
|
673
|
+
{!isClient && !msg.isSolution && !isClosed && (
|
|
674
|
+
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
|
|
675
|
+
<MarkSolutionButton messageId={msg.id} isSolution={false} />
|
|
676
|
+
</div>
|
|
677
|
+
)}
|
|
678
|
+
</div>
|
|
679
|
+
</div>
|
|
680
|
+
</div>
|
|
681
|
+
</React.Fragment>
|
|
682
|
+
)
|
|
683
|
+
})}
|
|
684
|
+
</CollapsibleMessages>
|
|
685
|
+
)}
|
|
686
|
+
</div>
|
|
687
|
+
|
|
688
|
+
{/* Typing indicator inside conversation */}
|
|
689
|
+
<div className="px-4 sm:px-6 pb-2">
|
|
690
|
+
<TypingIndicator ticketId={ticket.id} />
|
|
691
|
+
</div>
|
|
692
|
+
</div>
|
|
693
|
+
|
|
694
|
+
{/* Sticky reply form or resolved state at bottom */}
|
|
695
|
+
<div className="flex-shrink-0 border-t border-slate-200/80 dark:border-slate-700/80 bg-white dark:bg-slate-900 px-4 py-3">
|
|
696
|
+
{!isClosed ? (
|
|
697
|
+
<TicketReplyForm ticketId={ticket.id} />
|
|
698
|
+
) : (
|
|
699
|
+
<div className="space-y-4">
|
|
700
|
+
{/* Resolved card */}
|
|
701
|
+
<div className="rounded-2xl border border-emerald-200 dark:border-emerald-800/50 bg-gradient-to-br from-emerald-50 to-green-50 dark:from-emerald-900/20 dark:to-green-900/20 p-6 text-center">
|
|
702
|
+
<div className="mx-auto mb-3 flex h-11 w-11 items-center justify-center rounded-full bg-emerald-500 shadow-lg shadow-emerald-500/20">
|
|
703
|
+
<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-5 w-5 text-white">
|
|
704
|
+
<polyline points="20,6 9,17 4,12" />
|
|
705
|
+
</svg>
|
|
706
|
+
</div>
|
|
707
|
+
<p className="text-base font-bold text-emerald-900 dark:text-emerald-200">Ce ticket est résolu</p>
|
|
708
|
+
<p className="mt-1 text-sm text-emerald-600 dark:text-emerald-400">
|
|
709
|
+
Un souci persiste ?
|
|
710
|
+
</p>
|
|
711
|
+
<div className="mt-5 flex flex-wrap items-center justify-center gap-3">
|
|
712
|
+
<ReopenTicketButton ticketId={ticket.id} />
|
|
713
|
+
<Link
|
|
714
|
+
href="/support/tickets/new"
|
|
715
|
+
className="inline-flex items-center gap-2 rounded-xl border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-800 px-4 py-2.5 text-sm font-semibold text-slate-700 dark:text-slate-300 transition-all hover:bg-slate-50 dark:hover:bg-slate-700 hover:shadow-sm"
|
|
716
|
+
>
|
|
717
|
+
<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">
|
|
718
|
+
<line x1="12" y1="5" x2="12" y2="19" />
|
|
719
|
+
<line x1="5" y1="12" x2="19" y2="12" />
|
|
720
|
+
</svg>
|
|
721
|
+
Nouveau ticket
|
|
722
|
+
</Link>
|
|
723
|
+
</div>
|
|
724
|
+
</div>
|
|
725
|
+
{/* Satisfaction survey */}
|
|
726
|
+
{!hasSurvey && user.collection === 'support-clients' && (
|
|
727
|
+
<SatisfactionForm ticketId={ticket.id} />
|
|
728
|
+
)}
|
|
729
|
+
</div>
|
|
730
|
+
)}
|
|
731
|
+
</div>
|
|
732
|
+
</div>
|
|
733
|
+
|
|
734
|
+
{/* XL sidebar */}
|
|
735
|
+
{ticketSidebar}
|
|
736
|
+
</div>
|
|
737
|
+
)
|
|
738
|
+
}
|