@consilioweb/payload-support 0.8.1 → 0.9.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/RichTextEditor/index.cjs +233 -0
- package/dist/components/RichTextEditor/index.js +232 -0
- package/dist/components/TicketConversation/components/CodeBlock.cjs +24 -7
- package/dist/components/TicketConversation/components/CodeBlock.js +23 -9
- package/dist/index.cjs +19 -2
- package/dist/index.js +19 -2
- package/dist/views/BillingView/client.cjs +260 -103
- package/dist/views/BillingView/client.js +259 -103
- package/dist/views/ChatView/client.cjs +184 -137
- package/dist/views/ChatView/client.js +180 -137
- package/dist/views/CrmView/client.cjs +270 -122
- package/dist/views/CrmView/client.js +266 -122
- package/dist/views/EmailTrackingView/client.cjs +80 -69
- package/dist/views/EmailTrackingView/client.js +80 -70
- package/dist/views/ImportConversationView/client.cjs +127 -94
- package/dist/views/ImportConversationView/client.js +123 -94
- package/dist/views/LogsView/client.cjs +56 -58
- package/dist/views/LogsView/client.js +52 -58
- package/dist/views/NewTicketView/client.cjs +39 -55
- package/dist/views/NewTicketView/client.js +38 -55
- package/dist/views/PendingEmailsView/client.cjs +399 -102
- package/dist/views/PendingEmailsView/client.js +396 -103
- package/dist/views/SupportDashboardView/client.cjs +276 -137
- package/dist/views/SupportDashboardView/client.js +275 -137
- package/dist/views/TicketDetailView/client.cjs +487 -204
- package/dist/views/TicketDetailView/client.js +486 -204
- package/dist/views/TicketInboxView/client.cjs +62 -65
- package/dist/views/TicketInboxView/client.js +62 -66
- package/dist/views/TicketingSettingsView/client.cjs +10 -8
- package/dist/views/TicketingSettingsView/client.js +10 -8
- package/dist/views/TimeDashboardView/client.cjs +70 -59
- package/dist/views/TimeDashboardView/client.js +69 -59
- package/package.json +6 -2
- package/src/collections/ClientSummaries.ts +0 -1
- package/src/components/RichTextEditor/index.tsx +261 -0
- package/src/components/TicketConversation/components/CodeBlock.tsx +53 -14
- package/src/plugin.ts +2 -0
- package/src/utils/emailTemplate.ts +37 -0
- package/src/views/BillingView/client.tsx +362 -69
- package/src/views/ChatView/client.tsx +225 -140
- package/src/views/CrmView/client.tsx +447 -189
- package/src/views/EmailTrackingView/client.tsx +111 -71
- package/src/views/ImportConversationView/client.tsx +255 -70
- package/src/views/LogsView/client.tsx +85 -50
- package/src/views/NewTicketView/client.tsx +37 -53
- package/src/views/PendingEmailsView/client.tsx +512 -92
- package/src/views/SupportDashboardView/client.tsx +294 -134
- package/src/views/TicketDetailView/client.tsx +486 -213
- package/src/views/TicketInboxView/client.tsx +52 -61
- package/src/views/TicketingSettingsView/client.tsx +10 -9
- package/src/views/TimeDashboardView/client.tsx +184 -69
|
@@ -1,17 +1,31 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import React, { useState, useEffect, useCallback } from 'react'
|
|
4
|
+
import { Inbox, Plus, Link2, X, Search, ChevronDown, ChevronUp, Paperclip } from 'lucide-react'
|
|
5
|
+
import { SkeletonDashboard } from '../shared/Skeleton'
|
|
4
6
|
import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
|
|
5
|
-
import
|
|
7
|
+
import styles from '../../styles/PendingEmails.module.scss'
|
|
8
|
+
|
|
9
|
+
interface SuggestedTicket {
|
|
10
|
+
id: number
|
|
11
|
+
ticketNumber: string
|
|
12
|
+
subject: string
|
|
13
|
+
score: number
|
|
14
|
+
}
|
|
6
15
|
|
|
7
|
-
interface SuggestedTicket { id: number; ticketNumber: string; subject: string; score: number }
|
|
8
16
|
interface PendingEmail {
|
|
9
|
-
id: number
|
|
17
|
+
id: number
|
|
18
|
+
senderEmail: string
|
|
19
|
+
senderName?: string
|
|
20
|
+
subject: string
|
|
21
|
+
body: string
|
|
22
|
+
bodyHtml?: string
|
|
10
23
|
client?: { id: number; firstName?: string; lastName?: string; email?: string; company?: string } | number
|
|
11
24
|
attachments?: Array<{ file: { id: number; filename?: string } | number }>
|
|
12
25
|
status: 'pending' | 'processed' | 'ignored'
|
|
13
26
|
processedAction?: 'ticket_created' | 'message_added' | 'ignored'
|
|
14
27
|
processedTicket?: { id: number; ticketNumber?: string } | number
|
|
28
|
+
processedAt?: string
|
|
15
29
|
suggestedTickets?: SuggestedTicket[]
|
|
16
30
|
createdAt: string
|
|
17
31
|
}
|
|
@@ -21,103 +35,434 @@ type Tab = 'pending' | 'processed' | 'ignored'
|
|
|
21
35
|
function timeAgo(dateStr: string): string {
|
|
22
36
|
const diff = Date.now() - new Date(dateStr).getTime()
|
|
23
37
|
const mins = Math.floor(diff / 60000)
|
|
24
|
-
if (mins < 1) return "
|
|
38
|
+
if (mins < 1) return "à l'instant"
|
|
25
39
|
if (mins < 60) return `il y a ${mins}min`
|
|
26
40
|
const hours = Math.floor(mins / 60)
|
|
27
41
|
if (hours < 24) return `il y a ${hours}h`
|
|
28
|
-
|
|
42
|
+
const days = Math.floor(hours / 24)
|
|
43
|
+
return `il y a ${days}j`
|
|
29
44
|
}
|
|
30
45
|
|
|
31
|
-
function
|
|
46
|
+
function TicketSearchModal({
|
|
47
|
+
suggestions,
|
|
48
|
+
onSelect,
|
|
49
|
+
onClose,
|
|
50
|
+
}: {
|
|
51
|
+
suggestions: SuggestedTicket[]
|
|
52
|
+
onSelect: (ticketId: number) => void
|
|
53
|
+
onClose: () => void
|
|
54
|
+
}) {
|
|
55
|
+
const [search, setSearch] = useState('')
|
|
56
|
+
const [results, setResults] = useState<Array<{ id: number; ticketNumber: string; subject: string }>>([])
|
|
57
|
+
const [searching, setSearching] = useState(false)
|
|
58
|
+
|
|
59
|
+
const doSearch = useCallback(async (q: string) => {
|
|
60
|
+
if (q.length < 2) { setResults([]); return }
|
|
61
|
+
setSearching(true)
|
|
62
|
+
try {
|
|
63
|
+
const res = await fetch(`/api/tickets?where[or][0][ticketNumber][contains]=${encodeURIComponent(q)}&where[or][1][subject][contains]=${encodeURIComponent(q)}&limit=10&sort=-updatedAt&depth=0`)
|
|
64
|
+
if (res.ok) {
|
|
65
|
+
const data = await res.json()
|
|
66
|
+
setResults(data.docs.map((d: Record<string, unknown>) => ({
|
|
67
|
+
id: d.id,
|
|
68
|
+
ticketNumber: d.ticketNumber,
|
|
69
|
+
subject: d.subject,
|
|
70
|
+
})))
|
|
71
|
+
}
|
|
72
|
+
} catch { /* ignore */ }
|
|
73
|
+
setSearching(false)
|
|
74
|
+
}, [])
|
|
75
|
+
|
|
76
|
+
useEffect(() => {
|
|
77
|
+
const timer = setTimeout(() => doSearch(search), 300)
|
|
78
|
+
return () => clearTimeout(timer)
|
|
79
|
+
}, [search, doSearch])
|
|
80
|
+
|
|
81
|
+
const scoreClass = (score: number) =>
|
|
82
|
+
score >= 0.7 ? styles.scoreHigh : score >= 0.5 ? styles.scoreMedium : styles.scoreLow
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<div className={styles.overlay} onClick={onClose}>
|
|
86
|
+
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
|
|
87
|
+
<div className={styles.modalHeader}>
|
|
88
|
+
<h3 className={styles.modalTitle}>Rattacher a un ticket</h3>
|
|
89
|
+
<button onClick={onClose} className={styles.modalClose}>
|
|
90
|
+
<X size={20} />
|
|
91
|
+
</button>
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
{/* Suggestions */}
|
|
95
|
+
{suggestions.length > 0 && (
|
|
96
|
+
<div className={styles.sectionBlock}>
|
|
97
|
+
<div className={styles.sectionLabel}>Suggestions</div>
|
|
98
|
+
{suggestions.map((s) => (
|
|
99
|
+
<button key={s.id} onClick={() => onSelect(s.id)} className={styles.resultRow}>
|
|
100
|
+
<span className={styles.resultTicketNum}>{s.ticketNumber}</span>
|
|
101
|
+
<span className={styles.resultSubject}>{s.subject}</span>
|
|
102
|
+
<span className={`${styles.scoreBadge} ${scoreClass(s.score)}`}>
|
|
103
|
+
{Math.round(s.score * 100)}%
|
|
104
|
+
</span>
|
|
105
|
+
</button>
|
|
106
|
+
))}
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
|
|
110
|
+
{/* Search */}
|
|
111
|
+
<div className={styles.searchWrap}>
|
|
112
|
+
<Search size={16} className={styles.searchIcon} />
|
|
113
|
+
<input
|
|
114
|
+
type="text"
|
|
115
|
+
placeholder="Rechercher un ticket (TK-0042, sujet...)..."
|
|
116
|
+
value={search}
|
|
117
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
118
|
+
className={styles.searchInput}
|
|
119
|
+
/>
|
|
120
|
+
</div>
|
|
121
|
+
|
|
122
|
+
{searching && <div className={styles.searchingHint}>Recherche...</div>}
|
|
123
|
+
|
|
124
|
+
{results.map((r) => (
|
|
125
|
+
<button key={r.id} onClick={() => onSelect(r.id)} className={styles.resultRow}>
|
|
126
|
+
<span className={styles.resultTicketNum}>{r.ticketNumber}</span>
|
|
127
|
+
<span className={styles.resultSubject}>{r.subject}</span>
|
|
128
|
+
</button>
|
|
129
|
+
))}
|
|
130
|
+
|
|
131
|
+
{search.length >= 2 && !searching && results.length === 0 && (
|
|
132
|
+
<div className={styles.noResults}>Aucun ticket trouve</div>
|
|
133
|
+
)}
|
|
134
|
+
</div>
|
|
135
|
+
</div>
|
|
136
|
+
)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
interface ClientOption {
|
|
140
|
+
id: number
|
|
141
|
+
firstName?: string
|
|
142
|
+
lastName?: string
|
|
143
|
+
email?: string
|
|
144
|
+
company?: string
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function ClientPickerModal({
|
|
148
|
+
defaultEmail,
|
|
149
|
+
detectedClient,
|
|
150
|
+
onSelect,
|
|
151
|
+
onClose,
|
|
152
|
+
}: {
|
|
153
|
+
defaultEmail: string
|
|
154
|
+
detectedClient?: ClientOption | null
|
|
155
|
+
onSelect: (clientId: number) => void
|
|
156
|
+
onClose: () => void
|
|
157
|
+
}) {
|
|
158
|
+
const [search, setSearch] = useState('')
|
|
159
|
+
const [results, setResults] = useState<ClientOption[]>([])
|
|
160
|
+
const [searching, setSearching] = useState(false)
|
|
161
|
+
const [showCreate, setShowCreate] = useState(false)
|
|
162
|
+
const [creating, setCreating] = useState(false)
|
|
163
|
+
const [newClient, setNewClient] = useState({ firstName: '', lastName: '', email: defaultEmail, company: '' })
|
|
164
|
+
const [createError, setCreateError] = useState('')
|
|
165
|
+
|
|
166
|
+
const doSearch = useCallback(async (q: string) => {
|
|
167
|
+
if (q.length < 2) { setResults([]); return }
|
|
168
|
+
setSearching(true)
|
|
169
|
+
try {
|
|
170
|
+
const res = await fetch(`/api/support-clients?where[or][0][email][contains]=${encodeURIComponent(q)}&where[or][1][firstName][contains]=${encodeURIComponent(q)}&where[or][2][lastName][contains]=${encodeURIComponent(q)}&where[or][3][company][contains]=${encodeURIComponent(q)}&limit=10&sort=-updatedAt&depth=0`)
|
|
171
|
+
if (res.ok) {
|
|
172
|
+
const data = await res.json()
|
|
173
|
+
setResults(data.docs.map((d: Record<string, unknown>) => ({
|
|
174
|
+
id: d.id, firstName: d.firstName, lastName: d.lastName, email: d.email, company: d.company,
|
|
175
|
+
})))
|
|
176
|
+
}
|
|
177
|
+
} catch { /* ignore */ }
|
|
178
|
+
setSearching(false)
|
|
179
|
+
}, [])
|
|
180
|
+
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
const timer = setTimeout(() => doSearch(search), 300)
|
|
183
|
+
return () => clearTimeout(timer)
|
|
184
|
+
}, [search, doSearch])
|
|
185
|
+
|
|
186
|
+
const handleCreate = async () => {
|
|
187
|
+
setCreateError('')
|
|
188
|
+
if (!newClient.email.trim() || !newClient.firstName.trim() || !newClient.company.trim()) {
|
|
189
|
+
setCreateError('Email, prenom et entreprise sont obligatoires')
|
|
190
|
+
return
|
|
191
|
+
}
|
|
192
|
+
setCreating(true)
|
|
193
|
+
try {
|
|
194
|
+
const res = await fetch('/api/support-clients', {
|
|
195
|
+
method: 'POST',
|
|
196
|
+
headers: { 'Content-Type': 'application/json' },
|
|
197
|
+
body: JSON.stringify({
|
|
198
|
+
email: newClient.email.trim(),
|
|
199
|
+
firstName: newClient.firstName.trim(),
|
|
200
|
+
lastName: newClient.lastName.trim() || undefined,
|
|
201
|
+
company: newClient.company.trim(),
|
|
202
|
+
password: crypto.randomUUID(),
|
|
203
|
+
}),
|
|
204
|
+
})
|
|
205
|
+
if (res.ok) {
|
|
206
|
+
const data = await res.json()
|
|
207
|
+
onSelect(data.doc.id)
|
|
208
|
+
} else {
|
|
209
|
+
const err = await res.json().catch(() => ({}))
|
|
210
|
+
setCreateError(err.errors?.[0]?.message || 'Erreur lors de la creation')
|
|
211
|
+
}
|
|
212
|
+
} catch {
|
|
213
|
+
setCreateError('Erreur reseau')
|
|
214
|
+
}
|
|
215
|
+
setCreating(false)
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const clientLabel = (c: ClientOption) => {
|
|
219
|
+
const parts = [c.firstName, c.lastName].filter(Boolean).join(' ')
|
|
220
|
+
return parts ? `${parts}${c.company ? ` — ${c.company}` : ''}` : c.email || ''
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return (
|
|
224
|
+
<div className={styles.overlay} onClick={onClose}>
|
|
225
|
+
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
|
|
226
|
+
<div className={styles.modalHeader}>
|
|
227
|
+
<h3 className={styles.modalTitle}>Choisir un client</h3>
|
|
228
|
+
<button onClick={onClose} className={styles.modalClose}>
|
|
229
|
+
<X size={20} />
|
|
230
|
+
</button>
|
|
231
|
+
</div>
|
|
232
|
+
|
|
233
|
+
{/* Detected client */}
|
|
234
|
+
{detectedClient && (
|
|
235
|
+
<div className={styles.sectionBlock}>
|
|
236
|
+
<div className={styles.sectionLabel}>Client detecte</div>
|
|
237
|
+
<button onClick={() => onSelect(detectedClient.id)} className={styles.detectedRow}>
|
|
238
|
+
<span className={styles.detectedLabel}>
|
|
239
|
+
<span className={styles.detectedName}>{clientLabel(detectedClient)}</span>
|
|
240
|
+
{detectedClient.email && (
|
|
241
|
+
<span className={styles.detectedEmail}>{detectedClient.email}</span>
|
|
242
|
+
)}
|
|
243
|
+
</span>
|
|
244
|
+
<span className={styles.useBtn}>Utiliser</span>
|
|
245
|
+
</button>
|
|
246
|
+
</div>
|
|
247
|
+
)}
|
|
248
|
+
|
|
249
|
+
{/* Search existing client */}
|
|
250
|
+
<div className={styles.searchWrap}>
|
|
251
|
+
<Search size={16} className={styles.searchIcon} />
|
|
252
|
+
<input
|
|
253
|
+
type="text"
|
|
254
|
+
placeholder="Rechercher un client (nom, email, entreprise)..."
|
|
255
|
+
value={search}
|
|
256
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
257
|
+
className={styles.searchInput}
|
|
258
|
+
/>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
{searching && <div className={styles.searchingHint}>Recherche...</div>}
|
|
262
|
+
|
|
263
|
+
{results.map((r) => (
|
|
264
|
+
<button key={r.id} onClick={() => onSelect(r.id)} className={styles.resultRow}>
|
|
265
|
+
<span className={styles.clientResultLabel}>
|
|
266
|
+
<span className={styles.clientResultName}>{clientLabel(r)}</span>
|
|
267
|
+
{r.email && <span className={styles.clientResultEmail}>{r.email}</span>}
|
|
268
|
+
</span>
|
|
269
|
+
</button>
|
|
270
|
+
))}
|
|
271
|
+
|
|
272
|
+
{search.length >= 2 && !searching && results.length === 0 && (
|
|
273
|
+
<div className={styles.noResults}>Aucun client trouve</div>
|
|
274
|
+
)}
|
|
275
|
+
|
|
276
|
+
{/* Separator + create toggle */}
|
|
277
|
+
<div className={styles.separator}>
|
|
278
|
+
<button onClick={() => setShowCreate(!showCreate)} className={styles.createToggle}>
|
|
279
|
+
<Plus size={14} />
|
|
280
|
+
{showCreate ? 'Annuler la creation' : 'Creer un nouveau client'}
|
|
281
|
+
</button>
|
|
282
|
+
</div>
|
|
283
|
+
|
|
284
|
+
{showCreate && (
|
|
285
|
+
<div className={styles.createForm}>
|
|
286
|
+
{createError && <div className={styles.formError}>{createError}</div>}
|
|
287
|
+
<div className={styles.createFormRow}>
|
|
288
|
+
<input
|
|
289
|
+
type="text"
|
|
290
|
+
placeholder="Prenom *"
|
|
291
|
+
value={newClient.firstName}
|
|
292
|
+
onChange={(e) => setNewClient((p) => ({ ...p, firstName: e.target.value }))}
|
|
293
|
+
className={styles.formInputHalf}
|
|
294
|
+
/>
|
|
295
|
+
<input
|
|
296
|
+
type="text"
|
|
297
|
+
placeholder="Nom"
|
|
298
|
+
value={newClient.lastName}
|
|
299
|
+
onChange={(e) => setNewClient((p) => ({ ...p, lastName: e.target.value }))}
|
|
300
|
+
className={styles.formInputHalf}
|
|
301
|
+
/>
|
|
302
|
+
</div>
|
|
303
|
+
<input
|
|
304
|
+
type="email"
|
|
305
|
+
placeholder="Email *"
|
|
306
|
+
value={newClient.email}
|
|
307
|
+
onChange={(e) => setNewClient((p) => ({ ...p, email: e.target.value }))}
|
|
308
|
+
className={styles.formInput}
|
|
309
|
+
/>
|
|
310
|
+
<input
|
|
311
|
+
type="text"
|
|
312
|
+
placeholder="Entreprise *"
|
|
313
|
+
value={newClient.company}
|
|
314
|
+
onChange={(e) => setNewClient((p) => ({ ...p, company: e.target.value }))}
|
|
315
|
+
className={styles.formInput}
|
|
316
|
+
/>
|
|
317
|
+
<button onClick={handleCreate} disabled={creating} className={styles.submitBtn}>
|
|
318
|
+
{creating ? 'Creation...' : 'Creer et utiliser'}
|
|
319
|
+
</button>
|
|
320
|
+
</div>
|
|
321
|
+
)}
|
|
322
|
+
</div>
|
|
323
|
+
</div>
|
|
324
|
+
)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function EmailCard({
|
|
328
|
+
email,
|
|
329
|
+
onProcess,
|
|
330
|
+
processing,
|
|
331
|
+
}: {
|
|
332
|
+
email: PendingEmail
|
|
333
|
+
onProcess: (action: 'create_ticket' | 'add_to_ticket' | 'ignore', ticketId?: number, clientId?: number) => void
|
|
334
|
+
processing: boolean
|
|
335
|
+
}) {
|
|
32
336
|
const [expanded, setExpanded] = useState(false)
|
|
33
337
|
const [showLinkModal, setShowLinkModal] = useState(false)
|
|
34
|
-
const [
|
|
35
|
-
|
|
36
|
-
const
|
|
338
|
+
const [showClientPicker, setShowClientPicker] = useState(false)
|
|
339
|
+
|
|
340
|
+
const attachmentCount = email.attachments?.length || 0
|
|
37
341
|
const preview = email.body.slice(0, 200) + (email.body.length > 200 ? '...' : '')
|
|
38
342
|
const suggestions = email.suggestedTickets || []
|
|
343
|
+
const isPending = email.status === 'pending'
|
|
39
344
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
} catch (err) { console.warn('[support] ticket search error:', err) }
|
|
47
|
-
}, 300)
|
|
48
|
-
return () => clearTimeout(timer)
|
|
49
|
-
}, [linkSearch])
|
|
50
|
-
|
|
51
|
-
const S: Record<string, React.CSSProperties> = {
|
|
52
|
-
card: { padding: 16, borderRadius: 10, border: '1px solid var(--theme-elevation-150)', marginBottom: 12, opacity: processing ? 0.5 : 1 },
|
|
53
|
-
senderName: { fontWeight: 600, fontSize: 14 },
|
|
54
|
-
senderEmail: { fontSize: 12, color: 'var(--theme-elevation-500)' },
|
|
55
|
-
subject: { fontWeight: 600, fontSize: 13, marginTop: 4 },
|
|
56
|
-
meta: { fontSize: 12, color: 'var(--theme-elevation-400)', marginTop: 2 },
|
|
57
|
-
body: { fontSize: 13, color: 'var(--theme-text)', padding: '8px 0', whiteSpace: 'pre-wrap' as const, lineHeight: 1.5 },
|
|
58
|
-
actions: { display: 'flex', gap: 8, marginTop: 8 },
|
|
59
|
-
btn: { padding: '6px 14px', borderRadius: 6, border: '1px solid var(--theme-elevation-200)', fontSize: 12, fontWeight: 600, cursor: 'pointer', background: 'var(--theme-elevation-0)', color: 'var(--theme-text)' },
|
|
60
|
-
btnCreate: { background: '#2563eb', color: '#fff', border: 'none' },
|
|
61
|
-
btnIgnore: { color: '#dc2626', borderColor: '#dc2626' },
|
|
62
|
-
overlay: { position: 'fixed' as const, inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 100 },
|
|
63
|
-
modal: { background: 'var(--theme-elevation-0)', borderRadius: 12, padding: 24, maxWidth: 480, width: '100%', maxHeight: '80vh', overflowY: 'auto' as const },
|
|
64
|
-
}
|
|
345
|
+
const processedTicketNumber = typeof email.processedTicket === 'object'
|
|
346
|
+
? email.processedTicket?.ticketNumber
|
|
347
|
+
: null
|
|
348
|
+
|
|
349
|
+
const suggestionClass = (score: number) =>
|
|
350
|
+
score >= 0.7 ? styles.suggestionHigh : score >= 0.5 ? styles.suggestionMedium : styles.suggestionLow
|
|
65
351
|
|
|
66
352
|
return (
|
|
67
|
-
<div
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
353
|
+
<div className={`${styles.card} ${processing ? styles.cardProcessing : ''}`}>
|
|
354
|
+
{/* Header */}
|
|
355
|
+
<div className={styles.cardHeader}>
|
|
356
|
+
<div className={styles.cardInfo}>
|
|
357
|
+
<div className={styles.senderRow}>
|
|
358
|
+
<span className={styles.senderName}>
|
|
359
|
+
{email.senderName || email.senderEmail}
|
|
360
|
+
</span>
|
|
361
|
+
{email.senderName && (
|
|
362
|
+
<span className={styles.senderEmail}><{email.senderEmail}></span>
|
|
363
|
+
)}
|
|
364
|
+
</div>
|
|
365
|
+
<div className={styles.cardSubject}>{email.subject}</div>
|
|
366
|
+
<div className={styles.cardMeta}>
|
|
367
|
+
<span>{timeAgo(email.createdAt)}</span>
|
|
368
|
+
{attachmentCount > 0 && (
|
|
369
|
+
<span className={styles.attachment}>
|
|
370
|
+
<Paperclip size={12} /> {attachmentCount} PJ
|
|
371
|
+
</span>
|
|
372
|
+
)}
|
|
373
|
+
</div>
|
|
374
|
+
</div>
|
|
375
|
+
|
|
376
|
+
{/* Status badge for processed/ignored */}
|
|
377
|
+
{!isPending && (
|
|
378
|
+
<div className={`${styles.statusBadge} ${email.status === 'processed' ? styles.statusProcessed : styles.statusIgnored}`}>
|
|
379
|
+
{email.processedAction === 'ticket_created' && `Ticket cree${processedTicketNumber ? ` (${processedTicketNumber})` : ''}`}
|
|
380
|
+
{email.processedAction === 'message_added' && `Rattache${processedTicketNumber ? ` a ${processedTicketNumber}` : ''}`}
|
|
381
|
+
{email.processedAction === 'ignored' && 'Ignore'}
|
|
382
|
+
</div>
|
|
383
|
+
)}
|
|
71
384
|
</div>
|
|
72
|
-
<div style={S.subject}>{email.subject}</div>
|
|
73
|
-
<div style={S.meta}>{timeAgo(email.createdAt)} {email.attachments?.length ? `-- ${email.attachments.length} PJ` : ''}</div>
|
|
74
385
|
|
|
386
|
+
{/* Suggestions badges */}
|
|
75
387
|
{suggestions.length > 0 && isPending && (
|
|
76
|
-
<div
|
|
388
|
+
<div className={styles.suggestions}>
|
|
77
389
|
{suggestions.map((s) => (
|
|
78
|
-
<span key={s.id}
|
|
390
|
+
<span key={s.id} className={`${styles.suggestionChip} ${suggestionClass(s.score)}`}>
|
|
79
391
|
Similaire a {s.ticketNumber} ({Math.round(s.score * 100)}%)
|
|
80
392
|
</span>
|
|
81
393
|
))}
|
|
82
394
|
</div>
|
|
83
395
|
)}
|
|
84
396
|
|
|
85
|
-
|
|
86
|
-
|
|
397
|
+
{/* Body preview / expanded */}
|
|
398
|
+
<div className={styles.bodySection}>
|
|
399
|
+
<div className={`${styles.bodyText} ${expanded ? styles.bodyExpanded : ''}`}>
|
|
400
|
+
{expanded ? email.body : preview}
|
|
401
|
+
</div>
|
|
402
|
+
{email.body.length > 200 && (
|
|
403
|
+
<button onClick={() => setExpanded(!expanded)} className={styles.expandBtn}>
|
|
404
|
+
{expanded ? <><ChevronUp size={14} /> Reduire</> : <><ChevronDown size={14} /> Voir tout</>}
|
|
405
|
+
</button>
|
|
406
|
+
)}
|
|
407
|
+
</div>
|
|
87
408
|
|
|
409
|
+
{/* Actions */}
|
|
88
410
|
{isPending && (
|
|
89
|
-
<div
|
|
90
|
-
<button
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
411
|
+
<div className={styles.actions}>
|
|
412
|
+
<button
|
|
413
|
+
onClick={() => setShowClientPicker(true)}
|
|
414
|
+
disabled={processing}
|
|
415
|
+
className={`${styles.actionBtn} ${styles.btnCreate}`}
|
|
416
|
+
>
|
|
417
|
+
<Plus size={14} />
|
|
418
|
+
Creer un ticket
|
|
419
|
+
</button>
|
|
420
|
+
<button
|
|
421
|
+
onClick={() => setShowLinkModal(true)}
|
|
422
|
+
disabled={processing}
|
|
423
|
+
className={`${styles.actionBtn} ${styles.btnLink}`}
|
|
424
|
+
>
|
|
425
|
+
<Link2 size={14} />
|
|
426
|
+
Rattacher a un ticket
|
|
427
|
+
</button>
|
|
428
|
+
<button
|
|
429
|
+
onClick={() => onProcess('ignore')}
|
|
430
|
+
disabled={processing}
|
|
431
|
+
className={`${styles.actionBtn} ${styles.btnIgnore}`}
|
|
432
|
+
>
|
|
433
|
+
<X size={14} />
|
|
434
|
+
Ignorer
|
|
435
|
+
</button>
|
|
96
436
|
</div>
|
|
97
437
|
)}
|
|
98
438
|
|
|
439
|
+
{/* Link modal */}
|
|
99
440
|
{showLinkModal && (
|
|
100
|
-
<
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
441
|
+
<TicketSearchModal
|
|
442
|
+
suggestions={suggestions}
|
|
443
|
+
onSelect={(ticketId) => {
|
|
444
|
+
setShowLinkModal(false)
|
|
445
|
+
onProcess('add_to_ticket', ticketId)
|
|
446
|
+
}}
|
|
447
|
+
onClose={() => setShowLinkModal(false)}
|
|
448
|
+
/>
|
|
449
|
+
)}
|
|
450
|
+
|
|
451
|
+
{/* Client picker modal */}
|
|
452
|
+
{showClientPicker && (
|
|
453
|
+
<ClientPickerModal
|
|
454
|
+
defaultEmail={email.senderEmail}
|
|
455
|
+
detectedClient={
|
|
456
|
+
typeof email.client === 'object' && email.client
|
|
457
|
+
? { id: email.client.id, firstName: email.client.firstName, lastName: email.client.lastName, email: email.client.email, company: email.client.company }
|
|
458
|
+
: null
|
|
459
|
+
}
|
|
460
|
+
onSelect={(clientId) => {
|
|
461
|
+
setShowClientPicker(false)
|
|
462
|
+
onProcess('create_ticket', undefined, clientId)
|
|
463
|
+
}}
|
|
464
|
+
onClose={() => setShowClientPicker(false)}
|
|
465
|
+
/>
|
|
121
466
|
)}
|
|
122
467
|
</div>
|
|
123
468
|
)
|
|
@@ -129,49 +474,124 @@ export const PendingEmailsClient: React.FC = () => {
|
|
|
129
474
|
const [loading, setLoading] = useState(true)
|
|
130
475
|
const [tab, setTab] = useState<Tab>('pending')
|
|
131
476
|
const [processing, setProcessing] = useState<number | null>(null)
|
|
477
|
+
const [sessionExpired, setSessionExpired] = useState(false)
|
|
132
478
|
|
|
133
479
|
const fetchEmails = useCallback(async () => {
|
|
134
480
|
try {
|
|
135
481
|
const res = await fetch(`/api/pending-emails?where[status][equals]=${tab}&sort=-createdAt&limit=50&depth=1`)
|
|
136
|
-
if (res.ok) {
|
|
482
|
+
if (res.ok) {
|
|
483
|
+
const data = await res.json()
|
|
484
|
+
setEmails(data.docs)
|
|
485
|
+
} else if (res.status === 401 || res.status === 403) {
|
|
486
|
+
setSessionExpired(true)
|
|
487
|
+
}
|
|
137
488
|
} catch { /* ignore */ }
|
|
138
489
|
setLoading(false)
|
|
139
490
|
}, [tab])
|
|
140
491
|
|
|
141
|
-
useEffect(() => {
|
|
142
|
-
|
|
492
|
+
useEffect(() => {
|
|
493
|
+
setLoading(true)
|
|
494
|
+
fetchEmails()
|
|
495
|
+
}, [fetchEmails])
|
|
496
|
+
|
|
497
|
+
// Auto-refresh 30s (pending tab only)
|
|
498
|
+
useEffect(() => {
|
|
499
|
+
if (sessionExpired || tab !== 'pending') return
|
|
500
|
+
const interval = setInterval(fetchEmails, 30000)
|
|
501
|
+
return () => clearInterval(interval)
|
|
502
|
+
}, [fetchEmails, sessionExpired, tab])
|
|
503
|
+
|
|
504
|
+
// Refresh on focus
|
|
505
|
+
useEffect(() => {
|
|
506
|
+
if (sessionExpired) return
|
|
507
|
+
const onFocus = () => fetchEmails()
|
|
508
|
+
window.addEventListener('focus', onFocus)
|
|
509
|
+
return () => window.removeEventListener('focus', onFocus)
|
|
510
|
+
}, [fetchEmails, sessionExpired])
|
|
143
511
|
|
|
144
512
|
const handleProcess = async (emailId: number, action: 'create_ticket' | 'add_to_ticket' | 'ignore', ticketId?: number, clientId?: number) => {
|
|
145
513
|
setProcessing(emailId)
|
|
146
514
|
try {
|
|
147
|
-
const res = await fetch(`/api/support/pending-emails/${emailId}/process`, {
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
515
|
+
const res = await fetch(`/api/support/pending-emails/${emailId}/process`, {
|
|
516
|
+
method: 'POST',
|
|
517
|
+
headers: { 'Content-Type': 'application/json' },
|
|
518
|
+
body: JSON.stringify({ action, ticketId, clientId }),
|
|
519
|
+
})
|
|
520
|
+
if (res.ok) {
|
|
521
|
+
// Remove from list
|
|
522
|
+
setEmails((prev) => prev.filter((e) => e.id !== emailId))
|
|
523
|
+
} else {
|
|
524
|
+
const err = await res.json().catch(() => ({ error: 'Unknown error' }))
|
|
525
|
+
alert(`Erreur : ${err.error || res.statusText}`)
|
|
526
|
+
}
|
|
527
|
+
} catch {
|
|
528
|
+
alert('Erreur reseau')
|
|
529
|
+
}
|
|
151
530
|
setProcessing(null)
|
|
152
531
|
}
|
|
153
532
|
|
|
154
|
-
|
|
533
|
+
if (loading) {
|
|
534
|
+
return (
|
|
535
|
+
<div className={styles.loadingWrap}>
|
|
536
|
+
<SkeletonDashboard />
|
|
537
|
+
</div>
|
|
538
|
+
)
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const tabs: { key: Tab; label: string }[] = [
|
|
542
|
+
{ key: 'pending', label: t('pendingEmails.tabs.pending') },
|
|
543
|
+
{ key: 'processed', label: t('pendingEmails.tabs.processed') },
|
|
544
|
+
{ key: 'ignored', label: t('pendingEmails.tabs.ignored') },
|
|
545
|
+
]
|
|
155
546
|
|
|
156
547
|
return (
|
|
157
|
-
<div
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
<
|
|
548
|
+
<div className={styles.page}>
|
|
549
|
+
{/* Header */}
|
|
550
|
+
<div className={styles.header}>
|
|
551
|
+
<div className={styles.headerLeft}>
|
|
552
|
+
<h1 className={styles.title}>
|
|
553
|
+
<span className={styles.titleIcon}><Inbox size={24} /></span>
|
|
554
|
+
{t('pendingEmails.title')}
|
|
555
|
+
</h1>
|
|
556
|
+
<p className={styles.subtitle}>
|
|
557
|
+
{t('pendingEmails.subtitle')}
|
|
558
|
+
</p>
|
|
162
559
|
</div>
|
|
163
|
-
{tab === 'pending' && emails.length > 0 &&
|
|
560
|
+
{tab === 'pending' && emails.length > 0 && (
|
|
561
|
+
<span className={styles.pendingBadge}>
|
|
562
|
+
{t('pendingEmails.pendingCount', { count: String(emails.length) })}
|
|
563
|
+
</span>
|
|
564
|
+
)}
|
|
164
565
|
</div>
|
|
165
566
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
567
|
+
{/* Tabs */}
|
|
568
|
+
<div className={styles.tabs}>
|
|
569
|
+
{tabs.map((tk) => (
|
|
570
|
+
<button
|
|
571
|
+
key={tk.key}
|
|
572
|
+
onClick={() => setTab(tk.key)}
|
|
573
|
+
className={`${styles.tab} ${tab === tk.key ? styles.tabActive : ''}`}
|
|
574
|
+
>
|
|
575
|
+
{tk.label}
|
|
576
|
+
</button>
|
|
169
577
|
))}
|
|
170
578
|
</div>
|
|
171
579
|
|
|
172
|
-
{
|
|
173
|
-
|
|
174
|
-
|
|
580
|
+
{/* Email list */}
|
|
581
|
+
{emails.length === 0 ? (
|
|
582
|
+
<div className={styles.empty}>
|
|
583
|
+
{tab === 'pending' ? t('pendingEmails.empty.pending') : tab === 'processed' ? t('pendingEmails.empty.processed') : t('pendingEmails.empty.ignored')}
|
|
584
|
+
</div>
|
|
585
|
+
) : (
|
|
586
|
+
emails.map((email) => (
|
|
587
|
+
<EmailCard
|
|
588
|
+
key={email.id}
|
|
589
|
+
email={email}
|
|
590
|
+
onProcess={(action, ticketId, clientId) => handleProcess(email.id, action, ticketId, clientId)}
|
|
591
|
+
processing={processing === email.id}
|
|
592
|
+
/>
|
|
593
|
+
))
|
|
594
|
+
)}
|
|
175
595
|
</div>
|
|
176
596
|
)
|
|
177
597
|
}
|