@consilioweb/payload-support 0.15.0 → 1.0.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 +347 -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/components/ActionPanels.cjs +72 -0
- package/dist/components/TicketConversation/components/ActionPanels.js +73 -2
- package/dist/components/TicketConversation/components/QuickActions.cjs +22 -1
- package/dist/components/TicketConversation/components/QuickActions.js +22 -1
- 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/hooks/useTicketActions.cjs +59 -0
- package/dist/components/TicketConversation/hooks/useTicketActions.js +59 -0
- package/dist/components/TicketConversation/index.cjs +42 -149
- package/dist/components/TicketConversation/index.js +37 -144
- package/dist/index.cjs +1835 -741
- package/dist/index.d.cts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1833 -741
- 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 +23 -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/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/SatisfactionSurveys.ts +11 -2
- 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 +137 -31
- package/src/collections/Tickets.ts +31 -72
- package/src/collections/index.ts +3 -0
- package/src/components/TicketConversation/RewriteDropdown.tsx +86 -0
- package/src/components/TicketConversation/SkeletonText.tsx +23 -0
- package/src/components/TicketConversation/components/ActionPanels.tsx +82 -1
- package/src/components/TicketConversation/components/QuickActions.tsx +25 -0
- package/src/components/TicketConversation/constants.ts +29 -0
- package/src/components/TicketConversation/hooks/useDocumentIdFromUrl.ts +21 -0
- package/src/components/TicketConversation/hooks/useTicketActions.ts +51 -0
- package/src/components/TicketConversation/index.tsx +29 -157
- package/src/endpoints/admin-chat-stream.ts +3 -4
- package/src/endpoints/admin-chat.ts +11 -20
- package/src/endpoints/admin-stats.ts +70 -32
- 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 +38 -18
- 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 +24 -2
- 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/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 +186 -0
- 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 +48 -16
- package/src/plugin.ts +6 -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/readSettings.ts +22 -6
- package/src/utils/sanitizeHtml.ts +57 -0
- package/src/utils/slugs.ts +6 -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
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import PDFDocument from 'pdfkit'
|
|
2
|
+
|
|
3
|
+
export interface InvoiceLine {
|
|
4
|
+
ticketNumber: string
|
|
5
|
+
subject: string
|
|
6
|
+
client: string
|
|
7
|
+
minutes: number
|
|
8
|
+
amount: number
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface InvoiceData {
|
|
12
|
+
from: string
|
|
13
|
+
to: string
|
|
14
|
+
projectId?: string | null
|
|
15
|
+
lines: InvoiceLine[]
|
|
16
|
+
totalMinutes: number
|
|
17
|
+
totalAmount: number
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const fmtAmount = (n: number) => `${n.toFixed(2)} EUR`
|
|
21
|
+
const fmtTime = (min: number) => `${Math.floor(min / 60)}h${String(min % 60).padStart(2, '0')}`
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Render a real (binary) PDF invoice from billing data, using pdfkit. Returns the
|
|
25
|
+
* complete PDF as a Buffer (suitable for an HTTP `application/pdf` response).
|
|
26
|
+
*/
|
|
27
|
+
export function generateInvoicePdf(data: InvoiceData): Promise<Buffer> {
|
|
28
|
+
return new Promise((resolve, reject) => {
|
|
29
|
+
try {
|
|
30
|
+
const doc = new PDFDocument({ size: 'A4', margin: 50 })
|
|
31
|
+
const chunks: Buffer[] = []
|
|
32
|
+
doc.on('data', (c: Buffer) => chunks.push(c))
|
|
33
|
+
doc.on('end', () => resolve(Buffer.concat(chunks)))
|
|
34
|
+
doc.on('error', reject)
|
|
35
|
+
|
|
36
|
+
// Header
|
|
37
|
+
doc.fillColor('#0f172a').fontSize(22).text('Invoice / Pre-billing')
|
|
38
|
+
doc.moveDown(0.2)
|
|
39
|
+
doc.fillColor('#64748b').fontSize(10)
|
|
40
|
+
.text(`Period: ${data.from} -> ${data.to}${data.projectId ? ` · Project #${data.projectId}` : ''}`)
|
|
41
|
+
doc.moveDown(1)
|
|
42
|
+
|
|
43
|
+
// Column layout (x positions)
|
|
44
|
+
const X = { ticket: 50, subject: 130, client: 320, time: 430, amount: 500 }
|
|
45
|
+
const drawRow = (r: { ticket: string; subject: string; client: string; time: string; amount: string }, opts?: { header?: boolean; total?: boolean }) => {
|
|
46
|
+
const y = doc.y
|
|
47
|
+
doc.fontSize(opts?.header ? 9 : 10).fillColor(opts?.header ? '#475569' : '#0f172a')
|
|
48
|
+
if (opts?.total) doc.font('Helvetica-Bold')
|
|
49
|
+
doc.text(r.ticket, X.ticket, y, { width: X.subject - X.ticket - 6 })
|
|
50
|
+
doc.text(r.subject, X.subject, y, { width: X.client - X.subject - 6 })
|
|
51
|
+
doc.text(r.client, X.client, y, { width: X.time - X.client - 6 })
|
|
52
|
+
doc.text(r.time, X.time, y, { width: X.amount - X.time - 6, align: 'right' })
|
|
53
|
+
doc.text(r.amount, X.amount, y, { width: 545 - X.amount, align: 'right' })
|
|
54
|
+
doc.font('Helvetica')
|
|
55
|
+
doc.moveDown(0.4)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
drawRow({ ticket: 'Ticket', subject: 'Subject', client: 'Client', time: 'Time', amount: 'Amount' }, { header: true })
|
|
59
|
+
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#e2e8f0').stroke()
|
|
60
|
+
doc.moveDown(0.3)
|
|
61
|
+
|
|
62
|
+
for (const l of data.lines) {
|
|
63
|
+
if (doc.y > 760) doc.addPage()
|
|
64
|
+
drawRow({
|
|
65
|
+
ticket: l.ticketNumber || '',
|
|
66
|
+
subject: l.subject || '',
|
|
67
|
+
client: l.client || '',
|
|
68
|
+
time: fmtTime(l.minutes),
|
|
69
|
+
amount: fmtAmount(l.amount),
|
|
70
|
+
})
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
doc.moveTo(50, doc.y).lineTo(545, doc.y).strokeColor('#0f172a').stroke()
|
|
74
|
+
doc.moveDown(0.3)
|
|
75
|
+
drawRow({
|
|
76
|
+
ticket: '',
|
|
77
|
+
subject: `Total (${data.lines.length} ticket${data.lines.length > 1 ? 's' : ''})`,
|
|
78
|
+
client: '',
|
|
79
|
+
time: fmtTime(data.totalMinutes),
|
|
80
|
+
amount: fmtAmount(data.totalAmount),
|
|
81
|
+
}, { total: true })
|
|
82
|
+
|
|
83
|
+
doc.end()
|
|
84
|
+
} catch (err) {
|
|
85
|
+
reject(err as Error)
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Payload } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from './slugs'
|
|
3
|
+
import { dbCreate } from './db'
|
|
4
|
+
|
|
5
|
+
export interface QueuedNotification {
|
|
6
|
+
client: number | string
|
|
7
|
+
type: string
|
|
8
|
+
title: string
|
|
9
|
+
message: string
|
|
10
|
+
ticket?: number | string
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Queue a client notification for later digest delivery (best-effort). Used when
|
|
15
|
+
* the client opted into `daily`/`weekly` notification frequency instead of immediate.
|
|
16
|
+
*/
|
|
17
|
+
export async function queueClientNotification(
|
|
18
|
+
payload: Payload,
|
|
19
|
+
slugs: CollectionSlugs,
|
|
20
|
+
item: QueuedNotification,
|
|
21
|
+
): Promise<void> {
|
|
22
|
+
try {
|
|
23
|
+
await dbCreate(payload, slugs.notificationQueue, {
|
|
24
|
+
data: {
|
|
25
|
+
client: item.client,
|
|
26
|
+
type: item.type,
|
|
27
|
+
title: item.title,
|
|
28
|
+
message: item.message,
|
|
29
|
+
...(item.ticket ? { ticket: item.ticket } : {}),
|
|
30
|
+
},
|
|
31
|
+
overrideAccess: true,
|
|
32
|
+
})
|
|
33
|
+
} catch (err) {
|
|
34
|
+
console.error('[support] Failed to queue client notification:', err)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Payload } from 'payload'
|
|
2
|
+
import { dbFind } from './db'
|
|
2
3
|
|
|
3
4
|
const PREF_KEY = 'support-settings'
|
|
4
5
|
const USER_PREFS_KEY_PREFIX = 'support-user-prefs'
|
|
@@ -27,16 +28,31 @@ export const DEFAULT_USER_PREFS: UserPrefs = {
|
|
|
27
28
|
signature: '',
|
|
28
29
|
}
|
|
29
30
|
|
|
31
|
+
// In-process cache: readSupportSettings is hit many times per ticket mutation
|
|
32
|
+
// (the afterChange hook chain). The settings doc changes rarely, so a short TTL
|
|
33
|
+
// + explicit invalidation on save (endpoints/settings.ts) avoids redundant DB
|
|
34
|
+
// reads of the same `payload-preferences` row.
|
|
35
|
+
let settingsCache: { value: SupportSettings; ts: number } | null = null
|
|
36
|
+
const SETTINGS_TTL_MS = 60_000
|
|
37
|
+
|
|
38
|
+
/** Invalidate the settings cache — call right after writing support settings. */
|
|
39
|
+
export function invalidateSupportSettingsCache(): void {
|
|
40
|
+
settingsCache = null
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
export async function readSupportSettings(payload: Payload): Promise<SupportSettings> {
|
|
44
|
+
if (settingsCache && Date.now() - settingsCache.ts < SETTINGS_TTL_MS) {
|
|
45
|
+
return settingsCache.value
|
|
46
|
+
}
|
|
47
|
+
let value: SupportSettings = { ...DEFAULT_SETTINGS }
|
|
31
48
|
try {
|
|
32
|
-
const prefs = await payload
|
|
33
|
-
collection: 'payload-preferences' as any,
|
|
49
|
+
const prefs = await dbFind(payload, 'payload-preferences', {
|
|
34
50
|
where: { key: { equals: PREF_KEY } },
|
|
35
51
|
limit: 1, depth: 0, overrideAccess: true,
|
|
36
52
|
})
|
|
37
53
|
if (prefs.docs.length > 0) {
|
|
38
54
|
const stored = prefs.docs[0].value as Partial<SupportSettings>
|
|
39
|
-
|
|
55
|
+
value = {
|
|
40
56
|
email: { ...DEFAULT_SETTINGS.email, ...stored.email },
|
|
41
57
|
ai: { ...DEFAULT_SETTINGS.ai, ...stored.ai },
|
|
42
58
|
sla: { ...DEFAULT_SETTINGS.sla, ...stored.sla },
|
|
@@ -44,14 +60,14 @@ export async function readSupportSettings(payload: Payload): Promise<SupportSett
|
|
|
44
60
|
}
|
|
45
61
|
}
|
|
46
62
|
} catch { /* fallback to defaults */ }
|
|
47
|
-
|
|
63
|
+
settingsCache = { value, ts: Date.now() }
|
|
64
|
+
return value
|
|
48
65
|
}
|
|
49
66
|
|
|
50
67
|
export async function readUserPrefs(payload: Payload, userId: string | number): Promise<UserPrefs> {
|
|
51
68
|
try {
|
|
52
69
|
const key = `${USER_PREFS_KEY_PREFIX}-${userId}`
|
|
53
|
-
const prefs = await payload
|
|
54
|
-
collection: 'payload-preferences' as any,
|
|
70
|
+
const prefs = await dbFind(payload, 'payload-preferences', {
|
|
55
71
|
where: { key: { equals: key } },
|
|
56
72
|
limit: 1, depth: 0, overrideAccess: true,
|
|
57
73
|
})
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import sanitizeHtml from 'sanitize-html'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Allow-list sanitizer for user/agent-submitted rich message content (`bodyHtml`).
|
|
5
|
+
*
|
|
6
|
+
* `bodyHtml` is rendered with `dangerouslySetInnerHTML` in BOTH the portal and the
|
|
7
|
+
* admin views, so unsanitized HTML is a stored-XSS vector: a client could inject
|
|
8
|
+
* a payload that runs in an agent's session → session theft → cross-client access.
|
|
9
|
+
*
|
|
10
|
+
* Applied server-side on write (TicketMessages `beforeChange`). Strips <script>,
|
|
11
|
+
* <iframe>/<embed>/<object>, inline event handlers (`on*`) and `javascript:` URIs,
|
|
12
|
+
* and restricts inline styles to a safe property/value allow-list (no `url()` /
|
|
13
|
+
* `expression()`).
|
|
14
|
+
*/
|
|
15
|
+
const OPTIONS: sanitizeHtml.IOptions = {
|
|
16
|
+
allowedTags: [
|
|
17
|
+
'p', 'br', 'b', 'strong', 'i', 'em', 'u', 's', 'strike', 'del', 'ins', 'mark',
|
|
18
|
+
'a', 'ul', 'ol', 'li', 'blockquote', 'code', 'pre', 'kbd', 'span', 'div',
|
|
19
|
+
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'img',
|
|
20
|
+
'table', 'thead', 'tbody', 'tr', 'th', 'td',
|
|
21
|
+
],
|
|
22
|
+
allowedAttributes: {
|
|
23
|
+
a: ['href', 'title', 'target', 'rel'],
|
|
24
|
+
img: ['src', 'alt', 'title', 'width', 'height'],
|
|
25
|
+
code: ['class'],
|
|
26
|
+
pre: ['class'],
|
|
27
|
+
span: ['class'],
|
|
28
|
+
'*': ['style'],
|
|
29
|
+
},
|
|
30
|
+
allowedSchemes: ['http', 'https', 'mailto', 'tel'],
|
|
31
|
+
allowedSchemesByTag: { img: ['http', 'https', 'data'] },
|
|
32
|
+
allowProtocolRelative: false,
|
|
33
|
+
disallowedTagsMode: 'discard',
|
|
34
|
+
// Force safe rel on links opening a new tab (defense against tabnabbing).
|
|
35
|
+
transformTags: {
|
|
36
|
+
a: sanitizeHtml.simpleTransform('a', { rel: 'noopener noreferrer nofollow' }, true),
|
|
37
|
+
},
|
|
38
|
+
// Only allow CSS that cannot carry a script payload.
|
|
39
|
+
allowedStyles: {
|
|
40
|
+
'*': {
|
|
41
|
+
color: [/^#(0x)?[0-9a-f]+$/i, /^rgb\(\s*(\d{1,3}\s*,\s*){2}\d{1,3}\s*\)$/i, /^[a-z-]+$/i],
|
|
42
|
+
'background-color': [/^#(0x)?[0-9a-f]+$/i, /^rgb\(\s*(\d{1,3}\s*,\s*){2}\d{1,3}\s*\)$/i, /^[a-z-]+$/i],
|
|
43
|
+
'text-align': [/^(left|right|center|justify)$/],
|
|
44
|
+
'font-weight': [/^(normal|bold|[1-9]00)$/],
|
|
45
|
+
'font-style': [/^(normal|italic)$/],
|
|
46
|
+
'text-decoration': [/^(underline|line-through|none)$/],
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Sanitize a rich-HTML message body. Returns the input unchanged when empty.
|
|
53
|
+
*/
|
|
54
|
+
export function sanitizeMessageHtml(html: string): string {
|
|
55
|
+
if (!html) return html
|
|
56
|
+
return sanitizeHtml(html, OPTIONS)
|
|
57
|
+
}
|
package/src/utils/slugs.ts
CHANGED
|
@@ -20,6 +20,9 @@ export interface CollectionSlugs {
|
|
|
20
20
|
macros: string
|
|
21
21
|
ticketStatuses: string
|
|
22
22
|
ticketFeedback: string
|
|
23
|
+
notificationQueue: string
|
|
24
|
+
automationRules: string
|
|
25
|
+
supportTeams: string
|
|
23
26
|
users: string
|
|
24
27
|
media: string
|
|
25
28
|
}
|
|
@@ -42,6 +45,9 @@ export const DEFAULT_SLUGS: CollectionSlugs = {
|
|
|
42
45
|
macros: 'macros',
|
|
43
46
|
ticketStatuses: 'ticket-statuses',
|
|
44
47
|
ticketFeedback: 'ticket-feedback',
|
|
48
|
+
notificationQueue: 'notification-queue',
|
|
49
|
+
automationRules: 'automation-rules',
|
|
50
|
+
supportTeams: 'support-teams',
|
|
45
51
|
users: 'users',
|
|
46
52
|
media: 'media',
|
|
47
53
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Payload } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from './slugs'
|
|
3
|
+
import { dbFind } from './db'
|
|
4
|
+
|
|
5
|
+
/** Team scoping is opt-in — off by default so existing installs are unaffected. */
|
|
6
|
+
export function isTeamScopingEnabled(): boolean {
|
|
7
|
+
return process.env.SUPPORT_TEAM_SCOPING === '1' || process.env.SUPPORT_TEAM_SCOPING === 'true'
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Resolve the team IDs an agent belongs to. */
|
|
11
|
+
export async function resolveAgentTeamIds(
|
|
12
|
+
payload: Payload,
|
|
13
|
+
slugs: CollectionSlugs,
|
|
14
|
+
userId: number | string,
|
|
15
|
+
): Promise<Array<number | string>> {
|
|
16
|
+
try {
|
|
17
|
+
const teams = await dbFind(payload, slugs.supportTeams, {
|
|
18
|
+
where: { members: { in: [userId] } },
|
|
19
|
+
limit: 200,
|
|
20
|
+
depth: 0,
|
|
21
|
+
overrideAccess: true,
|
|
22
|
+
})
|
|
23
|
+
return teams.docs.map((t) => (t as { id: number | string }).id)
|
|
24
|
+
} catch {
|
|
25
|
+
return []
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { Payload } from 'payload'
|
|
2
|
+
import type { CollectionSlugs } from './slugs'
|
|
3
|
+
import { dbFind } from './db'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve the set of ticket IDs a support-client may access: tickets they own
|
|
7
|
+
* (`client = id`) plus tickets they collaborate on (`ticket-collaborators` table).
|
|
8
|
+
*
|
|
9
|
+
* Used as a SECURITY BOUNDARY for child collections (messages, activity log):
|
|
10
|
+
* callers constrain `ticket IN (...)` instead of relying on a nested relationship
|
|
11
|
+
* filter (`'ticket.client'`), whose behaviour through the SQLite/Drizzle adapter
|
|
12
|
+
* is not guaranteed. Mirrors the owner/collaborator resolution in `Tickets.ts`.
|
|
13
|
+
*
|
|
14
|
+
* Returns an empty array when the client has no accessible tickets — callers MUST
|
|
15
|
+
* treat that as "deny" (e.g. filter on a sentinel id) rather than "allow all".
|
|
16
|
+
*/
|
|
17
|
+
export async function resolveAccessibleTicketIds(
|
|
18
|
+
payload: Payload,
|
|
19
|
+
slugs: CollectionSlugs,
|
|
20
|
+
clientId: number | string,
|
|
21
|
+
): Promise<Array<number | string>> {
|
|
22
|
+
const ids = new Set<number | string>()
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const owned = await dbFind(payload, slugs.tickets, {
|
|
26
|
+
where: { client: { equals: clientId } },
|
|
27
|
+
limit: 5000,
|
|
28
|
+
depth: 0,
|
|
29
|
+
overrideAccess: true,
|
|
30
|
+
})
|
|
31
|
+
for (const t of owned.docs) ids.add(t.id as number | string)
|
|
32
|
+
} catch {
|
|
33
|
+
// ignore — fall through with whatever we resolved
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
try {
|
|
37
|
+
const collab = await dbFind(payload, 'ticket-collaborators', {
|
|
38
|
+
where: { client: { equals: clientId } },
|
|
39
|
+
limit: 1000,
|
|
40
|
+
depth: 0,
|
|
41
|
+
overrideAccess: true,
|
|
42
|
+
})
|
|
43
|
+
for (const r of collab.docs) {
|
|
44
|
+
const row = r as { ticket?: number | string | { id?: number | string } }
|
|
45
|
+
const tid = typeof row.ticket === 'object' ? row.ticket?.id : row.ticket
|
|
46
|
+
if (tid !== undefined && tid !== null) ids.add(tid)
|
|
47
|
+
}
|
|
48
|
+
} catch {
|
|
49
|
+
// ticket-collaborators may not exist on older deployments — owner-only fallback
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return Array.from(ids)
|
|
53
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import crypto from 'crypto'
|
|
2
2
|
import type { BasePayload } from 'payload'
|
|
3
3
|
import type { CollectionSlugs } from './slugs'
|
|
4
|
+
import { dbFind, dbUpdate } from './db'
|
|
4
5
|
|
|
5
6
|
type WebhookEvent = 'ticket_created' | 'ticket_resolved' | 'ticket_replied' | 'sla_breached'
|
|
6
7
|
|
|
@@ -32,8 +33,7 @@ async function _dispatch(
|
|
|
32
33
|
slugs: CollectionSlugs,
|
|
33
34
|
): Promise<void> {
|
|
34
35
|
try {
|
|
35
|
-
const { docs: endpoints } = await payload.
|
|
36
|
-
collection: slugs.webhookEndpoints as any,
|
|
36
|
+
const { docs: endpoints } = await dbFind(payload, slugs.webhookEndpoints, {
|
|
37
37
|
where: {
|
|
38
38
|
and: [
|
|
39
39
|
{ active: { equals: true } },
|
|
@@ -86,8 +86,7 @@ async function _sendToEndpoint(
|
|
|
86
86
|
})
|
|
87
87
|
|
|
88
88
|
// Update last triggered info on the endpoint
|
|
89
|
-
await payload.
|
|
90
|
-
collection: slugs.webhookEndpoints as any,
|
|
89
|
+
await dbUpdate(payload, slugs.webhookEndpoints, {
|
|
91
90
|
id: endpoint.id,
|
|
92
91
|
data: {
|
|
93
92
|
lastTriggeredAt: new Date().toISOString(),
|
|
@@ -104,8 +103,7 @@ async function _sendToEndpoint(
|
|
|
104
103
|
|
|
105
104
|
// Still update with error status
|
|
106
105
|
try {
|
|
107
|
-
await payload.
|
|
108
|
-
collection: slugs.webhookEndpoints as any,
|
|
106
|
+
await dbUpdate(payload, slugs.webhookEndpoints, {
|
|
109
107
|
id: endpoint.id,
|
|
110
108
|
data: {
|
|
111
109
|
lastTriggeredAt: new Date().toISOString(),
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { useState, useCallback } from 'react'
|
|
4
4
|
import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
|
|
5
|
+
import { DATE_LOCALE } from '../shared/dateLocale'
|
|
5
6
|
import styles from '../../styles/BillingView.module.scss'
|
|
6
7
|
|
|
7
8
|
interface BillingEntry {
|
|
@@ -215,28 +216,30 @@ export const BillingClient: React.FC = () => {
|
|
|
215
216
|
const copyRecap = useCallback(() => {
|
|
216
217
|
if (!data) return
|
|
217
218
|
const lines: string[] = []
|
|
218
|
-
lines.push(
|
|
219
|
-
lines.push(
|
|
219
|
+
lines.push(t('billing.recap.header', { from, to }))
|
|
220
|
+
lines.push(t('billing.recap.hourlyRate', { rate: String(rate) }))
|
|
220
221
|
lines.push('='.repeat(50))
|
|
221
222
|
|
|
222
223
|
for (const group of visibleGroups) {
|
|
223
224
|
lines.push('')
|
|
224
|
-
lines.push(
|
|
225
|
-
|
|
225
|
+
lines.push(group.project?.name
|
|
226
|
+
? t('billing.recap.project', { name: group.project.name })
|
|
227
|
+
: t('billing.recap.project', { name: t('billing.recap.noProject') }))
|
|
228
|
+
if (group.client?.company) lines.push(t('billing.recap.client', { company: group.client.company }))
|
|
226
229
|
lines.push('-'.repeat(40))
|
|
227
230
|
|
|
228
231
|
for (const ticket of group.tickets) {
|
|
229
|
-
const flag = ticket.hasNoTimeEntries ? '
|
|
232
|
+
const flag = ticket.hasNoTimeEntries ? t('billing.recap.noTime') : ''
|
|
230
233
|
lines.push(` ${ticket.ticketNumber} — ${ticket.subject}${flag}`)
|
|
231
234
|
for (const entry of ticket.entries) {
|
|
232
235
|
lines.push(` ${entry.date} | ${formatDuration(entry.duration)} | ${entry.description || '-'}`)
|
|
233
236
|
}
|
|
234
237
|
if (ticket.entries.length > 0) {
|
|
235
238
|
const ticketAmount = ticket.billedAmount || Number(formatAmount(ticket.totalMinutes, rate))
|
|
236
|
-
lines.push(`
|
|
239
|
+
lines.push(` ${t('billing.recap.subtotal')} : ${formatDuration(ticket.totalMinutes)} = ${ticketAmount.toFixed(2)}${t('billing.recap.eur')}${ticket.billedAmount ? t('billing.recap.forfait') : ''}`)
|
|
237
240
|
}
|
|
238
241
|
if (ticket.aiSummary) {
|
|
239
|
-
lines.push(
|
|
242
|
+
lines.push(` ${t('billing.recap.actionDetails')} :`)
|
|
240
243
|
for (const detailLine of ticket.aiSummary.split('\n')) {
|
|
241
244
|
lines.push(` ${detailLine}`)
|
|
242
245
|
}
|
|
@@ -245,17 +248,17 @@ export const BillingClient: React.FC = () => {
|
|
|
245
248
|
const groupAmount = group.totalBilledAmount > 0
|
|
246
249
|
? group.totalBilledAmount
|
|
247
250
|
: Number(formatAmount(group.totalMinutes, rate))
|
|
248
|
-
lines.push(`
|
|
251
|
+
lines.push(` ${t('billing.recap.projectTotal')} : ${formatDuration(group.totalMinutes)} = ${groupAmount.toFixed(2)}${t('billing.recap.eur')}`)
|
|
249
252
|
}
|
|
250
253
|
|
|
251
254
|
lines.push('')
|
|
252
255
|
lines.push('='.repeat(50))
|
|
253
|
-
lines.push(
|
|
256
|
+
lines.push(`${t('billing.recap.grandTotal')} : ${formatDuration(data.grandTotalMinutes)} = ${formatAmount(data.grandTotalMinutes, rate)}${t('billing.recap.eur')}`)
|
|
254
257
|
|
|
255
258
|
navigator.clipboard.writeText(lines.join('\n'))
|
|
256
259
|
setCopied(true)
|
|
257
260
|
setTimeout(() => setCopied(false), 2000)
|
|
258
|
-
}, [data, visibleGroups, from, to, rate])
|
|
261
|
+
}, [data, visibleGroups, from, to, rate, t])
|
|
259
262
|
|
|
260
263
|
const copyTicketSummary = useCallback((ticket: BillingTicket) => {
|
|
261
264
|
const lines = [`${ticket.ticketNumber} — ${ticket.subject}`]
|
|
@@ -324,11 +327,13 @@ export const BillingClient: React.FC = () => {
|
|
|
324
327
|
{data.ticketsWithoutTime > 0 && (
|
|
325
328
|
<div className={styles.warningBanner}>
|
|
326
329
|
<span>
|
|
327
|
-
|
|
330
|
+
{data.ticketsWithoutTime > 1
|
|
331
|
+
? t('billing.warningTicketsPlural', { count: String(data.ticketsWithoutTime) })
|
|
332
|
+
: t('billing.warningTickets', { count: String(data.ticketsWithoutTime) })}
|
|
328
333
|
</span>
|
|
329
334
|
<label className={styles.toggleLabel}>
|
|
330
335
|
<input type="checkbox" checked={hideEmpty} onChange={(e) => setHideEmpty(e.target.checked)} />
|
|
331
|
-
<span>
|
|
336
|
+
<span>{t('billing.hideEmpty')}</span>
|
|
332
337
|
</label>
|
|
333
338
|
</div>
|
|
334
339
|
)}
|
|
@@ -341,7 +346,7 @@ export const BillingClient: React.FC = () => {
|
|
|
341
346
|
<div key={gi} className={styles.groupCard}>
|
|
342
347
|
<div className={styles.groupHeader}>
|
|
343
348
|
<div>
|
|
344
|
-
<span className={styles.groupName}>{group.project?.name || '
|
|
349
|
+
<span className={styles.groupName}>{group.project?.name || t('billing.noProject')}</span>
|
|
345
350
|
{group.client?.company && (
|
|
346
351
|
<span className={styles.groupClient}>— {group.client.company}</span>
|
|
347
352
|
)}
|
|
@@ -350,7 +355,7 @@ export const BillingClient: React.FC = () => {
|
|
|
350
355
|
<div className={styles.groupDuration}>{formatDuration(group.totalMinutes)}</div>
|
|
351
356
|
{group.totalBilledAmount > 0 ? (
|
|
352
357
|
<>
|
|
353
|
-
<div className={styles.groupAmountBilled}>{group.totalBilledAmount.toFixed(2)} EUR
|
|
358
|
+
<div className={styles.groupAmountBilled}>{group.totalBilledAmount.toFixed(2)} EUR {t('billing.groupAmountBilled')}</div>
|
|
354
359
|
<div className={styles.groupAmountStrike}>{formatAmount(group.totalMinutes, rate)} EUR (temps)</div>
|
|
355
360
|
</>
|
|
356
361
|
) : (
|
|
@@ -377,16 +382,16 @@ export const BillingClient: React.FC = () => {
|
|
|
377
382
|
})
|
|
378
383
|
}}
|
|
379
384
|
className={styles.checkbox}
|
|
380
|
-
title=
|
|
385
|
+
title={t('billing.tableCheckboxTitle')}
|
|
381
386
|
/>
|
|
382
387
|
</th>
|
|
383
|
-
<th className={styles.th}>
|
|
384
|
-
<th className={styles.thLeft}>
|
|
385
|
-
<th className={styles.th}>
|
|
386
|
-
<th className={styles.th}>
|
|
387
|
-
<th className={styles.thLeft}>
|
|
388
|
-
<th className={styles.th}>
|
|
389
|
-
<th className={styles.th}>
|
|
388
|
+
<th className={styles.th}>{t('billing.tableHeaders.number')}</th>
|
|
389
|
+
<th className={styles.thLeft}>{t('billing.tableHeaders.subject')}</th>
|
|
390
|
+
<th className={styles.th}>{t('billing.tableHeaders.date')}</th>
|
|
391
|
+
<th className={styles.th}>{t('billing.tableHeaders.duration')}</th>
|
|
392
|
+
<th className={styles.thLeft}>{t('billing.tableHeaders.description')}</th>
|
|
393
|
+
<th className={styles.th}>{t('billing.tableHeaders.amount')}</th>
|
|
394
|
+
<th className={styles.th}>{t('billing.tableHeaders.billed')}</th>
|
|
390
395
|
</tr>
|
|
391
396
|
</thead>
|
|
392
397
|
<tbody>
|
|
@@ -403,7 +408,7 @@ export const BillingClient: React.FC = () => {
|
|
|
403
408
|
checked={isBilled}
|
|
404
409
|
onChange={() => toggleBilled(ticket.id)}
|
|
405
410
|
className={styles.checkbox}
|
|
406
|
-
title={isBilled ? '
|
|
411
|
+
title={isBilled ? t('billing.markUnbilledTitle') : t('billing.markBilledTitle')}
|
|
407
412
|
/>
|
|
408
413
|
</td>
|
|
409
414
|
<td className={`${styles.td} ${styles.bold} ${isBilled ? styles.strikethrough : ''}`} rowSpan={rowSpan}>
|
|
@@ -413,7 +418,7 @@ export const BillingClient: React.FC = () => {
|
|
|
413
418
|
<button
|
|
414
419
|
className={styles.summaryBtn}
|
|
415
420
|
onClick={() => toggleSummary(ticket.id)}
|
|
416
|
-
title={isExpanded ? '
|
|
421
|
+
title={isExpanded ? t('billing.summaryToggleHide') : t('billing.summaryToggleShow')}
|
|
417
422
|
>
|
|
418
423
|
{isExpanded ? '▼' : '▶'} IA
|
|
419
424
|
</button>
|
|
@@ -421,8 +426,8 @@ export const BillingClient: React.FC = () => {
|
|
|
421
426
|
<td className={`${styles.tdLeft} ${isBilled ? styles.strikethrough : ''}`} rowSpan={rowSpan}>
|
|
422
427
|
{ticket.subject}
|
|
423
428
|
{ticket.hasNoTimeEntries && (
|
|
424
|
-
<span className={styles.noTimeBadge} title=
|
|
425
|
-
|
|
429
|
+
<span className={styles.noTimeBadge} title={t('billing.noTimeBadgeTitle')}>
|
|
430
|
+
{t('billing.noTimeBadge')}
|
|
426
431
|
</span>
|
|
427
432
|
)}
|
|
428
433
|
</td>
|
|
@@ -439,7 +444,7 @@ export const BillingClient: React.FC = () => {
|
|
|
439
444
|
>
|
|
440
445
|
{renderTicketHeaderCells()}
|
|
441
446
|
<td className={`${styles.td} ${styles.secondary}`} colSpan={5}>
|
|
442
|
-
<em>
|
|
447
|
+
<em>{t('billing.noTimeRow')}</em>
|
|
443
448
|
</td>
|
|
444
449
|
</tr>,
|
|
445
450
|
)
|
|
@@ -477,11 +482,11 @@ export const BillingClient: React.FC = () => {
|
|
|
477
482
|
<tr key={`${ticket.id}-summary`} className={styles.summaryRow}>
|
|
478
483
|
<td colSpan={8} className={styles.summaryCell}>
|
|
479
484
|
<div className={styles.summaryHeader}>
|
|
480
|
-
<strong>
|
|
485
|
+
<strong>{t('billing.summaryTitle')}</strong>
|
|
481
486
|
<div className={styles.summaryActions}>
|
|
482
487
|
{ticket.aiSummaryGeneratedAt && (
|
|
483
488
|
<span className={styles.summaryMeta}>
|
|
484
|
-
|
|
489
|
+
{t('billing.summaryGeneratedAt', { date: new Date(ticket.aiSummaryGeneratedAt).toLocaleString(DATE_LOCALE) })}
|
|
485
490
|
</span>
|
|
486
491
|
)}
|
|
487
492
|
{ticket.aiSummary && (
|
|
@@ -489,7 +494,7 @@ export const BillingClient: React.FC = () => {
|
|
|
489
494
|
className={styles.summaryAction}
|
|
490
495
|
onClick={() => copyTicketSummary(ticket)}
|
|
491
496
|
>
|
|
492
|
-
|
|
497
|
+
{t('billing.summaryCopy')}
|
|
493
498
|
</button>
|
|
494
499
|
)}
|
|
495
500
|
<button
|
|
@@ -498,20 +503,20 @@ export const BillingClient: React.FC = () => {
|
|
|
498
503
|
disabled={isRegenerating}
|
|
499
504
|
>
|
|
500
505
|
{isRegenerating
|
|
501
|
-
? '
|
|
502
|
-
: ticket.aiSummary ? '
|
|
506
|
+
? t('billing.summaryGenerating')
|
|
507
|
+
: ticket.aiSummary ? t('billing.summaryRegenerate') : t('billing.summaryGenerate')}
|
|
503
508
|
</button>
|
|
504
509
|
</div>
|
|
505
510
|
</div>
|
|
506
511
|
{ticket.aiSummaryStatus === 'pending' && !ticket.aiSummary ? (
|
|
507
512
|
<div className={styles.summaryEmpty}>
|
|
508
|
-
|
|
513
|
+
{t('billing.summaryPending')}
|
|
509
514
|
</div>
|
|
510
515
|
) : ticket.aiSummary ? (
|
|
511
516
|
<pre className={styles.summaryText}>{ticket.aiSummary}</pre>
|
|
512
517
|
) : (
|
|
513
518
|
<div className={styles.summaryEmpty}>
|
|
514
|
-
|
|
519
|
+
{t('billing.summaryEmpty')}
|
|
515
520
|
</div>
|
|
516
521
|
)}
|
|
517
522
|
</td>
|
|
@@ -529,15 +534,22 @@ export const BillingClient: React.FC = () => {
|
|
|
529
534
|
<div className={styles.grandTotal}>
|
|
530
535
|
<div>
|
|
531
536
|
<div className={styles.totalMeta}>
|
|
532
|
-
{totalTickets
|
|
537
|
+
{totalTickets > 1
|
|
538
|
+
? t('billing.ticketsDisplayedPlural', { count: String(totalTickets) })
|
|
539
|
+
: t('billing.ticketsDisplayed', { count: String(totalTickets) })}
|
|
533
540
|
{billedTickets.size > 0 && (
|
|
534
541
|
<span className={styles.totalChecked}>
|
|
535
|
-
|
|
542
|
+
{(() => {
|
|
543
|
+
const checkedCount = allTicketIds.filter((id) => billedTickets.has(id)).length
|
|
544
|
+
return checkedCount > 1
|
|
545
|
+
? `(${t('billing.ticketsCheckedPlural', { count: String(checkedCount) })})`
|
|
546
|
+
: `(${t('billing.ticketsChecked', { count: String(checkedCount) })})`
|
|
547
|
+
})()}
|
|
536
548
|
</span>
|
|
537
549
|
)}
|
|
538
550
|
</div>
|
|
539
551
|
<div className={styles.totalAmount}>
|
|
540
|
-
|
|
552
|
+
{t('billing.totalLabel')} : {formatDuration(data.grandTotalMinutes)} ={' '}
|
|
541
553
|
{data.grandTotalBilledAmount > 0
|
|
542
554
|
? `${data.grandTotalBilledAmount.toFixed(2)} EUR`
|
|
543
555
|
: `${formatAmount(data.grandTotalMinutes, rate)} EUR`
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import React, { useState, useEffect, useRef, useCallback } from 'react'
|
|
4
4
|
import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
|
|
5
|
+
import { DATE_LOCALE } from '../shared/dateLocale'
|
|
5
6
|
import styles from '../../styles/ChatView.module.scss'
|
|
6
7
|
|
|
7
8
|
interface ChatSession {
|
|
@@ -255,7 +256,7 @@ export const ChatViewClient: React.FC = () => {
|
|
|
255
256
|
) : s.lastMessage}
|
|
256
257
|
</div>
|
|
257
258
|
<div className={styles.sessionMeta}>
|
|
258
|
-
{new Date(s.lastMessageAt).toLocaleString(
|
|
259
|
+
{new Date(s.lastMessageAt).toLocaleString(DATE_LOCALE, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
|
259
260
|
{' · '}{s.messageCount} {t('chat.msg')}
|
|
260
261
|
</div>
|
|
261
262
|
</button>
|
|
@@ -306,7 +307,7 @@ export const ChatViewClient: React.FC = () => {
|
|
|
306
307
|
: msg.message.replace(/Note:\s*[★☆]+\s*\(\d\/5\)\s*/, '').replace('Commentaire: ', '')}
|
|
307
308
|
</div>
|
|
308
309
|
<div className={styles.ratingMeta}>
|
|
309
|
-
{t('chat.clientReview')} · {new Date(msg.createdAt).toLocaleString(
|
|
310
|
+
{t('chat.clientReview')} · {new Date(msg.createdAt).toLocaleString(DATE_LOCALE, { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' })}
|
|
310
311
|
</div>
|
|
311
312
|
</div>
|
|
312
313
|
) : (
|
|
@@ -325,7 +326,7 @@ export const ChatViewClient: React.FC = () => {
|
|
|
325
326
|
{msg.message}
|
|
326
327
|
</div>
|
|
327
328
|
<div className={styles.bubbleTime}>
|
|
328
|
-
{new Date(msg.createdAt).toLocaleTimeString(
|
|
329
|
+
{new Date(msg.createdAt).toLocaleTimeString(DATE_LOCALE, { hour: '2-digit', minute: '2-digit' })}
|
|
329
330
|
</div>
|
|
330
331
|
</div>
|
|
331
332
|
)}
|