@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.
Files changed (51) hide show
  1. package/dist/components/RichTextEditor/index.cjs +233 -0
  2. package/dist/components/RichTextEditor/index.js +232 -0
  3. package/dist/components/TicketConversation/components/CodeBlock.cjs +24 -7
  4. package/dist/components/TicketConversation/components/CodeBlock.js +23 -9
  5. package/dist/index.cjs +19 -2
  6. package/dist/index.js +19 -2
  7. package/dist/views/BillingView/client.cjs +260 -103
  8. package/dist/views/BillingView/client.js +259 -103
  9. package/dist/views/ChatView/client.cjs +184 -137
  10. package/dist/views/ChatView/client.js +180 -137
  11. package/dist/views/CrmView/client.cjs +270 -122
  12. package/dist/views/CrmView/client.js +266 -122
  13. package/dist/views/EmailTrackingView/client.cjs +80 -69
  14. package/dist/views/EmailTrackingView/client.js +80 -70
  15. package/dist/views/ImportConversationView/client.cjs +127 -94
  16. package/dist/views/ImportConversationView/client.js +123 -94
  17. package/dist/views/LogsView/client.cjs +56 -58
  18. package/dist/views/LogsView/client.js +52 -58
  19. package/dist/views/NewTicketView/client.cjs +39 -55
  20. package/dist/views/NewTicketView/client.js +38 -55
  21. package/dist/views/PendingEmailsView/client.cjs +399 -102
  22. package/dist/views/PendingEmailsView/client.js +396 -103
  23. package/dist/views/SupportDashboardView/client.cjs +276 -137
  24. package/dist/views/SupportDashboardView/client.js +275 -137
  25. package/dist/views/TicketDetailView/client.cjs +487 -204
  26. package/dist/views/TicketDetailView/client.js +486 -204
  27. package/dist/views/TicketInboxView/client.cjs +62 -65
  28. package/dist/views/TicketInboxView/client.js +62 -66
  29. package/dist/views/TicketingSettingsView/client.cjs +10 -8
  30. package/dist/views/TicketingSettingsView/client.js +10 -8
  31. package/dist/views/TimeDashboardView/client.cjs +70 -59
  32. package/dist/views/TimeDashboardView/client.js +69 -59
  33. package/package.json +6 -2
  34. package/src/collections/ClientSummaries.ts +0 -1
  35. package/src/components/RichTextEditor/index.tsx +261 -0
  36. package/src/components/TicketConversation/components/CodeBlock.tsx +53 -14
  37. package/src/plugin.ts +2 -0
  38. package/src/utils/emailTemplate.ts +37 -0
  39. package/src/views/BillingView/client.tsx +362 -69
  40. package/src/views/ChatView/client.tsx +225 -140
  41. package/src/views/CrmView/client.tsx +447 -189
  42. package/src/views/EmailTrackingView/client.tsx +111 -71
  43. package/src/views/ImportConversationView/client.tsx +255 -70
  44. package/src/views/LogsView/client.tsx +85 -50
  45. package/src/views/NewTicketView/client.tsx +37 -53
  46. package/src/views/PendingEmailsView/client.tsx +512 -92
  47. package/src/views/SupportDashboardView/client.tsx +294 -134
  48. package/src/views/TicketDetailView/client.tsx +486 -213
  49. package/src/views/TicketInboxView/client.tsx +52 -61
  50. package/src/views/TicketingSettingsView/client.tsx +10 -9
  51. 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 s from '../../styles/PendingEmails.module.scss'
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; senderEmail: string; senderName?: string; subject: string; body: string
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 "a l'instant"
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
- return `il y a ${Math.floor(hours / 24)}j`
42
+ const days = Math.floor(hours / 24)
43
+ return `il y a ${days}j`
29
44
  }
30
45
 
31
- function EmailCard({ email, onProcess, processing, t }: { email: PendingEmail; onProcess: (action: 'create_ticket' | 'add_to_ticket' | 'ignore', ticketId?: number, clientId?: number) => void; processing: boolean; t: (key: string, vars?: Record<string, string | number>) => string }) {
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 [linkSearch, setLinkSearch] = useState('')
35
- const [linkResults, setLinkResults] = useState<Array<{ id: number; ticketNumber: string; subject: string }>>([])
36
- const isPending = email.status === 'pending'
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
- useEffect(() => {
41
- if (!linkSearch || linkSearch.length < 2) { setLinkResults([]); return }
42
- const timer = setTimeout(async () => {
43
- try {
44
- const res = await fetch(`/api/tickets?where[or][0][ticketNumber][contains]=${encodeURIComponent(linkSearch)}&where[or][1][subject][contains]=${encodeURIComponent(linkSearch)}&limit=10&sort=-updatedAt&depth=0`)
45
- if (res.ok) { const data = await res.json(); setLinkResults(data.docs.map((d: Record<string, unknown>) => ({ id: d.id, ticketNumber: d.ticketNumber, subject: d.subject }))) }
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 style={S.card}>
68
- <div>
69
- <span style={S.senderName}>{email.senderName || email.senderEmail}</span>
70
- {email.senderName && <span style={S.senderEmail}> &lt;{email.senderEmail}&gt;</span>}
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}>&lt;{email.senderEmail}&gt;</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 style={{ display: 'flex', gap: 6, marginTop: 6, flexWrap: 'wrap' }}>
388
+ <div className={styles.suggestions}>
77
389
  {suggestions.map((s) => (
78
- <span key={s.id} style={{ padding: '2px 8px', borderRadius: 4, fontSize: 11, background: s.score >= 0.7 ? '#dcfce7' : '#fef3c7', color: s.score >= 0.7 ? '#166534' : '#92400e' }}>
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
- <div style={S.body}>{expanded ? email.body : preview}</div>
86
- {email.body.length > 200 && <button onClick={() => setExpanded(!expanded)} style={{ ...S.btn, fontSize: 11, padding: '2px 8px' }}>{expanded ? t('pendingEmails.collapse') : t('pendingEmails.expand')}</button>}
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 style={S.actions}>
90
- <button onClick={() => {
91
- const clientId = typeof email.client === 'object' && email.client ? email.client.id : undefined
92
- onProcess('create_ticket', undefined, clientId)
93
- }} disabled={processing} style={{ ...S.btn, ...S.btnCreate }}>{t('pendingEmails.actions.createTicket')}</button>
94
- <button onClick={() => setShowLinkModal(true)} disabled={processing} style={S.btn}>{t('pendingEmails.actions.linkToTicket')}</button>
95
- <button onClick={() => onProcess('ignore')} disabled={processing} style={{ ...S.btn, ...S.btnIgnore }}>{t('pendingEmails.actions.ignore')}</button>
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
- <div style={S.overlay} onClick={() => setShowLinkModal(false)}>
101
- <div style={S.modal} onClick={(e) => e.stopPropagation()}>
102
- <h3 style={{ margin: '0 0 12px', fontSize: 16, fontWeight: 700 }}>{t('pendingEmails.linkModal.title')}</h3>
103
- {suggestions.length > 0 && (
104
- <div style={{ marginBottom: 12 }}>
105
- <div style={{ fontSize: 12, fontWeight: 600, marginBottom: 4, color: 'var(--theme-elevation-500)' }}>{t('pendingEmails.linkModal.suggestions')}</div>
106
- {suggestions.map((s) => (
107
- <button key={s.id} onClick={() => { setShowLinkModal(false); onProcess('add_to_ticket', s.id) }} style={{ display: 'block', width: '100%', padding: '8px 12px', border: '1px solid var(--theme-elevation-200)', borderRadius: 6, background: 'var(--theme-elevation-0)', cursor: 'pointer', textAlign: 'left', marginBottom: 4, fontSize: 13 }}>
108
- <strong>{s.ticketNumber}</strong> {s.subject} <span style={{ fontSize: 11, color: '#16a34a' }}>{Math.round(s.score * 100)}%</span>
109
- </button>
110
- ))}
111
- </div>
112
- )}
113
- <input type="text" placeholder={t('pendingEmails.linkModal.searchPlaceholder')} value={linkSearch} onChange={(e) => setLinkSearch(e.target.value)} style={{ width: '100%', padding: '8px 12px', borderRadius: 6, border: '1px solid var(--theme-elevation-200)', fontSize: 13, marginBottom: 8, color: 'var(--theme-text)', background: 'var(--theme-elevation-0)' }} />
114
- {linkResults.map((r) => (
115
- <button key={r.id} onClick={() => { setShowLinkModal(false); onProcess('add_to_ticket', r.id) }} style={{ display: 'block', width: '100%', padding: '8px 12px', border: '1px solid var(--theme-elevation-200)', borderRadius: 6, background: 'var(--theme-elevation-0)', cursor: 'pointer', textAlign: 'left', marginBottom: 4, fontSize: 13 }}>
116
- <strong>{r.ticketNumber}</strong> {r.subject}
117
- </button>
118
- ))}
119
- </div>
120
- </div>
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) { const data = await res.json(); setEmails(data.docs) }
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(() => { setLoading(true); fetchEmails() }, [fetchEmails])
142
- useEffect(() => { if (tab !== 'pending') return; const iv = setInterval(fetchEmails, 30000); return () => clearInterval(iv) }, [fetchEmails, tab])
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`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ action, ticketId, clientId }) })
148
- if (res.ok) { setEmails((prev) => prev.filter((e) => e.id !== emailId)) }
149
- else { const err = await res.json().catch(() => ({ error: 'Unknown error' })); alert(`Erreur : ${err.error || res.statusText}`) }
150
- } catch { alert('Erreur reseau') }
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
- const tabs: { key: Tab; label: string }[] = [{ key: 'pending', label: t('pendingEmails.tabs.pending') }, { key: 'processed', label: t('pendingEmails.tabs.processed') }, { key: 'ignored', label: t('pendingEmails.tabs.ignored') }]
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 style={{ padding: '20px 30px', maxWidth: 900, margin: '0 auto' }}>
158
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
159
- <div>
160
- <h1 style={{ fontSize: 22, fontWeight: 700, margin: 0, color: 'var(--theme-text)' }}>{t('pendingEmails.title')}</h1>
161
- <p style={{ fontSize: 13, color: 'var(--theme-elevation-500)', margin: '4px 0 0' }}>{t('pendingEmails.subtitle')}</p>
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 && <span style={{ padding: '4px 10px', borderRadius: 10, background: '#fef2f2', color: '#dc2626', fontSize: 12, fontWeight: 700 }}>{t('pendingEmails.pendingCount', { count: String(emails.length) })}</span>}
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
- <div style={{ display: 'flex', gap: 4, marginBottom: 16 }}>
167
- {tabs.map((tb) => (
168
- <button key={tb.key} onClick={() => setTab(tb.key)} style={{ padding: '6px 12px', borderRadius: 6, border: 'none', background: tab === tb.key ? 'var(--theme-elevation-100)' : 'none', cursor: 'pointer', fontSize: 13, fontWeight: tab === tb.key ? 700 : 500, color: tab === tb.key ? 'var(--theme-text)' : 'var(--theme-elevation-500)' }}>{tb.label}</button>
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
- {loading ? <div style={{ padding: 40, textAlign: 'center', color: '#94a3b8' }}>{t('common.loading')}</div>
173
- : emails.length === 0 ? <div style={{ padding: 40, textAlign: 'center', color: '#94a3b8' }}>{t(`pendingEmails.empty.${tab}`)}</div>
174
- : emails.map((email) => <EmailCard key={email.id} email={email} onProcess={(action, ticketId, clientId) => handleProcess(email.id, action, ticketId, clientId)} processing={processing === email.id} t={t} />)}
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
  }