@consilioweb/payload-support 0.8.2 → 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 (50) 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 -1
  6. package/dist/index.js +19 -1
  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/components/RichTextEditor/index.tsx +261 -0
  35. package/src/components/TicketConversation/components/CodeBlock.tsx +53 -14
  36. package/src/plugin.ts +2 -0
  37. package/src/utils/emailTemplate.ts +37 -0
  38. package/src/views/BillingView/client.tsx +362 -69
  39. package/src/views/ChatView/client.tsx +225 -140
  40. package/src/views/CrmView/client.tsx +447 -189
  41. package/src/views/EmailTrackingView/client.tsx +111 -71
  42. package/src/views/ImportConversationView/client.tsx +255 -70
  43. package/src/views/LogsView/client.tsx +85 -50
  44. package/src/views/NewTicketView/client.tsx +37 -53
  45. package/src/views/PendingEmailsView/client.tsx +512 -92
  46. package/src/views/SupportDashboardView/client.tsx +294 -134
  47. package/src/views/TicketDetailView/client.tsx +486 -213
  48. package/src/views/TicketInboxView/client.tsx +52 -61
  49. package/src/views/TicketingSettingsView/client.tsx +10 -9
  50. package/src/views/TimeDashboardView/client.tsx +184 -69
@@ -4,18 +4,41 @@ import React, { useState, useEffect, useCallback } from 'react'
4
4
  import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
5
5
  import s from '../../styles/EmailTracking.module.scss'
6
6
 
7
- interface EmailLog { id: number; status: 'success' | 'ignored' | 'error'; action?: string; subject?: string; recipientEmail?: string; ticketNumber?: string; errorMessage?: string; httpStatus?: number; processingTimeMs?: number; createdAt: string }
8
- interface Stats { total: number; success: number; errors: number; ignored: number; successRate: number; avgProcessingTime: number }
7
+ interface EmailLog {
8
+ id: number
9
+ status: 'success' | 'ignored' | 'error'
10
+ action?: string
11
+ senderEmail?: string
12
+ subject?: string
13
+ recipientEmail?: string
14
+ ticketNumber?: string
15
+ errorMessage?: string
16
+ httpStatus?: number
17
+ processingTimeMs?: number
18
+ createdAt: string
19
+ }
20
+
21
+ interface Stats {
22
+ total: number
23
+ success: number
24
+ errors: number
25
+ ignored: number
26
+ successRate: number
27
+ avgProcessingTime: number
28
+ }
29
+
9
30
  type StatusTab = 'all' | 'success' | 'error' | 'ignored'
10
31
  type DateRange = 7 | 30 | 90
11
32
 
12
- const STATUS_CFG: Record<string, { key: string; bg: string; color: string }> = {
13
- success: { key: 'emailTracking.status.success', bg: '#dcfce7', color: '#16a34a' },
14
- error: { key: 'emailTracking.status.error', bg: '#fef2f2', color: '#dc2626' },
15
- ignored: { key: 'emailTracking.status.ignored', bg: '#f3f4f6', color: '#6b7280' },
33
+ const STATUS_CFG: Record<string, { label: string; bg: string; color: string }> = {
34
+ success: { label: 'Succès', bg: '#dcfce7', color: '#16a34a' },
35
+ error: { label: 'Erreur', bg: '#fef2f2', color: '#dc2626' },
36
+ ignored: { label: 'Ignoré', bg: '#f3f4f6', color: '#6b7280' },
16
37
  }
17
38
 
18
- function fmtDate(d: string): string { return new Date(d).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) }
39
+ function fmtDate(d: string): string {
40
+ return new Date(d).toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' })
41
+ }
19
42
 
20
43
  export const EmailTrackingClient: React.FC = () => {
21
44
  const { t } = useTranslation()
@@ -29,94 +52,111 @@ export const EmailTrackingClient: React.FC = () => {
29
52
  const [page, setPage] = useState(1)
30
53
  const [hasMore, setHasMore] = useState(false)
31
54
  const [totalDocs, setTotalDocs] = useState(0)
55
+ const LIMIT = 30
56
+
57
+ const fetchStats = useCallback(async () => {
58
+ try { const r = await fetch(`/api/support/email-stats?days=${dateRange}`); if (r.ok) setStats(await r.json()) } catch {}
59
+ }, [dateRange])
32
60
 
33
- const fetchStats = useCallback(async () => { try { const r = await fetch(`/api/support/email-stats?days=${dateRange}`); if (r.ok) setStats(await r.json()) } catch (err) { console.warn('[support] fetchStats error:', err) } }, [dateRange])
34
61
  const fetchLogs = useCallback(async () => {
35
62
  const cutoff = new Date(Date.now() - dateRange * 86400000).toISOString()
36
63
  const where = [`where[createdAt][greater_than]=${cutoff}`]
37
64
  if (tab !== 'all') where.push(`where[status][equals]=${tab}`)
38
- if (search.trim()) { where.push(`where[or][0][recipientEmail][contains]=${encodeURIComponent(search)}`, `where[or][1][subject][contains]=${encodeURIComponent(search)}`) }
39
- try { const r = await fetch(`/api/email-logs?${where.join('&')}&sort=-createdAt&limit=30&page=${page}&depth=0`); if (r.ok) { const d = await r.json(); setLogs(d.docs); setHasMore(d.hasNextPage); setTotalDocs(d.totalDocs) } } catch (err) { console.warn('[support] fetchLogs error:', err) }
65
+ if (search.trim()) {
66
+ where.push(`where[or][0][recipientEmail][contains]=${encodeURIComponent(search)}`, `where[or][1][subject][contains]=${encodeURIComponent(search)}`)
67
+ }
68
+ try {
69
+ const r = await fetch(`/api/email-logs?${where.join('&')}&sort=-createdAt&limit=${LIMIT}&page=${page}&depth=0`)
70
+ if (r.ok) { const d = await r.json(); setLogs(d.docs); setHasMore(d.hasNextPage); setTotalDocs(d.totalDocs) }
71
+ } catch {}
40
72
  setLoading(false)
41
73
  }, [dateRange, tab, search, page])
42
74
 
43
75
  useEffect(() => { setLoading(true); setPage(1) }, [dateRange, tab, search])
44
76
  useEffect(() => { fetchStats(); fetchLogs() }, [fetchStats, fetchLogs])
45
77
 
46
- const S: Record<string, React.CSSProperties> = {
47
- page: { padding: '20px 30px', maxWidth: 1100, margin: '0 auto' },
48
- statGrid: { display: 'grid', gridTemplateColumns: 'repeat(4, 1fr)', gap: 12, marginBottom: 20 },
49
- statCard: { padding: '12px 16px', borderRadius: 8, border: '1px solid var(--theme-elevation-150)' },
50
- statLabel: { fontSize: 11, color: 'var(--theme-elevation-500)' },
51
- statValue: { fontSize: 22, fontWeight: 700, color: 'var(--theme-text)' },
52
- filters: { display: 'flex', gap: 12, alignItems: 'center', marginBottom: 16, flexWrap: 'wrap' as const },
53
- tab: { padding: '4px 10px', borderRadius: 6, border: 'none', background: 'none', cursor: 'pointer', fontSize: 12, color: 'var(--theme-elevation-500)' },
54
- tabActive: { background: 'var(--theme-elevation-100)', fontWeight: 700, color: 'var(--theme-text)' },
55
- dateBtn: { padding: '4px 8px', borderRadius: 4, border: '1px solid var(--theme-elevation-200)', fontSize: 11, cursor: 'pointer', background: 'var(--theme-elevation-0)', color: 'var(--theme-text)' },
56
- dateBtnActive: { background: '#2563eb', color: '#fff', borderColor: '#2563eb' },
57
- table: { width: '100%', borderCollapse: 'collapse' as const, fontSize: 13 },
58
- th: { textAlign: 'left' as const, padding: '6px 8px', borderBottom: '1px solid var(--theme-elevation-200)', fontSize: 11, color: 'var(--theme-elevation-500)' },
59
- td: { padding: '6px 8px', borderBottom: '1px solid var(--theme-elevation-100)' },
60
- badge: { padding: '2px 6px', borderRadius: 4, fontSize: 11, fontWeight: 600 },
61
- btn: { padding: '4px 10px', borderRadius: 6, border: '1px solid var(--theme-elevation-200)', fontSize: 11, cursor: 'pointer', background: 'var(--theme-elevation-0)', color: 'var(--theme-text)' },
62
- }
78
+ const tabsList: Array<{ key: StatusTab; label: string; count?: number }> = [
79
+ { key: 'all', label: t('emailTracking.tabs.all'), count: stats?.total },
80
+ { key: 'success', label: t('emailTracking.tabs.success'), count: stats?.success },
81
+ { key: 'error', label: t('emailTracking.tabs.errors'), count: stats?.errors },
82
+ { key: 'ignored', label: t('emailTracking.tabs.ignored'), count: stats?.ignored },
83
+ ]
84
+
85
+ if (loading && !stats) return <div className={s.loading}>{t('common.loading')}</div>
63
86
 
64
87
  return (
65
- <div style={S.page}>
66
- <h1 style={{ fontSize: 22, fontWeight: 700, marginBottom: 16, color: 'var(--theme-text)' }}>{t('emailTracking.title')}</h1>
67
-
68
- <div style={S.statGrid}>
69
- <div style={S.statCard}><div style={S.statLabel}>{t('emailTracking.stats.emailsSent')}</div><div style={S.statValue}>{stats?.total ?? '--'}</div></div>
70
- <div style={S.statCard}><div style={S.statLabel}>{t('emailTracking.stats.successRate')}</div><div style={S.statValue}>{stats ? `${stats.successRate}%` : '--'}</div></div>
71
- <div style={S.statCard}><div style={S.statLabel}>{t('emailTracking.stats.errors')}</div><div style={S.statValue}>{stats?.errors ?? '--'}</div></div>
72
- <div style={S.statCard}><div style={S.statLabel}>{t('emailTracking.stats.avgTime')}</div><div style={S.statValue}>{stats ? `${stats.avgProcessingTime}ms` : '--'}</div></div>
88
+ <div className={s.page}>
89
+ <div className={s.header}><h1 className={s.title}>{t('emailTracking.title')}</h1></div>
90
+
91
+ {/* Stats */}
92
+ <div className={s.statGrid}>
93
+ <div className={s.statCard}><div className={s.statLabel}>{t('emailTracking.stats.emailsSent')}</div><div className={s.statValue}>{stats?.total ?? ''}</div></div>
94
+ <div className={s.statCard}><div className={s.statLabel}>{t('emailTracking.stats.successRate')}</div><div className={s.statValue}>{stats ? `${stats.successRate}` : ''}<span className={s.statUnit}>%</span></div></div>
95
+ <div className={s.statCard}><div className={s.statLabel}>{t('emailTracking.stats.errors')}</div><div className={s.statValue}>{stats?.errors ?? ''}</div></div>
96
+ <div className={s.statCard}><div className={s.statLabel}>{t('emailTracking.stats.avgTime')}</div><div className={s.statValue}>{stats ? stats.avgProcessingTime : '—'}<span className={s.statUnit}>ms</span></div></div>
73
97
  </div>
74
98
 
75
- <div style={S.filters}>
76
- <div style={{ display: 'flex', gap: 4 }}>
77
- {(['all', 'success', 'error', 'ignored'] as StatusTab[]).map((st) => (
78
- <button key={st} style={{ ...S.tab, ...(tab === st ? S.tabActive : {}) }} onClick={() => setTab(st)}>{t(`emailTracking.tabs.${st === 'error' ? 'errors' : st}`)}</button>
99
+ {/* Filters */}
100
+ <div className={s.filters}>
101
+ <div className={s.tabs}>
102
+ {tabsList.map((t) => (
103
+ <button key={t.key} className={`${s.tab} ${tab === t.key ? s.tabActive : ''}`} onClick={() => setTab(t.key)}>
104
+ {t.label} {typeof t.count === 'number' && <span style={{ marginLeft: 4, opacity: 0.6 }}>({t.count})</span>}
105
+ </button>
79
106
  ))}
80
107
  </div>
81
- <div style={{ display: 'flex', gap: 4 }}>
108
+ <div className={s.dateRangeGroup}>
82
109
  {([7, 30, 90] as DateRange[]).map((v) => (
83
- <button key={v} style={{ ...S.dateBtn, ...(dateRange === v ? S.dateBtnActive : {}) }} onClick={() => setDateRange(v)}>{v}j</button>
110
+ <button key={v} className={`${s.dateRangeBtn} ${dateRange === v ? s.dateRangeActive : ''}`} onClick={() => setDateRange(v)}>{v}j</button>
84
111
  ))}
85
112
  </div>
86
- <input type="text" placeholder={t('emailTracking.searchPlaceholder')} value={search} onChange={(e) => setSearch(e.target.value)} style={{ padding: '6px 10px', borderRadius: 6, border: '1px solid var(--theme-elevation-200)', fontSize: 12, color: 'var(--theme-text)', background: 'var(--theme-elevation-0)' }} />
113
+ <input type="text" className={s.searchInput} placeholder={t('emailTracking.searchPlaceholder')} value={search} onChange={(e) => setSearch(e.target.value)} />
87
114
  </div>
88
115
 
89
- {loading ? <div style={{ padding: 40, textAlign: 'center', color: '#94a3b8' }}>{t('common.loading')}</div>
90
- : logs.length === 0 ? <div style={{ padding: 40, textAlign: 'center', color: '#94a3b8' }}>{t('emailTracking.noLogs')}</div>
91
- : (
92
- <table style={S.table}>
93
- <thead><tr><th style={S.th}>{t('emailTracking.tableHeaders.date')}</th><th style={S.th}>{t('emailTracking.tableHeaders.recipient')}</th><th style={S.th}>{t('emailTracking.tableHeaders.subject')}</th><th style={S.th}>{t('emailTracking.tableHeaders.ticket')}</th><th style={S.th}>{t('emailTracking.tableHeaders.status')}</th><th style={S.th}>{t('emailTracking.tableHeaders.action')}</th><th style={{ ...S.th, textAlign: 'right' }}>{t('emailTracking.tableHeaders.time')}</th></tr></thead>
94
- <tbody>
95
- {logs.map((log) => (
96
- <React.Fragment key={log.id}>
97
- <tr onClick={() => log.status === 'error' && log.errorMessage ? setExpandedRow(expandedRow === log.id ? null : log.id) : null} style={{ cursor: log.status === 'error' ? 'pointer' : 'default' }}>
98
- <td style={{ ...S.td, fontSize: 12, whiteSpace: 'nowrap' }}>{fmtDate(log.createdAt)}</td>
99
- <td style={{ ...S.td, maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{log.recipientEmail || '--'}</td>
100
- <td style={{ ...S.td, maxWidth: 250, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{log.subject || '--'}</td>
101
- <td style={S.td}>{log.ticketNumber || '--'}</td>
102
- <td style={S.td}><span style={{ ...S.badge, background: STATUS_CFG[log.status]?.bg, color: STATUS_CFG[log.status]?.color }}>{STATUS_CFG[log.status]?.key ? t(STATUS_CFG[log.status].key) : log.status}</span></td>
103
- <td style={{ ...S.td, fontSize: 12 }}>{log.action || '--'}</td>
104
- <td style={{ ...S.td, textAlign: 'right' }}>{log.processingTimeMs != null ? <span style={{ fontFamily: 'monospace', fontSize: 12, color: log.processingTimeMs > 2000 ? '#dc2626' : '#16a34a' }}>{log.processingTimeMs}ms</span> : '--'}</td>
105
- </tr>
106
- {expandedRow === log.id && log.errorMessage && (
107
- <tr><td colSpan={7}><div style={{ padding: '8px 12px', background: '#fef2f2', borderRadius: 6, fontSize: 12, color: '#dc2626' }}>Erreur{log.httpStatus ? ` (HTTP ${log.httpStatus})` : ''}: {log.errorMessage}</div></td></tr>
108
- )}
109
- </React.Fragment>
110
- ))}
111
- </tbody>
112
- </table>
113
- )}
116
+ {/* Table */}
117
+ {logs.length === 0 ? (
118
+ <div className={s.empty}>{t('emailTracking.noLogs')}</div>
119
+ ) : (
120
+ <table className={s.table}>
121
+ <thead>
122
+ <tr><th>{t('emailTracking.tableHeaders.date')}</th><th>{t('emailTracking.tableHeaders.recipient')}</th><th>{t('emailTracking.tableHeaders.subject')}</th><th>{t('emailTracking.tableHeaders.ticket')}</th><th>{t('emailTracking.tableHeaders.status')}</th><th>{t('emailTracking.tableHeaders.action')}</th><th style={{ textAlign: 'right' }}>{t('emailTracking.tableHeaders.time')}</th></tr>
123
+ </thead>
124
+ <tbody>
125
+ {logs.map((log) => (
126
+ <React.Fragment key={log.id}>
127
+ <tr onClick={() => log.status === 'error' && log.errorMessage ? setExpandedRow(expandedRow === log.id ? null : log.id) : null} style={{ cursor: log.status === 'error' && log.errorMessage ? 'pointer' : 'default' }}>
128
+ <td style={{ fontSize: 12, whiteSpace: 'nowrap' }}>{fmtDate(log.createdAt)}</td>
129
+ <td style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={log.recipientEmail}>{log.recipientEmail || '—'}</td>
130
+ <td style={{ maxWidth: 250, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={log.subject}>{log.subject || ''}</td>
131
+ <td>{log.ticketNumber ? <span className={s.ticketLink}>{log.ticketNumber}</span> : ''}</td>
132
+ <td><span className={s.statusBadge} style={{ background: STATUS_CFG[log.status]?.bg, color: STATUS_CFG[log.status]?.color }}>{STATUS_CFG[log.status]?.label || log.status}</span></td>
133
+ <td style={{ fontSize: 12 }}>{log.action || '—'}</td>
134
+ <td style={{ textAlign: 'right' }}>
135
+ {log.processingTimeMs != null ? (
136
+ <span className={`${s.processingTime} ${log.processingTimeMs > 2000 ? s.timeSlow : log.processingTimeMs > 500 ? s.timeMedium : s.timeFast}`}>{log.processingTimeMs}ms</span>
137
+ ) : '—'}
138
+ {log.status === 'error' && log.errorMessage && (
139
+ <button className={s.expandBtn} onClick={(e) => { e.stopPropagation(); setExpandedRow(expandedRow === log.id ? null : log.id) }}>
140
+ {expandedRow === log.id ? '▴' : '▾'}
141
+ </button>
142
+ )}
143
+ </td>
144
+ </tr>
145
+ {expandedRow === log.id && log.errorMessage && (
146
+ <tr><td colSpan={7}><div className={s.errorDetail}>Erreur{log.httpStatus ? ` (HTTP ${log.httpStatus})` : ''}: {log.errorMessage}</div></td></tr>
147
+ )}
148
+ </React.Fragment>
149
+ ))}
150
+ </tbody>
151
+ </table>
152
+ )}
114
153
 
154
+ {/* Pagination */}
115
155
  {totalDocs > 0 && (
116
- <div style={{ display: 'flex', justifyContent: 'center', gap: 12, marginTop: 16, alignItems: 'center' }}>
117
- <button style={S.btn} onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>{t('common.previous')}</button>
118
- <span style={{ fontSize: 12, color: 'var(--theme-elevation-500)' }}>{t('common.page')} {page} -- {totalDocs} {t('common.results')}</span>
119
- <button style={S.btn} onClick={() => setPage((p) => p + 1)} disabled={!hasMore}>{t('common.next')}</button>
156
+ <div className={s.pagination}>
157
+ <button className={s.pageBtn} onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>{t('common.previous')}</button>
158
+ <span style={{ fontSize: 12, color: 'var(--theme-elevation-500)', alignSelf: 'center' }}>{t('common.page')} {page} {totalDocs} {t('common.results')}</span>
159
+ <button className={s.pageBtn} onClick={() => setPage((p) => p + 1)} disabled={!hasMore}>{t('common.next')}</button>
120
160
  </div>
121
161
  )}
122
162
  </div>
@@ -2,11 +2,32 @@
2
2
 
3
3
  import React, { useState, useCallback, useRef } from 'react'
4
4
  import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
5
- import s from '../../styles/ImportConversation.module.scss'
5
+ import styles from '../../styles/ImportConversation.module.scss'
6
6
 
7
- interface PreviewMessage { from: 'client' | 'admin'; name: string; date: string; preview: string }
8
- interface PreviewData { client: { email: string; name: string; company: string }; subject: string; messageCount: number; messages: PreviewMessage[]; parseMethod: string }
9
- interface ImportResult { ticketNumber: string; ticketId: number; clientEmail: string; clientName: string; clientCompany: string; isNewClient: boolean; messagesImported: number }
7
+ interface PreviewMessage {
8
+ from: 'client' | 'admin'
9
+ name: string
10
+ date: string
11
+ preview: string
12
+ }
13
+
14
+ interface PreviewData {
15
+ client: { email: string; name: string; company: string }
16
+ subject: string
17
+ messageCount: number
18
+ messages: PreviewMessage[]
19
+ parseMethod: string
20
+ }
21
+
22
+ interface ImportResult {
23
+ ticketNumber: string
24
+ ticketId: number
25
+ clientEmail: string
26
+ clientName: string
27
+ clientCompany: string
28
+ isNewClient: boolean
29
+ messagesImported: number
30
+ }
10
31
 
11
32
  export function ImportConversationClient() {
12
33
  const { t } = useTranslation()
@@ -20,112 +41,276 @@ export function ImportConversationClient() {
20
41
  const fileRef = useRef<HTMLInputElement>(null)
21
42
 
22
43
  const handleFile = useCallback((file: File) => {
23
- if (!file.name.endsWith('.md') && !file.name.endsWith('.txt')) { setError(t('import.formatError')); return }
24
- if (file.size > 512_000) { setError(t('import.sizeError')); return }
25
- setError(''); setFileName(file.name); setResult(null); setPreview(null)
44
+ if (!file.name.endsWith('.md') && !file.name.endsWith('.txt')) {
45
+ setError(t('import.formatError'))
46
+ return
47
+ }
48
+ if (file.size > 512_000) {
49
+ setError(t('import.sizeError'))
50
+ return
51
+ }
52
+
53
+ setError('')
54
+ setFileName(file.name)
55
+ setResult(null)
56
+ setPreview(null)
57
+
26
58
  const reader = new FileReader()
27
- reader.onload = (e) => { setMarkdown(e.target?.result as string) }
59
+ reader.onload = (e) => {
60
+ const content = e.target?.result as string
61
+ setMarkdown(content)
62
+ }
28
63
  reader.readAsText(file)
29
64
  }, [])
30
65
 
31
- const onDrop = useCallback((e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); const file = e.dataTransfer.files[0]; if (file) handleFile(file) }, [handleFile])
32
- const onFileChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => { const file = e.target.files?.[0]; if (file) handleFile(file) }, [handleFile])
66
+ const onDrop = useCallback(
67
+ (e: React.DragEvent) => {
68
+ e.preventDefault()
69
+ setIsDragOver(false)
70
+ const file = e.dataTransfer.files[0]
71
+ if (file) handleFile(file)
72
+ },
73
+ [handleFile],
74
+ )
75
+
76
+ const onFileChange = useCallback(
77
+ (e: React.ChangeEvent<HTMLInputElement>) => {
78
+ const file = e.target.files?.[0]
79
+ if (file) handleFile(file)
80
+ },
81
+ [handleFile],
82
+ )
33
83
 
34
84
  const doPreview = useCallback(async () => {
35
- if (!markdown) return; setLoading(true); setError(''); setPreview(null)
36
- try { const res = await fetch('/api/support/import-conversation', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ markdown, previewOnly: true }) }); const data = await res.json(); if (!res.ok) { setError(data.error || 'Erreur'); return }; setPreview(data) } catch { setError('Erreur reseau') } finally { setLoading(false) }
85
+ if (!markdown) return
86
+ setLoading(true)
87
+ setError('')
88
+ setPreview(null)
89
+
90
+ try {
91
+ const res = await fetch('/api/support/import-conversation', {
92
+ method: 'POST',
93
+ headers: { 'Content-Type': 'application/json' },
94
+ body: JSON.stringify({ markdown, previewOnly: true }),
95
+ })
96
+
97
+ const data = await res.json()
98
+ if (!res.ok) {
99
+ setError(data.error || 'Erreur lors de l\'analyse')
100
+ return
101
+ }
102
+
103
+ setPreview(data)
104
+ } catch {
105
+ setError('Erreur reseau')
106
+ } finally {
107
+ setLoading(false)
108
+ }
37
109
  }, [markdown])
38
110
 
39
111
  const doImport = useCallback(async () => {
40
- if (!markdown) return; setLoading(true); setError('')
41
- try { const res = await fetch('/api/support/import-conversation', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ markdown }) }); const data = await res.json(); if (!res.ok) { setError(data.error || 'Erreur'); return }; setResult(data); setPreview(null) } catch { setError('Erreur reseau') } finally { setLoading(false) }
112
+ if (!markdown) return
113
+ setLoading(true)
114
+ setError('')
115
+
116
+ try {
117
+ const res = await fetch('/api/support/import-conversation', {
118
+ method: 'POST',
119
+ headers: { 'Content-Type': 'application/json' },
120
+ body: JSON.stringify({ markdown }),
121
+ })
122
+
123
+ const data = await res.json()
124
+ if (!res.ok) {
125
+ setError(data.error || 'Erreur lors de l\'import')
126
+ return
127
+ }
128
+
129
+ setResult(data)
130
+ setPreview(null)
131
+ } catch {
132
+ setError('Erreur reseau')
133
+ } finally {
134
+ setLoading(false)
135
+ }
42
136
  }, [markdown])
43
137
 
44
- const reset = useCallback(() => { setMarkdown(''); setFileName(''); setPreview(null); setResult(null); setError(''); if (fileRef.current) fileRef.current.value = '' }, [])
45
-
46
- const S: Record<string, React.CSSProperties> = {
47
- page: { padding: '20px 30px', maxWidth: 720, margin: '0 auto' },
48
- dropzone: { border: '2px dashed var(--theme-elevation-300)', borderRadius: 12, padding: 40, textAlign: 'center' as const, cursor: 'pointer', transition: 'all 150ms' },
49
- dropzoneDragOver: { border: '2px dashed #2563eb', borderRadius: 12, padding: 40, textAlign: 'center' as const, cursor: 'pointer', background: '#eff6ff' },
50
- dropzoneHasFile: { border: '2px solid #22c55e', borderRadius: 12, padding: 40, textAlign: 'center' as const, cursor: 'pointer', background: '#f0fdf4' },
51
- btn: { padding: '8px 16px', borderRadius: 8, border: '1px solid var(--theme-elevation-200)', fontSize: 13, cursor: 'pointer', background: 'var(--theme-elevation-0)', color: 'var(--theme-text)', fontWeight: 600 },
52
- btnPrimary: { padding: '8px 16px', borderRadius: 8, border: 'none', fontSize: 13, cursor: 'pointer', background: '#2563eb', color: '#fff', fontWeight: 600 },
53
- btnGreen: { padding: '8px 16px', borderRadius: 8, border: 'none', fontSize: 13, cursor: 'pointer', background: '#16a34a', color: '#fff', fontWeight: 600 },
54
- btnAmber: { padding: '8px 16px', borderRadius: 8, border: 'none', fontSize: 13, cursor: 'pointer', background: '#d97706', color: '#fff', fontWeight: 600 },
55
- section: { padding: 16, borderRadius: 10, border: '1px solid var(--theme-elevation-150)', marginBottom: 12 },
56
- infoRow: { display: 'flex', justifyContent: 'space-between', padding: '4px 0', fontSize: 13 },
57
- infoLabel: { color: 'var(--theme-elevation-500)' },
58
- infoValue: { fontWeight: 600, color: 'var(--theme-text)' },
59
- msgAdmin: { padding: '8px 12px', borderRadius: 8, background: '#dbeafe', marginBottom: 6 },
60
- msgClient: { padding: '8px 12px', borderRadius: 8, background: 'var(--theme-elevation-50)', marginBottom: 6 },
61
- resultSuccess: { padding: 20, borderRadius: 10, border: '2px solid #22c55e', background: '#f0fdf4' },
62
- resultError: { padding: 16, borderRadius: 10, border: '1px solid #fecaca', background: '#fef2f2', color: '#dc2626', marginBottom: 12 },
63
- }
138
+ const reset = useCallback(() => {
139
+ setMarkdown('')
140
+ setFileName('')
141
+ setPreview(null)
142
+ setResult(null)
143
+ setError('')
144
+ if (fileRef.current) fileRef.current.value = ''
145
+ }, [])
146
+
147
+ const dropzoneClass = isDragOver
148
+ ? styles.dropzoneDragOver
149
+ : markdown
150
+ ? styles.dropzoneHasFile
151
+ : styles.dropzone
64
152
 
65
153
  return (
66
- <div style={S.page}>
67
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 20 }}>
68
- <h1 style={{ fontSize: 22, fontWeight: 700, margin: 0 }}>{t('import.title')}</h1>
69
- {result && <button onClick={reset} style={S.btnPrimary}>{t('import.newImport')}</button>}
154
+ <div className={styles.page}>
155
+ {/* Header */}
156
+ <div className={styles.header}>
157
+ <div>
158
+ <h1 className={styles.title}>{t('import.title')}</h1>
159
+ </div>
160
+ {result && (
161
+ <button onClick={reset} className={styles.btnPrimary}>
162
+ {t('import.newImport')}
163
+ </button>
164
+ )}
70
165
  </div>
71
166
 
167
+ {/* Drop zone */}
72
168
  {!result && (
73
- <div style={isDragOver ? S.dropzoneDragOver : markdown ? S.dropzoneHasFile : S.dropzone} onDragOver={(e) => { e.preventDefault(); setIsDragOver(true) }} onDragLeave={() => setIsDragOver(false)} onDrop={onDrop} onClick={() => fileRef.current?.click()}>
74
- <div style={{ fontSize: 24, marginBottom: 8 }}>&#8593;</div>
75
- <p style={{ fontSize: 14, color: 'var(--theme-text)' }}>{markdown ? t('import.dropzoneLoaded') : t('import.dropzoneText')}</p>
76
- {!markdown && <p style={{ fontSize: 12, color: 'var(--theme-elevation-400)', marginTop: 8 }}>{t('import.acceptedFormats')}</p>}
77
- {fileName && <p style={{ fontSize: 12, fontWeight: 600, marginTop: 8 }}>{fileName}</p>}
78
- <input ref={fileRef} type="file" accept=".md,.txt" onChange={onFileChange} style={{ display: 'none' }} />
169
+ <div
170
+ className={dropzoneClass}
171
+ onDragOver={(e) => {
172
+ e.preventDefault()
173
+ setIsDragOver(true)
174
+ }}
175
+ onDragLeave={() => setIsDragOver(false)}
176
+ onDrop={onDrop}
177
+ onClick={() => fileRef.current?.click()}
178
+ >
179
+ <div className={styles.dropzoneIcon}>&#8593;</div>
180
+ <p className={styles.dropzoneText}>
181
+ {markdown
182
+ ? t('import.dropzoneLoaded')
183
+ : t('import.dropzoneText')}
184
+ </p>
185
+ {!markdown && (
186
+ <p style={{ fontSize: 12, color: 'var(--theme-elevation-400)', marginTop: 8 }}>
187
+ {t('import.acceptedFormats')}
188
+ </p>
189
+ )}
190
+ {fileName && <p className={styles.fileName}>{fileName}</p>}
191
+ <input
192
+ ref={fileRef}
193
+ type="file"
194
+ accept=".md,.txt"
195
+ onChange={onFileChange}
196
+ style={{ display: 'none' }}
197
+ />
79
198
  </div>
80
199
  )}
81
200
 
82
- {error && <div style={S.resultError}><strong>{t('common.error')}</strong><p style={{ margin: '4px 0 0' }}>{error}</p></div>}
201
+ {/* Error */}
202
+ {error && (
203
+ <div className={styles.resultError}>
204
+ <div className={styles.resultTitleError}>
205
+ &#9888; Erreur
206
+ </div>
207
+ <p className={styles.errorText}>{error}</p>
208
+ </div>
209
+ )}
83
210
 
211
+ {/* Actions */}
84
212
  {markdown && !preview && !result && (
85
- <div style={{ display: 'flex', justifyContent: 'center', marginTop: 16 }}>
86
- <button onClick={doPreview} disabled={loading} style={S.btnAmber}>{loading ? t('import.analyzing') : t('import.preview')}</button>
213
+ <div className={styles.btnRow}>
214
+ <button onClick={doPreview} disabled={loading} className={styles.btnAmber}>
215
+ <span className={styles.btnContent}>
216
+ &#128065; {loading ? t('import.analyzing') : t('import.preview')}
217
+ </span>
218
+ </button>
87
219
  </div>
88
220
  )}
89
221
 
222
+ {/* Preview */}
90
223
  {preview && (
91
224
  <>
92
- <div style={S.section}>
93
- <div style={{ fontWeight: 700, marginBottom: 8 }}>{t('import.client')}</div>
94
- <div style={S.infoRow}><span style={S.infoLabel}>{t('import.name')}</span><span style={S.infoValue}>{preview.client.name}</span></div>
95
- <div style={S.infoRow}><span style={S.infoLabel}>{t('import.email')}</span><span style={S.infoValue}>{preview.client.email}</span></div>
96
- <div style={S.infoRow}><span style={S.infoLabel}>{t('import.company')}</span><span style={S.infoValue}>{preview.client.company}</span></div>
97
- <div style={S.infoRow}><span style={S.infoLabel}>{t('import.parsing')}</span><span style={S.infoValue}>{preview.parseMethod === 'structured' ? t('import.parsingRegex') : t('import.parsingAi')}</span></div>
225
+ <div className={styles.section}>
226
+ <div className={styles.sectionTitle}>
227
+ &#128100; {t('import.client')}
228
+ </div>
229
+ <div className={styles.infoRow}>
230
+ <span className={styles.infoLabel}>{t('import.name')}</span>
231
+ <span className={styles.infoValue}>{preview.client.name}</span>
232
+ </div>
233
+ <div className={styles.infoRow}>
234
+ <span className={styles.infoLabel}>{t('import.email')}</span>
235
+ <span className={styles.infoValue}>{preview.client.email}</span>
236
+ </div>
237
+ <div className={styles.infoRow}>
238
+ <span className={styles.infoLabel}>{t('import.company')}</span>
239
+ <span className={styles.infoValue}>{preview.client.company}</span>
240
+ </div>
241
+ <div className={styles.infoRow}>
242
+ <span className={styles.infoLabel}>{t('import.parsing')}</span>
243
+ <span className={styles.infoValue}>
244
+ <span className={styles.badgeInfo}>
245
+ {preview.parseMethod === 'structured' ? t('import.parsingRegex') : t('import.parsingAi')}
246
+ </span>
247
+ </span>
248
+ </div>
98
249
  </div>
99
250
 
100
- <div style={S.section}>
101
- <div style={{ fontWeight: 700, marginBottom: 8, display: 'flex', justifyContent: 'space-between' }}>
102
- <span>{preview.subject}</span><span style={{ color: 'var(--theme-elevation-500)', fontSize: 12 }}>{preview.messageCount} {t('import.messages')}</span>
251
+ <div className={styles.section}>
252
+ <div className={styles.sectionTitle}>
253
+ &#128172; {preview.subject}
254
+ <span className={styles.sectionTitleRight}>
255
+ {preview.messageCount} {t('import.messages')}
256
+ </span>
103
257
  </div>
104
258
  {preview.messages.map((msg, i) => (
105
- <div key={i} style={msg.from === 'admin' ? S.msgAdmin : S.msgClient}>
106
- <div style={{ display: 'flex', justifyContent: 'space-between', fontSize: 12, marginBottom: 4 }}>
107
- <span style={{ fontWeight: 600 }}>{msg.from === 'admin' ? '>> ' : '<< '}{msg.name}</span>
108
- <span style={{ color: 'var(--theme-elevation-400)' }}>{msg.date}</span>
259
+ <div key={i} className={msg.from === 'admin' ? styles.msgAdmin : styles.msgClient}>
260
+ <div className={styles.msgHeader}>
261
+ <span className={styles.msgAuthor}>
262
+ {msg.from === 'admin' ? '>> ' : '<< '}
263
+ {msg.name}
264
+ </span>
265
+ <span className={styles.msgDate}>{msg.date}</span>
109
266
  </div>
110
- <div style={{ fontSize: 13 }}>{msg.preview}</div>
267
+ <div className={styles.msgPreview}>{msg.preview}</div>
111
268
  </div>
112
269
  ))}
113
270
  </div>
114
271
 
115
- <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 12 }}>
116
- <button onClick={() => setPreview(null)} style={S.btn}>{t('common.cancel')}</button>
117
- <button onClick={doImport} disabled={loading} style={S.btnGreen}>{loading ? t('import.importing') : t('import.importButton', { count: String(preview.messageCount) })}</button>
272
+ <div className={styles.btnRow}>
273
+ <button onClick={() => setPreview(null)} className={styles.btnMuted}>
274
+ {t('common.cancel')}
275
+ </button>
276
+ <button onClick={doImport} disabled={loading} className={styles.btnGreen}>
277
+ <span className={styles.btnContent}>
278
+ &#10140; {loading ? t('import.importing') : t('import.importButton', { count: String(preview.messageCount) })}
279
+ </span>
280
+ </button>
118
281
  </div>
119
282
  </>
120
283
  )}
121
284
 
285
+ {/* Result */}
122
286
  {result && (
123
- <div style={S.resultSuccess}>
124
- <div style={{ fontWeight: 700, fontSize: 16, marginBottom: 12, color: '#166534' }}>{t('import.resultTitle')}</div>
125
- <div style={S.infoRow}><span style={S.infoLabel}>{t('import.resultTicket')}</span><span style={S.infoValue}><a href={`/admin/support/ticket?id=${result.ticketId}`} style={{ color: '#2563eb' }}>{result.ticketNumber}</a></span></div>
126
- <div style={S.infoRow}><span style={S.infoLabel}>{t('import.resultClient')}</span><span style={S.infoValue}>{result.clientName} ({result.clientEmail}){result.isNewClient && <span style={{ marginLeft: 8, padding: '1px 6px', borderRadius: 4, background: '#dbeafe', color: '#1e40af', fontSize: 10 }}>{t('import.resultNew')}</span>}</span></div>
127
- <div style={S.infoRow}><span style={S.infoLabel}>{t('import.resultCompany')}</span><span style={S.infoValue}>{result.clientCompany}</span></div>
128
- <div style={S.infoRow}><span style={S.infoLabel}>{t('import.resultMessages')}</span><span style={S.infoValue}>{t('import.resultImported', { count: String(result.messagesImported) })}</span></div>
287
+ <div className={styles.resultSuccess}>
288
+ <div className={styles.resultTitleSuccess}>
289
+ &#10003; {t('import.resultTitle')}
290
+ </div>
291
+ <div className={styles.infoRow}>
292
+ <span className={styles.infoLabel}>{t('import.resultTicket')}</span>
293
+ <span className={styles.infoValue}>
294
+ <a href={`/admin/support/ticket?id=${result.ticketId}`} className={styles.link}>
295
+ {result.ticketNumber}
296
+ </a>
297
+ </span>
298
+ </div>
299
+ <div className={styles.infoRow}>
300
+ <span className={styles.infoLabel}>{t('import.resultClient')}</span>
301
+ <span className={styles.infoValue}>
302
+ {result.clientName} ({result.clientEmail})
303
+ {result.isNewClient && <span className={styles.badgeNew} style={{ marginLeft: 8 }}>{t('import.resultNew')}</span>}
304
+ </span>
305
+ </div>
306
+ <div className={styles.infoRow}>
307
+ <span className={styles.infoLabel}>{t('import.resultCompany')}</span>
308
+ <span className={styles.infoValue}>{result.clientCompany}</span>
309
+ </div>
310
+ <div className={styles.infoRow}>
311
+ <span className={styles.infoLabel}>{t('import.resultMessages')}</span>
312
+ <span className={styles.infoValue}>{t('import.resultImported', { count: String(result.messagesImported) })}</span>
313
+ </div>
129
314
  </div>
130
315
  )}
131
316
  </div>