@consilioweb/payload-support 5.0.0 → 6.0.1
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 +249 -22
- package/dist/components/RichTextEditor/index.js +1 -1
- package/dist/components/TicketConversation/RewriteDropdown.js +1 -1
- package/dist/components/TicketConversation/components/AISummaryPanel.js +3 -0
- package/dist/components/TicketConversation/components/ActionPanels.js +13 -8
- package/dist/components/TicketConversation/components/ClientHistory.js +1 -0
- package/dist/components/TicketConversation/components/CodeBlock.js +1 -0
- package/dist/components/TicketConversation/components/CodeBlockInserter.js +1 -1
- package/dist/components/TicketConversation/components/QuickActions.js +10 -8
- package/dist/components/TicketConversation/components/TicketHeader.js +2 -0
- package/dist/components/TicketConversation/components/TimeTrackingPanel.js +15 -9
- package/dist/components/TicketConversation/index.d.ts +10 -0
- package/dist/components/TicketConversation/index.js +25 -7
- package/dist/index.cjs +306 -60
- package/dist/index.d.cts +107 -38
- package/dist/index.d.ts +107 -38
- package/dist/index.js +300 -59
- package/dist/styles/BillingView.module.scss +11 -11
- package/dist/styles/ChatView.module.scss +7 -7
- package/dist/styles/CommandPalette.module.scss +1 -1
- package/dist/styles/CrmView.module.scss +9 -9
- package/dist/styles/EmailTracking.module.scss +6 -6
- package/dist/styles/ImportConversation.module.scss +5 -5
- package/dist/styles/Logs.module.scss +5 -5
- package/dist/styles/NewTicket.module.scss +2 -2
- package/dist/styles/PendingEmails.module.scss +13 -13
- package/dist/styles/SupportDashboard.module.scss +24 -8
- package/dist/styles/TicketDetail.module.scss +20 -20
- package/dist/styles/TicketInbox.module.scss +6 -6
- package/dist/styles/TicketingSettings.module.scss +10 -10
- package/dist/styles/TimeDashboard.module.scss +6 -6
- package/dist/styles/_tokens.scss +3 -1
- package/dist/utils/db.js +20 -0
- package/dist/utils/readSettings.js +150 -0
- package/dist/views/BillingView/client.js +15 -8
- package/dist/views/ChatView/client.js +2 -0
- package/dist/views/CrmView/client.js +2 -0
- package/dist/views/EmailTrackingView/client.js +23 -11
- package/dist/views/ImportConversationView/client.js +1 -0
- package/dist/views/LogsView/client.js +1 -1
- package/dist/views/NewTicketView/client.js +20 -13
- package/dist/views/PendingEmailsView/client.js +9 -3
- package/dist/views/SupportDashboardView/client.js +17 -19
- package/dist/views/TicketDetailView/client.js +23 -11
- package/dist/views/TicketInboxView/client.js +52 -26
- package/dist/views/TicketingSettingsView/TicketingSettings.module.scss +7 -7
- package/dist/views/TicketingSettingsView/client.js +44 -26
- package/dist/views/TimeDashboardView/client.js +10 -7
- package/dist/views/shared/ErrorBoundary.d.ts +36 -1
- package/dist/views/shared/ErrorBoundary.js +59 -28
- package/dist/views/shared/adminTokens.d.ts +15 -5
- package/dist/views/shared/adminTokens.js +23 -7
- package/dist/views/shared/icons.d.ts +49 -0
- package/dist/views/shared/icons.js +92 -0
- package/dist/views/shared/locales/en.json +15 -1
- package/dist/views/shared/locales/fr.json +15 -1
- package/package.json +9 -4
- package/scripts/copy-subpath-types.mjs +103 -0
- package/scripts/uninstall-data.mjs +178 -0
- package/scripts/uninstall.mjs +184 -0
- package/scripts/verify-dist-imports.mjs +84 -0
- package/src/collections/Tickets.ts +7 -0
- package/src/collections/TimeEntries.ts +11 -3
- package/src/components/RichTextEditor/index.tsx +1 -1
- package/src/components/TicketConversation/RewriteDropdown.tsx +1 -1
- package/src/components/TicketConversation/components/AISummaryPanel.tsx +3 -0
- package/src/components/TicketConversation/components/ActionPanels.tsx +13 -8
- package/src/components/TicketConversation/components/ClientHistory.tsx +1 -0
- package/src/components/TicketConversation/components/CodeBlock.tsx +1 -0
- package/src/components/TicketConversation/components/CodeBlockInserter.tsx +1 -1
- package/src/components/TicketConversation/components/QuickActions.tsx +10 -8
- package/src/components/TicketConversation/components/TicketHeader.tsx +2 -0
- package/src/components/TicketConversation/components/TimeTrackingPanel.tsx +17 -9
- package/src/components/TicketConversation/index.tsx +41 -7
- package/src/endpoints/delete-account.ts +191 -47
- package/src/endpoints/export-data.ts +104 -4
- package/src/endpoints/purge-logs.ts +6 -15
- package/src/index.ts +2 -0
- package/src/plugin.ts +51 -1
- package/src/portal/LiveChat.tsx +1 -1
- package/src/portal/auth/profile/page.tsx +3 -2
- package/src/portal/icons.tsx +91 -0
- package/src/styles/BillingView.module.scss +11 -11
- package/src/styles/ChatView.module.scss +7 -7
- package/src/styles/CommandPalette.module.scss +1 -1
- package/src/styles/CrmView.module.scss +9 -9
- package/src/styles/EmailTracking.module.scss +6 -6
- package/src/styles/ImportConversation.module.scss +5 -5
- package/src/styles/Logs.module.scss +5 -5
- package/src/styles/NewTicket.module.scss +2 -2
- package/src/styles/PendingEmails.module.scss +13 -13
- package/src/styles/SupportDashboard.module.scss +24 -8
- package/src/styles/TicketDetail.module.scss +20 -20
- package/src/styles/TicketInbox.module.scss +6 -6
- package/src/styles/TicketingSettings.module.scss +10 -10
- package/src/styles/TimeDashboard.module.scss +6 -6
- package/src/styles/_tokens.scss +3 -1
- package/src/types.ts +20 -0
- package/src/utils/retention.ts +111 -0
- package/src/utils/slugs.ts +15 -0
- package/src/views/BillingView/client.tsx +17 -8
- package/src/views/ChatView/client.tsx +2 -0
- package/src/views/CrmView/client.tsx +2 -0
- package/src/views/EmailTrackingView/client.tsx +18 -7
- package/src/views/ImportConversationView/client.tsx +1 -0
- package/src/views/LogsView/client.tsx +1 -1
- package/src/views/NewTicketView/client.tsx +22 -13
- package/src/views/PendingEmailsView/client.tsx +9 -3
- package/src/views/SupportDashboardView/client.tsx +11 -5
- package/src/views/TicketDetailView/client.tsx +25 -11
- package/src/views/TicketInboxView/client.tsx +47 -7
- package/src/views/TicketingSettingsView/TicketingSettings.module.scss +7 -7
- package/src/views/TicketingSettingsView/client.tsx +201 -144
- package/src/views/TimeDashboardView/client.tsx +10 -7
- package/src/views/shared/ErrorBoundary.tsx +95 -31
- package/src/views/shared/adminTokens.ts +28 -11
- package/src/views/shared/icons.tsx +146 -0
- package/src/views/shared/locales/en.json +15 -1
- package/src/views/shared/locales/fr.json +15 -1
- package/src/types/lucide-react.d.ts +0 -42
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Payload, PayloadRequest } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from './slugs'
|
|
3
|
+
import { dbDelete } from './db'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* How long the two journals are kept, in days.
|
|
7
|
+
*
|
|
8
|
+
* Defaults follow the CNIL guidance the README documents: six months would be
|
|
9
|
+
* the floor for connection logs, a year is the ceiling usually accepted, and
|
|
10
|
+
* these two values sit inside that range while staying long enough to
|
|
11
|
+
* investigate an intrusion. Set a value to 0 to disable the automatic purge of
|
|
12
|
+
* that journal — never to mean "delete everything", which is what `days=0`
|
|
13
|
+
* means on the manual endpoint.
|
|
14
|
+
*/
|
|
15
|
+
export interface RetentionConfig {
|
|
16
|
+
/** Retention of `auth-logs` in days. `0` disables the scheduled purge. */
|
|
17
|
+
authLogsDays?: number
|
|
18
|
+
/** Retention of `email-logs` in days. `0` disables the scheduled purge. */
|
|
19
|
+
emailLogsDays?: number
|
|
20
|
+
/**
|
|
21
|
+
* Cron for the scheduled purge. Payload's cron takes an optional leading
|
|
22
|
+
* seconds field. Default: every day at 03:30.
|
|
23
|
+
*/
|
|
24
|
+
cron?: string
|
|
25
|
+
/** Queue the scheduled job is pushed to. Default: `default`. */
|
|
26
|
+
queue?: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const DEFAULT_RETENTION = {
|
|
30
|
+
authLogsDays: 180,
|
|
31
|
+
emailLogsDays: 365,
|
|
32
|
+
cron: '0 30 3 * * *',
|
|
33
|
+
queue: 'default',
|
|
34
|
+
} as const
|
|
35
|
+
|
|
36
|
+
export const PURGE_LOGS_TASK_SLUG = 'support-purge-logs'
|
|
37
|
+
|
|
38
|
+
/** Journals the purge is allowed to touch, keyed by their public name. */
|
|
39
|
+
export function purgeableCollections(slugs: CollectionSlugs): Record<string, string> {
|
|
40
|
+
return {
|
|
41
|
+
'email-logs': slugs.emailLogs,
|
|
42
|
+
'auth-logs': slugs.authLogs,
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Delete rows of `slug` created more than `days` days ago.
|
|
48
|
+
*
|
|
49
|
+
* `days <= 0` means "everything" and is only reachable from the manual
|
|
50
|
+
* admin-only endpoint. The scheduled task refuses it: a misconfigured cron
|
|
51
|
+
* that wipes the whole security journal every night is a far worse outcome
|
|
52
|
+
* than a journal that grows.
|
|
53
|
+
*/
|
|
54
|
+
export async function purgeOlderThan(
|
|
55
|
+
payload: Payload,
|
|
56
|
+
slug: string,
|
|
57
|
+
days: number,
|
|
58
|
+
req?: PayloadRequest,
|
|
59
|
+
): Promise<number> {
|
|
60
|
+
const cutoff = days > 0 ? new Date(Date.now() - days * 86400000).toISOString() : null
|
|
61
|
+
|
|
62
|
+
const result = await dbDelete(payload, slug, {
|
|
63
|
+
where: cutoff
|
|
64
|
+
? { createdAt: { less_than: cutoff } }
|
|
65
|
+
: { id: { exists: true } },
|
|
66
|
+
overrideAccess: true,
|
|
67
|
+
...(req ? { req } : {}),
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
return Array.isArray(result?.docs) ? result.docs.length : 0
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ScheduledPurgeResult {
|
|
74
|
+
purged: Record<string, number>
|
|
75
|
+
skipped: string[]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The scheduled half of the purge. Never accepts `days <= 0`, and silently
|
|
80
|
+
* skips a journal whose collection is not registered (its feature flag is off).
|
|
81
|
+
*/
|
|
82
|
+
export async function runScheduledPurge(
|
|
83
|
+
payload: Payload,
|
|
84
|
+
slugs: CollectionSlugs,
|
|
85
|
+
retention: RetentionConfig | false | undefined,
|
|
86
|
+
req?: PayloadRequest,
|
|
87
|
+
): Promise<ScheduledPurgeResult> {
|
|
88
|
+
if (retention === false) return { purged: {}, skipped: ['auth-logs', 'email-logs'] }
|
|
89
|
+
|
|
90
|
+
const plan: Array<[string, string, number]> = [
|
|
91
|
+
['auth-logs', slugs.authLogs, retention?.authLogsDays ?? DEFAULT_RETENTION.authLogsDays],
|
|
92
|
+
['email-logs', slugs.emailLogs, retention?.emailLogsDays ?? DEFAULT_RETENTION.emailLogsDays],
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
const purged: Record<string, number> = {}
|
|
96
|
+
const skipped: string[] = []
|
|
97
|
+
|
|
98
|
+
for (const [name, slug, days] of plan) {
|
|
99
|
+
if (!Number.isFinite(days) || days <= 0) {
|
|
100
|
+
skipped.push(name)
|
|
101
|
+
continue
|
|
102
|
+
}
|
|
103
|
+
if (!(payload.collections as Record<string, unknown> | undefined)?.[slug]) {
|
|
104
|
+
skipped.push(name)
|
|
105
|
+
continue
|
|
106
|
+
}
|
|
107
|
+
purged[name] = await purgeOlderThan(payload, slug, days, req)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { purged, skipped }
|
|
111
|
+
}
|
package/src/utils/slugs.ts
CHANGED
|
@@ -58,6 +58,21 @@ export const DEFAULT_SLUGS: CollectionSlugs = {
|
|
|
58
58
|
media: 'media',
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Collections the plugin registers under a FIXED slug.
|
|
63
|
+
*
|
|
64
|
+
* Unlike everything in `CollectionSlugs`, these three hardcode their `slug`
|
|
65
|
+
* (ClientSummaries.ts, TicketCollaborators.ts, TicketFeedback.ts), so any code
|
|
66
|
+
* that reads or deletes their rows must use these literals rather than a
|
|
67
|
+
* configurable slug — otherwise an app that overrides `collectionSlugs` would
|
|
68
|
+
* address a collection that does not exist.
|
|
69
|
+
*/
|
|
70
|
+
export const FIXED_SLUGS = {
|
|
71
|
+
clientSummaries: 'client-summaries',
|
|
72
|
+
ticketCollaborators: 'ticket-collaborators',
|
|
73
|
+
ticketFeedback: 'ticket-feedback',
|
|
74
|
+
} as const
|
|
75
|
+
|
|
61
76
|
/**
|
|
62
77
|
* Resolve collection slugs merging user overrides with defaults.
|
|
63
78
|
*/
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import React, { useState, useCallback } from 'react'
|
|
3
|
+
import React, { useState, useCallback, useId } from 'react'
|
|
4
4
|
import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
|
|
5
5
|
import { DATE_LOCALE } from '../shared/dateLocale'
|
|
6
6
|
import styles from '../../styles/BillingView.module.scss'
|
|
@@ -86,6 +86,12 @@ function getQuarterRange(offset: number): { from: string; to: string } {
|
|
|
86
86
|
|
|
87
87
|
export const BillingClient: React.FC = () => {
|
|
88
88
|
const { t } = useTranslation()
|
|
89
|
+
// The `<label>`s in the filter row already carry the translated text; they
|
|
90
|
+
// just were not associated with their control.
|
|
91
|
+
const fromId = useId()
|
|
92
|
+
const toId = useId()
|
|
93
|
+
const projectFilterId = useId()
|
|
94
|
+
const rateId = useId()
|
|
89
95
|
const [from, setFrom] = useState(() => getMonthRange(0).from)
|
|
90
96
|
const [to, setTo] = useState(() => getMonthRange(0).to)
|
|
91
97
|
const [projectId, setProjectId] = useState('')
|
|
@@ -287,16 +293,16 @@ export const BillingClient: React.FC = () => {
|
|
|
287
293
|
|
|
288
294
|
<div className={styles.filterRow}>
|
|
289
295
|
<div className={styles.fieldGroup}>
|
|
290
|
-
<label className={styles.label}>{t('billing.filters.from')}</label>
|
|
291
|
-
<input type="date" value={from} onChange={(e) => setFrom(e.target.value)} className={styles.input} />
|
|
296
|
+
<label className={styles.label} htmlFor={fromId}>{t('billing.filters.from')}</label>
|
|
297
|
+
<input id={fromId} type="date" value={from} onChange={(e) => setFrom(e.target.value)} className={styles.input} />
|
|
292
298
|
</div>
|
|
293
299
|
<div className={styles.fieldGroup}>
|
|
294
|
-
<label className={styles.label}>{t('billing.filters.to')}</label>
|
|
295
|
-
<input type="date" value={to} onChange={(e) => setTo(e.target.value)} className={styles.input} />
|
|
300
|
+
<label className={styles.label} htmlFor={toId}>{t('billing.filters.to')}</label>
|
|
301
|
+
<input id={toId} type="date" value={to} onChange={(e) => setTo(e.target.value)} className={styles.input} />
|
|
296
302
|
</div>
|
|
297
303
|
<div className={styles.fieldGroup}>
|
|
298
|
-
<label className={styles.label}>{t('billing.filters.project')}</label>
|
|
299
|
-
<select value={projectId} onChange={(e) => setProjectId(e.target.value)} className={styles.select}>
|
|
304
|
+
<label className={styles.label} htmlFor={projectFilterId}>{t('billing.filters.project')}</label>
|
|
305
|
+
<select id={projectFilterId} value={projectId} onChange={(e) => setProjectId(e.target.value)} className={styles.select}>
|
|
300
306
|
<option value="">{t('ticket.allProjects')}</option>
|
|
301
307
|
{projects.map((p) => (
|
|
302
308
|
<option key={p.id} value={p.id}>{p.name}</option>
|
|
@@ -304,9 +310,10 @@ export const BillingClient: React.FC = () => {
|
|
|
304
310
|
</select>
|
|
305
311
|
</div>
|
|
306
312
|
<div className={styles.fieldGroup}>
|
|
307
|
-
<label className={styles.label}>{t('billing.filters.hourlyRate')}</label>
|
|
313
|
+
<label className={styles.label} htmlFor={rateId}>{t('billing.filters.hourlyRate')}</label>
|
|
308
314
|
<div className={styles.rateRow}>
|
|
309
315
|
<input
|
|
316
|
+
id={rateId}
|
|
310
317
|
type="number"
|
|
311
318
|
value={rate}
|
|
312
319
|
onChange={(e) => setRate(Number(e.target.value))}
|
|
@@ -370,6 +377,7 @@ export const BillingClient: React.FC = () => {
|
|
|
370
377
|
<th className={styles.thCheckbox}>
|
|
371
378
|
<input
|
|
372
379
|
type="checkbox"
|
|
380
|
+
aria-label={t('billing.tableCheckboxTitle')}
|
|
373
381
|
checked={group.tickets.every((t) => billedTickets.has(t.id))}
|
|
374
382
|
onChange={() => {
|
|
375
383
|
const ids = group.tickets.map((t) => t.id)
|
|
@@ -405,6 +413,7 @@ export const BillingClient: React.FC = () => {
|
|
|
405
413
|
<td className={styles.td} rowSpan={rowSpan} style={{ verticalAlign: 'middle' }}>
|
|
406
414
|
<input
|
|
407
415
|
type="checkbox"
|
|
416
|
+
aria-label={isBilled ? t('billing.markUnbilledTitle') : t('billing.markBilledTitle')}
|
|
408
417
|
checked={isBilled}
|
|
409
418
|
onChange={() => toggleBilled(ticket.id)}
|
|
410
419
|
className={styles.checkbox}
|
|
@@ -343,6 +343,7 @@ export const ChatViewClient: React.FC = () => {
|
|
|
343
343
|
<form onSubmit={sendMessage} className={styles.composer}>
|
|
344
344
|
{cannedResponses.length > 0 && (
|
|
345
345
|
<select
|
|
346
|
+
aria-label={t('chat.quickReply')}
|
|
346
347
|
onChange={(e) => {
|
|
347
348
|
const cr = cannedResponses.find((c) => String(c.id) === e.target.value)
|
|
348
349
|
if (cr) setInput(cr.body)
|
|
@@ -359,6 +360,7 @@ export const ChatViewClient: React.FC = () => {
|
|
|
359
360
|
<div className={styles.composerRow}>
|
|
360
361
|
<input
|
|
361
362
|
type="text"
|
|
363
|
+
aria-label={t('chat.inputPlaceholder')}
|
|
362
364
|
value={input}
|
|
363
365
|
onChange={(e) => setInput(e.target.value)}
|
|
364
366
|
placeholder={t('chat.inputPlaceholder')}
|
|
@@ -275,6 +275,7 @@ export const CrmClient: React.FC = () => {
|
|
|
275
275
|
<div className={styles.sidebarSearch}>
|
|
276
276
|
<input
|
|
277
277
|
type="text"
|
|
278
|
+
aria-label={t('crm.searchLabel')}
|
|
278
279
|
placeholder={t('crm.searchPlaceholder')}
|
|
279
280
|
value={search}
|
|
280
281
|
onChange={(e) => setSearch(e.target.value)}
|
|
@@ -379,6 +380,7 @@ export const CrmClient: React.FC = () => {
|
|
|
379
380
|
</p>
|
|
380
381
|
<input
|
|
381
382
|
type="text"
|
|
383
|
+
aria-label={t('crm.mergeTitle')}
|
|
382
384
|
value={mergeSearch}
|
|
383
385
|
onChange={(e) => setMergeSearch(e.target.value)}
|
|
384
386
|
placeholder={t('crm.mergeSearchPlaceholder')}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { useState, useEffect, useCallback } from 'react'
|
|
4
4
|
import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
|
|
5
|
+
import { V } from '../shared/adminTokens'
|
|
5
6
|
import { DATE_LOCALE } from '../shared/dateLocale'
|
|
6
7
|
import s from '../../styles/EmailTracking.module.scss'
|
|
7
8
|
|
|
@@ -111,7 +112,7 @@ export const EmailTrackingClient: React.FC = () => {
|
|
|
111
112
|
<button key={v} className={`${s.dateRangeBtn} ${dateRange === v ? s.dateRangeActive : ''}`} onClick={() => setDateRange(v)}>{v}j</button>
|
|
112
113
|
))}
|
|
113
114
|
</div>
|
|
114
|
-
<input type="text" className={s.searchInput} placeholder={t('emailTracking.searchPlaceholder')} value={search} onChange={(e) => setSearch(e.target.value)} />
|
|
115
|
+
<input type="text" className={s.searchInput} aria-label={t('emailTracking.searchLabel')} placeholder={t('emailTracking.searchPlaceholder')} value={search} onChange={(e) => setSearch(e.target.value)} />
|
|
115
116
|
</div>
|
|
116
117
|
|
|
117
118
|
{/* Table */}
|
|
@@ -125,7 +126,7 @@ export const EmailTrackingClient: React.FC = () => {
|
|
|
125
126
|
<tbody>
|
|
126
127
|
{logs.map((log) => (
|
|
127
128
|
<React.Fragment key={log.id}>
|
|
128
|
-
<tr
|
|
129
|
+
<tr>
|
|
129
130
|
<td style={{ fontSize: 12, whiteSpace: 'nowrap' }}>{fmtDate(log.createdAt)}</td>
|
|
130
131
|
<td style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={log.recipientEmail}>{log.recipientEmail || '—'}</td>
|
|
131
132
|
<td style={{ maxWidth: 250, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={log.subject}>{log.subject || '—'}</td>
|
|
@@ -137,14 +138,24 @@ export const EmailTrackingClient: React.FC = () => {
|
|
|
137
138
|
<span className={`${s.processingTime} ${log.processingTimeMs > 2000 ? s.timeSlow : log.processingTimeMs > 500 ? s.timeMedium : s.timeFast}`}>{log.processingTimeMs}ms</span>
|
|
138
139
|
) : '—'}
|
|
139
140
|
{log.status === 'error' && log.errorMessage && (
|
|
140
|
-
|
|
141
|
+
/* The row itself carries no handler: a `<tr onClick>` is
|
|
142
|
+
* unreachable by keyboard and role="button" on a row would
|
|
143
|
+
* destroy the table semantics. This button is the control. */
|
|
144
|
+
<button
|
|
145
|
+
type="button"
|
|
146
|
+
className={s.expandBtn}
|
|
147
|
+
aria-expanded={expandedRow === log.id}
|
|
148
|
+
aria-controls={`email-error-${log.id}`}
|
|
149
|
+
aria-label={t('emailTracking.toggleError')}
|
|
150
|
+
onClick={() => setExpandedRow(expandedRow === log.id ? null : log.id)}
|
|
151
|
+
>
|
|
141
152
|
{expandedRow === log.id ? '▴' : '▾'}
|
|
142
153
|
</button>
|
|
143
154
|
)}
|
|
144
155
|
</td>
|
|
145
156
|
</tr>
|
|
146
157
|
{expandedRow === log.id && log.errorMessage && (
|
|
147
|
-
<tr><td colSpan={7}><div className={s.errorDetail}>
|
|
158
|
+
<tr><td colSpan={7}><div className={s.errorDetail} id={`email-error-${log.id}`}>{t('emailTracking.errorLabel')}{log.httpStatus ? ` (HTTP ${log.httpStatus})` : ''}: {log.errorMessage}</div></td></tr>
|
|
148
159
|
)}
|
|
149
160
|
</React.Fragment>
|
|
150
161
|
))}
|
|
@@ -155,9 +166,9 @@ export const EmailTrackingClient: React.FC = () => {
|
|
|
155
166
|
{/* Pagination */}
|
|
156
167
|
{totalDocs > 0 && (
|
|
157
168
|
<div className={s.pagination}>
|
|
158
|
-
<button className={s.pageBtn} onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>{t('common.previous')}</button>
|
|
159
|
-
<span style={{ fontSize: 12, color:
|
|
160
|
-
<button className={s.pageBtn} onClick={() => setPage((p) => p + 1)} disabled={!hasMore}>{t('common.next')}</button>
|
|
169
|
+
<button type="button" className={s.pageBtn} onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>{t('common.previous')}</button>
|
|
170
|
+
<span style={{ fontSize: 12, color: V.textSecondary, alignSelf: 'center' }}>{t('common.page')} {page} — {totalDocs} {t('common.results')}</span>
|
|
171
|
+
<button type="button" className={s.pageBtn} onClick={() => setPage((p) => p + 1)} disabled={!hasMore}>{t('common.next')}</button>
|
|
161
172
|
</div>
|
|
162
173
|
)}
|
|
163
174
|
</div>
|
|
@@ -131,7 +131,7 @@ export const LogsClient: React.FC = () => {
|
|
|
131
131
|
</td>
|
|
132
132
|
<td className={s.truncate} title={log.recipientEmail}>{log.recipientEmail || '—'}</td>
|
|
133
133
|
<td className={s.truncate} title={log.subject}>{log.subject || '—'}</td>
|
|
134
|
-
<td style={{ fontSize: 12, color: 'var(--theme-elevation-
|
|
134
|
+
<td style={{ fontSize: 12, color: 'var(--theme-elevation-650)' }}>{log.action || '—'}</td>
|
|
135
135
|
<td style={{ textAlign: 'right' }}>
|
|
136
136
|
{log.processingTimeMs != null ? (
|
|
137
137
|
<span className={s.mono} style={{ color: log.processingTimeMs > 2000 ? '#dc2626' : log.processingTimeMs > 500 ? '#d97706' : '#16a34a' }}>
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
|
-
import React, { useState, useEffect } from 'react'
|
|
3
|
+
import React, { useState, useEffect, useId } from 'react'
|
|
4
4
|
import Link from 'next/link'
|
|
5
5
|
import { useRouter } from 'next/navigation'
|
|
6
6
|
import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
|
|
@@ -27,6 +27,14 @@ const PRIORITY_KEYS = [
|
|
|
27
27
|
|
|
28
28
|
export const NewTicketClient: React.FC = () => {
|
|
29
29
|
const { t } = useTranslation()
|
|
30
|
+
// Each `<label>` below already carries the right, already-translated text;
|
|
31
|
+
// it just was not associated with anything. useId, never a literal.
|
|
32
|
+
const clientSearchId = useId()
|
|
33
|
+
const subjectId = useId()
|
|
34
|
+
const categoryId = useId()
|
|
35
|
+
const priorityId = useId()
|
|
36
|
+
const projectId_ = useId()
|
|
37
|
+
const descriptionId = useId()
|
|
30
38
|
const router = useRouter()
|
|
31
39
|
const [subject, setSubject] = useState('')
|
|
32
40
|
const [description, setDescription] = useState('')
|
|
@@ -130,18 +138,19 @@ export const NewTicketClient: React.FC = () => {
|
|
|
130
138
|
<form className={s.form} onSubmit={handleSubmit}>
|
|
131
139
|
{/* Client search */}
|
|
132
140
|
<div className={s.fieldGroup}>
|
|
133
|
-
<label className={s.label}>{t('newTicket.clientLabel')} <span className={s.required}>*</span></label>
|
|
141
|
+
<label className={s.label} htmlFor={clientSearchId}>{t('newTicket.clientLabel')} <span className={s.required}>*</span></label>
|
|
134
142
|
{selectedClient ? (
|
|
135
143
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '8px 14px', borderRadius: 10, border: '1px solid var(--theme-elevation-200)', background: 'var(--theme-elevation-50)' }}>
|
|
136
144
|
<span style={{ fontWeight: 600, fontSize: 13, color: 'var(--theme-text)' }}>
|
|
137
145
|
{selectedClient.firstName} {selectedClient.lastName} — {selectedClient.company}
|
|
138
146
|
</span>
|
|
139
|
-
<span style={{ fontSize: 12, color: 'var(--theme-elevation-
|
|
147
|
+
<span style={{ fontSize: 12, color: 'var(--theme-elevation-650)' }}>{selectedClient.email}</span>
|
|
140
148
|
<button type="button" onClick={() => { setSelectedClient(null); setClientId(null); setClientSearch('') }} style={{ marginLeft: 'auto', background: 'none', border: 'none', cursor: 'pointer', color: 'var(--theme-elevation-400)', fontSize: 16 }}>×</button>
|
|
141
149
|
</div>
|
|
142
150
|
) : (
|
|
143
151
|
<div className={s.searchWrap}>
|
|
144
152
|
<input
|
|
153
|
+
id={clientSearchId}
|
|
145
154
|
type="text"
|
|
146
155
|
className={s.input}
|
|
147
156
|
placeholder={t('newTicket.clientSearchPlaceholder')}
|
|
@@ -169,27 +178,27 @@ export const NewTicketClient: React.FC = () => {
|
|
|
169
178
|
|
|
170
179
|
{/* Subject */}
|
|
171
180
|
<div className={s.fieldGroup}>
|
|
172
|
-
<label className={s.label}>{t('newTicket.subjectLabel')} <span className={s.required}>*</span></label>
|
|
173
|
-
<input type="text" className={s.input} placeholder={t('newTicket.subjectPlaceholder')} value={subject} onChange={(e) => setSubject(e.target.value)} />
|
|
181
|
+
<label className={s.label} htmlFor={subjectId}>{t('newTicket.subjectLabel')} <span className={s.required}>*</span></label>
|
|
182
|
+
<input id={subjectId} type="text" className={s.input} placeholder={t('newTicket.subjectPlaceholder')} value={subject} onChange={(e) => setSubject(e.target.value)} />
|
|
174
183
|
</div>
|
|
175
184
|
|
|
176
185
|
{/* Category + Priority + Project */}
|
|
177
186
|
<div className={s.row3}>
|
|
178
187
|
<div className={s.fieldGroup}>
|
|
179
|
-
<label className={s.label}>{t('newTicket.categoryLabel')}</label>
|
|
180
|
-
<select className={s.select} value={category} onChange={(e) => setCategory(e.target.value)}>
|
|
188
|
+
<label className={s.label} htmlFor={categoryId}>{t('newTicket.categoryLabel')}</label>
|
|
189
|
+
<select id={categoryId} className={s.select} value={category} onChange={(e) => setCategory(e.target.value)}>
|
|
181
190
|
{CATEGORY_KEYS.map((c) => <option key={c.value} value={c.value}>{t(c.key)}</option>)}
|
|
182
191
|
</select>
|
|
183
192
|
</div>
|
|
184
193
|
<div className={s.fieldGroup}>
|
|
185
|
-
<label className={s.label}>{t('newTicket.priorityLabel')}</label>
|
|
186
|
-
<select className={s.select} value={priority} onChange={(e) => setPriority(e.target.value)}>
|
|
194
|
+
<label className={s.label} htmlFor={priorityId}>{t('newTicket.priorityLabel')}</label>
|
|
195
|
+
<select id={priorityId} className={s.select} value={priority} onChange={(e) => setPriority(e.target.value)}>
|
|
187
196
|
{PRIORITY_KEYS.map((p) => <option key={p.value} value={p.value}>{t(p.key)}</option>)}
|
|
188
197
|
</select>
|
|
189
198
|
</div>
|
|
190
199
|
<div className={s.fieldGroup}>
|
|
191
|
-
<label className={s.label}>{t('newTicket.projectLabel')}</label>
|
|
192
|
-
<select className={s.select} value={projectId || ''} onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : null)}>
|
|
200
|
+
<label className={s.label} htmlFor={projectId_}>{t('newTicket.projectLabel')}</label>
|
|
201
|
+
<select id={projectId_} className={s.select} value={projectId || ''} onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : null)}>
|
|
193
202
|
<option value="">{t('newTicket.noProject')}</option>
|
|
194
203
|
{projects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
|
195
204
|
</select>
|
|
@@ -198,8 +207,8 @@ export const NewTicketClient: React.FC = () => {
|
|
|
198
207
|
|
|
199
208
|
{/* Description */}
|
|
200
209
|
<div className={s.fieldGroup}>
|
|
201
|
-
<label className={s.label}>{t('newTicket.descriptionLabel')}</label>
|
|
202
|
-
<textarea className={s.textarea} placeholder={t('newTicket.descriptionPlaceholder')} value={description} onChange={(e) => setDescription(e.target.value)} />
|
|
210
|
+
<label className={s.label} htmlFor={descriptionId}>{t('newTicket.descriptionLabel')}</label>
|
|
211
|
+
<textarea id={descriptionId} className={s.textarea} placeholder={t('newTicket.descriptionPlaceholder')} value={description} onChange={(e) => setDescription(e.target.value)} />
|
|
203
212
|
</div>
|
|
204
213
|
|
|
205
214
|
<button type="submit" className={s.submitBtn} disabled={submitting}>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client'
|
|
2
2
|
|
|
3
3
|
import React, { useState, useEffect, useCallback } from 'react'
|
|
4
|
-
import { Inbox, Plus, Link2, X, Search, ChevronDown, ChevronUp, Paperclip } from '
|
|
4
|
+
import { Inbox, Plus, Link2, X, Search, ChevronDown, ChevronUp, Paperclip } from '../shared/icons'
|
|
5
5
|
import { SkeletonDashboard } from '../shared/Skeleton'
|
|
6
6
|
import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
|
|
7
7
|
import styles from '../../styles/PendingEmails.module.scss'
|
|
@@ -86,7 +86,7 @@ function TicketSearchModal({
|
|
|
86
86
|
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
|
|
87
87
|
<div className={styles.modalHeader}>
|
|
88
88
|
<h3 className={styles.modalTitle}>Rattacher a un ticket</h3>
|
|
89
|
-
<button onClick={onClose} className={styles.modalClose}>
|
|
89
|
+
<button type="button" aria-label="Fermer" onClick={onClose} className={styles.modalClose}>
|
|
90
90
|
<X size={20} />
|
|
91
91
|
</button>
|
|
92
92
|
</div>
|
|
@@ -112,6 +112,7 @@ function TicketSearchModal({
|
|
|
112
112
|
<Search size={16} className={styles.searchIcon} />
|
|
113
113
|
<input
|
|
114
114
|
type="text"
|
|
115
|
+
aria-label="Rechercher un ticket"
|
|
115
116
|
placeholder="Rechercher un ticket (TK-0042, sujet...)..."
|
|
116
117
|
value={search}
|
|
117
118
|
onChange={(e) => setSearch(e.target.value)}
|
|
@@ -225,7 +226,7 @@ function ClientPickerModal({
|
|
|
225
226
|
<div className={styles.modal} onClick={(e) => e.stopPropagation()}>
|
|
226
227
|
<div className={styles.modalHeader}>
|
|
227
228
|
<h3 className={styles.modalTitle}>Choisir un client</h3>
|
|
228
|
-
<button onClick={onClose} className={styles.modalClose}>
|
|
229
|
+
<button type="button" aria-label="Fermer" onClick={onClose} className={styles.modalClose}>
|
|
229
230
|
<X size={20} />
|
|
230
231
|
</button>
|
|
231
232
|
</div>
|
|
@@ -251,6 +252,7 @@ function ClientPickerModal({
|
|
|
251
252
|
<Search size={16} className={styles.searchIcon} />
|
|
252
253
|
<input
|
|
253
254
|
type="text"
|
|
255
|
+
aria-label="Rechercher un client"
|
|
254
256
|
placeholder="Rechercher un client (nom, email, entreprise)..."
|
|
255
257
|
value={search}
|
|
256
258
|
onChange={(e) => setSearch(e.target.value)}
|
|
@@ -287,6 +289,7 @@ function ClientPickerModal({
|
|
|
287
289
|
<div className={styles.createFormRow}>
|
|
288
290
|
<input
|
|
289
291
|
type="text"
|
|
292
|
+
aria-label="Prenom"
|
|
290
293
|
placeholder="Prenom *"
|
|
291
294
|
value={newClient.firstName}
|
|
292
295
|
onChange={(e) => setNewClient((p) => ({ ...p, firstName: e.target.value }))}
|
|
@@ -294,6 +297,7 @@ function ClientPickerModal({
|
|
|
294
297
|
/>
|
|
295
298
|
<input
|
|
296
299
|
type="text"
|
|
300
|
+
aria-label="Nom"
|
|
297
301
|
placeholder="Nom"
|
|
298
302
|
value={newClient.lastName}
|
|
299
303
|
onChange={(e) => setNewClient((p) => ({ ...p, lastName: e.target.value }))}
|
|
@@ -302,6 +306,7 @@ function ClientPickerModal({
|
|
|
302
306
|
</div>
|
|
303
307
|
<input
|
|
304
308
|
type="email"
|
|
309
|
+
aria-label="Email"
|
|
305
310
|
placeholder="Email *"
|
|
306
311
|
value={newClient.email}
|
|
307
312
|
onChange={(e) => setNewClient((p) => ({ ...p, email: e.target.value }))}
|
|
@@ -309,6 +314,7 @@ function ClientPickerModal({
|
|
|
309
314
|
/>
|
|
310
315
|
<input
|
|
311
316
|
type="text"
|
|
317
|
+
aria-label="Entreprise"
|
|
312
318
|
placeholder="Entreprise *"
|
|
313
319
|
value={newClient.company}
|
|
314
320
|
onChange={(e) => setNewClient((p) => ({ ...p, company: e.target.value }))}
|
|
@@ -511,15 +511,21 @@ export const SupportDashboardClient: React.FC = () => {
|
|
|
511
511
|
</thead>
|
|
512
512
|
<tbody>
|
|
513
513
|
{tickets.map((tk) => (
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
514
|
+
/*
|
|
515
|
+
* The row is not the control: a `<tr onClick>` is invisible to
|
|
516
|
+
* the keyboard, and turning it into role="button" would cost
|
|
517
|
+
* the table semantics. The link lives in the subject cell.
|
|
518
|
+
*/
|
|
519
|
+
<tr key={tk.id}>
|
|
518
520
|
<td>
|
|
519
521
|
<span className={`${styles.statusDot} ${getStatusDotClass(tk.status)}`} />
|
|
520
522
|
</td>
|
|
521
523
|
<td className={styles.ticketNum}>#{tk.ticketNumber}</td>
|
|
522
|
-
<td className={styles.ticketSubject}>
|
|
524
|
+
<td className={styles.ticketSubject}>
|
|
525
|
+
<a className={styles.ticketLink} href={`/admin/support/ticket?id=${tk.id}`}>
|
|
526
|
+
{tk.subject}
|
|
527
|
+
</a>
|
|
528
|
+
</td>
|
|
523
529
|
<td className={styles.ticketClient}>{getClientName(tk)}</td>
|
|
524
530
|
<td className={styles.ticketTime}>{timeAgo(tk.updatedAt)}</td>
|
|
525
531
|
<td className={styles.ticketArrow}>→</td>
|