@consilioweb/payload-support 0.16.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/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 +1604 -671
- package/dist/index.d.cts +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1602 -671
- 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 +129 -31
- package/src/collections/Tickets.ts +31 -73
- 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/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 +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 +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 +18 -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/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 +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,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
|
)}
|
|
@@ -71,14 +71,14 @@ function formatDuration(minutes: number): string {
|
|
|
71
71
|
return `${h}h${m}m`
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
function timeAgo(dateStr: string): string {
|
|
74
|
+
function timeAgo(dateStr: string, tFn: (k: string, vars?: Record<string, string | number>) => string): string {
|
|
75
75
|
const diff = Date.now() - new Date(dateStr).getTime()
|
|
76
76
|
const days = Math.floor(diff / 86400000)
|
|
77
|
-
if (days === 0) return
|
|
78
|
-
if (days === 1) return '
|
|
79
|
-
if (days < 30) return
|
|
77
|
+
if (days === 0) return tFn('crm.timeAgo.today')
|
|
78
|
+
if (days === 1) return tFn('crm.timeAgo.yesterday')
|
|
79
|
+
if (days < 30) return tFn('crm.timeAgo.daysAgo', { count: days })
|
|
80
80
|
const months = Math.floor(days / 30)
|
|
81
|
-
return
|
|
81
|
+
return tFn('crm.timeAgo.monthsAgo', { count: months })
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
function getStatColorClass(key: string, detail: ClientDetail['stats']): string {
|
|
@@ -416,7 +416,7 @@ export const CrmClient: React.FC = () => {
|
|
|
416
416
|
{ key: 'open', label: t('crm.stats.openTickets'), value: String(detail.stats.openTickets) },
|
|
417
417
|
{ key: 'resolved', label: t('crm.stats.resolvedTickets'), value: String(detail.stats.resolvedTickets) },
|
|
418
418
|
{ key: 'time', label: t('crm.stats.timeSpent'), value: formatDuration(detail.stats.totalTimeMinutes) },
|
|
419
|
-
{ key: 'activity', label: t('crm.stats.lastActivity'), value: detail.stats.lastActivity ? timeAgo(detail.stats.lastActivity) : '-' },
|
|
419
|
+
{ key: 'activity', label: t('crm.stats.lastActivity'), value: detail.stats.lastActivity ? timeAgo(detail.stats.lastActivity, t) : '-' },
|
|
420
420
|
].map((stat) => (
|
|
421
421
|
<div key={stat.key} className={styles.statCard}>
|
|
422
422
|
<div className={styles.statLabel}>{stat.label}</div>
|
|
@@ -554,7 +554,7 @@ export const CrmClient: React.FC = () => {
|
|
|
554
554
|
</span>
|
|
555
555
|
</td>
|
|
556
556
|
<td className={styles.ticketDate}>
|
|
557
|
-
{timeAgo(tk.createdAt)}
|
|
557
|
+
{timeAgo(tk.createdAt, t)}
|
|
558
558
|
</td>
|
|
559
559
|
</tr>
|
|
560
560
|
))}
|
|
@@ -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 { DATE_LOCALE } from '../shared/dateLocale'
|
|
5
6
|
import s from '../../styles/EmailTracking.module.scss'
|
|
6
7
|
|
|
7
8
|
interface EmailLog {
|
|
@@ -30,14 +31,14 @@ interface Stats {
|
|
|
30
31
|
type StatusTab = 'all' | 'success' | 'error' | 'ignored'
|
|
31
32
|
type DateRange = 7 | 30 | 90
|
|
32
33
|
|
|
33
|
-
const STATUS_CFG: Record<string, {
|
|
34
|
-
success: {
|
|
35
|
-
error: {
|
|
36
|
-
ignored: {
|
|
34
|
+
const STATUS_CFG: Record<string, { labelKey: string; bg: string; color: string }> = {
|
|
35
|
+
success: { labelKey: 'logs.statusSuccess', bg: '#dcfce7', color: '#16a34a' },
|
|
36
|
+
error: { labelKey: 'emailTracking.errorLabel', bg: '#fef2f2', color: '#dc2626' },
|
|
37
|
+
ignored: { labelKey: 'logs.statusIgnored', bg: '#f3f4f6', color: '#6b7280' },
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
function fmtDate(d: string): string {
|
|
40
|
-
return new Date(d).toLocaleDateString(
|
|
41
|
+
return new Date(d).toLocaleDateString(DATE_LOCALE, { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' })
|
|
41
42
|
}
|
|
42
43
|
|
|
43
44
|
export const EmailTrackingClient: React.FC = () => {
|
|
@@ -129,7 +130,7 @@ export const EmailTrackingClient: React.FC = () => {
|
|
|
129
130
|
<td style={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={log.recipientEmail}>{log.recipientEmail || '—'}</td>
|
|
130
131
|
<td style={{ maxWidth: 250, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={log.subject}>{log.subject || '—'}</td>
|
|
131
132
|
<td>{log.ticketNumber ? <span className={s.ticketLink}>{log.ticketNumber}</span> : '—'}</td>
|
|
132
|
-
<td><span className={s.statusBadge} style={{ background: STATUS_CFG[log.status]?.bg, color: STATUS_CFG[log.status]?.color }}>{STATUS_CFG[log.status]?.
|
|
133
|
+
<td><span className={s.statusBadge} style={{ background: STATUS_CFG[log.status]?.bg, color: STATUS_CFG[log.status]?.color }}>{STATUS_CFG[log.status]?.labelKey ? t(STATUS_CFG[log.status].labelKey) : log.status}</span></td>
|
|
133
134
|
<td style={{ fontSize: 12 }}>{log.action || '—'}</td>
|
|
134
135
|
<td style={{ textAlign: 'right' }}>
|
|
135
136
|
{log.processingTimeMs != null ? (
|
|
@@ -96,13 +96,13 @@ export function ImportConversationClient() {
|
|
|
96
96
|
|
|
97
97
|
const data = await res.json()
|
|
98
98
|
if (!res.ok) {
|
|
99
|
-
setError(data.error || '
|
|
99
|
+
setError(data.error || t('import.analyzeError'))
|
|
100
100
|
return
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
setPreview(data)
|
|
104
104
|
} catch {
|
|
105
|
-
setError('
|
|
105
|
+
setError(t('import.networkError'))
|
|
106
106
|
} finally {
|
|
107
107
|
setLoading(false)
|
|
108
108
|
}
|
|
@@ -122,14 +122,14 @@ export function ImportConversationClient() {
|
|
|
122
122
|
|
|
123
123
|
const data = await res.json()
|
|
124
124
|
if (!res.ok) {
|
|
125
|
-
setError(data.error || '
|
|
125
|
+
setError(data.error || t('import.importError'))
|
|
126
126
|
return
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
setResult(data)
|
|
130
130
|
setPreview(null)
|
|
131
131
|
} catch {
|
|
132
|
-
setError('
|
|
132
|
+
setError(t('import.networkError'))
|
|
133
133
|
} finally {
|
|
134
134
|
setLoading(false)
|
|
135
135
|
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import React, { useState, useEffect, useCallback } from 'react'
|
|
4
4
|
import { useSearchParams } from 'next/navigation'
|
|
5
5
|
import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
|
|
6
|
+
import { DATE_LOCALE } from '../shared/dateLocale'
|
|
6
7
|
import s from '../../styles/Logs.module.scss'
|
|
7
8
|
|
|
8
9
|
type LogType = 'email' | 'auth'
|
|
@@ -26,7 +27,7 @@ interface LogEntry {
|
|
|
26
27
|
}
|
|
27
28
|
|
|
28
29
|
function fmtDate(d: string): string {
|
|
29
|
-
return new Date(d).toLocaleDateString(
|
|
30
|
+
return new Date(d).toLocaleDateString(DATE_LOCALE, { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' })
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
export const LogsClient: React.FC = () => {
|
|
@@ -83,7 +84,7 @@ export const LogsClient: React.FC = () => {
|
|
|
83
84
|
<div className={s.headerActions}>
|
|
84
85
|
<button className={s.purgeBtn} onClick={() => handlePurge(7)}>{t('logs.purge7')}</button>
|
|
85
86
|
<button className={s.purgeBtn} onClick={() => handlePurge(30)}>{t('logs.purge30')}</button>
|
|
86
|
-
<button className={s.purgeBtn} onClick={() => handlePurge(90)}>
|
|
87
|
+
<button className={s.purgeBtn} onClick={() => handlePurge(90)}>{t('logs.purge90')}</button>
|
|
87
88
|
<button className={`${s.purgeBtn} ${s.purgeBtnDanger}`} onClick={() => handlePurge(0)}>{t('logs.purgeAll')}</button>
|
|
88
89
|
</div>
|
|
89
90
|
</div>
|
|
@@ -190,7 +190,7 @@ export const NewTicketClient: React.FC = () => {
|
|
|
190
190
|
<div className={s.fieldGroup}>
|
|
191
191
|
<label className={s.label}>{t('newTicket.projectLabel')}</label>
|
|
192
192
|
<select className={s.select} value={projectId || ''} onChange={(e) => setProjectId(e.target.value ? Number(e.target.value) : null)}>
|
|
193
|
-
<option value=""
|
|
193
|
+
<option value="">{t('newTicket.noProject')}</option>
|
|
194
194
|
{projects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}
|
|
195
195
|
</select>
|
|
196
196
|
</div>
|