@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.
Files changed (189) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +525 -0
  3. package/dist/client.cjs +7 -0
  4. package/dist/client.d.cts +3 -0
  5. package/dist/client.d.ts +3 -0
  6. package/dist/client.js +5 -0
  7. package/dist/index.cjs +7766 -0
  8. package/dist/index.d.cts +384 -0
  9. package/dist/index.d.ts +384 -0
  10. package/dist/index.js +7730 -0
  11. package/dist/views.d.cts +30 -0
  12. package/dist/views.d.ts +30 -0
  13. package/package.json +131 -0
  14. package/src/client.ts +1 -0
  15. package/src/collections/AuthLogs.ts +65 -0
  16. package/src/collections/CannedResponses.ts +69 -0
  17. package/src/collections/ChatMessages.ts +98 -0
  18. package/src/collections/EmailLogs.ts +94 -0
  19. package/src/collections/KnowledgeBase.ts +99 -0
  20. package/src/collections/Macros.ts +98 -0
  21. package/src/collections/PendingEmails.ts +122 -0
  22. package/src/collections/SatisfactionSurveys.ts +98 -0
  23. package/src/collections/SlaPolicies.ts +123 -0
  24. package/src/collections/SupportClients.ts +210 -0
  25. package/src/collections/TicketActivityLog.ts +81 -0
  26. package/src/collections/TicketMessages.ts +364 -0
  27. package/src/collections/TicketStatuses.ts +108 -0
  28. package/src/collections/Tickets.ts +704 -0
  29. package/src/collections/TimeEntries.ts +105 -0
  30. package/src/collections/WebhookEndpoints.ts +96 -0
  31. package/src/collections/index.ts +16 -0
  32. package/src/components/TicketConversation/components/AISummaryPanel.tsx +85 -0
  33. package/src/components/TicketConversation/components/ActionPanels.tsx +140 -0
  34. package/src/components/TicketConversation/components/ActivityLog.tsx +39 -0
  35. package/src/components/TicketConversation/components/ClientBar.tsx +37 -0
  36. package/src/components/TicketConversation/components/ClientHistory.tsx +117 -0
  37. package/src/components/TicketConversation/components/CodeBlock.tsx +186 -0
  38. package/src/components/TicketConversation/components/CodeBlockInserter.tsx +166 -0
  39. package/src/components/TicketConversation/components/QuickActions.tsx +82 -0
  40. package/src/components/TicketConversation/components/TicketHeader.tsx +91 -0
  41. package/src/components/TicketConversation/components/TimeTrackingPanel.tsx +161 -0
  42. package/src/components/TicketConversation/config.ts +82 -0
  43. package/src/components/TicketConversation/constants.ts +74 -0
  44. package/src/components/TicketConversation/context.ts +63 -0
  45. package/src/components/TicketConversation/hooks/useAI.ts +180 -0
  46. package/src/components/TicketConversation/hooks/useMessageActions.ts +131 -0
  47. package/src/components/TicketConversation/hooks/useReply.ts +190 -0
  48. package/src/components/TicketConversation/hooks/useTicketActions.ts +205 -0
  49. package/src/components/TicketConversation/hooks/useTimeTracking.ts +107 -0
  50. package/src/components/TicketConversation/hooks/useTranslation.ts +116 -0
  51. package/src/components/TicketConversation/index.tsx +1110 -0
  52. package/src/components/TicketConversation/locales/en.json +878 -0
  53. package/src/components/TicketConversation/locales/fr.json +878 -0
  54. package/src/components/TicketConversation/types.ts +54 -0
  55. package/src/components/TicketConversation/utils.ts +25 -0
  56. package/src/endpoints/admin-chat-stream.ts +238 -0
  57. package/src/endpoints/admin-chat.ts +263 -0
  58. package/src/endpoints/admin-stats.ts +200 -0
  59. package/src/endpoints/ai.ts +199 -0
  60. package/src/endpoints/apply-macro.ts +144 -0
  61. package/src/endpoints/auth-2fa.ts +163 -0
  62. package/src/endpoints/auto-close.ts +175 -0
  63. package/src/endpoints/billing.ts +167 -0
  64. package/src/endpoints/bulk-action.ts +103 -0
  65. package/src/endpoints/chat-stream.ts +127 -0
  66. package/src/endpoints/chat.ts +188 -0
  67. package/src/endpoints/chatbot.ts +113 -0
  68. package/src/endpoints/delete-account.ts +129 -0
  69. package/src/endpoints/email-stats.ts +109 -0
  70. package/src/endpoints/export-csv.ts +84 -0
  71. package/src/endpoints/export-data.ts +104 -0
  72. package/src/endpoints/import-conversation.ts +307 -0
  73. package/src/endpoints/index.ts +154 -0
  74. package/src/endpoints/login.ts +92 -0
  75. package/src/endpoints/merge-clients.ts +132 -0
  76. package/src/endpoints/merge-tickets.ts +137 -0
  77. package/src/endpoints/oauth-google.ts +179 -0
  78. package/src/endpoints/pending-emails-process.ts +224 -0
  79. package/src/endpoints/presence.ts +104 -0
  80. package/src/endpoints/process-scheduled.ts +144 -0
  81. package/src/endpoints/purge-logs.ts +58 -0
  82. package/src/endpoints/resend-notification.ts +99 -0
  83. package/src/endpoints/round-robin-config.ts +92 -0
  84. package/src/endpoints/satisfaction.ts +93 -0
  85. package/src/endpoints/search.ts +106 -0
  86. package/src/endpoints/seed-kb.ts +153 -0
  87. package/src/endpoints/settings.ts +144 -0
  88. package/src/endpoints/signature.ts +93 -0
  89. package/src/endpoints/sla-check.ts +124 -0
  90. package/src/endpoints/split-ticket.ts +131 -0
  91. package/src/endpoints/statuses.ts +45 -0
  92. package/src/endpoints/track-open.ts +154 -0
  93. package/src/endpoints/typing.ts +101 -0
  94. package/src/endpoints/user-prefs.ts +125 -0
  95. package/src/hooks/checkSLA.ts +414 -0
  96. package/src/hooks/ticketStatusEmail.ts +182 -0
  97. package/src/index.ts +51 -0
  98. package/src/plugin.ts +157 -0
  99. package/src/portal/LiveChat.tsx +1353 -0
  100. package/src/portal/auth/ChatWidget.tsx +350 -0
  101. package/src/portal/auth/ChatbotWidget.tsx +285 -0
  102. package/src/portal/auth/SupportHeader.tsx +409 -0
  103. package/src/portal/auth/dashboard/DashboardClient.tsx +650 -0
  104. package/src/portal/auth/dashboard/page.tsx +84 -0
  105. package/src/portal/auth/faq/FAQSearch.tsx +117 -0
  106. package/src/portal/auth/faq/page.tsx +199 -0
  107. package/src/portal/auth/layout.tsx +61 -0
  108. package/src/portal/auth/profile/page.tsx +705 -0
  109. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +74 -0
  110. package/src/portal/auth/tickets/detail/CollapsibleMessages.tsx +46 -0
  111. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +50 -0
  112. package/src/portal/auth/tickets/detail/MessageActions.tsx +158 -0
  113. package/src/portal/auth/tickets/detail/PrintButton.tsx +16 -0
  114. package/src/portal/auth/tickets/detail/ReadReceipt.tsx +34 -0
  115. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +74 -0
  116. package/src/portal/auth/tickets/detail/SatisfactionForm.tsx +156 -0
  117. package/src/portal/auth/tickets/detail/TicketPolling.tsx +57 -0
  118. package/src/portal/auth/tickets/detail/TicketReplyForm.tsx +294 -0
  119. package/src/portal/auth/tickets/detail/TypingIndicator.tsx +58 -0
  120. package/src/portal/auth/tickets/detail/page.tsx +738 -0
  121. package/src/portal/auth/tickets/new/page.tsx +515 -0
  122. package/src/portal/forgot-password/page.tsx +114 -0
  123. package/src/portal/layout.tsx +26 -0
  124. package/src/portal/locales/en.json +374 -0
  125. package/src/portal/locales/fr.json +374 -0
  126. package/src/portal/login/page.tsx +351 -0
  127. package/src/portal/page.tsx +162 -0
  128. package/src/portal/register/page.tsx +281 -0
  129. package/src/portal/reset-password/page.tsx +152 -0
  130. package/src/styles/BillingView.module.scss +311 -0
  131. package/src/styles/ChatView.module.scss +438 -0
  132. package/src/styles/CommandPalette.module.scss +160 -0
  133. package/src/styles/CrmView.module.scss +554 -0
  134. package/src/styles/EmailTracking.module.scss +238 -0
  135. package/src/styles/ImportConversation.module.scss +267 -0
  136. package/src/styles/Layout.module.scss +55 -0
  137. package/src/styles/Logs.module.scss +164 -0
  138. package/src/styles/NewTicket.module.scss +143 -0
  139. package/src/styles/PendingEmails.module.scss +629 -0
  140. package/src/styles/SupportDashboard.module.scss +649 -0
  141. package/src/styles/TicketDetail.module.scss +1043 -0
  142. package/src/styles/TicketInbox.module.scss +296 -0
  143. package/src/styles/TicketingSettings.module.scss +358 -0
  144. package/src/styles/TimeDashboard.module.scss +287 -0
  145. package/src/styles/_tokens.scss +78 -0
  146. package/src/styles/theme.css +633 -0
  147. package/src/types.ts +255 -0
  148. package/src/utils/adminNotification.ts +38 -0
  149. package/src/utils/auth.ts +46 -0
  150. package/src/utils/emailTemplate.ts +343 -0
  151. package/src/utils/fireWebhooks.ts +84 -0
  152. package/src/utils/index.ts +22 -0
  153. package/src/utils/rateLimiter.ts +52 -0
  154. package/src/utils/readSettings.ts +67 -0
  155. package/src/utils/slugs.ts +54 -0
  156. package/src/utils/webhookDispatcher.ts +120 -0
  157. package/src/views/BillingView/client.tsx +137 -0
  158. package/src/views/BillingView/index.tsx +33 -0
  159. package/src/views/ChatView/client.tsx +294 -0
  160. package/src/views/ChatView/index.tsx +33 -0
  161. package/src/views/CrmView/client.tsx +206 -0
  162. package/src/views/CrmView/index.tsx +33 -0
  163. package/src/views/EmailTrackingView/client.tsx +124 -0
  164. package/src/views/EmailTrackingView/index.tsx +33 -0
  165. package/src/views/ImportConversationView/client.tsx +133 -0
  166. package/src/views/ImportConversationView/index.tsx +33 -0
  167. package/src/views/LogsView/client.tsx +151 -0
  168. package/src/views/LogsView/index.tsx +30 -0
  169. package/src/views/NewTicketView/client.tsx +227 -0
  170. package/src/views/NewTicketView/index.tsx +30 -0
  171. package/src/views/PendingEmailsView/client.tsx +177 -0
  172. package/src/views/PendingEmailsView/index.tsx +33 -0
  173. package/src/views/SupportDashboardView/client.tsx +424 -0
  174. package/src/views/SupportDashboardView/index.tsx +33 -0
  175. package/src/views/TicketDetailView/client.tsx +775 -0
  176. package/src/views/TicketDetailView/index.tsx +33 -0
  177. package/src/views/TicketInboxView/client.tsx +313 -0
  178. package/src/views/TicketInboxView/index.tsx +30 -0
  179. package/src/views/TicketingSettingsView/client.tsx +866 -0
  180. package/src/views/TicketingSettingsView/index.tsx +33 -0
  181. package/src/views/TimeDashboardView/client.tsx +144 -0
  182. package/src/views/TimeDashboardView/index.tsx +33 -0
  183. package/src/views/shared/AdminViewHeader.tsx +69 -0
  184. package/src/views/shared/ErrorBoundary.tsx +68 -0
  185. package/src/views/shared/Skeleton.tsx +125 -0
  186. package/src/views/shared/adminTokens.ts +37 -0
  187. package/src/views/shared/config.ts +82 -0
  188. package/src/views/shared/index.ts +6 -0
  189. package/src/views.ts +16 -0
@@ -0,0 +1,650 @@
1
+ 'use client'
2
+
3
+ import React, { useState, useMemo, useEffect } from 'react'
4
+ import Link from 'next/link'
5
+
6
+ interface TicketData {
7
+ id: number | string
8
+ ticketNumber: string | null | undefined
9
+ subject: string
10
+ status: string | null | undefined
11
+ priority: string | null | undefined
12
+ category: string | null | undefined
13
+ projectName: string | null
14
+ updatedAt: string | null | undefined
15
+ createdAt: string | null | undefined
16
+ hasNewMessage: boolean
17
+ messageCount: number
18
+ totalTimeMinutes: number | null | undefined
19
+ lastMessagePreview: string | null | undefined
20
+ }
21
+
22
+ const statusConfig: Record<string, { label: string; dot: string; bg: string }> = {
23
+ open: { label: 'Ouvert', dot: 'bg-emerald-500', bg: 'bg-emerald-50 text-emerald-700 ring-emerald-600/20 dark:bg-emerald-950/40 dark:text-emerald-400 dark:ring-emerald-400/20' },
24
+ waiting_client: { label: 'En attente', dot: 'bg-amber-500', bg: 'bg-amber-50 text-amber-700 ring-amber-600/20 dark:bg-amber-950/40 dark:text-amber-400 dark:ring-amber-400/20' },
25
+ resolved: { label: 'Resolu', dot: 'bg-slate-400', bg: 'bg-slate-100 text-slate-600 ring-slate-500/20 dark:bg-slate-800 dark:text-slate-400 dark:ring-slate-400/20' },
26
+ }
27
+
28
+ const priorityConfig: Record<string, { label: string; color: string }> = {
29
+ low: { label: 'Basse', color: 'text-slate-400 dark:text-slate-500' },
30
+ normal: { label: 'Normale', color: 'text-blue-600 dark:text-blue-400' },
31
+ high: { label: 'Haute', color: 'text-orange-600 dark:text-orange-400' },
32
+ urgent: { label: 'Urgente', color: 'text-red-600 dark:text-red-400' },
33
+ }
34
+
35
+ const categoryLabels: Record<string, string> = {
36
+ bug: 'Bug',
37
+ content: 'Contenu',
38
+ feature: 'Fonctionnalité',
39
+ question: 'Question',
40
+ hosting: 'Hébergement',
41
+ }
42
+
43
+ type Tab = 'active' | 'archived'
44
+ type SortBy = 'updatedAt' | 'createdAt' | 'priority'
45
+
46
+ const PAGE_SIZE = 20
47
+
48
+ function timeAgo(dateStr: string): string {
49
+ const now = new Date()
50
+ const date = new Date(dateStr)
51
+ const diffMs = now.getTime() - date.getTime()
52
+ const diffMin = Math.floor(diffMs / 60000)
53
+ const diffH = Math.floor(diffMin / 60)
54
+ const diffD = Math.floor(diffH / 24)
55
+
56
+ if (diffMin < 1) return "A l'instant"
57
+ if (diffMin < 60) return `il y a ${diffMin}min`
58
+ if (diffH < 24) return `il y a ${diffH}h`
59
+ if (diffD < 7) return `il y a ${diffD}j`
60
+ return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' })
61
+ }
62
+
63
+ export function DashboardClient({ tickets }: { tickets: TicketData[] }) {
64
+ const [tab, setTab] = useState<Tab>('active')
65
+ const [searchQuery, setSearchQuery] = useState('')
66
+ const [filterStatus, setFilterStatus] = useState('')
67
+ const [filterCategory, setFilterCategory] = useState('')
68
+ const [filterProject, setFilterProject] = useState('')
69
+ const [sortBy, setSortBy] = useState<SortBy>('updatedAt')
70
+ const [showFilters, setShowFilters] = useState(false)
71
+ const [currentPage, setCurrentPage] = useState(1)
72
+
73
+ // Global search across messages
74
+ const [searchMatchedIds, setSearchMatchedIds] = useState<Set<number | string>>(new Set())
75
+ const [messageSearchLoading, setMessageSearchLoading] = useState(false)
76
+ const [messageSearchCount, setMessageSearchCount] = useState(0)
77
+
78
+ // Extract unique projects
79
+ const projects = useMemo(() => {
80
+ const names = new Set<string>()
81
+ tickets.forEach((t) => { if (t.projectName) names.add(t.projectName) })
82
+ return Array.from(names).sort()
83
+ }, [tickets])
84
+
85
+ // Search in ticket messages when query >= 3 chars
86
+ useEffect(() => {
87
+ if (searchQuery.length < 3) {
88
+ setSearchMatchedIds(new Set())
89
+ setMessageSearchCount(0)
90
+ return
91
+ }
92
+
93
+ setMessageSearchLoading(true)
94
+ const timer = setTimeout(async () => {
95
+ try {
96
+ const res = await fetch(
97
+ `/api/ticket-messages?where[body][like]=${encodeURIComponent(searchQuery)}&limit=50&depth=1`,
98
+ { credentials: 'include' },
99
+ )
100
+ if (res.ok) {
101
+ const data = await res.json()
102
+ const docs = data.docs || []
103
+ const matchedIds = new Set<number | string>(
104
+ docs.map((m: { ticket: { id: number | string } | number | string }) =>
105
+ typeof m.ticket === 'object' ? m.ticket.id : m.ticket,
106
+ ),
107
+ )
108
+ setSearchMatchedIds(matchedIds)
109
+ setMessageSearchCount(matchedIds.size)
110
+ }
111
+ } catch (err) {
112
+ console.warn('[DashboardClient] Message search failed:', err)
113
+ } finally {
114
+ setMessageSearchLoading(false)
115
+ }
116
+ }, 500)
117
+
118
+ return () => {
119
+ clearTimeout(timer)
120
+ setMessageSearchLoading(false)
121
+ }
122
+ }, [searchQuery])
123
+
124
+ // Split tickets by tab
125
+ const archivedStatuses = ['resolved']
126
+ const activeTickets = tickets.filter((t) => !archivedStatuses.includes(t.status || ''))
127
+ const archivedTickets = tickets.filter((t) => archivedStatuses.includes(t.status || ''))
128
+
129
+ const baseTickets = tab === 'active' ? activeTickets : archivedTickets
130
+
131
+ // Apply search + filters
132
+ const filtered = useMemo(() => {
133
+ const query = searchQuery.toLowerCase().trim()
134
+ return baseTickets.filter((t) => {
135
+ if (query) {
136
+ const matchSubject = t.subject.toLowerCase().includes(query)
137
+ const matchNumber = (t.ticketNumber || '').toLowerCase().includes(query)
138
+ const matchMessages = searchMatchedIds.has(t.id)
139
+ if (!matchSubject && !matchNumber && !matchMessages) return false
140
+ }
141
+ if (filterStatus && t.status !== filterStatus) return false
142
+ if (filterCategory && t.category !== filterCategory) return false
143
+ if (filterProject && t.projectName !== filterProject) return false
144
+ return true
145
+ })
146
+ }, [baseTickets, searchQuery, filterStatus, filterCategory, filterProject, searchMatchedIds])
147
+
148
+ // Apply sort
149
+ const sorted = useMemo(() => {
150
+ const priorityOrder: Record<string, number> = { urgent: 0, high: 1, normal: 2, low: 3 }
151
+ return [...filtered].sort((a, b) => {
152
+ if (sortBy === 'priority') {
153
+ return (priorityOrder[a.priority || 'normal'] || 2) - (priorityOrder[b.priority || 'normal'] || 2)
154
+ }
155
+ const dateA = new Date(a[sortBy] || 0).getTime()
156
+ const dateB = new Date(b[sortBy] || 0).getTime()
157
+ return dateB - dateA
158
+ })
159
+ }, [filtered, sortBy])
160
+
161
+ // Pagination
162
+ const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE))
163
+ const safePage = Math.min(currentPage, totalPages)
164
+ const paginatedTickets = sorted.slice((safePage - 1) * PAGE_SIZE, safePage * PAGE_SIZE)
165
+
166
+ const resetPage = () => setCurrentPage(1)
167
+
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
+ const activeFilterCount = [filterStatus, filterCategory, filterProject].filter(Boolean).length
179
+
180
+ return (
181
+ <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>
189
+ </div>
190
+ <Link
191
+ 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]"
193
+ >
194
+ <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
+ <line x1="12" y1="5" x2="12" y2="19" />
196
+ <line x1="5" y1="12" x2="19" y2="12" />
197
+ </svg>
198
+ Nouveau ticket
199
+ </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>
251
+
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.
277
+ </p>
278
+ </div>
279
+
280
+ {/* Main content card */}
281
+ <div className="overflow-hidden rounded-xl border border-slate-200 bg-white dark:border-slate-800 dark:bg-slate-900">
282
+ {/* Tabs + Search bar */}
283
+ <div className="border-b border-slate-200 dark:border-slate-800">
284
+ <div className="flex items-center justify-between px-4 sm:px-5">
285
+ <div className="flex gap-0">
286
+ <button
287
+ onClick={() => { setTab('active'); setFilterStatus(''); resetPage() }}
288
+ className={`relative cursor-pointer px-4 py-3.5 text-sm font-medium transition-colors ${
289
+ tab === 'active'
290
+ ? 'text-blue-600 dark:text-blue-400'
291
+ : 'text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300'
292
+ }`}
293
+ >
294
+ Actifs
295
+ <span className={`ml-1.5 rounded-full px-1.5 py-0.5 text-xs font-semibold ${
296
+ tab === 'active'
297
+ ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
298
+ : 'bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400'
299
+ }`}>
300
+ {activeTickets.length}
301
+ </span>
302
+ {tab === 'active' && (
303
+ <span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-blue-600 dark:bg-blue-400" />
304
+ )}
305
+ </button>
306
+ <button
307
+ onClick={() => { setTab('archived'); setFilterStatus(''); resetPage() }}
308
+ className={`relative cursor-pointer px-4 py-3.5 text-sm font-medium transition-colors ${
309
+ tab === 'archived'
310
+ ? 'text-blue-600 dark:text-blue-400'
311
+ : 'text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-300'
312
+ }`}
313
+ >
314
+ Archives
315
+ <span className={`ml-1.5 rounded-full px-1.5 py-0.5 text-xs font-semibold ${
316
+ tab === 'archived'
317
+ ? 'bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300'
318
+ : 'bg-slate-100 text-slate-500 dark:bg-slate-800 dark:text-slate-400'
319
+ }`}>
320
+ {archivedTickets.length}
321
+ </span>
322
+ {tab === 'archived' && (
323
+ <span className="absolute inset-x-0 bottom-0 h-0.5 rounded-full bg-blue-600 dark:bg-blue-400" />
324
+ )}
325
+ </button>
326
+ </div>
327
+ </div>
328
+ </div>
329
+
330
+ {/* Search + Sort + Filter bar */}
331
+ <div className="flex flex-wrap items-center gap-2 border-b border-slate-100 px-4 py-3 dark:border-slate-800 sm:px-5">
332
+ <div className="relative flex-1 sm:max-w-xs">
333
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400 dark:text-slate-500">
334
+ <circle cx="11" cy="11" r="8" />
335
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
336
+ </svg>
337
+ <input
338
+ type="text"
339
+ value={searchQuery}
340
+ onChange={(e) => { setSearchQuery(e.target.value); resetPage() }}
341
+ placeholder="Rechercher..."
342
+ className="w-full rounded-lg border border-slate-200 bg-slate-50 py-2 pl-9 pr-3 text-sm text-slate-900 placeholder-slate-400 outline-none transition-colors focus:border-blue-300 focus:bg-white focus:ring-2 focus:ring-blue-100 dark:border-slate-700 dark:bg-slate-800 dark:text-white dark:placeholder-slate-500 dark:focus:border-blue-600 dark:focus:ring-blue-900/30"
343
+ />
344
+ {messageSearchLoading && (
345
+ <div className="absolute right-3 top-1/2 -translate-y-1/2">
346
+ <div className="h-4 w-4 animate-spin rounded-full border-2 border-slate-300 border-t-blue-600 dark:border-slate-600 dark:border-t-blue-400" />
347
+ </div>
348
+ )}
349
+ </div>
350
+
351
+ <select
352
+ value={sortBy}
353
+ onChange={(e) => setSortBy(e.target.value as SortBy)}
354
+ className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-600 outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300"
355
+ >
356
+ <option value="updatedAt">Derniere activite</option>
357
+ <option value="createdAt">Date de creation</option>
358
+ <option value="priority">Priorite</option>
359
+ </select>
360
+
361
+ <button
362
+ type="button"
363
+ onClick={() => setShowFilters(!showFilters)}
364
+ className={`flex items-center gap-1.5 rounded-lg border px-3 py-2 text-sm font-medium transition-colors ${
365
+ showFilters || activeFilterCount > 0
366
+ ? 'border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-800 dark:bg-blue-950/30 dark:text-blue-400'
367
+ : 'border-slate-200 text-slate-600 hover:bg-slate-50 dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800'
368
+ }`}
369
+ >
370
+ <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">
371
+ <line x1="4" y1="21" x2="4" y2="14" />
372
+ <line x1="4" y1="10" x2="4" y2="3" />
373
+ <line x1="12" y1="21" x2="12" y2="12" />
374
+ <line x1="12" y1="8" x2="12" y2="3" />
375
+ <line x1="20" y1="21" x2="20" y2="16" />
376
+ <line x1="20" y1="12" x2="20" y2="3" />
377
+ <line x1="1" y1="14" x2="7" y2="14" />
378
+ <line x1="9" y1="8" x2="15" y2="8" />
379
+ <line x1="17" y1="16" x2="23" y2="16" />
380
+ </svg>
381
+ Filtres
382
+ {activeFilterCount > 0 && (
383
+ <span className="flex h-5 min-w-5 items-center justify-center rounded-full bg-blue-600 px-1 text-[11px] font-bold text-white">
384
+ {activeFilterCount}
385
+ </span>
386
+ )}
387
+ </button>
388
+ </div>
389
+
390
+ {/* Message search indicator */}
391
+ {searchQuery.length >= 3 && !messageSearchLoading && messageSearchCount > 0 && (
392
+ <div className="border-b border-slate-100 px-5 py-2 dark:border-slate-800">
393
+ <p className="text-xs text-slate-500 dark:text-slate-400">
394
+ {messageSearchCount} ticket{messageSearchCount > 1 ? 's' : ''} trouve{messageSearchCount > 1 ? 's' : ''} via les messages
395
+ </p>
396
+ </div>
397
+ )}
398
+
399
+ {/* Collapsible filters */}
400
+ {showFilters && (
401
+ <div className="flex flex-wrap items-center gap-2 border-b border-slate-100 bg-slate-50/50 px-5 py-3 dark:border-slate-800 dark:bg-slate-800/30">
402
+ {tab === 'active' && (
403
+ <select
404
+ value={filterStatus}
405
+ onChange={(e) => { setFilterStatus(e.target.value); resetPage() }}
406
+ className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-600 outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300"
407
+ >
408
+ <option value="">Tous les statuts</option>
409
+ <option value="open">Ouvert</option>
410
+ <option value="waiting_client">En attente</option>
411
+ </select>
412
+ )}
413
+ <select
414
+ value={filterCategory}
415
+ onChange={(e) => { setFilterCategory(e.target.value); resetPage() }}
416
+ className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-600 outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300"
417
+ >
418
+ <option value="">Toutes categories</option>
419
+ {Object.entries(categoryLabels).map(([k, v]) => (
420
+ <option key={k} value={k}>{v}</option>
421
+ ))}
422
+ </select>
423
+ {projects.length > 0 && (
424
+ <select
425
+ value={filterProject}
426
+ onChange={(e) => { setFilterProject(e.target.value); resetPage() }}
427
+ className="rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm text-slate-600 outline-none dark:border-slate-700 dark:bg-slate-800 dark:text-slate-300"
428
+ >
429
+ <option value="">Tous les projets</option>
430
+ {projects.map((p) => (
431
+ <option key={p} value={p}>{p}</option>
432
+ ))}
433
+ </select>
434
+ )}
435
+ {activeFilterCount > 0 && (
436
+ <button
437
+ type="button"
438
+ onClick={() => { setFilterStatus(''); setFilterCategory(''); setFilterProject(''); resetPage() }}
439
+ className="text-sm text-slate-500 underline decoration-slate-300 underline-offset-2 transition-colors hover:text-slate-700 dark:text-slate-400 dark:decoration-slate-600 dark:hover:text-slate-300"
440
+ >
441
+ Effacer
442
+ </button>
443
+ )}
444
+ </div>
445
+ )}
446
+
447
+ {/* Results count when searching */}
448
+ {searchQuery && (
449
+ <div className="border-b border-slate-100 px-5 py-2 dark:border-slate-800">
450
+ <p className="text-xs text-slate-500 dark:text-slate-400">
451
+ {sorted.length} resultat{sorted.length !== 1 ? 's' : ''} pour « {searchQuery} »
452
+ </p>
453
+ </div>
454
+ )}
455
+
456
+ {/* Ticket list (inbox style) */}
457
+ {paginatedTickets.length === 0 ? (
458
+ <div className="px-5 py-16 text-center">
459
+ <div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full bg-slate-100 dark:bg-slate-800">
460
+ {searchQuery ? (
461
+ <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 dark:text-slate-500">
462
+ <circle cx="11" cy="11" r="8" />
463
+ <line x1="21" y1="21" x2="16.65" y2="16.65" />
464
+ </svg>
465
+ ) : (
466
+ <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 dark:text-slate-500">
467
+ <polyline points="22 12 16 12 14 15 10 15 8 12 2 12" />
468
+ <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" />
469
+ </svg>
470
+ )}
471
+ </div>
472
+ <h3 className="text-base font-semibold text-slate-700 dark:text-slate-300">
473
+ {searchQuery ? 'Aucun resultat' : tab === 'archived' ? 'Aucun ticket archive' : 'Aucun ticket'}
474
+ </h3>
475
+ <p className="mt-1 text-sm text-slate-400 dark:text-slate-500">
476
+ {searchQuery
477
+ ? `Aucun ticket ne correspond a "${searchQuery}"`
478
+ : tab === 'archived'
479
+ ? 'Vos tickets resolus apparaitront ici'
480
+ : "Vous n'avez pas encore de demande de support"}
481
+ </p>
482
+ {!searchQuery && tab === 'active' && (
483
+ <Link
484
+ href="/support/tickets/new"
485
+ className="mt-5 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"
486
+ >
487
+ <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">
488
+ <line x1="12" y1="5" x2="12" y2="19" />
489
+ <line x1="5" y1="12" x2="19" y2="12" />
490
+ </svg>
491
+ Nouveau ticket
492
+ </Link>
493
+ )}
494
+ </div>
495
+ ) : (
496
+ <div className="divide-y divide-slate-100 dark:divide-slate-800">
497
+ {paginatedTickets.map((ticket) => {
498
+ const status = statusConfig[ticket.status || 'open'] || statusConfig.open
499
+ const priority = priorityConfig[ticket.priority || 'normal']
500
+ const category = ticket.category ? categoryLabels[ticket.category] : null
501
+
502
+ return (
503
+ <Link
504
+ key={ticket.id}
505
+ href={`/support/tickets/${ticket.id}`}
506
+ className={`group flex items-start gap-3 px-4 py-4 transition-colors hover:bg-slate-50 dark:hover:bg-slate-800/50 sm:items-center sm:gap-4 sm:px-5 ${
507
+ ticket.hasNewMessage ? 'bg-blue-50/30 dark:bg-blue-950/10' : ''
508
+ }`}
509
+ >
510
+ {/* Status dot */}
511
+ <div className="mt-1.5 flex-shrink-0 sm:mt-0">
512
+ <div className={`h-2.5 w-2.5 rounded-full ${status.dot} ${
513
+ ticket.hasNewMessage ? 'ring-4 ring-blue-100 dark:ring-blue-900/30' : ''
514
+ }`} />
515
+ </div>
516
+
517
+ {/* Content */}
518
+ <div className="min-w-0 flex-1">
519
+ <div className="flex flex-col gap-1 sm:flex-row sm:items-center sm:gap-2">
520
+ <span className="font-mono text-xs font-medium text-slate-400 dark:text-slate-500">
521
+ {ticket.ticketNumber}
522
+ </span>
523
+ <h3 className={`truncate text-sm ${
524
+ ticket.hasNewMessage
525
+ ? 'font-semibold text-slate-900 dark:text-white'
526
+ : 'font-medium text-slate-700 dark:text-slate-300'
527
+ }`}>
528
+ {ticket.subject}
529
+ </h3>
530
+ </div>
531
+
532
+ {/* Preview + meta */}
533
+ <div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1">
534
+ {ticket.lastMessagePreview && (
535
+ <p className="max-w-md truncate text-xs text-slate-400 dark:text-slate-500">
536
+ {ticket.lastMessagePreview}
537
+ </p>
538
+ )}
539
+ </div>
540
+
541
+ {/* Tags row (mobile friendly) */}
542
+ <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}
545
+ </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
549
+ </span>
550
+ )}
551
+ {category && (
552
+ <span className="inline-flex items-center rounded-md bg-slate-50 px-2 py-0.5 text-[11px] font-medium text-slate-600 ring-1 ring-inset ring-slate-500/10 dark:bg-slate-800 dark:text-slate-400 dark:ring-slate-400/10">
553
+ {category}
554
+ </span>
555
+ )}
556
+ {ticket.projectName && (
557
+ <span className="inline-flex items-center rounded-md bg-cyan-50 px-2 py-0.5 text-[11px] font-medium text-cyan-700 ring-1 ring-inset ring-cyan-600/20 dark:bg-cyan-950/40 dark:text-cyan-400 dark:ring-cyan-400/20">
558
+ {ticket.projectName}
559
+ </span>
560
+ )}
561
+ </div>
562
+ </div>
563
+
564
+ {/* Right side meta */}
565
+ <div className="flex flex-shrink-0 flex-col items-end gap-1.5">
566
+ <span className="text-xs text-slate-400 dark:text-slate-500">
567
+ {ticket.updatedAt ? timeAgo(ticket.updatedAt) : ''}
568
+ </span>
569
+ <div className="flex items-center gap-2">
570
+ {ticket.messageCount > 0 && (
571
+ <span className="flex items-center gap-1 text-xs text-slate-400 dark:text-slate-500">
572
+ <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">
573
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
574
+ </svg>
575
+ {ticket.messageCount}
576
+ </span>
577
+ )}
578
+ {priority && (
579
+ <span className={`text-xs font-medium ${priority.color}`}>
580
+ {priority.label}
581
+ </span>
582
+ )}
583
+ </div>
584
+ {/* Chevron */}
585
+ <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-slate-300 transition-colors group-hover:text-slate-500 dark:text-slate-600 dark:group-hover:text-slate-400">
586
+ <polyline points="9 18 15 12 9 6" />
587
+ </svg>
588
+ </div>
589
+ </Link>
590
+ )
591
+ })}
592
+ </div>
593
+ )}
594
+
595
+ {/* Pagination */}
596
+ {totalPages > 1 && (
597
+ <div className="flex items-center justify-between border-t border-slate-100 px-5 py-3 dark:border-slate-800">
598
+ <p className="text-xs text-slate-500 dark:text-slate-400">
599
+ Page {safePage} sur {totalPages} ({sorted.length} tickets)
600
+ </p>
601
+ <div className="flex items-center gap-1.5">
602
+ <button
603
+ onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
604
+ disabled={safePage <= 1}
605
+ className="rounded-lg border border-slate-200 px-2.5 py-1.5 text-sm text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-30 dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800"
606
+ >
607
+ <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">
608
+ <polyline points="15 18 9 12 15 6" />
609
+ </svg>
610
+ </button>
611
+ {Array.from({ length: totalPages }, (_, i) => i + 1)
612
+ .filter((p) => p === 1 || p === totalPages || Math.abs(p - safePage) <= 1)
613
+ .reduce<(number | 'ellipsis')[]>((acc, p, idx, arr) => {
614
+ if (idx > 0 && p - (arr[idx - 1] as number) > 1) acc.push('ellipsis')
615
+ acc.push(p)
616
+ return acc
617
+ }, [])
618
+ .map((item, idx) =>
619
+ item === 'ellipsis' ? (
620
+ <span key={`e-${idx}`} className="px-1 text-xs text-slate-400">...</span>
621
+ ) : (
622
+ <button
623
+ key={item}
624
+ onClick={() => setCurrentPage(item)}
625
+ className={`rounded-lg px-2.5 py-1.5 text-sm font-medium transition-colors ${
626
+ safePage === item
627
+ ? 'bg-blue-600 text-white'
628
+ : 'text-slate-600 hover:bg-slate-50 dark:text-slate-400 dark:hover:bg-slate-800'
629
+ }`}
630
+ >
631
+ {item}
632
+ </button>
633
+ ),
634
+ )}
635
+ <button
636
+ onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
637
+ disabled={safePage >= totalPages}
638
+ className="rounded-lg border border-slate-200 px-2.5 py-1.5 text-sm text-slate-600 transition-colors hover:bg-slate-50 disabled:opacity-30 dark:border-slate-700 dark:text-slate-400 dark:hover:bg-slate-800"
639
+ >
640
+ <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">
641
+ <polyline points="9 18 15 12 9 6" />
642
+ </svg>
643
+ </button>
644
+ </div>
645
+ </div>
646
+ )}
647
+ </div>
648
+ </div>
649
+ )
650
+ }