@consilioweb/payload-support 0.16.0 → 1.1.0
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/README.md +357 -435
- package/dist/components/TicketConversation/RewriteDropdown.cjs +98 -0
- package/dist/components/TicketConversation/RewriteDropdown.js +93 -0
- package/dist/components/TicketConversation/SkeletonText.cjs +21 -0
- package/dist/components/TicketConversation/SkeletonText.js +20 -0
- package/dist/components/TicketConversation/constants.cjs +27 -0
- package/dist/components/TicketConversation/constants.js +26 -1
- package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.cjs +20 -0
- package/dist/components/TicketConversation/hooks/useDocumentIdFromUrl.js +19 -0
- package/dist/components/TicketConversation/index.cjs +10 -147
- package/dist/components/TicketConversation/index.js +4 -141
- package/dist/index.cjs +1763 -677
- package/dist/index.d.cts +4 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +1760 -677
- package/dist/views/BillingView/client.cjs +40 -54
- package/dist/views/BillingView/client.js +40 -54
- package/dist/views/ChatView/client.cjs +4 -3
- package/dist/views/ChatView/client.js +4 -3
- package/dist/views/CrmView/client.cjs +7 -7
- package/dist/views/CrmView/client.js +7 -7
- package/dist/views/EmailTrackingView/client.cjs +6 -5
- package/dist/views/EmailTrackingView/client.js +6 -5
- package/dist/views/ImportConversationView/client.cjs +4 -4
- package/dist/views/ImportConversationView/client.js +4 -4
- package/dist/views/LogsView/client.cjs +3 -2
- package/dist/views/LogsView/client.js +3 -2
- package/dist/views/NewTicketView/client.cjs +1 -1
- package/dist/views/NewTicketView/client.js +1 -1
- package/dist/views/PendingEmailsView/client.cjs +7 -6
- package/dist/views/PendingEmailsView/client.js +7 -6
- package/dist/views/SupportDashboardView/client.cjs +19 -22
- package/dist/views/SupportDashboardView/client.js +19 -22
- package/dist/views/TicketDetailView/NextActionItem.cjs +40 -0
- package/dist/views/TicketDetailView/NextActionItem.js +34 -0
- package/dist/views/TicketDetailView/RewriteDropdown.cjs +80 -0
- package/dist/views/TicketDetailView/RewriteDropdown.js +78 -0
- package/dist/views/TicketDetailView/client.cjs +59 -190
- package/dist/views/TicketDetailView/client.js +52 -183
- package/dist/views/TicketDetailView/constants.cjs +20 -0
- package/dist/views/TicketDetailView/constants.js +16 -0
- package/dist/views/TicketDetailView/helpers.cjs +23 -0
- package/dist/views/TicketDetailView/helpers.js +20 -0
- package/dist/views/TicketDetailView/types.cjs +2 -0
- package/dist/views/TicketDetailView/types.js +1 -0
- package/dist/views/TicketInboxView/client.cjs +14 -10
- package/dist/views/TicketInboxView/client.js +14 -10
- package/dist/views/TicketingSettingsView/client.cjs +101 -96
- package/dist/views/TicketingSettingsView/client.js +92 -88
- package/dist/views/TimeDashboardView/client.cjs +2 -2
- package/dist/views/TimeDashboardView/client.js +2 -2
- package/dist/views/shared/StatusPill.cjs +23 -12
- package/dist/views/shared/StatusPill.js +23 -12
- package/dist/views/shared/dateLocale.cjs +5 -0
- package/dist/views/shared/dateLocale.js +3 -0
- package/package.json +28 -15
- package/src/__tests__/generateTrackingToken.test.ts +53 -0
- package/src/__tests__/integration/ai-agent.test.ts +46 -0
- package/src/__tests__/integration/auth.test.ts +43 -0
- package/src/__tests__/integration/automation-rules.test.ts +68 -0
- package/src/__tests__/integration/buildTestPayload.ts +52 -0
- package/src/__tests__/integration/channels.test.ts +52 -0
- package/src/__tests__/integration/core-behaviors.test.ts +58 -0
- package/src/__tests__/integration/dashboard-volume.test.ts +31 -0
- package/src/__tests__/integration/digest.test.ts +48 -0
- package/src/__tests__/integration/invoice-pdf.test.ts +30 -0
- package/src/__tests__/integration/isolation.test.ts +81 -0
- package/src/__tests__/integration/mentions-invoice.test.ts +50 -0
- package/src/__tests__/integration/nps.test.ts +37 -0
- package/src/__tests__/integration/per-team-sla.test.ts +44 -0
- package/src/__tests__/integration/push.test.ts +75 -0
- package/src/__tests__/integration/sanitization.test.ts +35 -0
- package/src/__tests__/integration/sla-pause.test.ts +39 -0
- package/src/__tests__/integration/smoke.test.ts +26 -0
- package/src/__tests__/integration/snooze.test.ts +68 -0
- package/src/__tests__/integration/teams.test.ts +38 -0
- package/src/__tests__/rateLimiter.test.ts +89 -0
- package/src/__tests__/sanitizeHtml.test.ts +165 -0
- package/src/__tests__/sla.test.ts +258 -0
- package/src/collections/AutomationRules.ts +66 -0
- package/src/collections/NotificationQueue.ts +30 -0
- package/src/collections/PushSubscription.ts +28 -0
- package/src/collections/SatisfactionSurveys.ts +11 -2
- package/src/collections/SlaPolicies.ts +10 -0
- package/src/collections/SupportClients.ts +58 -1
- package/src/collections/SupportTeam.ts +27 -0
- package/src/collections/TicketActivityLog.ts +9 -4
- package/src/collections/TicketMessages.ts +140 -31
- package/src/collections/Tickets.ts +31 -73
- package/src/collections/index.ts +4 -0
- package/src/components/TicketConversation/RewriteDropdown.tsx +86 -0
- package/src/components/TicketConversation/SkeletonText.tsx +23 -0
- package/src/components/TicketConversation/constants.ts +29 -0
- package/src/components/TicketConversation/hooks/useDocumentIdFromUrl.ts +21 -0
- package/src/components/TicketConversation/index.tsx +4 -152
- package/src/endpoints/admin-chat-stream.ts +3 -4
- package/src/endpoints/admin-chat.ts +11 -20
- package/src/endpoints/admin-stats.ts +77 -35
- package/src/endpoints/ai-agent.ts +36 -0
- package/src/endpoints/apply-macro.ts +8 -14
- package/src/endpoints/auth-2fa.ts +12 -12
- package/src/endpoints/auto-close.ts +6 -10
- package/src/endpoints/billing.ts +17 -4
- package/src/endpoints/bulk-action.ts +7 -12
- package/src/endpoints/channels.ts +89 -0
- package/src/endpoints/chat-stream.ts +3 -4
- package/src/endpoints/chat.ts +7 -12
- package/src/endpoints/chatbot.ts +2 -2
- package/src/endpoints/client-intelligence.ts +4 -4
- package/src/endpoints/delete-account.ts +9 -16
- package/src/endpoints/email-stats.ts +2 -2
- package/src/endpoints/escalate.ts +2 -2
- package/src/endpoints/export-csv.ts +2 -2
- package/src/endpoints/export-data.ts +5 -8
- package/src/endpoints/import-conversation.ts +6 -10
- package/src/endpoints/index.ts +22 -1
- package/src/endpoints/invite-collaborator.ts +30 -15
- package/src/endpoints/invoice.ts +127 -0
- package/src/endpoints/kb-search.ts +2 -2
- package/src/endpoints/login.ts +12 -4
- package/src/endpoints/merge-clients.ts +14 -19
- package/src/endpoints/merge-tickets.ts +11 -18
- package/src/endpoints/oauth-google.ts +86 -39
- package/src/endpoints/pending-emails-process.ts +15 -25
- package/src/endpoints/process-digests.ts +93 -0
- package/src/endpoints/process-scheduled.ts +6 -10
- package/src/endpoints/process-snooze.ts +78 -0
- package/src/endpoints/purge-logs.ts +2 -2
- package/src/endpoints/push.ts +49 -0
- package/src/endpoints/resend-notification.ts +3 -4
- package/src/endpoints/round-robin-config.ts +3 -4
- package/src/endpoints/satisfaction.ts +21 -14
- package/src/endpoints/search.ts +5 -8
- package/src/endpoints/seed-kb.ts +3 -4
- package/src/endpoints/send-reminder.ts +6 -9
- package/src/endpoints/settings.ts +5 -2
- package/src/endpoints/signature.ts +3 -4
- package/src/endpoints/sla-check.ts +2 -2
- package/src/endpoints/split-ticket.ts +7 -12
- package/src/endpoints/statuses.ts +2 -2
- package/src/endpoints/ticket-feedback.ts +4 -6
- package/src/endpoints/ticket-synthesis.ts +2 -2
- package/src/endpoints/track-open.ts +16 -28
- package/src/endpoints/transfer-ticket.ts +21 -9
- package/src/endpoints/user-prefs.ts +3 -4
- package/src/hooks/automationRules.ts +84 -0
- package/src/hooks/checkSLA.ts +63 -17
- package/src/plugin.ts +8 -0
- package/src/portal/LiveChat.tsx +2 -63
- package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +10 -0
- package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +14 -2
- package/src/portal/auth/tickets/detail/MessageActions.tsx +37 -18
- package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +10 -0
- package/src/portal/auth/tickets/detail/TransferAndInviteActions.tsx +3 -2
- package/src/portal/auth/tickets/detail/page.tsx +2 -2
- package/src/portal/helpers.ts +46 -0
- package/src/portal/login/page.tsx +3 -8
- package/src/portal/types.ts +15 -0
- package/src/types/lucide-react.d.ts +34 -0
- package/src/types/payload-config.d.ts +10 -0
- package/src/utils/adminNotification.ts +2 -2
- package/src/utils/aiAgent.ts +147 -0
- package/src/utils/auth.ts +5 -4
- package/src/utils/channels.ts +36 -0
- package/src/utils/db.ts +73 -0
- package/src/utils/emailTemplate.ts +8 -1
- package/src/utils/fireWebhooks.ts +4 -6
- package/src/utils/generateTicketSynthesis.ts +7 -12
- package/src/utils/invoicePdf.ts +88 -0
- package/src/utils/notificationQueue.ts +36 -0
- package/src/utils/push.ts +63 -0
- package/src/utils/readSettings.ts +22 -6
- package/src/utils/sanitizeHtml.ts +57 -0
- package/src/utils/slugs.ts +8 -0
- package/src/utils/teamAccess.ts +27 -0
- package/src/utils/ticketAccess.ts +53 -0
- package/src/utils/webhookDispatcher.ts +4 -6
- package/src/views/BillingView/client.tsx +49 -37
- package/src/views/ChatView/client.tsx +4 -3
- package/src/views/CrmView/client.tsx +7 -7
- package/src/views/EmailTrackingView/client.tsx +7 -6
- package/src/views/ImportConversationView/client.tsx +4 -4
- package/src/views/LogsView/client.tsx +3 -2
- package/src/views/NewTicketView/client.tsx +1 -1
- package/src/views/PendingEmailsView/client.tsx +7 -6
- package/src/views/SupportDashboardView/client.tsx +21 -21
- package/src/views/TicketDetailView/NextActionItem.tsx +30 -0
- package/src/views/TicketDetailView/RewriteDropdown.tsx +63 -0
- package/src/views/TicketDetailView/client.tsx +54 -179
- package/src/views/TicketDetailView/constants.ts +23 -0
- package/src/views/TicketDetailView/helpers.ts +17 -0
- package/src/views/TicketDetailView/types.ts +10 -0
- package/src/views/TicketInboxView/client.tsx +16 -10
- package/src/views/TicketingSettingsView/client.tsx +90 -87
- package/src/views/TimeDashboardView/client.tsx +2 -2
- package/src/views/shared/StatusPill.tsx +24 -12
- package/src/views/shared/dateLocale.ts +5 -0
- package/src/views/shared/locales/en.json +708 -0
- package/src/views/shared/locales/fr.json +708 -0
|
@@ -9,138 +9,13 @@ import { CodeBlockInserter } from '../../components/TicketConversation/component
|
|
|
9
9
|
import { getFeatures } from '../shared/config'
|
|
10
10
|
import { computeSlaState, formatSlaRemaining } from '../shared/sla'
|
|
11
11
|
import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
|
|
12
|
+
import { DATE_LOCALE } from '../shared/dateLocale'
|
|
12
13
|
import s from '../../styles/TicketDetail.module.scss'
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
attachments?: Array<{ file: { id: number; url?: string; filename?: string; mimeType?: string } | number }>
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
const ALIASES: Array<{ value: string; label: string }> = [
|
|
22
|
-
{ value: 'Support Consilioweb', label: 'En tant que : Support Consilioweb' },
|
|
23
|
-
{ value: 'contact@consilioweb.fr', label: 'En tant que : contact@consilioweb.fr' },
|
|
24
|
-
]
|
|
25
|
-
interface ClientInfo { id: number; company: string; firstName: string; lastName: string; email: string; phone?: string }
|
|
26
|
-
interface TimeEntry { id: string | number; duration: number; description?: string; date: string }
|
|
27
|
-
interface ActivityEntry { id: string | number; action: string; detail?: string; actorType?: string; createdAt: string }
|
|
28
|
-
|
|
29
|
-
const STATUS_STYLE: Record<string, { bg: string; color: string }> = {
|
|
30
|
-
open: { bg: '#dbeafe', color: '#1e40af' },
|
|
31
|
-
waiting_client: { bg: '#fef3c7', color: '#92400e' },
|
|
32
|
-
resolved: { bg: '#dcfce7', color: '#166534' },
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function timeAgo(d: string): string {
|
|
36
|
-
const date = new Date(d), now = new Date()
|
|
37
|
-
if (date.toDateString() === now.toDateString()) return date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })
|
|
38
|
-
const y = new Date(now); y.setDate(y.getDate() - 1)
|
|
39
|
-
if (date.toDateString() === y.toDateString()) return `Hier, ${date.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })}`
|
|
40
|
-
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function dateLabel(d: string): string {
|
|
44
|
-
const date = new Date(d), now = new Date()
|
|
45
|
-
if (date.toDateString() === now.toDateString()) return "Aujourd'hui"
|
|
46
|
-
const y = new Date(now); y.setDate(y.getDate() - 1)
|
|
47
|
-
if (date.toDateString() === y.toDateString()) return 'Hier'
|
|
48
|
-
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'long' })
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
// ── Rewrite style dropdown ──────────────────────────────────────────────
|
|
52
|
-
const REWRITE_STYLES = [
|
|
53
|
-
{ id: 'auto', label: '✏️ Auto', desc: 'Garde le ton actuel' },
|
|
54
|
-
{ id: 'tutoyer', label: '👋 Tutoyer', desc: 'Passe en tu' },
|
|
55
|
-
{ id: 'vouvoyer', label: '🎩 Vouvoyer', desc: 'Passe en vous' },
|
|
56
|
-
{ id: 'formel', label: '💼 Formel', desc: 'Ton professionnel' },
|
|
57
|
-
{ id: 'amical', label: '😊 Amical', desc: 'Ton chaleureux' },
|
|
58
|
-
{ id: 'court', label: '⚡ Court', desc: 'Version concise' },
|
|
59
|
-
]
|
|
60
|
-
|
|
61
|
-
const RewriteDropdown: React.FC<{
|
|
62
|
-
disabled: boolean
|
|
63
|
-
loading: boolean
|
|
64
|
-
onSelect: (style: string) => void
|
|
65
|
-
toolbarBtnClass?: string
|
|
66
|
-
}> = ({ disabled, loading, onSelect, toolbarBtnClass }) => {
|
|
67
|
-
const [open, setOpen] = useState(false)
|
|
68
|
-
const ref = useRef<HTMLDivElement>(null)
|
|
69
|
-
|
|
70
|
-
useEffect(() => {
|
|
71
|
-
if (!open) return
|
|
72
|
-
const close = (e: MouseEvent) => {
|
|
73
|
-
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false)
|
|
74
|
-
}
|
|
75
|
-
document.addEventListener('mousedown', close)
|
|
76
|
-
return () => document.removeEventListener('mousedown', close)
|
|
77
|
-
}, [open])
|
|
78
|
-
|
|
79
|
-
return (
|
|
80
|
-
<div ref={ref} style={{ position: 'relative', display: 'inline-block' }}>
|
|
81
|
-
<button
|
|
82
|
-
type="button"
|
|
83
|
-
className={toolbarBtnClass}
|
|
84
|
-
onClick={() => setOpen(!open)}
|
|
85
|
-
disabled={disabled}
|
|
86
|
-
style={{ fontSize: 11, fontWeight: 700, padding: '4px 10px', width: 'auto', display: 'flex', alignItems: 'center', gap: 4 }}
|
|
87
|
-
>
|
|
88
|
-
{loading ? '...' : '✏️ Reformuler'}
|
|
89
|
-
{!loading && <span style={{ fontSize: 9, opacity: 0.6 }}>▼</span>}
|
|
90
|
-
</button>
|
|
91
|
-
{open && !disabled && !loading && (
|
|
92
|
-
<div style={{
|
|
93
|
-
position: 'absolute', bottom: '100%', left: 0, marginBottom: 4,
|
|
94
|
-
background: '#fff', border: '1px solid #e5e7eb', borderRadius: 8,
|
|
95
|
-
boxShadow: '0 4px 16px rgba(0,0,0,0.12)', zIndex: 100, minWidth: 180, overflow: 'hidden',
|
|
96
|
-
}}>
|
|
97
|
-
{REWRITE_STYLES.map((style) => (
|
|
98
|
-
<button
|
|
99
|
-
key={style.id}
|
|
100
|
-
type="button"
|
|
101
|
-
onClick={() => { setOpen(false); onSelect(style.id) }}
|
|
102
|
-
style={{
|
|
103
|
-
display: 'flex', flexDirection: 'column', width: '100%', padding: '8px 12px',
|
|
104
|
-
border: 'none', background: 'transparent', cursor: 'pointer', textAlign: 'left',
|
|
105
|
-
borderBottom: '1px solid #f3f4f6',
|
|
106
|
-
}}
|
|
107
|
-
onMouseEnter={(e) => { (e.currentTarget as HTMLElement).style.background = '#f9fafb' }}
|
|
108
|
-
onMouseLeave={(e) => { (e.currentTarget as HTMLElement).style.background = 'transparent' }}
|
|
109
|
-
>
|
|
110
|
-
<span style={{ fontSize: 12, fontWeight: 600 }}>{style.label}</span>
|
|
111
|
-
<span style={{ fontSize: 10, color: '#9ca3af' }}>{style.desc}</span>
|
|
112
|
-
</button>
|
|
113
|
-
))}
|
|
114
|
-
</div>
|
|
115
|
-
)}
|
|
116
|
-
</div>
|
|
117
|
-
)
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function NextActionItem({ action, index, onToggle }: { action: any; index: number; onToggle: (done: boolean) => Promise<void> }) {
|
|
121
|
-
const [pending, setPending] = useState(false)
|
|
122
|
-
const labelMap: Record<string, string> = { now: 'Maintenant', today: "Aujourd'hui", 'this-week': 'Cette semaine' }
|
|
123
|
-
const done = !!action.done
|
|
124
|
-
return (
|
|
125
|
-
<li className={s.aiCardNextAction}>
|
|
126
|
-
<button
|
|
127
|
-
type="button"
|
|
128
|
-
className={`${s.aiCardNextCheck} ${done ? s.aiCardNextCheckDone : ''}`}
|
|
129
|
-
onClick={async () => {
|
|
130
|
-
setPending(true)
|
|
131
|
-
try { await onToggle(!done) } finally { setPending(false) }
|
|
132
|
-
}}
|
|
133
|
-
aria-pressed={done}
|
|
134
|
-
aria-label={done ? 'Marquer non fait' : 'Marquer fait'}
|
|
135
|
-
disabled={pending}
|
|
136
|
-
>
|
|
137
|
-
{done && <span aria-hidden>✓</span>}
|
|
138
|
-
</button>
|
|
139
|
-
<span className={`${s.aiCardNextLabel} ${done ? s.aiCardNextLabelDone : ''}`}>{action.label}</span>
|
|
140
|
-
<span className={s.aiCardNextWhen}>{labelMap[action.priority] || action.priority}</span>
|
|
141
|
-
</li>
|
|
142
|
-
)
|
|
143
|
-
}
|
|
14
|
+
import type { Message, ClientInfo, TimeEntry, ActivityEntry } from './types'
|
|
15
|
+
import { ALIASES, STATUS_STYLE } from './constants'
|
|
16
|
+
import { timeAgo, dateLabel } from './helpers'
|
|
17
|
+
import { RewriteDropdown } from './RewriteDropdown'
|
|
18
|
+
import { NextActionItem } from './NextActionItem'
|
|
144
19
|
|
|
145
20
|
export const TicketDetailClient: React.FC = () => {
|
|
146
21
|
const { t } = useTranslation()
|
|
@@ -663,7 +538,7 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
663
538
|
<div className={s.page}>
|
|
664
539
|
{/* TOP BAR — detail__header maquette : back-btn + (id+meta + title) + quick-actions */}
|
|
665
540
|
<div className={s.topBar}>
|
|
666
|
-
<Link href="/admin/support/inbox" className={s.backLink} aria-label=
|
|
541
|
+
<Link href="/admin/support/inbox" className={s.backLink} aria-label={t('detail.backToInbox')}>←</Link>
|
|
667
542
|
<div className={s.ticketMeta}>
|
|
668
543
|
{/* detail__id : ticketNumber · category · source email */}
|
|
669
544
|
<span className={s.ticketNumber}>
|
|
@@ -717,7 +592,7 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
717
592
|
})
|
|
718
593
|
if (slaInfo.state !== 'breach') return null
|
|
719
594
|
const duration = formatSlaRemaining(slaInfo.remainingMs).replace(/^−/, '')
|
|
720
|
-
const target = slaInfo.due ? new Date(slaInfo.due).toLocaleString(
|
|
595
|
+
const target = slaInfo.due ? new Date(slaInfo.due).toLocaleString(DATE_LOCALE, { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' }) : ''
|
|
721
596
|
return (
|
|
722
597
|
<div className={s.slaBanner} role="alert">
|
|
723
598
|
<span className={s.slaBannerIcon} aria-hidden>
|
|
@@ -787,17 +662,17 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
787
662
|
<div className={s.messageHeader}>
|
|
788
663
|
<span className={s.messageAuthor}>{msg.fromAlias || (isAdmin ? 'Support' : msg.authorType === 'email' ? 'Email' : client?.firstName || 'Client')}</span>
|
|
789
664
|
<span className={s.messageTime}>{timeAgo(msg.createdAt)}</span>
|
|
790
|
-
{msg.isInternal && <span className={s.badge} style={{ background: '#fef3c7', color: '#92400e' }}>
|
|
791
|
-
{msg.isSolution && <span className={s.badge} style={{ background: '#dcfce7', color: '#166534' }}>
|
|
665
|
+
{msg.isInternal && <span className={s.badge} style={{ background: '#fef3c7', color: '#92400e' }}>{t('detail.badgeInternal')}</span>}
|
|
666
|
+
{msg.isSolution && <span className={s.badge} style={{ background: '#dcfce7', color: '#166534' }}>{t('detail.badgeSolution')}</span>}
|
|
792
667
|
<span className={s.messageMeta}>
|
|
793
668
|
{isAdmin && !msg.isInternal && (() => {
|
|
794
669
|
const ext = msg as unknown as { emailOpenedAt?: string; emailSentAt?: string }
|
|
795
670
|
if (ext.emailOpenedAt) {
|
|
796
|
-
const d = new Date(ext.emailOpenedAt).toLocaleString(
|
|
671
|
+
const d = new Date(ext.emailOpenedAt).toLocaleString(DATE_LOCALE, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
|
|
797
672
|
return <span style={{ color: '#16a34a', cursor: 'help' }} title={`Ouvert le ${d}`}>✓✓ Lu {d}</span>
|
|
798
673
|
}
|
|
799
674
|
if (ext.emailSentAt) {
|
|
800
|
-
const d = new Date(ext.emailSentAt).toLocaleString(
|
|
675
|
+
const d = new Date(ext.emailSentAt).toLocaleString(DATE_LOCALE, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
|
|
801
676
|
return <span style={{ color: '#2563eb', cursor: 'help' }} title={`Envoyé le ${d}`}>✓ Envoyé {d}</span>
|
|
802
677
|
}
|
|
803
678
|
return <span style={{ color: '#94a3b8' }}>✓</span>
|
|
@@ -810,7 +685,7 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
810
685
|
ref={editEditorRef}
|
|
811
686
|
initialValue={editingHtml}
|
|
812
687
|
onChange={(html, text) => { setEditingHtml(html); setEditingBody(text) }}
|
|
813
|
-
placeholder=
|
|
688
|
+
placeholder={t('detail.editPlaceholder')}
|
|
814
689
|
minHeight={120}
|
|
815
690
|
onFileUpload={async (file) => {
|
|
816
691
|
try {
|
|
@@ -825,8 +700,8 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
825
700
|
}}
|
|
826
701
|
/>
|
|
827
702
|
<div style={{ display: 'flex', gap: 6, justifyContent: 'flex-end' }}>
|
|
828
|
-
<button onClick={cancelEditMessage} disabled={editSaving} style={{ padding: '5px 12px', fontSize: 12, borderRadius: 5, border: '1px solid var(--theme-elevation-200)', background: 'var(--theme-elevation-0)', color: 'var(--theme-text)', cursor: 'pointer' }}>
|
|
829
|
-
<button onClick={saveEditMessage} disabled={editSaving || !editingBody.trim()} style={{ padding: '5px 12px', fontSize: 12, fontWeight: 600, borderRadius: 5, border: 'none', background: '#2563eb', color: '#fff', cursor: editSaving ? 'wait' : 'pointer', opacity: editSaving ? 0.6 : 1 }}>{editSaving ? '
|
|
703
|
+
<button onClick={cancelEditMessage} disabled={editSaving} style={{ padding: '5px 12px', fontSize: 12, borderRadius: 5, border: '1px solid var(--theme-elevation-200)', background: 'var(--theme-elevation-0)', color: 'var(--theme-text)', cursor: 'pointer' }}>{t('common.cancel')}</button>
|
|
704
|
+
<button onClick={saveEditMessage} disabled={editSaving || !editingBody.trim()} style={{ padding: '5px 12px', fontSize: 12, fontWeight: 600, borderRadius: 5, border: 'none', background: '#2563eb', color: '#fff', cursor: editSaving ? 'wait' : 'pointer', opacity: editSaving ? 0.6 : 1 }}>{editSaving ? t('detail.editSaving') : t('detail.editSave')}</button>
|
|
830
705
|
</div>
|
|
831
706
|
</div>
|
|
832
707
|
) : (msg as unknown as { deletedAt?: string }).deletedAt ? (
|
|
@@ -855,7 +730,7 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
855
730
|
{/* Hover actions — icon buttons with aria-labels (#7) */}
|
|
856
731
|
{editingMsgId !== msg.id && !(msg as unknown as { deletedAt?: string }).deletedAt && (
|
|
857
732
|
<div className={s.messageActions}>
|
|
858
|
-
<button className={s.actionIcon} title=
|
|
733
|
+
<button className={s.actionIcon} title={t('detail.editMessage')} aria-label={t('detail.editMessageLabel')} onClick={() => startEditMessage(msg)} style={{ fontSize: 11, width: 'auto', padding: '4px 8px' }}>{t('detail.editMessage')}</button>
|
|
859
734
|
{/* #2 — Undo toast instead of confirm() */}
|
|
860
735
|
<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>
|
|
861
736
|
{/* #2 — Split modal instead of prompt() */}
|
|
@@ -942,7 +817,7 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
942
817
|
onChange={(e) => { const id = Number(e.target.value); if (id) handleApplyMacro(id); e.target.value = '' }}
|
|
943
818
|
disabled={applyingMacro}
|
|
944
819
|
style={{ marginLeft: 0 }}
|
|
945
|
-
aria-label=
|
|
820
|
+
aria-label={t('detail.macros')}
|
|
946
821
|
>
|
|
947
822
|
<option value="">{applyingMacro ? t('detail.applyingMacro') : t('detail.macros')}</option>
|
|
948
823
|
{macros.map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
|
|
@@ -951,7 +826,7 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
951
826
|
<span className={s.toolbarDivider} />
|
|
952
827
|
{/* #9 — "Canned" -> "Réponses types" */}
|
|
953
828
|
{features.canned && cannedResponses.length > 0 && (
|
|
954
|
-
<select className={s.cannedSelect} aria-label=
|
|
829
|
+
<select className={s.cannedSelect} aria-label={t('detail.cannedResponses')} onChange={(e) => {
|
|
955
830
|
const cr = cannedResponses.find((c) => String(c.id) === e.target.value)
|
|
956
831
|
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/>')) }
|
|
957
832
|
e.target.value = ''
|
|
@@ -986,8 +861,8 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
986
861
|
<div className={s.uploadPreview}>
|
|
987
862
|
{pendingFiles.map((f, i) => (
|
|
988
863
|
<div key={i} className={s.uploadPreviewItem}>
|
|
989
|
-
<span>
|
|
990
|
-
<button className={s.uploadRemoveBtn} aria-label={
|
|
864
|
+
<span>{t('detail.uploadItem', { name: f.name })}</span>
|
|
865
|
+
<button className={s.uploadRemoveBtn} aria-label={t('detail.removeUpload', { name: f.name })} onClick={() => setPendingFiles((prev) => prev.filter((_, j) => j !== i))}>×</button>
|
|
991
866
|
</div>
|
|
992
867
|
))}
|
|
993
868
|
</div>
|
|
@@ -1004,8 +879,8 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
1004
879
|
}}
|
|
1005
880
|
style={{ fontSize: '12px', padding: '4px 8px', fontWeight: 600, borderRadius: 6, border: '1px solid var(--theme-elevation-200)', background: 'var(--theme-elevation-0)', color: 'var(--theme-text)' }}
|
|
1006
881
|
>
|
|
1007
|
-
<option value="admin">
|
|
1008
|
-
<option value="client">
|
|
882
|
+
<option value="admin">{t('detail.sendAsSupport')}</option>
|
|
883
|
+
<option value="client">{t('detail.sendAsClient')}</option>
|
|
1009
884
|
</select>
|
|
1010
885
|
{!sendAsClient && (
|
|
1011
886
|
<select
|
|
@@ -1030,7 +905,7 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
1030
905
|
<kbd>⌘</kbd><kbd>↵</kbd>
|
|
1031
906
|
</span>
|
|
1032
907
|
<button className={`${s.sendBtn} ${isInternal ? s.sendBtnInternal : ''}`} onClick={handleSend} disabled={sending || !replyBody.trim()} data-action="send-reply">
|
|
1033
|
-
{sending ? t('detail.sending') : sendAsClient ? '
|
|
908
|
+
{sending ? t('detail.sending') : sendAsClient ? t('detail.addClientMessage') : isInternal ? t('detail.sendNote') : t('detail.sendReply')}
|
|
1034
909
|
</button>
|
|
1035
910
|
</div>
|
|
1036
911
|
</div>
|
|
@@ -1103,7 +978,7 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
1103
978
|
<div className={`${s.sideSection} ${s.aiCard}`}>
|
|
1104
979
|
<div className={s.aiCardTitle}>
|
|
1105
980
|
<span className={s.aiCardIcon} aria-hidden>✨</span>
|
|
1106
|
-
|
|
981
|
+
{t('detail.aiCard.title')}
|
|
1107
982
|
</div>
|
|
1108
983
|
<p className={s.aiCardLead}>{clientSummary.summary}</p>
|
|
1109
984
|
{clientSummary.recurringTopics && clientSummary.recurringTopics.length > 0 && (
|
|
@@ -1200,12 +1075,12 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
1200
1075
|
{String(Math.floor(timerSeconds / 60)).padStart(2, '0')}:{String(timerSeconds % 60).padStart(2, '0')}
|
|
1201
1076
|
</span>
|
|
1202
1077
|
{!timerRunning ? (
|
|
1203
|
-
<button className={s.timerBtn} onClick={() => setTimerRunning(true)} aria-label=
|
|
1078
|
+
<button className={s.timerBtn} onClick={() => setTimerRunning(true)} aria-label={t('detail.timerStart')}>{timerSeconds > 0 ? '▶' : '▶ Go'}</button>
|
|
1204
1079
|
) : (
|
|
1205
|
-
<button className={`${s.timerBtn} ${s.timerBtnRunning}`} onClick={() => setTimerRunning(false)} aria-label=
|
|
1080
|
+
<button className={`${s.timerBtn} ${s.timerBtnRunning}`} onClick={() => setTimerRunning(false)} aria-label={t('detail.timerPause')}>⏸ Pause</button>
|
|
1206
1081
|
)}
|
|
1207
1082
|
{timerSeconds >= 60 && !timerRunning && (
|
|
1208
|
-
<button className={`${s.timerBtn} ${s.timerBtnGhost}`} onClick={() => { handleTimerSave(); localStorage.removeItem(`timer-sec-${ticketId}`); localStorage.removeItem(`timer-run-${ticketId}`) }} aria-label=
|
|
1083
|
+
<button className={`${s.timerBtn} ${s.timerBtnGhost}`} onClick={() => { handleTimerSave(); localStorage.removeItem(`timer-sec-${ticketId}`); localStorage.removeItem(`timer-run-${ticketId}`) }} aria-label={t('detail.timerSave')}>💾 {Math.round(timerSeconds / 60)}m</button>
|
|
1209
1084
|
)}
|
|
1210
1085
|
</div>
|
|
1211
1086
|
{/* Progress bar (tt__bar maquette) — full = 120 min reference */}
|
|
@@ -1224,12 +1099,12 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
1224
1099
|
if (input) input.value = ''
|
|
1225
1100
|
fetchAll()
|
|
1226
1101
|
} catch {}
|
|
1227
|
-
}} style={{ fontSize: 11 }}
|
|
1102
|
+
}} style={{ fontSize: 11 }}>{t('detail.addTime')}</button>
|
|
1228
1103
|
</div>
|
|
1229
1104
|
{/* Billing info */}
|
|
1230
1105
|
<div style={{ marginTop: 8, fontSize: 11, color: 'var(--theme-elevation-500)' }}>
|
|
1231
1106
|
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '2px 0', alignItems: 'center' }}>
|
|
1232
|
-
<span>
|
|
1107
|
+
<span>{t('detail.billing.billable')}</span>
|
|
1233
1108
|
<button
|
|
1234
1109
|
onClick={async () => {
|
|
1235
1110
|
const newVal = ticket.billable === false ? true : false
|
|
@@ -1237,12 +1112,12 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
1237
1112
|
}}
|
|
1238
1113
|
style={{ fontWeight: 600, color: (ticket.billable !== false) ? '#16a34a' : '#dc2626', background: 'none', border: 'none', cursor: 'pointer', fontSize: 11, textDecoration: 'underline' }}
|
|
1239
1114
|
>
|
|
1240
|
-
{(ticket.billable !== false) ? '
|
|
1115
|
+
{(ticket.billable !== false) ? t('detail.billing.yes') : t('detail.billing.no')}
|
|
1241
1116
|
</button>
|
|
1242
1117
|
</div>
|
|
1243
1118
|
{totalMin > 0 && (
|
|
1244
1119
|
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '2px 0' }}>
|
|
1245
|
-
<span>
|
|
1120
|
+
<span>{t('detail.billing.estimatedAmount')}</span>
|
|
1246
1121
|
<span style={{ fontWeight: 700, color: 'var(--theme-text)' }}>{((totalMin / 60) * 60).toFixed(0)}€</span>
|
|
1247
1122
|
</div>
|
|
1248
1123
|
)}
|
|
@@ -1252,7 +1127,7 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
1252
1127
|
<div style={{ marginTop: 8, fontSize: 11 }}>
|
|
1253
1128
|
{timeEntries.slice(0, 6).map((e) => (
|
|
1254
1129
|
<div key={e.id} style={{ display: 'flex', justifyContent: 'space-between', padding: '3px 0', color: 'var(--theme-elevation-500)' }}>
|
|
1255
|
-
<span>{new Date(e.date).toLocaleDateString(
|
|
1130
|
+
<span>{new Date(e.date).toLocaleDateString(DATE_LOCALE, { day: 'numeric', month: 'short' })}</span>
|
|
1256
1131
|
<span title={e.description} style={{ fontWeight: 600, cursor: e.description ? 'help' : 'default' }}>{e.duration}min</span>
|
|
1257
1132
|
</div>
|
|
1258
1133
|
))}
|
|
@@ -1268,7 +1143,7 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
1268
1143
|
{tags.map((tag) => (
|
|
1269
1144
|
<span key={tag} className={s.tagChip}>
|
|
1270
1145
|
{tag}
|
|
1271
|
-
<button className={s.tagRemove} aria-label={
|
|
1146
|
+
<button className={s.tagRemove} aria-label={t('detail.removeTag', { tag })} onClick={() => handleRemoveTag(tag)}>×</button>
|
|
1272
1147
|
</span>
|
|
1273
1148
|
))}
|
|
1274
1149
|
{addingTag ? (
|
|
@@ -1278,21 +1153,21 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
1278
1153
|
onChange={(e) => setNewTagValue(e.target.value)}
|
|
1279
1154
|
onKeyDown={(e) => { if (e.key === 'Enter') handleAddTag(); if (e.key === 'Escape') { setAddingTag(false); setNewTagValue('') } }}
|
|
1280
1155
|
onBlur={handleAddTag}
|
|
1281
|
-
placeholder=
|
|
1156
|
+
placeholder={t('detail.tagPlaceholder')}
|
|
1282
1157
|
autoFocus
|
|
1283
1158
|
/>
|
|
1284
1159
|
) : (
|
|
1285
|
-
<button className={s.tagAddBtn} onClick={() => setAddingTag(true)} aria-label=
|
|
1160
|
+
<button className={s.tagAddBtn} onClick={() => setAddingTag(true)} aria-label={t('detail.addTag')}>{t('detail.addTagBtn')}</button>
|
|
1286
1161
|
)}
|
|
1287
1162
|
</div>
|
|
1288
1163
|
</div>
|
|
1289
1164
|
|
|
1290
1165
|
{/* ===== BILLING ===== */}
|
|
1291
1166
|
<div className={s.sideSection}>
|
|
1292
|
-
<div className={s.sideSectionTitle}>
|
|
1167
|
+
<div className={s.sideSectionTitle}>{t('detail.billing.title')}</div>
|
|
1293
1168
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 8 }}>
|
|
1294
1169
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
1295
|
-
<span style={{ fontSize: 12 }}>
|
|
1170
|
+
<span style={{ fontSize: 12 }}>{t('detail.billing.type')}</span>
|
|
1296
1171
|
<select
|
|
1297
1172
|
value={(ticket as any)?.billingType || 'hourly'}
|
|
1298
1173
|
onChange={async (e) => {
|
|
@@ -1307,13 +1182,13 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
1307
1182
|
}}
|
|
1308
1183
|
style={{ fontSize: 12, padding: '4px 8px', borderRadius: 6, border: '1px solid #e5e7eb', background: '#fff' }}
|
|
1309
1184
|
>
|
|
1310
|
-
<option value="hourly">
|
|
1311
|
-
<option value="flat">
|
|
1185
|
+
<option value="hourly">{t('detail.billing.hourly')}</option>
|
|
1186
|
+
<option value="flat">{t('detail.billing.flat')}</option>
|
|
1312
1187
|
</select>
|
|
1313
1188
|
</div>
|
|
1314
1189
|
{(ticket as any)?.billingType === 'flat' && (
|
|
1315
1190
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
1316
|
-
<span style={{ fontSize: 12 }}>
|
|
1191
|
+
<span style={{ fontSize: 12 }}>{t('detail.billing.flatAmount')}</span>
|
|
1317
1192
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
1318
1193
|
<input
|
|
1319
1194
|
type="number"
|
|
@@ -1337,7 +1212,7 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
1337
1212
|
</div>
|
|
1338
1213
|
)}
|
|
1339
1214
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
1340
|
-
<span style={{ fontSize: 12 }}>
|
|
1215
|
+
<span style={{ fontSize: 12 }}>{t('detail.billing.billedAmount')}</span>
|
|
1341
1216
|
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
1342
1217
|
<input
|
|
1343
1218
|
type="number"
|
|
@@ -1360,7 +1235,7 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
1360
1235
|
</div>
|
|
1361
1236
|
</div>
|
|
1362
1237
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
|
1363
|
-
<span style={{ fontSize: 12 }}>
|
|
1238
|
+
<span style={{ fontSize: 12 }}>{t('detail.billing.payment')}</span>
|
|
1364
1239
|
<select
|
|
1365
1240
|
value={(ticket as any)?.paymentStatus || 'unpaid'}
|
|
1366
1241
|
onChange={async (e) => {
|
|
@@ -1375,9 +1250,9 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
1375
1250
|
}}
|
|
1376
1251
|
style={{ fontSize: 12, padding: '4px 8px', borderRadius: 6, border: '1px solid #e5e7eb', background: '#fff' }}
|
|
1377
1252
|
>
|
|
1378
|
-
<option value="unpaid">
|
|
1379
|
-
<option value="partial">
|
|
1380
|
-
<option value="paid">
|
|
1253
|
+
<option value="unpaid">{t('detail.billing.unpaid')}</option>
|
|
1254
|
+
<option value="partial">{t('detail.billing.partial')}</option>
|
|
1255
|
+
<option value="paid">{t('detail.billing.paid')}</option>
|
|
1381
1256
|
</select>
|
|
1382
1257
|
</div>
|
|
1383
1258
|
</div>
|
|
@@ -1385,7 +1260,7 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
1385
1260
|
|
|
1386
1261
|
{summaryLoading && (
|
|
1387
1262
|
<div className={s.sideSection}>
|
|
1388
|
-
<div style={{ fontSize: 11, color: 'var(--theme-elevation-400)', textAlign: 'center', padding: 8 }}
|
|
1263
|
+
<div style={{ fontSize: 11, color: 'var(--theme-elevation-400)', textAlign: 'center', padding: 8 }}>{t('detail.loadingSummary')}</div>
|
|
1389
1264
|
</div>
|
|
1390
1265
|
)}
|
|
1391
1266
|
</div>
|
|
@@ -1426,7 +1301,7 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
1426
1301
|
<span className={s.previousTicketsSubject}>{pt.subject || `#${pt.id}`}</span>
|
|
1427
1302
|
<span className={s.previousTicketsMeta}>
|
|
1428
1303
|
{pt.status ? <span className={s.previousTicketsStatus}>{pt.status}</span> : null}
|
|
1429
|
-
<span className={s.previousTicketsDate}>{new Date(pt.createdAt).toLocaleDateString(
|
|
1304
|
+
<span className={s.previousTicketsDate}>{new Date(pt.createdAt).toLocaleDateString(DATE_LOCALE, { day: 'numeric', month: 'short', year: 'numeric' })}</span>
|
|
1430
1305
|
</span>
|
|
1431
1306
|
</Link>
|
|
1432
1307
|
</li>
|
|
@@ -1470,28 +1345,28 @@ const [clientTyping, setClientTyping] = useState(false)
|
|
|
1470
1345
|
{/* #2 — Undo delete toast */}
|
|
1471
1346
|
{undoToast && (
|
|
1472
1347
|
<div className={s.undoToast} role="alert">
|
|
1473
|
-
<span>
|
|
1474
|
-
<button className={s.undoBtn} onClick={handleUndoDelete}>
|
|
1348
|
+
<span>{t('detail.messageDeletedToast')}</span>
|
|
1349
|
+
<button className={s.undoBtn} onClick={handleUndoDelete}>{t('common.cancel')}</button>
|
|
1475
1350
|
</div>
|
|
1476
1351
|
)}
|
|
1477
1352
|
|
|
1478
1353
|
{/* #2 — Split ticket modal */}
|
|
1479
1354
|
{splitModal && (
|
|
1480
1355
|
<div className={s.splitOverlay} onClick={(e) => { if (e.target === e.currentTarget) { setSplitModal(null); setSplitSubject('') } }}>
|
|
1481
|
-
<div className={s.splitModal} role="dialog" aria-label=
|
|
1482
|
-
<h3 className={s.splitTitle}>
|
|
1356
|
+
<div className={s.splitModal} role="dialog" aria-label={t('actions.extractToNewTicket')}>
|
|
1357
|
+
<h3 className={s.splitTitle}>{t('actions.extractToNewTicket')}</h3>
|
|
1483
1358
|
<div className={s.splitPreview}>{splitModal.preview}</div>
|
|
1484
1359
|
<input
|
|
1485
1360
|
className={s.splitInput}
|
|
1486
1361
|
value={splitSubject}
|
|
1487
1362
|
onChange={(e) => setSplitSubject(e.target.value)}
|
|
1488
1363
|
onKeyDown={(e) => { if (e.key === 'Enter') handleSplitConfirm() }}
|
|
1489
|
-
placeholder=
|
|
1364
|
+
placeholder={t('detail.split.subjectPlaceholder')}
|
|
1490
1365
|
autoFocus
|
|
1491
1366
|
/>
|
|
1492
1367
|
<div className={s.splitActions}>
|
|
1493
|
-
<button className={s.splitCancelBtn} onClick={() => { setSplitModal(null); setSplitSubject('') }}>
|
|
1494
|
-
<button className={s.splitConfirmBtn} onClick={handleSplitConfirm} disabled={!splitSubject.trim()}>
|
|
1368
|
+
<button className={s.splitCancelBtn} onClick={() => { setSplitModal(null); setSplitSubject('') }}>{t('common.cancel')}</button>
|
|
1369
|
+
<button className={s.splitConfirmBtn} onClick={handleSplitConfirm} disabled={!splitSubject.trim()}>{t('detail.split.createBtn')}</button>
|
|
1495
1370
|
</div>
|
|
1496
1371
|
</div>
|
|
1497
1372
|
</div>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// "Send as" aliases — host-configured via NEXT_PUBLIC_SUPPORT_SEND_ALIASES
|
|
2
|
+
// (comma-separated). Kept config-driven so the plugin carries no hardcoded
|
|
3
|
+
// brand identity. Empty by default: the composer then offers only "self".
|
|
4
|
+
export const ALIASES: Array<{ value: string; label: string }> = (process.env.NEXT_PUBLIC_SUPPORT_SEND_ALIASES || '')
|
|
5
|
+
.split(',')
|
|
6
|
+
.map((v) => v.trim())
|
|
7
|
+
.filter(Boolean)
|
|
8
|
+
.map((value) => ({ value, label: `En tant que : ${value}` }))
|
|
9
|
+
|
|
10
|
+
export const STATUS_STYLE: Record<string, { bg: string; color: string }> = {
|
|
11
|
+
open: { bg: '#dbeafe', color: '#1e40af' },
|
|
12
|
+
waiting_client: { bg: '#fef3c7', color: '#92400e' },
|
|
13
|
+
resolved: { bg: '#dcfce7', color: '#166534' },
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export const REWRITE_STYLES = [
|
|
17
|
+
{ id: 'auto', label: '✏️ Auto', desc: 'Garde le ton actuel' },
|
|
18
|
+
{ id: 'tutoyer', label: '👋 Tutoyer', desc: 'Passe en tu' },
|
|
19
|
+
{ id: 'vouvoyer', label: '🎩 Vouvoyer', desc: 'Passe en vous' },
|
|
20
|
+
{ id: 'formel', label: '💼 Formel', desc: 'Ton professionnel' },
|
|
21
|
+
{ id: 'amical', label: '😊 Amical', desc: 'Ton chaleureux' },
|
|
22
|
+
{ id: 'court', label: '⚡ Court', desc: 'Version concise' },
|
|
23
|
+
]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { DATE_LOCALE } from '../shared/dateLocale'
|
|
2
|
+
|
|
3
|
+
export function timeAgo(d: string): string {
|
|
4
|
+
const date = new Date(d), now = new Date()
|
|
5
|
+
if (date.toDateString() === now.toDateString()) return date.toLocaleTimeString(DATE_LOCALE, { hour: '2-digit', minute: '2-digit' })
|
|
6
|
+
const y = new Date(now); y.setDate(y.getDate() - 1)
|
|
7
|
+
if (date.toDateString() === y.toDateString()) return `Hier, ${date.toLocaleTimeString(DATE_LOCALE, { hour: '2-digit', minute: '2-digit' })}`
|
|
8
|
+
return date.toLocaleDateString(DATE_LOCALE, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function dateLabel(d: string): string {
|
|
12
|
+
const date = new Date(d), now = new Date()
|
|
13
|
+
if (date.toDateString() === now.toDateString()) return "Aujourd'hui"
|
|
14
|
+
const y = new Date(now); y.setDate(y.getDate() - 1)
|
|
15
|
+
if (date.toDateString() === y.toDateString()) return 'Hier'
|
|
16
|
+
return date.toLocaleDateString(DATE_LOCALE, { day: 'numeric', month: 'long' })
|
|
17
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface Message {
|
|
2
|
+
id: string | number; body: string; bodyHtml?: string; authorType: 'client' | 'admin' | 'email'
|
|
3
|
+
isInternal?: boolean; isSolution?: boolean; createdAt: string; fromChat?: boolean
|
|
4
|
+
fromAlias?: string
|
|
5
|
+
attachments?: Array<{ file: { id: number; url?: string; filename?: string; mimeType?: string } | number }>
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface ClientInfo { id: number; company: string; firstName: string; lastName: string; email: string; phone?: string }
|
|
9
|
+
export interface TimeEntry { id: string | number; duration: number; description?: string; date: string }
|
|
10
|
+
export interface ActivityEntry { id: string | number; action: string; detail?: string; actorType?: string; createdAt: string }
|
|
@@ -4,6 +4,7 @@ import React, { useState, useEffect, useCallback } from 'react'
|
|
|
4
4
|
import { useSearchParams } from 'next/navigation'
|
|
5
5
|
import Link from 'next/link'
|
|
6
6
|
import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
|
|
7
|
+
import { DATE_LOCALE } from '../shared/dateLocale'
|
|
7
8
|
import { StatusPill } from '../shared/StatusPill'
|
|
8
9
|
import { computeSlaState, formatSlaRemaining } from '../shared/sla'
|
|
9
10
|
import s from '../../styles/TicketInbox.module.scss'
|
|
@@ -45,16 +46,16 @@ const STATUS_VARIANT: Record<string, 'open' | 'pending' | 'resolved' | 'closed'>
|
|
|
45
46
|
closed: 'closed',
|
|
46
47
|
}
|
|
47
48
|
|
|
48
|
-
function relativeTime(dateStr: string): string {
|
|
49
|
+
function relativeTime(dateStr: string, tFn: (k: string) => string): string {
|
|
49
50
|
const diff = Date.now() - new Date(dateStr).getTime()
|
|
50
51
|
const mins = Math.floor(diff / 60000)
|
|
51
|
-
if (mins < 1) return '
|
|
52
|
+
if (mins < 1) return tFn('inbox.timeAgo.now')
|
|
52
53
|
if (mins < 60) return `${mins}m`
|
|
53
54
|
const hours = Math.floor(mins / 60)
|
|
54
55
|
if (hours < 24) return `${hours}h`
|
|
55
56
|
const days = Math.floor(hours / 24)
|
|
56
57
|
if (days < 7) return `${days}j`
|
|
57
|
-
return new Date(dateStr).toLocaleDateString(
|
|
58
|
+
return new Date(dateStr).toLocaleDateString(DATE_LOCALE, { day: 'numeric', month: 'short' })
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
export const TicketInboxClient: React.FC = () => {
|
|
@@ -138,6 +139,9 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
138
139
|
params.push(`where[or][0][subject][contains]=${encodeURIComponent(search)}`)
|
|
139
140
|
params.push(`where[or][1][ticketNumber][contains]=${encodeURIComponent(search)}`)
|
|
140
141
|
}
|
|
142
|
+
// Hide snoozed tickets (snoozeUntil in the future); they resurface automatically once due.
|
|
143
|
+
params.push(`where[and][0][or][0][snoozeUntil][exists]=false`)
|
|
144
|
+
params.push(`where[and][0][or][1][snoozeUntil][less_than_equal]=${encodeURIComponent(new Date().toISOString())}`)
|
|
141
145
|
try {
|
|
142
146
|
const url = `/api/tickets?${params.join('&')}`
|
|
143
147
|
const res = await fetch(url, { credentials: 'include' })
|
|
@@ -159,12 +163,14 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
159
163
|
useEffect(() => {
|
|
160
164
|
const fetchCounts = async () => {
|
|
161
165
|
try {
|
|
166
|
+
// Exclude snoozed tickets from the counts too (mirrors the list query).
|
|
167
|
+
const sn = `where[and][0][or][0][snoozeUntil][exists]=false&where[and][0][or][1][snoozeUntil][less_than_equal]=${encodeURIComponent(new Date().toISOString())}`
|
|
162
168
|
const [all, openRes, waiting, resolved, breach] = await Promise.all([
|
|
163
|
-
fetch(
|
|
164
|
-
fetch(
|
|
165
|
-
fetch(
|
|
166
|
-
fetch(
|
|
167
|
-
fetch(
|
|
169
|
+
fetch(`/api/tickets?limit=0&depth=0&${sn}`, { credentials: 'include' }),
|
|
170
|
+
fetch(`/api/tickets?limit=0&depth=0&where[status][equals]=open&${sn}`, { credentials: 'include' }),
|
|
171
|
+
fetch(`/api/tickets?limit=0&depth=0&where[status][equals]=waiting_client&${sn}`, { credentials: 'include' }),
|
|
172
|
+
fetch(`/api/tickets?limit=0&depth=0&where[status][equals]=resolved&${sn}`, { credentials: 'include' }),
|
|
173
|
+
fetch(`/api/tickets?limit=0&depth=0&where[or][0][slaFirstResponseBreached][equals]=true&where[or][1][slaResolutionBreached][equals]=true&${sn}`, { credentials: 'include' }),
|
|
168
174
|
])
|
|
169
175
|
const [a, o, w, r, b] = await Promise.all([all.json(), openRes.json(), waiting.json(), resolved.json(), breach.json()])
|
|
170
176
|
setCounts({
|
|
@@ -339,11 +345,11 @@ export const TicketInboxClient: React.FC = () => {
|
|
|
339
345
|
sla.state === 'warn' ? s.slaWarn : '',
|
|
340
346
|
sla.state === 'ok' ? s.slaOk : '',
|
|
341
347
|
].filter(Boolean).join(' ')}
|
|
342
|
-
title={sla.due ? new Date(sla.due).toLocaleString(
|
|
348
|
+
title={sla.due ? new Date(sla.due).toLocaleString(DATE_LOCALE) : ''}
|
|
343
349
|
>
|
|
344
350
|
{formatSlaRemaining(sla.remainingMs)}
|
|
345
351
|
</span>
|
|
346
|
-
<span className={s.timeAgo}>{relativeTime(tk.updatedAt)}</span>
|
|
352
|
+
<span className={s.timeAgo}>{relativeTime(tk.updatedAt, t)}</span>
|
|
347
353
|
{isUnread ? <div className={s.unreadDot} /> : <span />}
|
|
348
354
|
</a>
|
|
349
355
|
)
|