@consilioweb/payload-support 0.5.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.
Files changed (189) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +525 -0
  3. package/dist/client.cjs +7 -0
  4. package/dist/client.d.cts +3 -0
  5. package/dist/client.d.ts +3 -0
  6. package/dist/client.js +5 -0
  7. package/dist/index.cjs +7766 -0
  8. package/dist/index.d.cts +384 -0
  9. package/dist/index.d.ts +384 -0
  10. package/dist/index.js +7730 -0
  11. package/dist/views.d.cts +30 -0
  12. package/dist/views.d.ts +30 -0
  13. package/package.json +131 -0
  14. package/src/client.ts +1 -0
  15. package/src/collections/AuthLogs.ts +65 -0
  16. package/src/collections/CannedResponses.ts +69 -0
  17. package/src/collections/ChatMessages.ts +98 -0
  18. package/src/collections/EmailLogs.ts +94 -0
  19. package/src/collections/KnowledgeBase.ts +99 -0
  20. package/src/collections/Macros.ts +98 -0
  21. package/src/collections/PendingEmails.ts +122 -0
  22. package/src/collections/SatisfactionSurveys.ts +98 -0
  23. package/src/collections/SlaPolicies.ts +123 -0
  24. package/src/collections/SupportClients.ts +210 -0
  25. package/src/collections/TicketActivityLog.ts +81 -0
  26. package/src/collections/TicketMessages.ts +364 -0
  27. package/src/collections/TicketStatuses.ts +108 -0
  28. package/src/collections/Tickets.ts +704 -0
  29. package/src/collections/TimeEntries.ts +105 -0
  30. package/src/collections/WebhookEndpoints.ts +96 -0
  31. package/src/collections/index.ts +16 -0
  32. package/src/components/TicketConversation/components/AISummaryPanel.tsx +85 -0
  33. package/src/components/TicketConversation/components/ActionPanels.tsx +140 -0
  34. package/src/components/TicketConversation/components/ActivityLog.tsx +39 -0
  35. package/src/components/TicketConversation/components/ClientBar.tsx +37 -0
  36. package/src/components/TicketConversation/components/ClientHistory.tsx +117 -0
  37. package/src/components/TicketConversation/components/CodeBlock.tsx +186 -0
  38. package/src/components/TicketConversation/components/CodeBlockInserter.tsx +166 -0
  39. package/src/components/TicketConversation/components/QuickActions.tsx +82 -0
  40. package/src/components/TicketConversation/components/TicketHeader.tsx +91 -0
  41. package/src/components/TicketConversation/components/TimeTrackingPanel.tsx +161 -0
  42. package/src/components/TicketConversation/config.ts +82 -0
  43. package/src/components/TicketConversation/constants.ts +74 -0
  44. package/src/components/TicketConversation/context.ts +63 -0
  45. package/src/components/TicketConversation/hooks/useAI.ts +180 -0
  46. package/src/components/TicketConversation/hooks/useMessageActions.ts +131 -0
  47. package/src/components/TicketConversation/hooks/useReply.ts +190 -0
  48. package/src/components/TicketConversation/hooks/useTicketActions.ts +205 -0
  49. package/src/components/TicketConversation/hooks/useTimeTracking.ts +107 -0
  50. package/src/components/TicketConversation/hooks/useTranslation.ts +116 -0
  51. package/src/components/TicketConversation/index.tsx +1110 -0
  52. package/src/components/TicketConversation/locales/en.json +878 -0
  53. package/src/components/TicketConversation/locales/fr.json +878 -0
  54. package/src/components/TicketConversation/types.ts +54 -0
  55. package/src/components/TicketConversation/utils.ts +25 -0
  56. package/src/endpoints/admin-chat-stream.ts +238 -0
  57. package/src/endpoints/admin-chat.ts +263 -0
  58. package/src/endpoints/admin-stats.ts +200 -0
  59. package/src/endpoints/ai.ts +199 -0
  60. package/src/endpoints/apply-macro.ts +144 -0
  61. package/src/endpoints/auth-2fa.ts +163 -0
  62. package/src/endpoints/auto-close.ts +175 -0
  63. package/src/endpoints/billing.ts +167 -0
  64. package/src/endpoints/bulk-action.ts +103 -0
  65. package/src/endpoints/chat-stream.ts +127 -0
  66. package/src/endpoints/chat.ts +188 -0
  67. package/src/endpoints/chatbot.ts +113 -0
  68. package/src/endpoints/delete-account.ts +129 -0
  69. package/src/endpoints/email-stats.ts +109 -0
  70. package/src/endpoints/export-csv.ts +84 -0
  71. package/src/endpoints/export-data.ts +104 -0
  72. package/src/endpoints/import-conversation.ts +307 -0
  73. package/src/endpoints/index.ts +154 -0
  74. package/src/endpoints/login.ts +92 -0
  75. package/src/endpoints/merge-clients.ts +132 -0
  76. package/src/endpoints/merge-tickets.ts +137 -0
  77. package/src/endpoints/oauth-google.ts +179 -0
  78. package/src/endpoints/pending-emails-process.ts +224 -0
  79. package/src/endpoints/presence.ts +104 -0
  80. package/src/endpoints/process-scheduled.ts +144 -0
  81. package/src/endpoints/purge-logs.ts +58 -0
  82. package/src/endpoints/resend-notification.ts +99 -0
  83. package/src/endpoints/round-robin-config.ts +92 -0
  84. package/src/endpoints/satisfaction.ts +93 -0
  85. package/src/endpoints/search.ts +106 -0
  86. package/src/endpoints/seed-kb.ts +153 -0
  87. package/src/endpoints/settings.ts +144 -0
  88. package/src/endpoints/signature.ts +93 -0
  89. package/src/endpoints/sla-check.ts +124 -0
  90. package/src/endpoints/split-ticket.ts +131 -0
  91. package/src/endpoints/statuses.ts +45 -0
  92. package/src/endpoints/track-open.ts +154 -0
  93. package/src/endpoints/typing.ts +101 -0
  94. package/src/endpoints/user-prefs.ts +125 -0
  95. package/src/hooks/checkSLA.ts +414 -0
  96. package/src/hooks/ticketStatusEmail.ts +182 -0
  97. package/src/index.ts +51 -0
  98. package/src/plugin.ts +157 -0
  99. package/src/portal/LiveChat.tsx +1353 -0
  100. package/src/portal/auth/ChatWidget.tsx +350 -0
  101. package/src/portal/auth/ChatbotWidget.tsx +285 -0
  102. package/src/portal/auth/SupportHeader.tsx +409 -0
  103. package/src/portal/auth/dashboard/DashboardClient.tsx +650 -0
  104. package/src/portal/auth/dashboard/page.tsx +84 -0
  105. package/src/portal/auth/faq/FAQSearch.tsx +117 -0
  106. package/src/portal/auth/faq/page.tsx +199 -0
  107. package/src/portal/auth/layout.tsx +61 -0
  108. package/src/portal/auth/profile/page.tsx +705 -0
  109. package/src/portal/auth/tickets/detail/CloseTicketButton.tsx +74 -0
  110. package/src/portal/auth/tickets/detail/CollapsibleMessages.tsx +46 -0
  111. package/src/portal/auth/tickets/detail/MarkSolutionButton.tsx +50 -0
  112. package/src/portal/auth/tickets/detail/MessageActions.tsx +158 -0
  113. package/src/portal/auth/tickets/detail/PrintButton.tsx +16 -0
  114. package/src/portal/auth/tickets/detail/ReadReceipt.tsx +34 -0
  115. package/src/portal/auth/tickets/detail/ReopenTicketButton.tsx +74 -0
  116. package/src/portal/auth/tickets/detail/SatisfactionForm.tsx +156 -0
  117. package/src/portal/auth/tickets/detail/TicketPolling.tsx +57 -0
  118. package/src/portal/auth/tickets/detail/TicketReplyForm.tsx +294 -0
  119. package/src/portal/auth/tickets/detail/TypingIndicator.tsx +58 -0
  120. package/src/portal/auth/tickets/detail/page.tsx +738 -0
  121. package/src/portal/auth/tickets/new/page.tsx +515 -0
  122. package/src/portal/forgot-password/page.tsx +114 -0
  123. package/src/portal/layout.tsx +26 -0
  124. package/src/portal/locales/en.json +374 -0
  125. package/src/portal/locales/fr.json +374 -0
  126. package/src/portal/login/page.tsx +351 -0
  127. package/src/portal/page.tsx +162 -0
  128. package/src/portal/register/page.tsx +281 -0
  129. package/src/portal/reset-password/page.tsx +152 -0
  130. package/src/styles/BillingView.module.scss +311 -0
  131. package/src/styles/ChatView.module.scss +438 -0
  132. package/src/styles/CommandPalette.module.scss +160 -0
  133. package/src/styles/CrmView.module.scss +554 -0
  134. package/src/styles/EmailTracking.module.scss +238 -0
  135. package/src/styles/ImportConversation.module.scss +267 -0
  136. package/src/styles/Layout.module.scss +55 -0
  137. package/src/styles/Logs.module.scss +164 -0
  138. package/src/styles/NewTicket.module.scss +143 -0
  139. package/src/styles/PendingEmails.module.scss +629 -0
  140. package/src/styles/SupportDashboard.module.scss +649 -0
  141. package/src/styles/TicketDetail.module.scss +1043 -0
  142. package/src/styles/TicketInbox.module.scss +296 -0
  143. package/src/styles/TicketingSettings.module.scss +358 -0
  144. package/src/styles/TimeDashboard.module.scss +287 -0
  145. package/src/styles/_tokens.scss +78 -0
  146. package/src/styles/theme.css +633 -0
  147. package/src/types.ts +255 -0
  148. package/src/utils/adminNotification.ts +38 -0
  149. package/src/utils/auth.ts +46 -0
  150. package/src/utils/emailTemplate.ts +343 -0
  151. package/src/utils/fireWebhooks.ts +84 -0
  152. package/src/utils/index.ts +22 -0
  153. package/src/utils/rateLimiter.ts +52 -0
  154. package/src/utils/readSettings.ts +67 -0
  155. package/src/utils/slugs.ts +54 -0
  156. package/src/utils/webhookDispatcher.ts +120 -0
  157. package/src/views/BillingView/client.tsx +137 -0
  158. package/src/views/BillingView/index.tsx +33 -0
  159. package/src/views/ChatView/client.tsx +294 -0
  160. package/src/views/ChatView/index.tsx +33 -0
  161. package/src/views/CrmView/client.tsx +206 -0
  162. package/src/views/CrmView/index.tsx +33 -0
  163. package/src/views/EmailTrackingView/client.tsx +124 -0
  164. package/src/views/EmailTrackingView/index.tsx +33 -0
  165. package/src/views/ImportConversationView/client.tsx +133 -0
  166. package/src/views/ImportConversationView/index.tsx +33 -0
  167. package/src/views/LogsView/client.tsx +151 -0
  168. package/src/views/LogsView/index.tsx +30 -0
  169. package/src/views/NewTicketView/client.tsx +227 -0
  170. package/src/views/NewTicketView/index.tsx +30 -0
  171. package/src/views/PendingEmailsView/client.tsx +177 -0
  172. package/src/views/PendingEmailsView/index.tsx +33 -0
  173. package/src/views/SupportDashboardView/client.tsx +424 -0
  174. package/src/views/SupportDashboardView/index.tsx +33 -0
  175. package/src/views/TicketDetailView/client.tsx +775 -0
  176. package/src/views/TicketDetailView/index.tsx +33 -0
  177. package/src/views/TicketInboxView/client.tsx +313 -0
  178. package/src/views/TicketInboxView/index.tsx +30 -0
  179. package/src/views/TicketingSettingsView/client.tsx +866 -0
  180. package/src/views/TicketingSettingsView/index.tsx +33 -0
  181. package/src/views/TimeDashboardView/client.tsx +144 -0
  182. package/src/views/TimeDashboardView/index.tsx +33 -0
  183. package/src/views/shared/AdminViewHeader.tsx +69 -0
  184. package/src/views/shared/ErrorBoundary.tsx +68 -0
  185. package/src/views/shared/Skeleton.tsx +125 -0
  186. package/src/views/shared/adminTokens.ts +37 -0
  187. package/src/views/shared/config.ts +82 -0
  188. package/src/views/shared/index.ts +6 -0
  189. package/src/views.ts +16 -0
@@ -0,0 +1,84 @@
1
+ import type { Payload } from 'payload'
2
+ import type { CollectionSlugs } from './slugs'
3
+
4
+ /**
5
+ * Fire all active webhooks matching the given event.
6
+ * Uses fire-and-forget pattern (Promise.allSettled) so hook callers are never blocked.
7
+ * Each webhook request has a 10-second timeout.
8
+ */
9
+ export async function fireWebhooks(
10
+ payload: Payload,
11
+ slugs: CollectionSlugs,
12
+ event: string,
13
+ data: Record<string, unknown>,
14
+ ): Promise<void> {
15
+ try {
16
+ // Find all active webhook endpoints subscribed to this event
17
+ const endpoints = await payload.find({
18
+ collection: slugs.webhookEndpoints as any,
19
+ where: {
20
+ active: { equals: true },
21
+ events: { contains: event },
22
+ },
23
+ limit: 50,
24
+ depth: 0,
25
+ overrideAccess: true,
26
+ })
27
+
28
+ if (endpoints.docs.length === 0) return
29
+
30
+ const timestamp = new Date().toISOString()
31
+
32
+ // Fire each webhook in parallel (fire-and-forget)
33
+ await Promise.allSettled(
34
+ endpoints.docs.map(async (endpoint: any) => {
35
+ try {
36
+ const res = await fetch(endpoint.url, {
37
+ method: 'POST',
38
+ headers: {
39
+ 'Content-Type': 'application/json',
40
+ ...(endpoint.secret ? { 'X-Webhook-Secret': endpoint.secret } : {}),
41
+ },
42
+ body: JSON.stringify({ event, data, timestamp }),
43
+ signal: AbortSignal.timeout(10000),
44
+ })
45
+
46
+ // Update webhook metadata (best-effort, don't throw on failure)
47
+ try {
48
+ await payload.update({
49
+ collection: slugs.webhookEndpoints as any,
50
+ id: endpoint.id,
51
+ data: {
52
+ lastTriggeredAt: timestamp,
53
+ lastStatus: res.status,
54
+ },
55
+ overrideAccess: true,
56
+ })
57
+ } catch {
58
+ // Ignore metadata update failures
59
+ }
60
+ } catch (error) {
61
+ console.warn(`[support] Webhook delivery failed: ${endpoint.url}`, error)
62
+
63
+ // Record the failure
64
+ try {
65
+ await payload.update({
66
+ collection: slugs.webhookEndpoints as any,
67
+ id: endpoint.id,
68
+ data: {
69
+ lastTriggeredAt: timestamp,
70
+ lastStatus: 0,
71
+ },
72
+ overrideAccess: true,
73
+ })
74
+ } catch {
75
+ // Ignore metadata update failures
76
+ }
77
+ }
78
+ }),
79
+ )
80
+ } catch (error) {
81
+ // Never let webhook errors propagate to the caller
82
+ console.error('[support] Failed to fire webhooks:', error)
83
+ }
84
+ }
@@ -0,0 +1,22 @@
1
+ export { readSupportSettings, DEFAULT_SETTINGS } from './readSettings'
2
+ export type { SupportSettings } from './readSettings'
3
+ export { resolveSlugs, DEFAULT_SLUGS } from './slugs'
4
+ export type { CollectionSlugs } from './slugs'
5
+ export { RateLimiter } from './rateLimiter'
6
+ export { AuthError, requireAdmin, requireClient, handleAuthError } from './auth'
7
+ export { fireWebhooks } from './fireWebhooks'
8
+ export { createAdminNotification } from './adminNotification'
9
+ export { dispatchWebhook } from './webhookDispatcher'
10
+
11
+ export {
12
+ escapeHtml,
13
+ emailTrackingPixel,
14
+ emailRichContent,
15
+ emailButton,
16
+ emailQuote,
17
+ emailInfoRow,
18
+ emailParagraph,
19
+ emailWrapper,
20
+ createEmailTemplateFactory,
21
+ } from './emailTemplate'
22
+ export type { EmailTemplateConfig, EmailTemplateFactory } from './emailTemplate'
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Shared in-memory rate limiter with automatic cleanup.
3
+ * Replaces duplicated rate-limiting logic across endpoints.
4
+ */
5
+ export class RateLimiter {
6
+ private store = new Map<string, { count: number; resetAt: number }>()
7
+
8
+ constructor(
9
+ private windowMs: number,
10
+ private maxRequests: number,
11
+ ) {
12
+ // Periodic cleanup to prevent memory leaks
13
+ const timer = setInterval(() => this.cleanup(), windowMs)
14
+ timer.unref()
15
+ }
16
+
17
+ /**
18
+ * Check if a key has exceeded the rate limit.
19
+ * Returns true if the request should be blocked.
20
+ */
21
+ check(key: string): boolean {
22
+ const now = Date.now()
23
+ const entry = this.store.get(key)
24
+
25
+ if (!entry || now > entry.resetAt) {
26
+ this.store.set(key, { count: 1, resetAt: now + this.windowMs })
27
+ return false
28
+ }
29
+
30
+ entry.count++
31
+ return entry.count > this.maxRequests
32
+ }
33
+
34
+ /**
35
+ * Reset the counter for a specific key.
36
+ */
37
+ reset(key: string): void {
38
+ this.store.delete(key)
39
+ }
40
+
41
+ /**
42
+ * Remove expired entries from the store.
43
+ */
44
+ private cleanup(): void {
45
+ const now = Date.now()
46
+ for (const [key, entry] of this.store) {
47
+ if (now > entry.resetAt) {
48
+ this.store.delete(key)
49
+ }
50
+ }
51
+ }
52
+ }
@@ -0,0 +1,67 @@
1
+ import type { Payload } from 'payload'
2
+
3
+ const PREF_KEY = 'support-settings'
4
+ const USER_PREFS_KEY_PREFIX = 'support-user-prefs'
5
+
6
+ export interface SupportSettings {
7
+ email: { fromAddress: string; fromName: string; replyToAddress: string }
8
+ ai: { provider: string; model: string; enableSentiment: boolean; enableSynthesis: boolean; enableSuggestion: boolean; enableRewrite: boolean }
9
+ sla: { firstResponseMinutes: number; resolutionMinutes: number; businessHoursOnly: boolean; escalationEmail: string }
10
+ autoClose: { enabled: boolean; daysBeforeClose: number; reminderDaysBefore: number }
11
+ }
12
+
13
+ export interface UserPrefs {
14
+ locale: 'fr' | 'en'
15
+ signature: string
16
+ }
17
+
18
+ export const DEFAULT_SETTINGS: SupportSettings = {
19
+ email: { fromAddress: '', fromName: 'Support', replyToAddress: '' },
20
+ ai: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001', enableSentiment: true, enableSynthesis: true, enableSuggestion: true, enableRewrite: true },
21
+ sla: { firstResponseMinutes: 120, resolutionMinutes: 1440, businessHoursOnly: true, escalationEmail: '' },
22
+ autoClose: { enabled: true, daysBeforeClose: 7, reminderDaysBefore: 2 },
23
+ }
24
+
25
+ export const DEFAULT_USER_PREFS: UserPrefs = {
26
+ locale: 'fr',
27
+ signature: '',
28
+ }
29
+
30
+ export async function readSupportSettings(payload: Payload): Promise<SupportSettings> {
31
+ try {
32
+ const prefs = await payload.find({
33
+ collection: 'payload-preferences' as any,
34
+ where: { key: { equals: PREF_KEY } },
35
+ limit: 1, depth: 0, overrideAccess: true,
36
+ })
37
+ if (prefs.docs.length > 0) {
38
+ const stored = prefs.docs[0].value as Partial<SupportSettings>
39
+ return {
40
+ email: { ...DEFAULT_SETTINGS.email, ...stored.email },
41
+ ai: { ...DEFAULT_SETTINGS.ai, ...stored.ai },
42
+ sla: { ...DEFAULT_SETTINGS.sla, ...stored.sla },
43
+ autoClose: { ...DEFAULT_SETTINGS.autoClose, ...stored.autoClose },
44
+ }
45
+ }
46
+ } catch { /* fallback to defaults */ }
47
+ return { ...DEFAULT_SETTINGS }
48
+ }
49
+
50
+ export async function readUserPrefs(payload: Payload, userId: string | number): Promise<UserPrefs> {
51
+ try {
52
+ const key = `${USER_PREFS_KEY_PREFIX}-${userId}`
53
+ const prefs = await payload.find({
54
+ collection: 'payload-preferences' as any,
55
+ where: { key: { equals: key } },
56
+ limit: 1, depth: 0, overrideAccess: true,
57
+ })
58
+ if (prefs.docs.length > 0) {
59
+ const stored = prefs.docs[0].value as Partial<UserPrefs>
60
+ return {
61
+ locale: stored.locale || DEFAULT_USER_PREFS.locale,
62
+ signature: stored.signature ?? DEFAULT_USER_PREFS.signature,
63
+ }
64
+ }
65
+ } catch { /* fallback to defaults */ }
66
+ return { ...DEFAULT_USER_PREFS }
67
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Default collection slugs used by the support plugin.
3
+ * All slugs can be overridden via SupportPluginConfig.collectionSlugs.
4
+ */
5
+ export interface CollectionSlugs {
6
+ tickets: string
7
+ ticketMessages: string
8
+ supportClients: string
9
+ timeEntries: string
10
+ cannedResponses: string
11
+ ticketActivityLog: string
12
+ satisfactionSurveys: string
13
+ knowledgeBase: string
14
+ chatMessages: string
15
+ pendingEmails: string
16
+ emailLogs: string
17
+ authLogs: string
18
+ webhookEndpoints: string
19
+ slaPolicies: string
20
+ macros: string
21
+ ticketStatuses: string
22
+ users: string
23
+ media: string
24
+ }
25
+
26
+ export const DEFAULT_SLUGS: CollectionSlugs = {
27
+ tickets: 'tickets',
28
+ ticketMessages: 'ticket-messages',
29
+ supportClients: 'support-clients',
30
+ timeEntries: 'time-entries',
31
+ cannedResponses: 'canned-responses',
32
+ ticketActivityLog: 'ticket-activity-log',
33
+ satisfactionSurveys: 'satisfaction-surveys',
34
+ knowledgeBase: 'knowledge-base',
35
+ chatMessages: 'chat-messages',
36
+ pendingEmails: 'pending-emails',
37
+ emailLogs: 'email-logs',
38
+ authLogs: 'auth-logs',
39
+ webhookEndpoints: 'webhook-endpoints',
40
+ slaPolicies: 'sla-policies',
41
+ macros: 'macros',
42
+ ticketStatuses: 'ticket-statuses',
43
+ users: 'users',
44
+ media: 'media',
45
+ }
46
+
47
+ /**
48
+ * Resolve collection slugs merging user overrides with defaults.
49
+ */
50
+ export function resolveSlugs(
51
+ overrides?: Partial<CollectionSlugs>,
52
+ ): CollectionSlugs {
53
+ return { ...DEFAULT_SLUGS, ...overrides }
54
+ }
@@ -0,0 +1,120 @@
1
+ import crypto from 'crypto'
2
+ import type { BasePayload } from 'payload'
3
+ import type { CollectionSlugs } from './slugs'
4
+
5
+ type WebhookEvent = 'ticket_created' | 'ticket_resolved' | 'ticket_replied' | 'sla_breached'
6
+
7
+ /**
8
+ * Dispatch outbound webhooks for a given event.
9
+ * Fetches all active webhook endpoints matching the event,
10
+ * POSTs JSON to each with optional HMAC-SHA256 signature.
11
+ * Fire-and-forget: errors are logged but never thrown.
12
+ *
13
+ * @param data - Payload data to send
14
+ * @param event - Webhook event type
15
+ * @param payload - Payload instance
16
+ * @param slugs - Collection slugs for dynamic collection references
17
+ */
18
+ export function dispatchWebhook(
19
+ data: Record<string, unknown>,
20
+ event: WebhookEvent,
21
+ payload: BasePayload,
22
+ slugs: CollectionSlugs,
23
+ ): void {
24
+ // Fire and forget — do not await
25
+ void _dispatch(data, event, payload, slugs)
26
+ }
27
+
28
+ async function _dispatch(
29
+ data: Record<string, unknown>,
30
+ event: WebhookEvent,
31
+ payload: BasePayload,
32
+ slugs: CollectionSlugs,
33
+ ): Promise<void> {
34
+ try {
35
+ const { docs: endpoints } = await payload.find({
36
+ collection: slugs.webhookEndpoints as any,
37
+ where: {
38
+ and: [
39
+ { active: { equals: true } },
40
+ { events: { contains: event } },
41
+ ],
42
+ },
43
+ limit: 50,
44
+ depth: 0,
45
+ overrideAccess: true,
46
+ })
47
+
48
+ if (endpoints.length === 0) return
49
+
50
+ const body = JSON.stringify({ event, data, timestamp: new Date().toISOString() })
51
+
52
+ for (const endpoint of endpoints) {
53
+ void _sendToEndpoint(endpoint as any, body, payload, slugs)
54
+ }
55
+ } catch (err) {
56
+ console.error(`[webhook] Failed to fetch endpoints for event ${event}:`, err)
57
+ }
58
+ }
59
+
60
+ async function _sendToEndpoint(
61
+ endpoint: { id: number | string; url: string; secret?: string | null; name?: string | null },
62
+ body: string,
63
+ payload: BasePayload,
64
+ slugs: CollectionSlugs,
65
+ ): Promise<void> {
66
+ try {
67
+ const headers: Record<string, string> = {
68
+ 'Content-Type': 'application/json',
69
+ 'User-Agent': 'PayloadSupport-Webhook/1.0',
70
+ }
71
+
72
+ // Sign payload with HMAC-SHA256 if secret is configured
73
+ if (endpoint.secret) {
74
+ const signature = crypto
75
+ .createHmac('sha256', endpoint.secret)
76
+ .update(body)
77
+ .digest('hex')
78
+ headers['X-Webhook-Signature'] = signature
79
+ }
80
+
81
+ const response = await fetch(endpoint.url, {
82
+ method: 'POST',
83
+ headers,
84
+ body,
85
+ signal: AbortSignal.timeout(10000), // 10s timeout
86
+ })
87
+
88
+ // Update last triggered info on the endpoint
89
+ await payload.update({
90
+ collection: slugs.webhookEndpoints as any,
91
+ id: endpoint.id,
92
+ data: {
93
+ lastTriggeredAt: new Date().toISOString(),
94
+ lastStatus: response.status,
95
+ },
96
+ overrideAccess: true,
97
+ })
98
+
99
+ if (!response.ok) {
100
+ console.warn(`[webhook] ${endpoint.name || endpoint.url} returned ${response.status}`)
101
+ }
102
+ } catch (err) {
103
+ console.error(`[webhook] Failed to call ${endpoint.name || endpoint.url}:`, err)
104
+
105
+ // Still update with error status
106
+ try {
107
+ await payload.update({
108
+ collection: slugs.webhookEndpoints as any,
109
+ id: endpoint.id,
110
+ data: {
111
+ lastTriggeredAt: new Date().toISOString(),
112
+ lastStatus: 0,
113
+ },
114
+ overrideAccess: true,
115
+ })
116
+ } catch {
117
+ // Ignore secondary error
118
+ }
119
+ }
120
+ }
@@ -0,0 +1,137 @@
1
+ 'use client'
2
+
3
+ import React, { useState, useCallback } from 'react'
4
+ import { useTranslation } from '../../components/TicketConversation/hooks/useTranslation'
5
+ import s from '../../styles/BillingView.module.scss'
6
+
7
+ interface BillingEntry { duration: number; description: string; date: string }
8
+ interface BillingTicket { id: number; ticketNumber: string; subject: string; entries: BillingEntry[]; totalMinutes: number; billedAmount: number | null }
9
+ interface BillingGroup { project: { id: number; name: string } | null; client: { company: string } | null; tickets: BillingTicket[]; totalMinutes: number; totalBilledAmount: number }
10
+ interface BillingData { groups: BillingGroup[]; grandTotalMinutes: number; grandTotalBilledAmount: number }
11
+ interface ProjectOption { id: number; name: string }
12
+
13
+ function formatDuration(minutes: number): string { const h = Math.floor(minutes / 60); const m = minutes % 60; if (h === 0) return `${m}min`; if (m === 0) return `${h}h`; return `${h}h ${m}min` }
14
+ function formatAmount(minutes: number, rate: number): string { return ((minutes / 60) * rate).toFixed(2) }
15
+ function getMonthRange(offset: number): { from: string; to: string } { const now = new Date(); const start = new Date(now.getFullYear(), now.getMonth() + offset, 1); const end = new Date(now.getFullYear(), now.getMonth() + offset + 1, 0); return { from: start.toISOString().split('T')[0], to: end.toISOString().split('T')[0] } }
16
+ function getQuarterRange(offset: number): { from: string; to: string } { const now = new Date(); const q = Math.floor(now.getMonth() / 3) + offset; const start = new Date(now.getFullYear(), q * 3, 1); const end = new Date(now.getFullYear(), q * 3 + 3, 0); return { from: start.toISOString().split('T')[0], to: end.toISOString().split('T')[0] } }
17
+
18
+ export const BillingClient: React.FC = () => {
19
+ const { t } = useTranslation()
20
+ const [from, setFrom] = useState(() => getMonthRange(0).from)
21
+ const [to, setTo] = useState(() => getMonthRange(0).to)
22
+ const [projectId, setProjectId] = useState('')
23
+ const [rate, setRate] = useState(60)
24
+ const [data, setData] = useState<BillingData | null>(null)
25
+ const [loading, setLoading] = useState(false)
26
+ const [projects, setProjects] = useState<ProjectOption[]>([])
27
+ const [projectsLoaded, setProjectsLoaded] = useState(false)
28
+ const [copied, setCopied] = useState(false)
29
+
30
+ const loadProjects = useCallback(async () => {
31
+ if (projectsLoaded) return
32
+ try { const res = await fetch('/api/projects?limit=100&depth=0&sort=name'); if (res.ok) { const json = await res.json(); setProjects(json.docs?.map((p: { id: number; name: string }) => ({ id: p.id, name: p.name })) || []) } } catch (err) { console.warn('[support] loadProjects error:', err) }
33
+ setProjectsLoaded(true)
34
+ }, [projectsLoaded])
35
+
36
+ React.useEffect(() => { loadProjects() }, [loadProjects])
37
+
38
+ const fetchBilling = useCallback(async () => {
39
+ setLoading(true)
40
+ try { const params = new URLSearchParams({ from, to }); if (projectId) params.set('projectId', projectId); const res = await fetch(`/api/support/billing?${params}`); if (res.ok) setData(await res.json()) } catch (err) { console.warn('[support] fetchBilling error:', err) }
41
+ setLoading(false)
42
+ }, [from, to, projectId])
43
+
44
+ const setPeriod = (range: { from: string; to: string }) => { setFrom(range.from); setTo(range.to) }
45
+
46
+ const copyRecap = useCallback(() => {
47
+ if (!data) return
48
+ const lines: string[] = [`PRE-FACTURATION -- Du ${from} au ${to}`, `Taux horaire : ${rate} EUR/h`, '='.repeat(50)]
49
+ for (const group of data.groups) {
50
+ lines.push('', `PROJET : ${group.project?.name || 'Sans projet'}`)
51
+ if (group.client?.company) lines.push(`Client : ${group.client.company}`)
52
+ for (const ticket of group.tickets) {
53
+ lines.push(` ${ticket.ticketNumber} -- ${ticket.subject}`)
54
+ for (const entry of ticket.entries) lines.push(` ${entry.date} | ${formatDuration(entry.duration)} | ${entry.description || '-'}`)
55
+ lines.push(` Sous-total : ${formatDuration(ticket.totalMinutes)} = ${formatAmount(ticket.totalMinutes, rate)} EUR`)
56
+ }
57
+ }
58
+ lines.push('', '='.repeat(50), `TOTAL : ${formatDuration(data.grandTotalMinutes)} = ${formatAmount(data.grandTotalMinutes, rate)} EUR`)
59
+ navigator.clipboard.writeText(lines.join('\n'))
60
+ setCopied(true); setTimeout(() => setCopied(false), 2000)
61
+ }, [data, from, to, rate])
62
+
63
+ const S: Record<string, React.CSSProperties> = {
64
+ page: { padding: '20px 30px', maxWidth: 1100, margin: '0 auto' },
65
+ filters: { marginBottom: 20 },
66
+ quickPeriod: { display: 'flex', gap: 6, marginBottom: 8 },
67
+ btn: { padding: '6px 12px', borderRadius: 6, border: '1px solid var(--theme-elevation-200)', fontSize: 12, cursor: 'pointer', background: 'var(--theme-elevation-0)', color: 'var(--theme-text)' },
68
+ btnPrimary: { padding: '6px 12px', borderRadius: 6, border: 'none', fontSize: 12, cursor: 'pointer', background: '#2563eb', color: '#fff', fontWeight: 600 },
69
+ filterRow: { display: 'flex', gap: 12, alignItems: 'flex-end', flexWrap: 'wrap' as const },
70
+ fieldGroup: { display: 'flex', flexDirection: 'column' as const, gap: 4 },
71
+ label: { fontSize: 11, fontWeight: 600, color: 'var(--theme-elevation-500)' },
72
+ input: { padding: '6px 10px', borderRadius: 6, border: '1px solid var(--theme-elevation-200)', fontSize: 12, color: 'var(--theme-text)', background: 'var(--theme-elevation-0)' },
73
+ select: { padding: '6px 10px', borderRadius: 6, border: '1px solid var(--theme-elevation-200)', fontSize: 12, color: 'var(--theme-text)', background: 'var(--theme-elevation-0)' },
74
+ groupCard: { marginBottom: 16, borderRadius: 10, border: '1px solid var(--theme-elevation-150)', overflow: 'hidden' },
75
+ groupHeader: { display: 'flex', justifyContent: 'space-between', padding: '12px 16px', background: 'var(--theme-elevation-50)', borderBottom: '1px solid var(--theme-elevation-150)' },
76
+ table: { width: '100%', borderCollapse: 'collapse' as const, fontSize: 12 },
77
+ th: { textAlign: 'left' as const, padding: '6px 8px', borderBottom: '1px solid var(--theme-elevation-200)', fontSize: 11, color: 'var(--theme-elevation-500)' },
78
+ td: { padding: '6px 8px', borderBottom: '1px solid var(--theme-elevation-100)' },
79
+ grandTotal: { padding: 16, borderRadius: 10, border: '2px solid #2563eb', display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: 16 },
80
+ }
81
+
82
+ return (
83
+ <div style={S.page}>
84
+ <div style={{ marginBottom: 16 }}>
85
+ <h1 style={{ fontSize: 22, fontWeight: 700, margin: 0 }}>{t('billing.title')}</h1>
86
+ <p style={{ fontSize: 13, color: 'var(--theme-elevation-500)', margin: '4px 0 0' }}>{t('billing.subtitle')}</p>
87
+ </div>
88
+
89
+ <div style={S.filters}>
90
+ <div style={S.quickPeriod}>
91
+ <button style={S.btnPrimary} onClick={() => setPeriod(getMonthRange(0))}>{t('billing.filters.thisMonth')}</button>
92
+ <button style={S.btn} onClick={() => setPeriod(getMonthRange(-1))}>{t('billing.filters.lastMonth')}</button>
93
+ <button style={S.btn} onClick={() => setPeriod(getQuarterRange(0))}>{t('billing.filters.thisQuarter')}</button>
94
+ </div>
95
+ <div style={S.filterRow}>
96
+ <div style={S.fieldGroup}><label style={S.label}>{t('billing.filters.from')}</label><input type="date" value={from} onChange={(e) => setFrom(e.target.value)} style={S.input} /></div>
97
+ <div style={S.fieldGroup}><label style={S.label}>{t('billing.filters.to')}</label><input type="date" value={to} onChange={(e) => setTo(e.target.value)} style={S.input} /></div>
98
+ <div style={S.fieldGroup}><label style={S.label}>{t('billing.filters.project')}</label><select value={projectId} onChange={(e) => setProjectId(e.target.value)} style={S.select}><option value="">{t('common.all')}</option>{projects.map((p) => <option key={p.id} value={p.id}>{p.name}</option>)}</select></div>
99
+ <div style={S.fieldGroup}><label style={S.label}>{t('billing.filters.hourlyRate')}</label><div style={{ display: 'flex', gap: 4, alignItems: 'center' }}><input type="number" value={rate} onChange={(e) => setRate(Number(e.target.value))} style={{ ...S.input, width: 70 }} min={0} /><span style={{ fontSize: 11 }}>{t('billing.filters.rateUnit')}</span></div></div>
100
+ <button style={S.btnPrimary} onClick={fetchBilling} disabled={loading}>{loading ? t('billing.filters.loading') : t('billing.filters.load')}</button>
101
+ </div>
102
+ </div>
103
+
104
+ {data && (
105
+ <>
106
+ {data.groups.length === 0 ? <div style={{ padding: 40, textAlign: 'center', color: '#94a3b8' }}>{t('billing.empty')}</div> : (
107
+ <>
108
+ {data.groups.map((group, gi) => (
109
+ <div key={gi} style={S.groupCard}>
110
+ <div style={S.groupHeader}>
111
+ <div><span style={{ fontWeight: 700 }}>{group.project?.name || 'Sans projet'}</span>{group.client?.company && <span style={{ color: 'var(--theme-elevation-500)' }}> -- {group.client.company}</span>}</div>
112
+ <div style={{ textAlign: 'right' }}><div style={{ fontWeight: 700 }}>{formatDuration(group.totalMinutes)}</div><div style={{ fontSize: 12, color: '#2563eb', fontWeight: 600 }}>{formatAmount(group.totalMinutes, rate)} EUR</div></div>
113
+ </div>
114
+ <table style={S.table}>
115
+ <thead><tr><th style={S.th}>{t('billing.table.ticketNumber')}</th><th style={S.th}>{t('billing.table.subject')}</th><th style={S.th}>{t('billing.table.date')}</th><th style={S.th}>{t('billing.table.duration')}</th><th style={S.th}>{t('billing.table.description')}</th><th style={{ ...S.th, textAlign: 'right' }}>{t('billing.table.amount')}</th></tr></thead>
116
+ <tbody>
117
+ {group.tickets.map((ticket) => ticket.entries.map((entry, ei) => (
118
+ <tr key={`${ticket.id}-${ei}`}>
119
+ {ei === 0 && <><td style={{ ...S.td, fontWeight: 600 }} rowSpan={ticket.entries.length}><a href={`/admin/support/ticket?id=${ticket.id}`} style={{ color: '#2563eb', textDecoration: 'none' }}>{ticket.ticketNumber}</a></td><td style={S.td} rowSpan={ticket.entries.length}>{ticket.subject}</td></>}
120
+ <td style={S.td}>{entry.date}</td><td style={S.td}>{formatDuration(entry.duration)}</td><td style={S.td}>{entry.description || '-'}</td><td style={{ ...S.td, textAlign: 'right', fontWeight: 600 }}>{formatAmount(entry.duration, rate)} EUR</td>
121
+ </tr>
122
+ )))}
123
+ </tbody>
124
+ </table>
125
+ </div>
126
+ ))}
127
+ <div style={S.grandTotal}>
128
+ <div><div style={{ fontSize: 12, color: 'var(--theme-elevation-500)' }}>{t('billing.totals.billableTicketsPlural', { count: String(data.groups.reduce((s, g) => s + g.tickets.length, 0)) })}</div><div style={{ fontSize: 18, fontWeight: 700 }}>{t('billing.totals.total')} : {formatDuration(data.grandTotalMinutes)} = {formatAmount(data.grandTotalMinutes, rate)} EUR</div></div>
129
+ <button style={S.btnPrimary} onClick={copyRecap}>{copied ? t('billing.totals.copiedRecap') : t('billing.totals.copyRecap')}</button>
130
+ </div>
131
+ </>
132
+ )}
133
+ </>
134
+ )}
135
+ </div>
136
+ )
137
+ }
@@ -0,0 +1,33 @@
1
+ import type { AdminViewServerProps } from 'payload'
2
+ import { DefaultTemplate } from '@payloadcms/next/templates'
3
+ import { redirect } from 'next/navigation'
4
+ import React from 'react'
5
+ import { AdminErrorBoundary } from '../shared/ErrorBoundary'
6
+ import { BillingClient } from './client'
7
+
8
+ export const BillingView: React.FC<AdminViewServerProps> = ({ initPageResult }) => {
9
+ const { req, visibleEntities } = initPageResult
10
+
11
+ if (!req.user) {
12
+ redirect('/admin/login')
13
+ }
14
+
15
+ return (
16
+ <DefaultTemplate
17
+ i18n={req.i18n}
18
+ locale={initPageResult.locale}
19
+ params={{}}
20
+ payload={req.payload}
21
+ permissions={initPageResult.permissions}
22
+ searchParams={{}}
23
+ user={req.user}
24
+ visibleEntities={visibleEntities}
25
+ >
26
+ <AdminErrorBoundary viewName="BillingView">
27
+ <BillingClient />
28
+ </AdminErrorBoundary>
29
+ </DefaultTemplate>
30
+ )
31
+ }
32
+
33
+ export default BillingView