@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.
- 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 -1
- package/dist/index.js +19 -1
- 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/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
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
import React, { useState, useEffect, useCallback, useRef } from 'react'
|
|
4
4
|
import { useSearchParams } from 'next/navigation'
|
|
5
5
|
import Link from 'next/link'
|
|
6
|
+
import { RichTextEditor, type RichTextEditorHandle } from '../../components/RichTextEditor/index'
|
|
7
|
+
import { hasCodeBlocks, MessageWithCodeBlocks, CodeBlockRendererHtml } from '../../components/TicketConversation/components/CodeBlock'
|
|
8
|
+
import { CodeBlockInserter } from '../../components/TicketConversation/components/CodeBlockInserter'
|
|
6
9
|
import { getFeatures } from '../shared/config'
|
|
7
10
|
import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
|
|
8
11
|
import s from '../../styles/TicketDetail.module.scss'
|
|
@@ -16,10 +19,10 @@ interface ClientInfo { id: number; company: string; firstName: string; lastName:
|
|
|
16
19
|
interface TimeEntry { id: string | number; duration: number; description?: string; date: string }
|
|
17
20
|
interface ActivityEntry { id: string | number; action: string; detail?: string; actorType?: string; createdAt: string }
|
|
18
21
|
|
|
19
|
-
const
|
|
20
|
-
open: {
|
|
21
|
-
waiting_client: {
|
|
22
|
-
resolved: {
|
|
22
|
+
const STATUS_STYLE: Record<string, { bg: string; color: string }> = {
|
|
23
|
+
open: { bg: '#dbeafe', color: '#1e40af' },
|
|
24
|
+
waiting_client: { bg: '#fef3c7', color: '#92400e' },
|
|
25
|
+
resolved: { bg: '#dcfce7', color: '#166534' },
|
|
23
26
|
}
|
|
24
27
|
|
|
25
28
|
function timeAgo(d: string): string {
|
|
@@ -38,11 +41,81 @@ function dateLabel(d: string): string {
|
|
|
38
41
|
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' })
|
|
39
42
|
}
|
|
40
43
|
|
|
44
|
+
// ── Rewrite style dropdown ──────────────────────────────────────────────
|
|
45
|
+
const REWRITE_STYLES = [
|
|
46
|
+
{ id: 'auto', label: '✏️ Auto', desc: 'Garde le ton actuel' },
|
|
47
|
+
{ id: 'tutoyer', label: '👋 Tutoyer', desc: 'Passe en tu' },
|
|
48
|
+
{ id: 'vouvoyer', label: '🎩 Vouvoyer', desc: 'Passe en vous' },
|
|
49
|
+
{ id: 'formel', label: '💼 Formel', desc: 'Ton professionnel' },
|
|
50
|
+
{ id: 'amical', label: '😊 Amical', desc: 'Ton chaleureux' },
|
|
51
|
+
{ id: 'court', label: '⚡ Court', desc: 'Version concise' },
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
const RewriteDropdown: React.FC<{
|
|
55
|
+
disabled: boolean
|
|
56
|
+
loading: boolean
|
|
57
|
+
onSelect: (style: string) => void
|
|
58
|
+
toolbarBtnClass?: string
|
|
59
|
+
}> = ({ disabled, loading, onSelect, toolbarBtnClass }) => {
|
|
60
|
+
const [open, setOpen] = useState(false)
|
|
61
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
62
|
+
|
|
63
|
+
useEffect(() => {
|
|
64
|
+
if (!open) return
|
|
65
|
+
const close = (e: MouseEvent) => {
|
|
66
|
+
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
|
67
|
+
}
|
|
68
|
+
document.addEventListener('mousedown', close)
|
|
69
|
+
return () => document.removeEventListener('mousedown', close)
|
|
70
|
+
}, [open])
|
|
71
|
+
|
|
72
|
+
return (
|
|
73
|
+
<div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
|
|
74
|
+
<button
|
|
75
|
+
type="button"
|
|
76
|
+
className={toolbarBtnClass}
|
|
77
|
+
onClick={() => setOpen(!open)}
|
|
78
|
+
disabled={disabled}
|
|
79
|
+
style={{ fontSize: 11, fontWeight: 700, padding: '4px 10px', width: 'auto', display: 'flex', alignItems: 'center', gap: 4 }}
|
|
80
|
+
>
|
|
81
|
+
{loading ? '...' : '✏️ Reformuler'}
|
|
82
|
+
{!loading && <span style={{ fontSize: 9, opacity: 0.6 }}>▼</span>}
|
|
83
|
+
</button>
|
|
84
|
+
{open && !disabled && !loading && (
|
|
85
|
+
<div style={{
|
|
86
|
+
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4,
|
|
87
|
+
background: '#fff', border: '1px solid #e5e7eb', borderRadius: 8,
|
|
88
|
+
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', zIndex: 100, minWidth: 180, overflow: 'hidden',
|
|
89
|
+
}}>
|
|
90
|
+
{REWRITE_STYLES.map((style) => (
|
|
91
|
+
<button
|
|
92
|
+
key={style.id}
|
|
93
|
+
type="button"
|
|
94
|
+
onClick={() => { setOpen(false); onSelect(style.id) }}
|
|
95
|
+
style={{
|
|
96
|
+
display: 'flex', flexDirection: 'column', width: '100%', padding: '8px 12px',
|
|
97
|
+
border: 'none', background: 'transparent', cursor: 'pointer', textAlign: 'left',
|
|
98
|
+
borderBottom: '1px solid #f3f4f6',
|
|
99
|
+
}}
|
|
100
|
+
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = '#f9fafb' }}
|
|
101
|
+
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = 'transparent' }}
|
|
102
|
+
>
|
|
103
|
+
<span style={{ fontSize: 12, fontWeight: 600 }}>{style.label}</span>
|
|
104
|
+
<span style={{ fontSize: 10, color: '#9ca3af' }}>{style.desc}</span>
|
|
105
|
+
</button>
|
|
106
|
+
))}
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
112
|
+
|
|
41
113
|
export const TicketDetailClient: React.FC = () => {
|
|
42
114
|
const { t } = useTranslation()
|
|
43
115
|
const searchParams = useSearchParams()
|
|
44
116
|
const ticketId = searchParams.get('id')
|
|
45
117
|
const features = getFeatures()
|
|
118
|
+
const editorRef = useRef<RichTextEditorHandle>(null)
|
|
46
119
|
const threadEndRef = useRef<HTMLDivElement>(null)
|
|
47
120
|
const dropdownRef = useRef<HTMLDivElement>(null)
|
|
48
121
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
@@ -56,17 +129,21 @@ export const TicketDetailClient: React.FC = () => {
|
|
|
56
129
|
const [loading, setLoading] = useState(true)
|
|
57
130
|
|
|
58
131
|
const [replyBody, setReplyBody] = useState('')
|
|
132
|
+
const [replyHtml, setReplyHtml] = useState('')
|
|
59
133
|
const [isInternal, setIsInternal] = useState(false)
|
|
60
134
|
const [notifyClient, setNotifyClient] = useState(true)
|
|
61
135
|
const [sending, setSending] = useState(false)
|
|
62
136
|
|
|
63
137
|
const [showMenu, setShowMenu] = useState(false)
|
|
64
|
-
|
|
138
|
+
const [clientTyping, setClientTyping] = useState(false)
|
|
65
139
|
const [aiReplying, setAiReplying] = useState(false)
|
|
66
140
|
const [aiRewriting, setAiRewriting] = useState(false)
|
|
67
141
|
const [sentiment, setSentiment] = useState<{ emoji: string; label: string; color: string } | null>(null)
|
|
68
142
|
const [statusUpdating, setStatusUpdating] = useState(false)
|
|
69
143
|
const [showActivity, setShowActivity] = useState(false)
|
|
144
|
+
// Client Intelligence
|
|
145
|
+
const [clientSummary, setClientSummary] = useState<{ summary: string; recurringTopics?: { topic: string; count: number }[]; keyFacts?: string[] } | null>(null)
|
|
146
|
+
const [summaryLoading, setSummaryLoading] = useState(false)
|
|
70
147
|
const [timerRunning, setTimerRunning] = useState(() => {
|
|
71
148
|
if (typeof window === 'undefined' || !ticketId) return false
|
|
72
149
|
return localStorage.getItem(`timer-run-${ticketId}`) === '1'
|
|
@@ -87,23 +164,23 @@ export const TicketDetailClient: React.FC = () => {
|
|
|
87
164
|
const [macros, setMacros] = useState<Array<{ id: number; name: string }>>([])
|
|
88
165
|
const [applyingMacro, setApplyingMacro] = useState(false)
|
|
89
166
|
|
|
90
|
-
// Undo toast state
|
|
167
|
+
// #2 — Undo toast state
|
|
91
168
|
const [undoToast, setUndoToast] = useState<{ msgId: string | number; timer: ReturnType<typeof setTimeout> } | null>(null)
|
|
92
169
|
|
|
93
|
-
// Split modal state
|
|
170
|
+
// #2 — Split modal state
|
|
94
171
|
const [splitModal, setSplitModal] = useState<{ messageId: string | number; preview: string } | null>(null)
|
|
95
172
|
const [splitSubject, setSplitSubject] = useState('')
|
|
96
173
|
|
|
97
|
-
// File upload state
|
|
174
|
+
// #5 — File upload state
|
|
98
175
|
const [pendingFiles, setPendingFiles] = useState<File[]>([])
|
|
99
176
|
const [composerDragOver, setComposerDragOver] = useState(false)
|
|
100
177
|
|
|
101
|
-
// Tags state
|
|
178
|
+
// #6 — Tags state
|
|
102
179
|
const [tags, setTags] = useState<string[]>([])
|
|
103
180
|
const [addingTag, setAddingTag] = useState(false)
|
|
104
181
|
const [newTagValue, setNewTagValue] = useState('')
|
|
105
182
|
|
|
106
|
-
//
|
|
183
|
+
// ─── DATA FETCHING ───
|
|
107
184
|
const fetchAll = useCallback(async () => {
|
|
108
185
|
if (!ticketId) return
|
|
109
186
|
try {
|
|
@@ -119,6 +196,7 @@ export const TicketDetailClient: React.FC = () => {
|
|
|
119
196
|
const d = await tr.json()
|
|
120
197
|
setTicket(d)
|
|
121
198
|
if (d.client && typeof d.client === 'object') setClient(d.client)
|
|
199
|
+
// #6 — Sync tags
|
|
122
200
|
if (Array.isArray(d.tags)) setTags(d.tags.map((t: { tag?: string } | string) => typeof t === 'object' ? (t.tag || '') : t).filter(Boolean))
|
|
123
201
|
}
|
|
124
202
|
if (ter.ok) { const d = await ter.json(); setTimeEntries(d.docs || []) }
|
|
@@ -128,52 +206,77 @@ export const TicketDetailClient: React.FC = () => {
|
|
|
128
206
|
setLoading(false)
|
|
129
207
|
}, [ticketId])
|
|
130
208
|
|
|
209
|
+
// Circuit breaker: stop polling after consecutive failures
|
|
210
|
+
const failCountRef = useRef({ messages: 0, typing: 0, presence: 0 })
|
|
211
|
+
const MAX_FAILS = 3
|
|
212
|
+
|
|
131
213
|
useEffect(() => { fetchAll() }, [fetchAll])
|
|
214
|
+
// Fetch client summary when ticket loads
|
|
132
215
|
useEffect(() => {
|
|
216
|
+
if (!ticket) return
|
|
217
|
+
const clientId = typeof ticket.client === 'object' ? (ticket.client as any)?.id : ticket.client
|
|
218
|
+
if (!clientId) return
|
|
219
|
+
setSummaryLoading(true)
|
|
220
|
+
fetch(`/api/support/client-intelligence?clientId=${clientId}`, { credentials: 'include' })
|
|
221
|
+
.then((r) => r.ok ? r.json() : null)
|
|
222
|
+
.then((d) => { if (d) setClientSummary(d) })
|
|
223
|
+
.catch(() => {})
|
|
224
|
+
.finally(() => setSummaryLoading(false))
|
|
225
|
+
}, [ticket?.id]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
226
|
+
useEffect(() => { // Poll 10s
|
|
133
227
|
if (!ticketId || loading) return
|
|
134
228
|
const iv = setInterval(async () => {
|
|
229
|
+
if (failCountRef.current.messages >= MAX_FAILS) return
|
|
135
230
|
try {
|
|
136
231
|
const [mr, tr] = await Promise.all([
|
|
137
232
|
fetch(`/api/ticket-messages?where[ticket][equals]=${ticketId}&sort=createdAt&limit=200&depth=1`, { credentials: 'include' }),
|
|
138
233
|
fetch(`/api/tickets/${ticketId}?depth=0`, { credentials: 'include' }),
|
|
139
234
|
])
|
|
140
|
-
if (mr.ok
|
|
141
|
-
|
|
142
|
-
|
|
235
|
+
if (mr.ok && tr.ok) {
|
|
236
|
+
failCountRef.current.messages = 0
|
|
237
|
+
const d = await mr.json(); setMessages(d.docs || [])
|
|
238
|
+
const td = await tr.json(); setTicket((p) => p ? { ...p, ...td } : td)
|
|
239
|
+
} else { failCountRef.current.messages++ }
|
|
240
|
+
} catch { failCountRef.current.messages++ }
|
|
143
241
|
}, 10000)
|
|
144
242
|
return () => clearInterval(iv)
|
|
145
243
|
}, [ticketId, loading])
|
|
146
|
-
useEffect(() => {
|
|
244
|
+
useEffect(() => { // Mark read
|
|
147
245
|
if (!ticketId) return
|
|
148
246
|
fetch(`/api/tickets/${ticketId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ lastAdminReadAt: new Date().toISOString() }) }).catch(() => {})
|
|
149
247
|
}, [ticketId, messages.length])
|
|
150
|
-
useEffect(() => {
|
|
248
|
+
useEffect(() => { // Typing
|
|
151
249
|
if (!ticketId) return
|
|
152
250
|
const iv = setInterval(async () => {
|
|
153
|
-
|
|
154
|
-
|
|
251
|
+
if (failCountRef.current.typing >= MAX_FAILS) return
|
|
252
|
+
try {
|
|
253
|
+
const r = await fetch(`/api/support/typing?ticketId=${ticketId}`, { credentials: 'include' })
|
|
254
|
+
if (r.ok) { failCountRef.current.typing = 0; const d = await r.json(); setClientTyping(d.typing) }
|
|
255
|
+
else { failCountRef.current.typing++ }
|
|
256
|
+
} catch { failCountRef.current.typing++ }
|
|
257
|
+
}, 3000)
|
|
155
258
|
return () => clearInterval(iv)
|
|
156
259
|
}, [ticketId])
|
|
157
|
-
useEffect(() => {
|
|
260
|
+
useEffect(() => { // Sentiment
|
|
158
261
|
if (!features.ai || messages.length === 0) return
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
262
|
+
// Use last 3 client messages for better context (not just the last one)
|
|
263
|
+
const clientMsgs = messages.filter((m) => m.authorType === 'client' || m.authorType === 'email').slice(-3)
|
|
264
|
+
if (clientMsgs.length === 0) return
|
|
265
|
+
const contextText = clientMsgs.map((m) => m.body).join('\n---\n').slice(0, 1000)
|
|
266
|
+
fetch('/api/support/ai', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ action: 'sentiment', text: contextText }) })
|
|
162
267
|
.then((r) => r.json()).then((d) => {
|
|
163
|
-
const raw = (d.sentiment || '').toLowerCase()
|
|
268
|
+
const raw = (d.sentiment || '').toLowerCase().replace(/[^a-zéèêàùûîôëüöç]/g, '')
|
|
164
269
|
const map: Record<string, { emoji: string; label: string; color: string }> = {
|
|
165
|
-
'
|
|
166
|
-
'
|
|
167
|
-
'urgent': { emoji: '
|
|
168
|
-
'neutre': { emoji: '-', label: 'Neutre', color: '#6b7280' },
|
|
169
|
-
'satisfait': { emoji: ':)', label: 'Satisfait', color: '#16a34a' },
|
|
270
|
+
'frustré': { emoji: '😤', label: 'Frustré', color: '#dc2626' }, 'frustre': { emoji: '😤', label: 'Frustré', color: '#dc2626' },
|
|
271
|
+
'mécontent': { emoji: '😠', label: 'Mécontent', color: '#ea580c' }, 'mecontent': { emoji: '😠', label: 'Mécontent', color: '#ea580c' },
|
|
272
|
+
'urgent': { emoji: '🔥', label: 'Urgent', color: '#dc2626' }, 'neutre': { emoji: '😐', label: 'Neutre', color: '#6b7280' }, 'satisfait': { emoji: '😊', label: 'Satisfait', color: '#16a34a' },
|
|
170
273
|
}
|
|
171
274
|
const m = Object.keys(map).find((k) => raw.includes(k))
|
|
172
|
-
setSentiment(m ? map[m] : { emoji: '
|
|
275
|
+
setSentiment(m ? map[m] : { emoji: '😐', label: 'Neutre', color: '#6b7280' })
|
|
173
276
|
}).catch(() => {})
|
|
174
277
|
}, [messages.length, features.ai]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
175
278
|
useEffect(() => { threadEndRef.current?.scrollIntoView({ behavior: 'smooth' }) }, [messages.length])
|
|
176
|
-
useEffect(() => {
|
|
279
|
+
useEffect(() => { // Timer with localStorage persistence
|
|
177
280
|
if (timerRunning) {
|
|
178
281
|
localStorage.setItem(`timer-run-${ticketId}`, '1')
|
|
179
282
|
localStorage.setItem(`timer-ts-${ticketId}`, String(Date.now()))
|
|
@@ -194,7 +297,7 @@ export const TicketDetailClient: React.FC = () => {
|
|
|
194
297
|
return () => { if (timerRef.current) clearInterval(timerRef.current) }
|
|
195
298
|
}, [timerRunning, ticketId]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
196
299
|
|
|
197
|
-
//
|
|
300
|
+
// ─── PRESENCE / COLLISION DETECTION ───
|
|
198
301
|
useEffect(() => {
|
|
199
302
|
if (!ticketId) return
|
|
200
303
|
const join = () => fetch('/api/support/presence', {
|
|
@@ -215,15 +318,17 @@ export const TicketDetailClient: React.FC = () => {
|
|
|
215
318
|
useEffect(() => {
|
|
216
319
|
if (!ticketId) return
|
|
217
320
|
const poll = setInterval(async () => {
|
|
321
|
+
if (failCountRef.current.presence >= MAX_FAILS) return
|
|
218
322
|
try {
|
|
219
323
|
const r = await fetch(`/api/support/presence?ticketId=${ticketId}`, { credentials: 'include' })
|
|
220
|
-
if (r.ok) { const d = await r.json(); setOtherViewers(d.viewers || []) }
|
|
221
|
-
|
|
324
|
+
if (r.ok) { failCountRef.current.presence = 0; const d = await r.json(); setOtherViewers(d.viewers || []) }
|
|
325
|
+
else { failCountRef.current.presence++ }
|
|
326
|
+
} catch { failCountRef.current.presence++ }
|
|
222
327
|
}, 5_000)
|
|
223
328
|
return () => clearInterval(poll)
|
|
224
329
|
}, [ticketId])
|
|
225
330
|
|
|
226
|
-
//
|
|
331
|
+
// ─── FETCH MACROS ───
|
|
227
332
|
useEffect(() => {
|
|
228
333
|
fetch('/api/macros?where[isActive][equals]=true&depth=0&limit=50', { credentials: 'include' })
|
|
229
334
|
.then((r) => r.ok ? r.json() : null)
|
|
@@ -231,7 +336,7 @@ export const TicketDetailClient: React.FC = () => {
|
|
|
231
336
|
.catch(() => {})
|
|
232
337
|
}, [])
|
|
233
338
|
|
|
234
|
-
// Close dropdown on outside click
|
|
339
|
+
// #12 — Close dropdown on outside click
|
|
235
340
|
useEffect(() => {
|
|
236
341
|
if (!showMenu) return
|
|
237
342
|
const handler = (e: MouseEvent) => {
|
|
@@ -243,19 +348,22 @@ export const TicketDetailClient: React.FC = () => {
|
|
|
243
348
|
return () => document.removeEventListener('mousedown', handler)
|
|
244
349
|
}, [showMenu])
|
|
245
350
|
|
|
246
|
-
// Keyboard shortcuts
|
|
351
|
+
// #3 — Keyboard shortcuts
|
|
247
352
|
useEffect(() => {
|
|
248
353
|
const handler = (e: KeyboardEvent) => {
|
|
249
354
|
const mod = e.metaKey || e.ctrlKey
|
|
355
|
+
// Cmd/Ctrl+Enter -> send
|
|
250
356
|
if (mod && e.key === 'Enter') {
|
|
251
357
|
e.preventDefault()
|
|
252
358
|
const sendBtn = document.querySelector('[data-action="send-reply"]') as HTMLButtonElement | null
|
|
253
359
|
if (sendBtn && !sendBtn.disabled) sendBtn.click()
|
|
254
360
|
}
|
|
361
|
+
// Cmd/Ctrl+Shift+N -> toggle internal
|
|
255
362
|
if (mod && e.shiftKey && e.key.toLowerCase() === 'n') {
|
|
256
363
|
e.preventDefault()
|
|
257
364
|
setIsInternal((prev) => !prev)
|
|
258
365
|
}
|
|
366
|
+
// Escape -> close dropdown / split modal
|
|
259
367
|
if (e.key === 'Escape') {
|
|
260
368
|
setShowMenu(false)
|
|
261
369
|
setSplitModal(null)
|
|
@@ -265,12 +373,12 @@ export const TicketDetailClient: React.FC = () => {
|
|
|
265
373
|
return () => document.removeEventListener('keydown', handler)
|
|
266
374
|
}, [])
|
|
267
375
|
|
|
268
|
-
//
|
|
376
|
+
// ─── HANDLERS ───
|
|
269
377
|
const handleSend = async () => {
|
|
270
|
-
if (!replyBody.trim() || !ticketId) return
|
|
378
|
+
if ((!replyBody.trim() && !replyHtml.trim()) || !ticketId) return
|
|
271
379
|
setSending(true)
|
|
272
380
|
try {
|
|
273
|
-
// Upload pending files first
|
|
381
|
+
// #5 — Upload pending files first
|
|
274
382
|
const uploadedLinks: string[] = []
|
|
275
383
|
for (const file of pendingFiles) {
|
|
276
384
|
const formData = new FormData()
|
|
@@ -284,19 +392,21 @@ export const TicketDetailClient: React.FC = () => {
|
|
|
284
392
|
}
|
|
285
393
|
} catch { /* silent */ }
|
|
286
394
|
}
|
|
287
|
-
const finalBody = uploadedLinks.length > 0 ? `${replyBody.trim()}\n\n${uploadedLinks.join('\n')}` : replyBody.trim()
|
|
395
|
+
const finalBody = uploadedLinks.length > 0 ? `${replyBody.trim()}\n\n${uploadedLinks.join('\n')}` : (replyBody.trim() || '[Contenu enrichi]')
|
|
396
|
+
const finalHtml = uploadedLinks.length > 0 ? `${replyHtml || replyBody.trim()}<br/><br/>${uploadedLinks.map((l) => l.replace(/\[(.+?)\]\((.+?)\)/g, '<a href="$2">$1</a>')).join('<br/>')}` : (replyHtml || undefined)
|
|
288
397
|
|
|
289
398
|
const res = await fetch('/api/ticket-messages', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
|
290
|
-
body: JSON.stringify({ ticket: Number(ticketId), body: finalBody, authorType: 'admin', isInternal, skipNotification: isInternal || !notifyClient }) })
|
|
291
|
-
if (res.ok) { setReplyBody(''); setIsInternal(false); setPendingFiles([]); fetchAll() }
|
|
292
|
-
} catch
|
|
399
|
+
body: JSON.stringify({ ticket: Number(ticketId), body: finalBody, ...(finalHtml ? { bodyHtml: finalHtml } : {}), authorType: 'admin', isInternal, skipNotification: isInternal || !notifyClient }) })
|
|
400
|
+
if (res.ok) { setReplyBody(''); setReplyHtml(''); setIsInternal(false); setPendingFiles([]); editorRef.current?.clear(); fetchAll() }
|
|
401
|
+
} catch {} finally { setSending(false) }
|
|
293
402
|
}
|
|
294
403
|
|
|
295
404
|
const handleStatusChange = async (v: string) => {
|
|
296
405
|
if (!ticketId) return; setStatusUpdating(true)
|
|
297
|
-
try { await fetch(`/api/tickets/${ticketId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ status: v }) }); fetchAll() } catch
|
|
406
|
+
try { await fetch(`/api/tickets/${ticketId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ status: v }) }); fetchAll() } catch {} finally { setStatusUpdating(false) }
|
|
298
407
|
}
|
|
299
408
|
|
|
409
|
+
// #1 — Inline field patch
|
|
300
410
|
const handleFieldPatch = async (field: string, value: string) => {
|
|
301
411
|
if (!ticketId) return
|
|
302
412
|
try {
|
|
@@ -305,6 +415,7 @@ export const TicketDetailClient: React.FC = () => {
|
|
|
305
415
|
} catch { /* silent */ }
|
|
306
416
|
}
|
|
307
417
|
|
|
418
|
+
// #2 — Delete with undo toast
|
|
308
419
|
const handleDeleteMessage = (msgId: string | number) => {
|
|
309
420
|
if (undoToast) clearTimeout(undoToast.timer)
|
|
310
421
|
const timer = setTimeout(() => {
|
|
@@ -321,6 +432,7 @@ export const TicketDetailClient: React.FC = () => {
|
|
|
321
432
|
}
|
|
322
433
|
}
|
|
323
434
|
|
|
435
|
+
// #2 — Split ticket with modal
|
|
324
436
|
const handleSplitConfirm = async () => {
|
|
325
437
|
if (!splitModal || !splitSubject.trim()) return
|
|
326
438
|
try {
|
|
@@ -337,21 +449,21 @@ export const TicketDetailClient: React.FC = () => {
|
|
|
337
449
|
try {
|
|
338
450
|
const r = await fetch('/api/support/ai', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
|
|
339
451
|
body: JSON.stringify({ action: 'suggest_reply', messages: messages.slice(-10).map((m) => ({ authorType: m.authorType, body: m.body })), clientName: `${client?.firstName || ''} ${client?.lastName || ''}`.trim(), clientCompany: client?.company }) })
|
|
340
|
-
if (r.ok) { const d = await r.json(); if (d.reply) { setReplyBody(d.reply) } }
|
|
341
|
-
} catch
|
|
452
|
+
if (r.ok) { const d = await r.json(); if (d.reply) { setReplyBody(d.reply); setReplyHtml(d.reply.replace(/\n/g, '<br/>')); editorRef.current?.setContent(d.reply.replace(/\n/g, '<br/>')) } }
|
|
453
|
+
} catch {} finally { setAiReplying(false) }
|
|
342
454
|
}
|
|
343
455
|
|
|
344
|
-
const handleAiRewrite = async () => {
|
|
456
|
+
const handleAiRewrite = async (style: string = 'auto') => {
|
|
345
457
|
if (!replyBody.trim()) return; setAiRewriting(true)
|
|
346
458
|
try {
|
|
347
|
-
const r = await fetch('/api/support/ai', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ action: 'rewrite', text: replyBody }) })
|
|
348
|
-
if (r.ok) { const d = await r.json(); if (d.rewritten) { setReplyBody(d.rewritten) } }
|
|
349
|
-
} catch
|
|
459
|
+
const r = await fetch('/api/support/ai', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ action: 'rewrite', text: replyBody, style }) })
|
|
460
|
+
if (r.ok) { const d = await r.json(); if (d.rewritten) { setReplyBody(d.rewritten); setReplyHtml(d.rewritten.replace(/\n/g, '<br/>')); editorRef.current?.setContent(d.rewritten.replace(/\n/g, '<br/>')) } }
|
|
461
|
+
} catch {} finally { setAiRewriting(false) }
|
|
350
462
|
}
|
|
351
463
|
|
|
352
464
|
const handleTimerSave = async () => {
|
|
353
465
|
if (!ticketId || timerSeconds < 60) return
|
|
354
|
-
try { await fetch('/api/time-entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ ticket: Number(ticketId), duration: Math.round(timerSeconds / 60), date: new Date().toISOString(), description: 'Timer' }) }); setTimerSeconds(0); setTimerRunning(false); fetchAll() } catch
|
|
466
|
+
try { await fetch('/api/time-entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ ticket: Number(ticketId), duration: Math.round(timerSeconds / 60), date: new Date().toISOString(), description: 'Timer' }) }); setTimerSeconds(0); setTimerRunning(false); fetchAll() } catch {}
|
|
355
467
|
}
|
|
356
468
|
|
|
357
469
|
const handleApplyMacro = async (macroId: number) => {
|
|
@@ -363,9 +475,10 @@ export const TicketDetailClient: React.FC = () => {
|
|
|
363
475
|
body: JSON.stringify({ macroId, ticketId: Number(ticketId) }),
|
|
364
476
|
})
|
|
365
477
|
if (r.ok) { fetchAll() }
|
|
366
|
-
} catch
|
|
478
|
+
} catch { /* silent */ } finally { setApplyingMacro(false) }
|
|
367
479
|
}
|
|
368
480
|
|
|
481
|
+
// #5 — File handling
|
|
369
482
|
const handleFileSelect = (files: FileList | null) => {
|
|
370
483
|
if (!files) return
|
|
371
484
|
setPendingFiles((prev) => [...prev, ...Array.from(files)])
|
|
@@ -377,6 +490,7 @@ export const TicketDetailClient: React.FC = () => {
|
|
|
377
490
|
handleFileSelect(e.dataTransfer.files)
|
|
378
491
|
}
|
|
379
492
|
|
|
493
|
+
// #6 — Tags
|
|
380
494
|
const handleRemoveTag = async (tag: string) => {
|
|
381
495
|
const newTags = tags.filter((t) => t !== tag)
|
|
382
496
|
setTags(newTags)
|
|
@@ -398,75 +512,42 @@ export const TicketDetailClient: React.FC = () => {
|
|
|
398
512
|
}
|
|
399
513
|
}
|
|
400
514
|
|
|
401
|
-
//
|
|
515
|
+
// ─── RENDER ───
|
|
402
516
|
if (!ticketId) return <div style={{ padding: 60, textAlign: 'center', color: '#94a3b8', fontSize: 14 }}>{t('detail.selectTicket')}</div>
|
|
403
517
|
if (loading) return <div style={{ padding: 60, textAlign: 'center', color: '#94a3b8', fontSize: 14 }}>{t('common.loading')}</div>
|
|
404
518
|
if (!ticket) return <div style={{ padding: 60, textAlign: 'center', color: '#94a3b8', fontSize: 14 }}>{t('detail.notFound')}</div>
|
|
405
519
|
|
|
406
|
-
const st =
|
|
520
|
+
const st = STATUS_STYLE[(ticket.status as string) || 'open'] || STATUS_STYLE.open
|
|
407
521
|
const totalMin = timeEntries.reduce((a, e) => a + (e.duration || 0), 0)
|
|
408
522
|
const initials = client ? `${(client.firstName?.[0] || '').toUpperCase()}${(client.lastName?.[0] || '').toUpperCase()}` : '?'
|
|
409
523
|
|
|
410
|
-
const S: Record<string, React.CSSProperties> = {
|
|
411
|
-
page: { padding: '16px 20px', maxWidth: 1200, margin: '0 auto' },
|
|
412
|
-
topBar: { display: 'flex', alignItems: 'center', gap: 12, marginBottom: 12, padding: '8px 0', borderBottom: '1px solid var(--theme-elevation-200)' },
|
|
413
|
-
backLink: { fontSize: 18, textDecoration: 'none', color: 'var(--theme-elevation-500)', padding: '4px 8px' },
|
|
414
|
-
ticketNumber: { fontWeight: 700, fontSize: 14, color: 'var(--theme-elevation-400)' },
|
|
415
|
-
ticketSubject: { fontWeight: 600, fontSize: 15, color: 'var(--theme-text)', flex: 1 },
|
|
416
|
-
statusChip: { padding: '4px 10px', borderRadius: 6, border: 'none', fontWeight: 600, fontSize: 12, cursor: 'pointer' },
|
|
417
|
-
layout: { display: 'grid', gridTemplateColumns: '1fr 280px', gap: 20 },
|
|
418
|
-
thread: { display: 'flex', flexDirection: 'column' as const, gap: 12, maxHeight: 'calc(100vh - 400px)', overflowY: 'auto' as const, padding: '8px 0' },
|
|
419
|
-
message: { display: 'flex', gap: 10, padding: '12px 14px', borderRadius: 10, border: '1px solid var(--theme-elevation-150)', position: 'relative' as const },
|
|
420
|
-
avatar: { width: 32, height: 32, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: 11, fontWeight: 700, flexShrink: 0 },
|
|
421
|
-
messageBody: { fontSize: 14, lineHeight: 1.6, color: 'var(--theme-text)', whiteSpace: 'pre-wrap' as const },
|
|
422
|
-
composer: { marginTop: 12, border: '1px solid var(--theme-elevation-200)', borderRadius: 10, padding: 12 },
|
|
423
|
-
composerInternal: { borderColor: '#fbbf24', background: '#fefce8' },
|
|
424
|
-
textarea: { width: '100%', minHeight: 100, padding: 10, border: 'none', outline: 'none', resize: 'vertical' as const, fontSize: 14, fontFamily: 'inherit', background: 'transparent', color: 'var(--theme-text)' },
|
|
425
|
-
composerFooter: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 8 },
|
|
426
|
-
sendBtn: { padding: '8px 16px', borderRadius: 6, border: 'none', background: '#2563eb', color: '#fff', fontWeight: 600, fontSize: 13, cursor: 'pointer' },
|
|
427
|
-
sidebar: { display: 'flex', flexDirection: 'column' as const, gap: 16 },
|
|
428
|
-
sideSection: { padding: '12px 14px', borderRadius: 10, border: '1px solid var(--theme-elevation-150)', fontSize: 13 },
|
|
429
|
-
sideSectionTitle: { fontSize: 12, fontWeight: 700, textTransform: 'uppercase' as const, color: 'var(--theme-elevation-500)', marginBottom: 8, display: 'flex', justifyContent: 'space-between', alignItems: 'center' },
|
|
430
|
-
sideField: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', padding: '4px 0' },
|
|
431
|
-
sideLabel: { fontSize: 12, color: 'var(--theme-elevation-500)' },
|
|
432
|
-
sideSelect: { padding: '2px 6px', borderRadius: 4, border: '1px solid var(--theme-elevation-200)', fontSize: 12, background: 'var(--theme-elevation-0)', color: 'var(--theme-text)' },
|
|
433
|
-
sideValue: { fontSize: 12, fontWeight: 500, color: 'var(--theme-text)' },
|
|
434
|
-
badge: { padding: '2px 6px', borderRadius: 4, fontSize: 10, fontWeight: 600 },
|
|
435
|
-
tagChip: { display: 'inline-flex', alignItems: 'center', gap: 4, padding: '2px 8px', borderRadius: 4, background: 'var(--theme-elevation-100)', fontSize: 11, fontWeight: 500 },
|
|
436
|
-
tagRemove: { background: 'none', border: 'none', cursor: 'pointer', fontSize: 14, color: 'var(--theme-elevation-400)', padding: 0 },
|
|
437
|
-
tagInput: { padding: '2px 6px', borderRadius: 4, border: '1px solid var(--theme-elevation-200)', fontSize: 11, width: 80 },
|
|
438
|
-
tagAddBtn: { padding: '2px 8px', borderRadius: 4, border: '1px dashed var(--theme-elevation-300)', background: 'none', fontSize: 11, cursor: 'pointer', color: 'var(--theme-elevation-400)' },
|
|
439
|
-
toolbarBtn: { padding: '4px 10px', borderRadius: 4, border: '1px solid var(--theme-elevation-200)', background: 'var(--theme-elevation-0)', fontSize: 11, fontWeight: 700, cursor: 'pointer', color: 'var(--theme-text)' },
|
|
440
|
-
dateSeparator: { textAlign: 'center' as const, padding: '8px 0', fontSize: 11, color: 'var(--theme-elevation-400)' },
|
|
441
|
-
}
|
|
442
|
-
|
|
443
524
|
return (
|
|
444
|
-
<div
|
|
525
|
+
<div className={s.page}>
|
|
445
526
|
{/* TOP BAR */}
|
|
446
|
-
<div
|
|
447
|
-
<Link href="/admin/support/inbox"
|
|
448
|
-
<div
|
|
449
|
-
<span
|
|
450
|
-
<span
|
|
527
|
+
<div className={s.topBar}>
|
|
528
|
+
<Link href="/admin/support/inbox" className={s.backLink} aria-label="Retour à la boîte de réception">←</Link>
|
|
529
|
+
<div className={s.ticketMeta}>
|
|
530
|
+
<span className={s.ticketNumber}>{ticket.ticketNumber as string}</span>
|
|
531
|
+
<span className={s.ticketSubject}>{ticket.subject as string}</span>
|
|
451
532
|
</div>
|
|
452
|
-
<div
|
|
453
|
-
<select style={{
|
|
533
|
+
<div className={s.topBarRight}>
|
|
534
|
+
<select className={s.statusChip} style={{ background: st.bg, color: st.color }} value={(ticket.status as string) || 'open'} onChange={(e) => handleStatusChange(e.target.value)} disabled={statusUpdating} aria-label={t('ticket.status.label')}>
|
|
454
535
|
<option value="open">{t('detail.statusOpen')}</option>
|
|
455
536
|
<option value="waiting_client">{t('detail.statusWaiting')}</option>
|
|
456
537
|
<option value="resolved">{t('detail.statusResolved')}</option>
|
|
457
538
|
</select>
|
|
458
539
|
{sentiment && features.ai && (
|
|
459
|
-
<span style={{
|
|
540
|
+
<span className={s.sentimentBadge} style={{ background: `${sentiment.color}12`, color: sentiment.color }}>
|
|
460
541
|
{sentiment.emoji} {sentiment.label}
|
|
461
542
|
</span>
|
|
462
543
|
)}
|
|
463
|
-
<div
|
|
464
|
-
<button onClick={() => setShowMenu(!showMenu)}
|
|
544
|
+
<div className={s.dropdown} ref={dropdownRef}>
|
|
545
|
+
<button className={s.moreBtn} onClick={() => setShowMenu(!showMenu)} aria-label="Plus d'options">···</button>
|
|
465
546
|
{showMenu && (
|
|
466
|
-
<div
|
|
467
|
-
<button
|
|
468
|
-
<Link href={`/admin/collections/tickets/${ticketId}`}
|
|
469
|
-
<a href={`/support/tickets/${ticketId}`} target="_blank" rel="noopener noreferrer"
|
|
547
|
+
<div className={s.dropdownMenu}>
|
|
548
|
+
<button className={s.dropdownItem} onClick={() => { navigator.clipboard.writeText(window.location.href); setShowMenu(false) }}>{t('detail.copyLink')}</button>
|
|
549
|
+
<Link href={`/admin/collections/tickets/${ticketId}`} className={s.dropdownItem} onClick={() => setShowMenu(false)}>{t('detail.payloadView')}</Link>
|
|
550
|
+
<a href={`/support/tickets/${ticketId}`} target="_blank" rel="noopener noreferrer" className={s.dropdownItem} onClick={() => setShowMenu(false)}>{t('detail.clientView')}</a>
|
|
470
551
|
</div>
|
|
471
552
|
)}
|
|
472
553
|
</div>
|
|
@@ -475,58 +556,88 @@ export const TicketDetailClient: React.FC = () => {
|
|
|
475
556
|
|
|
476
557
|
{/* PRESENCE BANNER */}
|
|
477
558
|
{otherViewers.length > 0 && (
|
|
478
|
-
<div style={{
|
|
479
|
-
|
|
559
|
+
<div style={{
|
|
560
|
+
display: 'flex', alignItems: 'center', gap: 8,
|
|
561
|
+
padding: '8px 14px', marginBottom: 12, borderRadius: 8,
|
|
562
|
+
background: '#fef3c7', border: '1px solid #fde68a',
|
|
563
|
+
fontSize: 13, fontWeight: 500, color: '#92400e',
|
|
564
|
+
}}>
|
|
565
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" style={{ flexShrink: 0 }}><path d="M9 1L3 9h4l-1 6 6-8H8l1-6z" fill="#92400e"/></svg>
|
|
566
|
+
{otherViewers.length === 1 ? t('detail.viewingAlso', { names: otherViewers.map((v) => v.name).join(', ') }) : t('detail.viewingAlsoPlural', { names: otherViewers.map((v) => v.name).join(', ') })}
|
|
480
567
|
</div>
|
|
481
568
|
)}
|
|
482
569
|
|
|
483
570
|
{/* LAYOUT */}
|
|
484
|
-
<div
|
|
571
|
+
<div className={s.layout}>
|
|
485
572
|
{/* LEFT: Conversation */}
|
|
486
|
-
<div>
|
|
487
|
-
<div
|
|
573
|
+
<div className={s.conversationCol}>
|
|
574
|
+
<div className={s.thread}>
|
|
488
575
|
{messages.map((msg, idx) => {
|
|
489
576
|
const prev = idx > 0 ? messages[idx - 1] : null
|
|
490
577
|
const showDate = msg.createdAt && (!prev?.createdAt || new Date(msg.createdAt).toDateString() !== new Date(prev.createdAt).toDateString())
|
|
491
578
|
const isAdmin = msg.authorType === 'admin'
|
|
579
|
+
// #2 — Hide message visually if pending delete
|
|
492
580
|
const isPendingDelete = undoToast?.msgId === msg.id
|
|
493
581
|
|
|
494
582
|
return (
|
|
495
583
|
<React.Fragment key={msg.id}>
|
|
496
|
-
{showDate && <div
|
|
497
|
-
<div
|
|
498
|
-
|
|
584
|
+
{showDate && <div className={s.dateSeparator}><span className={s.dateSeparatorText}>{dateLabel(msg.createdAt)}</span></div>}
|
|
585
|
+
<div className={`${s.message} ${msg.isInternal ? s.messageInternal : ''}`} style={isPendingDelete ? { opacity: 0.3, pointerEvents: 'none' } : undefined}>
|
|
586
|
+
{/* #10 — Client avatar purple */}
|
|
587
|
+
<div className={s.avatar} style={{ backgroundColor: isAdmin ? '#2563eb' : msg.authorType === 'email' ? '#ea580c' : '#7c3aed' }}>
|
|
499
588
|
{isAdmin ? 'CW' : initials}
|
|
500
589
|
</div>
|
|
501
|
-
<div
|
|
502
|
-
<div
|
|
503
|
-
<span
|
|
504
|
-
<span
|
|
505
|
-
{msg.isInternal && <span style={{
|
|
506
|
-
{msg.isSolution && <span style={{
|
|
590
|
+
<div className={s.messageContent}>
|
|
591
|
+
<div className={s.messageHeader}>
|
|
592
|
+
<span className={s.messageAuthor}>{isAdmin ? 'Support' : msg.authorType === 'email' ? 'Email' : client?.firstName || 'Client'}</span>
|
|
593
|
+
<span className={s.messageTime}>{timeAgo(msg.createdAt)}</span>
|
|
594
|
+
{msg.isInternal && <span className={s.badge} style={{ background: '#fef3c7', color: '#92400e' }}>Interne</span>}
|
|
595
|
+
{msg.isSolution && <span className={s.badge} style={{ background: '#dcfce7', color: '#166534' }}>Solution</span>}
|
|
596
|
+
<span className={s.messageMeta}>
|
|
597
|
+
{isAdmin && !msg.isInternal && (() => {
|
|
598
|
+
const ext = msg as unknown as { emailOpenedAt?: string; emailSentAt?: string }
|
|
599
|
+
if (ext.emailOpenedAt) {
|
|
600
|
+
const d = new Date(ext.emailOpenedAt).toLocaleString('fr-FR', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
|
|
601
|
+
return <span style={{ color: '#16a34a', cursor: 'help' }} title={`Ouvert le ${d}`}>✓✓ Lu {d}</span>
|
|
602
|
+
}
|
|
603
|
+
if (ext.emailSentAt) {
|
|
604
|
+
const d = new Date(ext.emailSentAt).toLocaleString('fr-FR', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
|
|
605
|
+
return <span style={{ color: '#2563eb', cursor: 'help' }} title={`Envoyé le ${d}`}>✓ Envoyé {d}</span>
|
|
606
|
+
}
|
|
607
|
+
return <span style={{ color: '#94a3b8' }}>✓</span>
|
|
608
|
+
})()}
|
|
609
|
+
</span>
|
|
507
610
|
</div>
|
|
508
|
-
{msg.
|
|
509
|
-
<div
|
|
611
|
+
{(msg as unknown as { deletedAt?: string }).deletedAt ? (
|
|
612
|
+
<div className={s.messageBody} style={{ color: '#94a3b8', fontStyle: 'italic' }}>{t('detail.messageDeleted')}</div>
|
|
613
|
+
) : msg.bodyHtml && hasCodeBlocks(msg.bodyHtml.replace(/<[^>]+>/g, '')) ? (
|
|
614
|
+
<CodeBlockRendererHtml html={msg.bodyHtml} />
|
|
615
|
+
) : msg.bodyHtml ? (
|
|
616
|
+
<div className={`${s.messageBody} ${s.rteDisplay}`} dangerouslySetInnerHTML={{ __html: msg.bodyHtml }} />
|
|
617
|
+
) : hasCodeBlocks(msg.body) ? (
|
|
618
|
+
<MessageWithCodeBlocks text={msg.body} style={{ fontSize: '13px', lineHeight: 1.5 }} />
|
|
510
619
|
) : (
|
|
511
|
-
<div
|
|
620
|
+
<div className={s.messageBody}>{msg.body}</div>
|
|
512
621
|
)}
|
|
513
622
|
{Array.isArray(msg.attachments) && msg.attachments.length > 0 && (
|
|
514
|
-
<div
|
|
623
|
+
<div className={s.attachments}>
|
|
515
624
|
{msg.attachments.map((att, i) => {
|
|
516
625
|
const file = typeof att.file === 'object' ? att.file : null
|
|
517
626
|
if (!file) return null
|
|
518
627
|
return (file.mimeType || '').startsWith('image/')
|
|
519
|
-
? <a key={i} href={file.url || '#'} target="_blank" rel="noopener noreferrer"><img src={file.url || ''} alt=""
|
|
520
|
-
: <a key={i} href={file.url || '#'} target="_blank" rel="noopener noreferrer"
|
|
628
|
+
? <a key={i} href={file.url || '#'} target="_blank" rel="noopener noreferrer"><img src={file.url || ''} alt="" className={s.attachmentImg} /></a>
|
|
629
|
+
: <a key={i} href={file.url || '#'} target="_blank" rel="noopener noreferrer" className={s.attachmentFile}>PJ {file.filename || 'Fichier'}</a>
|
|
521
630
|
})}
|
|
522
631
|
</div>
|
|
523
632
|
)}
|
|
524
633
|
</div>
|
|
525
|
-
{/* Hover actions */}
|
|
526
|
-
<div
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
{
|
|
634
|
+
{/* Hover actions — icon buttons with aria-labels (#7) */}
|
|
635
|
+
<div className={s.messageActions}>
|
|
636
|
+
{/* #2 — Undo toast instead of confirm() */}
|
|
637
|
+
<button className={`${s.actionIcon} ${s.danger}`} title={t('actions.deleteMessage')} aria-label={t('actions.deleteMessage')} onClick={() => handleDeleteMessage(msg.id)} style={{ fontSize: 11, width: 'auto', padding: '4px 8px' }}>{t('actions.deleteMessage')}</button>
|
|
638
|
+
{/* #2 — Split modal instead of prompt() */}
|
|
639
|
+
{features.splitTicket && !msg.isInternal && <button className={s.actionIcon} title={t('actions.extractMessage')} aria-label={t('actions.extractToNewTicket')} onClick={() => { setSplitModal({ messageId: msg.id, preview: msg.body.slice(0, 200) }); setSplitSubject(`Split: ${ticket.subject}`) }} style={{ fontSize: 11, width: 'auto', padding: '4px 8px' }}>{t('actions.extractMessage')}</button>}
|
|
640
|
+
{isAdmin && !msg.isInternal && <button className={s.actionIcon} title={t('actions.resendEmail')} aria-label={t('actions.resendEmail')} onClick={() => fetch('/api/support/resend-notification', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ messageId: msg.id }) })} style={{ fontSize: 11, width: 'auto', padding: '4px 8px' }}>{t('actions.resendEmail')}</button>}
|
|
530
641
|
</div>
|
|
531
642
|
</div>
|
|
532
643
|
</React.Fragment>
|
|
@@ -536,41 +647,56 @@ export const TicketDetailClient: React.FC = () => {
|
|
|
536
647
|
</div>
|
|
537
648
|
|
|
538
649
|
{clientTyping && (
|
|
539
|
-
<div
|
|
540
|
-
{
|
|
650
|
+
<div className={s.typing}>
|
|
651
|
+
<span className={s.typingDots}><span /><span /><span /></span>
|
|
652
|
+
{t('detail.typing', { name: client?.firstName || 'Client' })}
|
|
541
653
|
</div>
|
|
542
654
|
)}
|
|
543
655
|
|
|
544
656
|
{/* COMPOSER */}
|
|
545
657
|
<div
|
|
546
|
-
|
|
658
|
+
className={`${s.composer} ${isInternal ? s.composerInternal : ''} ${composerDragOver ? s.composerDragOver : ''}`}
|
|
547
659
|
onDragOver={(e) => { e.preventDefault(); setComposerDragOver(true) }}
|
|
548
660
|
onDragLeave={() => setComposerDragOver(false)}
|
|
549
661
|
onDrop={handleFileDrop}
|
|
550
662
|
>
|
|
551
|
-
<div
|
|
663
|
+
<div className={s.composerToolbar}>
|
|
552
664
|
{features.ai && (
|
|
553
665
|
<>
|
|
554
|
-
<button
|
|
555
|
-
<
|
|
666
|
+
<button className={s.toolbarBtn} data-tooltip={t('detail.iaSuggestion')} aria-label={t('detail.iaSuggestion')} onClick={handleAiSuggest} disabled={aiReplying || messages.length === 0} style={{ fontSize: 11, fontWeight: 700, padding: '4px 10px', width: 'auto' }}>{aiReplying ? '...' : `✨ ${t('detail.iaSuggestion')}`}</button>
|
|
667
|
+
<RewriteDropdown disabled={aiRewriting || !replyBody.trim()} loading={aiRewriting} onSelect={(style) => handleAiRewrite(style)} toolbarBtnClass={s.toolbarBtn} />
|
|
556
668
|
</>
|
|
557
669
|
)}
|
|
558
|
-
<
|
|
559
|
-
|
|
670
|
+
<CodeBlockInserter
|
|
671
|
+
className={s.toolbarBtn}
|
|
672
|
+
onInsert={(block) => {
|
|
673
|
+
const nb = replyBody ? replyBody + block : block
|
|
674
|
+
setReplyBody(nb)
|
|
675
|
+
setReplyHtml(nb.replace(/\n/g, '<br/>'))
|
|
676
|
+
editorRef.current?.setContent(nb.replace(/\n/g, '<br/>'))
|
|
677
|
+
}}
|
|
678
|
+
/>
|
|
679
|
+
{/* #5 — File upload button */}
|
|
680
|
+
<button className={s.toolbarBtn} data-tooltip={t('detail.file')} aria-label={t('detail.file')} onClick={() => fileInputRef.current?.click()} style={{ fontSize: 11, fontWeight: 700, padding: '4px 10px', width: 'auto' }}>📎 {t('detail.file')}</button>
|
|
681
|
+
<input ref={fileInputRef} type="file" multiple className={s.hiddenFileInput} onChange={(e) => handleFileSelect(e.target.files)} />
|
|
560
682
|
{macros.length > 0 && (
|
|
561
683
|
<select
|
|
562
|
-
|
|
684
|
+
className={s.cannedSelect}
|
|
563
685
|
onChange={(e) => { const id = Number(e.target.value); if (id) handleApplyMacro(id); e.target.value = '' }}
|
|
564
686
|
disabled={applyingMacro}
|
|
687
|
+
style={{ marginLeft: 0 }}
|
|
688
|
+
aria-label="Appliquer une macro"
|
|
565
689
|
>
|
|
566
690
|
<option value="">{applyingMacro ? t('detail.applyingMacro') : t('detail.macros')}</option>
|
|
567
691
|
{macros.map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
|
|
568
692
|
</select>
|
|
569
693
|
)}
|
|
694
|
+
<span className={s.toolbarDivider} />
|
|
695
|
+
{/* #9 — "Canned" -> "Réponses types" */}
|
|
570
696
|
{features.canned && cannedResponses.length > 0 && (
|
|
571
|
-
<select
|
|
697
|
+
<select className={s.cannedSelect} aria-label="Réponses types" onChange={(e) => {
|
|
572
698
|
const cr = cannedResponses.find((c) => String(c.id) === e.target.value)
|
|
573
|
-
if (cr) { let b = cr.body; if (client) { b = b.replace(/\{\{client\.firstName\}\}/g, client.firstName).replace(/\{\{client\.company\}\}/g, client.company) }; setReplyBody(b) }
|
|
699
|
+
if (cr) { let b = cr.body; if (client) { b = b.replace(/\{\{client\.firstName\}\}/g, client.firstName).replace(/\{\{client\.company\}\}/g, client.company) }; setReplyBody(b); setReplyHtml(b.replace(/\n/g, '<br/>')); editorRef.current?.setContent(b.replace(/\n/g, '<br/>')) }
|
|
574
700
|
e.target.value = ''
|
|
575
701
|
}}>
|
|
576
702
|
<option value="">{t('detail.cannedResponses')}</option>
|
|
@@ -578,28 +704,26 @@ export const TicketDetailClient: React.FC = () => {
|
|
|
578
704
|
</select>
|
|
579
705
|
)}
|
|
580
706
|
</div>
|
|
581
|
-
<
|
|
582
|
-
|
|
583
|
-
value={replyBody}
|
|
584
|
-
onChange={(e) => setReplyBody(e.target.value)}
|
|
585
|
-
placeholder={isInternal ? t('composer.placeholderInternal') : t('composer.placeholderReplyTo', { name: client?.firstName || 'client' })}
|
|
586
|
-
/>
|
|
707
|
+
<RichTextEditor ref={editorRef} onChange={(html, text) => { setReplyHtml(html); setReplyBody(text) }} placeholder={isInternal ? t('composer.placeholderInternal') : t('composer.placeholderReplyTo', { name: client?.firstName || 'client' })} minHeight={100} borderColor="transparent" />
|
|
708
|
+
{/* #5 — File upload preview */}
|
|
587
709
|
{pendingFiles.length > 0 && (
|
|
588
|
-
<div
|
|
710
|
+
<div className={s.uploadPreview}>
|
|
589
711
|
{pendingFiles.map((f, i) => (
|
|
590
|
-
<div key={i}
|
|
712
|
+
<div key={i} className={s.uploadPreviewItem}>
|
|
591
713
|
<span>PJ {f.name}</span>
|
|
592
|
-
<button
|
|
714
|
+
<button className={s.uploadRemoveBtn} aria-label={`Retirer ${f.name}`} onClick={() => setPendingFiles((prev) => prev.filter((_, j) => j !== i))}>×</button>
|
|
593
715
|
</div>
|
|
594
716
|
))}
|
|
595
717
|
</div>
|
|
596
718
|
)}
|
|
597
|
-
<div
|
|
598
|
-
<div
|
|
719
|
+
<div className={s.composerFooter}>
|
|
720
|
+
<div className={s.composerOptions}>
|
|
721
|
+
{/* #9 — "Internal" -> "Note interne", "Notify" -> "Notifier" */}
|
|
599
722
|
<label><input type="checkbox" checked={isInternal} onChange={(e) => setIsInternal(e.target.checked)} /> {t('detail.internalNote')}</label>
|
|
600
723
|
<label><input type="checkbox" checked={notifyClient} onChange={(e) => setNotifyClient(e.target.checked)} disabled={isInternal} /> {t('detail.notify')}</label>
|
|
601
724
|
</div>
|
|
602
|
-
|
|
725
|
+
{/* #9 — "Send ->" -> "Envoyer ->" */}
|
|
726
|
+
<button className={`${s.sendBtn} ${isInternal ? s.sendBtnInternal : ''}`} onClick={handleSend} disabled={sending || !replyBody.trim()} data-action="send-reply">
|
|
603
727
|
{sending ? t('detail.sending') : isInternal ? t('detail.sendNote') : t('detail.sendReply')}
|
|
604
728
|
</button>
|
|
605
729
|
</div>
|
|
@@ -607,40 +731,40 @@ export const TicketDetailClient: React.FC = () => {
|
|
|
607
731
|
</div>
|
|
608
732
|
|
|
609
733
|
{/* RIGHT: Sidebar */}
|
|
610
|
-
<div
|
|
734
|
+
<div className={s.sidebar}>
|
|
611
735
|
{client && (
|
|
612
|
-
<div
|
|
613
|
-
<div
|
|
614
|
-
<div
|
|
615
|
-
<div>
|
|
616
|
-
<div
|
|
617
|
-
<div
|
|
618
|
-
<a href={`mailto:${client.email}`}
|
|
736
|
+
<div className={s.sideSection}>
|
|
737
|
+
<div className={s.clientCard}>
|
|
738
|
+
<div className={s.clientAvatar}>{initials}</div>
|
|
739
|
+
<div className={s.clientInfo}>
|
|
740
|
+
<div className={s.clientName}>{client.firstName} {client.lastName}</div>
|
|
741
|
+
<div className={s.clientCompany}>{client.company}</div>
|
|
742
|
+
<a href={`mailto:${client.email}`} className={s.clientEmail}>{client.email}</a>
|
|
619
743
|
</div>
|
|
620
744
|
</div>
|
|
621
|
-
<div
|
|
622
|
-
<Link href={`/admin/collections/support-clients/${client.id}`}
|
|
623
|
-
<button
|
|
745
|
+
<div className={s.clientActions}>
|
|
746
|
+
<Link href={`/admin/collections/support-clients/${client.id}`} className={s.smallBtn}>{t('client.clientSheet')}</Link>
|
|
747
|
+
<button className={s.smallBtn} onClick={() => window.open(`/api/admin/impersonate?clientId=${client.id}`, '_blank')}>{t('client.clientPortal')}</button>
|
|
624
748
|
</div>
|
|
625
749
|
</div>
|
|
626
750
|
)}
|
|
627
751
|
|
|
628
|
-
{/* Editable sidebar fields */}
|
|
629
|
-
<div
|
|
630
|
-
<div
|
|
631
|
-
<div
|
|
632
|
-
<span
|
|
633
|
-
<select
|
|
752
|
+
{/* #1 — Editable sidebar fields */}
|
|
753
|
+
<div className={s.sideSection}>
|
|
754
|
+
<div className={s.sideSectionTitle}>{t('detail.details')}</div>
|
|
755
|
+
<div className={s.sideField}>
|
|
756
|
+
<span className={s.sideLabel}>{t('detail.priority')}</span>
|
|
757
|
+
<select className={s.sideSelect} value={(ticket.priority as string) || 'normal'} onChange={(e) => handleFieldPatch('priority', e.target.value)} aria-label={t('detail.priority')}>
|
|
634
758
|
<option value="low">{t('ticket.priority.low')}</option>
|
|
635
759
|
<option value="normal">{t('ticket.priority.normal')}</option>
|
|
636
760
|
<option value="high">{t('ticket.priority.high')}</option>
|
|
637
761
|
<option value="urgent">{t('ticket.priority.urgent')}</option>
|
|
638
762
|
</select>
|
|
639
763
|
</div>
|
|
640
|
-
<div
|
|
641
|
-
<span
|
|
642
|
-
<select
|
|
643
|
-
<option value=""
|
|
764
|
+
<div className={s.sideField}>
|
|
765
|
+
<span className={s.sideLabel}>{t('detail.category')}</span>
|
|
766
|
+
<select className={s.sideSelect} value={(ticket.category as string) || ''} onChange={(e) => handleFieldPatch('category', e.target.value)} aria-label={t('detail.category')}>
|
|
767
|
+
<option value="">—</option>
|
|
644
768
|
<option value="bug">{t('ticket.category.bug')}</option>
|
|
645
769
|
<option value="content">{t('ticket.category.content')}</option>
|
|
646
770
|
<option value="feature">{t('ticket.category.feature')}</option>
|
|
@@ -648,23 +772,23 @@ export const TicketDetailClient: React.FC = () => {
|
|
|
648
772
|
<option value="hosting">{t('ticket.category.hosting')}</option>
|
|
649
773
|
</select>
|
|
650
774
|
</div>
|
|
651
|
-
<div
|
|
652
|
-
<div
|
|
775
|
+
<div className={s.sideField}><span className={s.sideLabel}>{t('detail.source')}</span><span className={s.sideValue}>{(ticket.source as string) || t('ticket.source.portal')}</span></div>
|
|
776
|
+
<div className={s.sideField}><span className={s.sideLabel}>{t('detail.assigned')}</span><span className={s.sideValue}>{typeof ticket.assignedTo === 'object' && ticket.assignedTo ? (ticket.assignedTo as { firstName?: string }).firstName || 'Admin' : '—'}</span></div>
|
|
653
777
|
</div>
|
|
654
778
|
|
|
655
|
-
{/* Tags */}
|
|
656
|
-
<div
|
|
657
|
-
<div
|
|
658
|
-
<div
|
|
779
|
+
{/* #6 — Tags section */}
|
|
780
|
+
<div className={s.sideSection}>
|
|
781
|
+
<div className={s.sideSectionTitle}>{t('detail.tags')}</div>
|
|
782
|
+
<div className={s.tagsWrap}>
|
|
659
783
|
{tags.map((tag) => (
|
|
660
|
-
<span key={tag}
|
|
784
|
+
<span key={tag} className={s.tagChip}>
|
|
661
785
|
{tag}
|
|
662
|
-
<button
|
|
786
|
+
<button className={s.tagRemove} aria-label={`Retirer le tag ${tag}`} onClick={() => handleRemoveTag(tag)}>×</button>
|
|
663
787
|
</span>
|
|
664
788
|
))}
|
|
665
789
|
{addingTag ? (
|
|
666
790
|
<input
|
|
667
|
-
|
|
791
|
+
className={s.tagInput}
|
|
668
792
|
value={newTagValue}
|
|
669
793
|
onChange={(e) => setNewTagValue(e.target.value)}
|
|
670
794
|
onKeyDown={(e) => { if (e.key === 'Enter') handleAddTag(); if (e.key === 'Escape') { setAddingTag(false); setNewTagValue('') } }}
|
|
@@ -673,30 +797,128 @@ export const TicketDetailClient: React.FC = () => {
|
|
|
673
797
|
autoFocus
|
|
674
798
|
/>
|
|
675
799
|
) : (
|
|
676
|
-
<button
|
|
800
|
+
<button className={s.tagAddBtn} onClick={() => setAddingTag(true)} aria-label="Ajouter un tag">+ Tag</button>
|
|
677
801
|
)}
|
|
678
802
|
</div>
|
|
679
803
|
</div>
|
|
680
804
|
|
|
805
|
+
{/* ===== BILLING ===== */}
|
|
806
|
+
<div className={s.sideSection}>
|
|
807
|
+
<div className={s.sideSectionTitle}>Facturation</div>
|
|
808
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
809
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
810
|
+
<span style={{ fontSize: 12 }}>Type</span>
|
|
811
|
+
<select
|
|
812
|
+
value={(ticket as any)?.billingType || 'hourly'}
|
|
813
|
+
onChange={async (e) => {
|
|
814
|
+
try {
|
|
815
|
+
await fetch(`/api/tickets/${ticketId}`, {
|
|
816
|
+
method: 'PATCH', credentials: 'include',
|
|
817
|
+
headers: { 'Content-Type': 'application/json' },
|
|
818
|
+
body: JSON.stringify({ billingType: e.target.value }),
|
|
819
|
+
})
|
|
820
|
+
fetchAll()
|
|
821
|
+
} catch {}
|
|
822
|
+
}}
|
|
823
|
+
style={{ fontSize: 12, padding: '4px 8px', borderRadius: 6, border: '1px solid #e5e7eb', background: '#fff' }}
|
|
824
|
+
>
|
|
825
|
+
<option value="hourly">Au temps</option>
|
|
826
|
+
<option value="flat">Forfait</option>
|
|
827
|
+
</select>
|
|
828
|
+
</div>
|
|
829
|
+
{(ticket as any)?.billingType === 'flat' && (
|
|
830
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
831
|
+
<span style={{ fontSize: 12 }}>Montant forfait</span>
|
|
832
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
833
|
+
<input
|
|
834
|
+
type="number"
|
|
835
|
+
defaultValue={(ticket as any)?.flatRateAmount ?? ''}
|
|
836
|
+
placeholder="0"
|
|
837
|
+
onBlur={async (e) => {
|
|
838
|
+
const val = e.target.value ? Number(e.target.value) : null
|
|
839
|
+
try {
|
|
840
|
+
await fetch(`/api/tickets/${ticketId}`, {
|
|
841
|
+
method: 'PATCH', credentials: 'include',
|
|
842
|
+
headers: { 'Content-Type': 'application/json' },
|
|
843
|
+
body: JSON.stringify({ flatRateAmount: val }),
|
|
844
|
+
})
|
|
845
|
+
fetchAll()
|
|
846
|
+
} catch {}
|
|
847
|
+
}}
|
|
848
|
+
style={{ fontSize: 12, padding: '4px 8px', borderRadius: 6, border: '1px solid #e5e7eb', width: 80, textAlign: 'right' }}
|
|
849
|
+
/>
|
|
850
|
+
<span style={{ fontSize: 12, color: '#9ca3af' }}>€</span>
|
|
851
|
+
</div>
|
|
852
|
+
</div>
|
|
853
|
+
)}
|
|
854
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
855
|
+
<span style={{ fontSize: 12 }}>Montant facturé</span>
|
|
856
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
857
|
+
<input
|
|
858
|
+
type="number"
|
|
859
|
+
defaultValue={(ticket as any)?.billedAmount ?? ''}
|
|
860
|
+
placeholder="0"
|
|
861
|
+
onBlur={async (e) => {
|
|
862
|
+
const val = e.target.value ? Number(e.target.value) : null
|
|
863
|
+
try {
|
|
864
|
+
await fetch(`/api/tickets/${ticketId}`, {
|
|
865
|
+
method: 'PATCH', credentials: 'include',
|
|
866
|
+
headers: { 'Content-Type': 'application/json' },
|
|
867
|
+
body: JSON.stringify({ billedAmount: val }),
|
|
868
|
+
})
|
|
869
|
+
fetchAll()
|
|
870
|
+
} catch {}
|
|
871
|
+
}}
|
|
872
|
+
style={{ fontSize: 12, padding: '4px 8px', borderRadius: 6, border: '1px solid #e5e7eb', width: 80, textAlign: 'right' }}
|
|
873
|
+
/>
|
|
874
|
+
<span style={{ fontSize: 12, color: '#9ca3af' }}>€</span>
|
|
875
|
+
</div>
|
|
876
|
+
</div>
|
|
877
|
+
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
878
|
+
<span style={{ fontSize: 12 }}>Paiement</span>
|
|
879
|
+
<select
|
|
880
|
+
value={(ticket as any)?.paymentStatus || 'unpaid'}
|
|
881
|
+
onChange={async (e) => {
|
|
882
|
+
try {
|
|
883
|
+
await fetch(`/api/tickets/${ticketId}`, {
|
|
884
|
+
method: 'PATCH', credentials: 'include',
|
|
885
|
+
headers: { 'Content-Type': 'application/json' },
|
|
886
|
+
body: JSON.stringify({ paymentStatus: e.target.value }),
|
|
887
|
+
})
|
|
888
|
+
fetchAll()
|
|
889
|
+
} catch {}
|
|
890
|
+
}}
|
|
891
|
+
style={{ fontSize: 12, padding: '4px 8px', borderRadius: 6, border: '1px solid #e5e7eb', background: '#fff' }}
|
|
892
|
+
>
|
|
893
|
+
<option value="unpaid">Non payé</option>
|
|
894
|
+
<option value="partial">Partiel</option>
|
|
895
|
+
<option value="paid">Payé</option>
|
|
896
|
+
</select>
|
|
897
|
+
</div>
|
|
898
|
+
</div>
|
|
899
|
+
</div>
|
|
900
|
+
|
|
681
901
|
{features.timeTracking && (
|
|
682
|
-
<div
|
|
683
|
-
<div
|
|
684
|
-
|
|
685
|
-
|
|
902
|
+
<div className={s.sideSection}>
|
|
903
|
+
<div className={s.sideSectionTitle}>{t('detail.time')} <span style={{ fontWeight: 700, fontSize: 13, color: '#d97706' }}>{totalMin > 0 ? `${Math.floor(totalMin / 60)}h${String(totalMin % 60).padStart(2, '0')} ${t('detail.total')}` : '0min'}</span></div>
|
|
904
|
+
{/* Timer */}
|
|
905
|
+
<div className={s.timer}>
|
|
906
|
+
<span className={`${s.timerDisplay} ${timerRunning ? s.timerActive : ''}`}>
|
|
686
907
|
{String(Math.floor(timerSeconds / 60)).padStart(2, '0')}:{String(timerSeconds % 60).padStart(2, '0')}
|
|
687
908
|
</span>
|
|
688
909
|
{!timerRunning ? (
|
|
689
|
-
<button style={{
|
|
910
|
+
<button className={s.timerBtn} onClick={() => setTimerRunning(true)} style={{ color: '#dc2626', borderColor: '#dc2626' }} aria-label="Démarrer le timer">{timerSeconds > 0 ? '▶' : '▶ Go'}</button>
|
|
690
911
|
) : (
|
|
691
|
-
<button
|
|
912
|
+
<button className={s.timerBtn} onClick={() => setTimerRunning(false)} aria-label="Mettre en pause le timer">⏸</button>
|
|
692
913
|
)}
|
|
693
914
|
{timerSeconds >= 60 && !timerRunning && (
|
|
694
|
-
<button
|
|
915
|
+
<button className={s.timerBtn} onClick={() => { handleTimerSave(); localStorage.removeItem(`timer-sec-${ticketId}`); localStorage.removeItem(`timer-run-${ticketId}`) }} style={{ color: '#16a34a', borderColor: '#16a34a' }} aria-label="Sauvegarder le temps">💾 {Math.round(timerSeconds / 60)}m</button>
|
|
695
916
|
)}
|
|
696
917
|
</div>
|
|
918
|
+
{/* Manual time entry */}
|
|
697
919
|
<div style={{ display: 'flex', gap: 6, marginTop: 8, alignItems: 'center' }}>
|
|
698
920
|
<input type="number" min="1" placeholder="min" style={{ width: 60, padding: '4px 8px', borderRadius: 6, border: '1px solid var(--theme-elevation-200)', fontSize: 12, color: 'var(--theme-text)', background: 'var(--theme-elevation-0)' }} id="manual-time-input" />
|
|
699
|
-
<button
|
|
921
|
+
<button className={s.timerBtn} onClick={async () => {
|
|
700
922
|
const input = document.getElementById('manual-time-input') as HTMLInputElement
|
|
701
923
|
const mins = Number(input?.value)
|
|
702
924
|
if (!mins || mins < 1 || !ticketId) return
|
|
@@ -704,9 +926,31 @@ export const TicketDetailClient: React.FC = () => {
|
|
|
704
926
|
await fetch('/api/time-entries', { method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ ticket: Number(ticketId), duration: mins, date: new Date().toISOString(), description: 'Saisie manuelle' }) })
|
|
705
927
|
if (input) input.value = ''
|
|
706
928
|
fetchAll()
|
|
707
|
-
} catch
|
|
708
|
-
}}>+ Ajouter</button>
|
|
929
|
+
} catch {}
|
|
930
|
+
}} style={{ fontSize: 11 }}>+ Ajouter</button>
|
|
709
931
|
</div>
|
|
932
|
+
{/* Billing info */}
|
|
933
|
+
<div style={{ marginTop: 8, fontSize: 11, color: 'var(--theme-elevation-500)' }}>
|
|
934
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '2px 0', alignItems: 'center' }}>
|
|
935
|
+
<span>Facturable</span>
|
|
936
|
+
<button
|
|
937
|
+
onClick={async () => {
|
|
938
|
+
const newVal = ticket.billable === false ? true : false
|
|
939
|
+
try { await fetch(`/api/tickets/${ticketId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, credentials: 'include', body: JSON.stringify({ billable: newVal }) }); fetchAll() } catch {}
|
|
940
|
+
}}
|
|
941
|
+
style={{ fontWeight: 600, color: (ticket.billable !== false) ? '#16a34a' : '#dc2626', background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, textDecoration: 'underline' }}
|
|
942
|
+
>
|
|
943
|
+
{(ticket.billable !== false) ? 'Oui' : 'Non'}
|
|
944
|
+
</button>
|
|
945
|
+
</div>
|
|
946
|
+
{totalMin > 0 && (
|
|
947
|
+
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '2px 0' }}>
|
|
948
|
+
<span>Montant estimé</span>
|
|
949
|
+
<span style={{ fontWeight: 700, color: 'var(--theme-text)' }}>{((totalMin / 60) * 60).toFixed(0)}€</span>
|
|
950
|
+
</div>
|
|
951
|
+
)}
|
|
952
|
+
</div>
|
|
953
|
+
{/* Time entries */}
|
|
710
954
|
{timeEntries.length > 0 && (
|
|
711
955
|
<div style={{ marginTop: 8, fontSize: 11 }}>
|
|
712
956
|
{timeEntries.slice(0, 6).map((e) => (
|
|
@@ -721,51 +965,80 @@ export const TicketDetailClient: React.FC = () => {
|
|
|
721
965
|
)}
|
|
722
966
|
|
|
723
967
|
{features.activityLog && (
|
|
724
|
-
<div
|
|
725
|
-
<div
|
|
726
|
-
<button onClick={() => setShowActivity(!showActivity)}
|
|
727
|
-
|
|
968
|
+
<div className={s.sideSection}>
|
|
969
|
+
<div className={s.sideSectionTitle}>
|
|
970
|
+
<button className={s.collapseBtn} onClick={() => setShowActivity(!showActivity)} aria-label={showActivity ? 'Masquer le journal' : 'Afficher le journal'}>
|
|
971
|
+
Activité {showActivity ? '▾' : '▸'}
|
|
728
972
|
</button>
|
|
729
973
|
</div>
|
|
730
974
|
{showActivity && activityLog.slice(0, 8).map((a) => (
|
|
731
|
-
<div key={a.id}
|
|
732
|
-
<div style={{
|
|
733
|
-
<div>
|
|
734
|
-
<div
|
|
735
|
-
<div
|
|
975
|
+
<div key={a.id} className={s.activityItem}>
|
|
976
|
+
<div className={s.activityDot} style={{ backgroundColor: a.actorType === 'admin' ? '#2563eb' : a.actorType === 'system' ? '#6b7280' : '#16a34a' }} />
|
|
977
|
+
<div className={s.activityContent}>
|
|
978
|
+
<div className={s.activityText}>{(a.detail || a.action).slice(0, 60)}</div>
|
|
979
|
+
<div className={s.activityTime}>{timeAgo(a.createdAt)}</div>
|
|
736
980
|
</div>
|
|
737
981
|
</div>
|
|
738
982
|
))}
|
|
739
983
|
</div>
|
|
740
984
|
)}
|
|
985
|
+
|
|
986
|
+
{/* Client Intelligence (compact) */}
|
|
987
|
+
{clientSummary && clientSummary.summary && (
|
|
988
|
+
<div className={s.sideSection} style={{ background: 'linear-gradient(135deg, rgba(37,99,235,0.03) 0%, rgba(139,92,246,0.03) 100%)' }}>
|
|
989
|
+
<div className={s.sideSectionTitle} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
990
|
+
<span style={{ fontSize: 13 }}>🧠</span> Résumé client
|
|
991
|
+
</div>
|
|
992
|
+
<p style={{ margin: '0 0 8px', fontSize: 11, lineHeight: 1.6, color: '#374151' }}>{clientSummary.summary}</p>
|
|
993
|
+
{clientSummary.recurringTopics && clientSummary.recurringTopics.length > 0 && (
|
|
994
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, marginBottom: 6 }}>
|
|
995
|
+
{clientSummary.recurringTopics.slice(0, 3).map((tp, i) => (
|
|
996
|
+
<span key={i} style={{ padding: '2px 8px', borderRadius: 10, background: 'rgba(37,99,235,0.08)', color: '#2563eb', fontSize: 10, fontWeight: 600 }}>{tp.topic}</span>
|
|
997
|
+
))}
|
|
998
|
+
</div>
|
|
999
|
+
)}
|
|
1000
|
+
{clientSummary.keyFacts && clientSummary.keyFacts.length > 0 && (
|
|
1001
|
+
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4 }}>
|
|
1002
|
+
{clientSummary.keyFacts.slice(0, 3).map((f, i) => (
|
|
1003
|
+
<span key={i} style={{ padding: '2px 8px', borderRadius: 10, background: 'rgba(22,163,74,0.08)', color: '#16a34a', fontSize: 10, fontWeight: 600 }}>{f}</span>
|
|
1004
|
+
))}
|
|
1005
|
+
</div>
|
|
1006
|
+
)}
|
|
1007
|
+
</div>
|
|
1008
|
+
)}
|
|
1009
|
+
{summaryLoading && (
|
|
1010
|
+
<div className={s.sideSection}>
|
|
1011
|
+
<div style={{ fontSize: 11, color: '#94a3b8', textAlign: 'center', padding: 8 }}>🧠 Chargement résumé...</div>
|
|
1012
|
+
</div>
|
|
1013
|
+
)}
|
|
741
1014
|
</div>
|
|
742
1015
|
</div>
|
|
743
1016
|
|
|
744
|
-
|
|
1017
|
+
{/* #2 — Undo delete toast */}
|
|
745
1018
|
{undoToast && (
|
|
746
|
-
<div
|
|
747
|
-
<span>
|
|
748
|
-
<button
|
|
1019
|
+
<div className={s.undoToast} role="alert">
|
|
1020
|
+
<span>Message supprimé</span>
|
|
1021
|
+
<button className={s.undoBtn} onClick={handleUndoDelete}>Annuler</button>
|
|
749
1022
|
</div>
|
|
750
1023
|
)}
|
|
751
1024
|
|
|
752
|
-
{/* Split ticket modal */}
|
|
1025
|
+
{/* #2 — Split ticket modal */}
|
|
753
1026
|
{splitModal && (
|
|
754
|
-
<div
|
|
755
|
-
<div
|
|
756
|
-
<h3
|
|
757
|
-
<div
|
|
1027
|
+
<div className={s.splitOverlay} onClick={(e) => { if (e.target === e.currentTarget) { setSplitModal(null); setSplitSubject('') } }}>
|
|
1028
|
+
<div className={s.splitModal} role="dialog" aria-label="Extraire dans un nouveau ticket">
|
|
1029
|
+
<h3 className={s.splitTitle}>Extraire dans un nouveau ticket</h3>
|
|
1030
|
+
<div className={s.splitPreview}>{splitModal.preview}</div>
|
|
758
1031
|
<input
|
|
759
|
-
|
|
1032
|
+
className={s.splitInput}
|
|
760
1033
|
value={splitSubject}
|
|
761
1034
|
onChange={(e) => setSplitSubject(e.target.value)}
|
|
762
1035
|
onKeyDown={(e) => { if (e.key === 'Enter') handleSplitConfirm() }}
|
|
763
|
-
placeholder=
|
|
1036
|
+
placeholder="Sujet du nouveau ticket..."
|
|
764
1037
|
autoFocus
|
|
765
1038
|
/>
|
|
766
|
-
<div
|
|
767
|
-
<button
|
|
768
|
-
<button
|
|
1039
|
+
<div className={s.splitActions}>
|
|
1040
|
+
<button className={s.splitCancelBtn} onClick={() => { setSplitModal(null); setSplitSubject('') }}>Annuler</button>
|
|
1041
|
+
<button className={s.splitConfirmBtn} onClick={handleSplitConfirm} disabled={!splitSubject.trim()}>Créer le ticket</button>
|
|
769
1042
|
</div>
|
|
770
1043
|
</div>
|
|
771
1044
|
</div>
|