@hed-hog/core 0.0.349 → 0.0.350

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 (126) hide show
  1. package/dist/auth/auth.controller.d.ts +3 -3
  2. package/dist/auth/auth.service.d.ts +8 -8
  3. package/dist/core.module.d.ts.map +1 -1
  4. package/dist/core.module.js +3 -0
  5. package/dist/core.module.js.map +1 -1
  6. package/dist/index.d.ts +3 -0
  7. package/dist/index.d.ts.map +1 -1
  8. package/dist/index.js +4 -0
  9. package/dist/index.js.map +1 -1
  10. package/dist/mcp/guards/mcp-auth.guard.d.ts +6 -1
  11. package/dist/mcp/guards/mcp-auth.guard.d.ts.map +1 -1
  12. package/dist/mcp/guards/mcp-auth.guard.js +29 -2
  13. package/dist/mcp/guards/mcp-auth.guard.js.map +1 -1
  14. package/dist/mcp/mcp-api-key.controller.d.ts +32 -0
  15. package/dist/mcp/mcp-api-key.controller.d.ts.map +1 -0
  16. package/dist/mcp/mcp-api-key.controller.js +64 -0
  17. package/dist/mcp/mcp-api-key.controller.js.map +1 -0
  18. package/dist/mcp/mcp-api-key.service.d.ts +27 -0
  19. package/dist/mcp/mcp-api-key.service.d.ts.map +1 -0
  20. package/dist/mcp/mcp-api-key.service.js +74 -0
  21. package/dist/mcp/mcp-api-key.service.js.map +1 -0
  22. package/dist/mcp/mcp-registry.service.d.ts +1 -2
  23. package/dist/mcp/mcp-registry.service.d.ts.map +1 -1
  24. package/dist/mcp/mcp-registry.service.js +6 -2
  25. package/dist/mcp/mcp-registry.service.js.map +1 -1
  26. package/dist/mcp/mcp.module.d.ts.map +1 -1
  27. package/dist/mcp/mcp.module.js +8 -1
  28. package/dist/mcp/mcp.module.js.map +1 -1
  29. package/dist/mcp-chat/mcp-chat.controller.d.ts +4 -0
  30. package/dist/mcp-chat/mcp-chat.controller.d.ts.map +1 -1
  31. package/dist/mcp-chat/mcp-chat.controller.js +46 -0
  32. package/dist/mcp-chat/mcp-chat.controller.js.map +1 -1
  33. package/dist/mcp-chat/mcp-chat.service.d.ts +6 -1
  34. package/dist/mcp-chat/mcp-chat.service.d.ts.map +1 -1
  35. package/dist/mcp-chat/mcp-chat.service.js +125 -17
  36. package/dist/mcp-chat/mcp-chat.service.js.map +1 -1
  37. package/dist/notification/dto/create-notification.dto.d.ts +26 -0
  38. package/dist/notification/dto/create-notification.dto.d.ts.map +1 -0
  39. package/dist/notification/dto/create-notification.dto.js +86 -0
  40. package/dist/notification/dto/create-notification.dto.js.map +1 -0
  41. package/dist/notification/dto/update-progress.dto.d.ts +6 -0
  42. package/dist/notification/dto/update-progress.dto.d.ts.map +1 -0
  43. package/dist/notification/dto/update-progress.dto.js +33 -0
  44. package/dist/notification/dto/update-progress.dto.js.map +1 -0
  45. package/dist/notification/notification.controller.d.ts +108 -0
  46. package/dist/notification/notification.controller.d.ts.map +1 -0
  47. package/dist/notification/notification.controller.js +118 -0
  48. package/dist/notification/notification.controller.js.map +1 -0
  49. package/dist/notification/notification.module.d.ts +3 -0
  50. package/dist/notification/notification.module.d.ts.map +1 -0
  51. package/dist/notification/notification.module.js +29 -0
  52. package/dist/notification/notification.module.js.map +1 -0
  53. package/dist/notification/notification.service.d.ts +98 -0
  54. package/dist/notification/notification.service.d.ts.map +1 -0
  55. package/dist/notification/notification.service.js +123 -0
  56. package/dist/notification/notification.service.js.map +1 -0
  57. package/dist/profile/profile.controller.d.ts +1 -1
  58. package/dist/profile/profile.service.d.ts +1 -1
  59. package/dist/session/session.controller.d.ts +1 -1
  60. package/dist/session/session.service.d.ts +3 -3
  61. package/dist/setting/dto/update-setting-list.dto.d.ts +9 -0
  62. package/dist/setting/dto/update-setting-list.dto.d.ts.map +1 -0
  63. package/dist/setting/dto/update-setting-list.dto.js +44 -0
  64. package/dist/setting/dto/update-setting-list.dto.js.map +1 -0
  65. package/dist/setting/setting.controller.d.ts +19 -0
  66. package/dist/setting/setting.controller.d.ts.map +1 -1
  67. package/dist/setting/setting.controller.js +12 -0
  68. package/dist/setting/setting.controller.js.map +1 -1
  69. package/dist/setting/setting.service.d.ts +19 -0
  70. package/dist/setting/setting.service.d.ts.map +1 -1
  71. package/dist/setting/setting.service.js +79 -0
  72. package/dist/setting/setting.service.js.map +1 -1
  73. package/dist/task/task.service.d.ts +12 -0
  74. package/dist/task/task.service.d.ts.map +1 -1
  75. package/dist/task/task.service.js +60 -0
  76. package/dist/task/task.service.js.map +1 -1
  77. package/dist/user/user.controller.d.ts +2 -2
  78. package/dist/user/user.service.d.ts +6 -6
  79. package/dist/webhook-integration/webhook-integration.service.d.ts +4 -0
  80. package/dist/webhook-integration/webhook-integration.service.d.ts.map +1 -1
  81. package/dist/webhook-integration/webhook-integration.service.js +184 -34
  82. package/dist/webhook-integration/webhook-integration.service.js.map +1 -1
  83. package/dist/webhook-integration/webhook-utils.d.ts +1 -0
  84. package/dist/webhook-integration/webhook-utils.d.ts.map +1 -1
  85. package/dist/webhook-integration/webhook-utils.js +38 -0
  86. package/dist/webhook-integration/webhook-utils.js.map +1 -1
  87. package/hedhog/data/menu.yaml +48 -4
  88. package/hedhog/data/route.yaml +133 -0
  89. package/hedhog/data/setting_group.yaml +45 -0
  90. package/hedhog/data/setting_subgroup.yaml +16 -0
  91. package/hedhog/frontend/app/account/components/mcp-tokens.tsx.ejs +285 -0
  92. package/hedhog/frontend/app/account/layout.tsx.ejs +32 -55
  93. package/hedhog/frontend/app/account/mcp-tokens/page.tsx.ejs +5 -0
  94. package/hedhog/frontend/app/integrations-webhooks/page.tsx.ejs +2098 -1835
  95. package/hedhog/frontend/app/mcp_chat/chat-components.tsx.ejs +595 -0
  96. package/hedhog/frontend/app/mcp_chat/chat-types.ts.ejs +16 -0
  97. package/hedhog/frontend/app/mcp_chat/page.tsx.ejs +554 -383
  98. package/hedhog/frontend/app/setting-list/page.tsx.ejs +649 -0
  99. package/hedhog/frontend/app/users/page.tsx.ejs +445 -203
  100. package/hedhog/frontend/app/users/user-sheet.tsx.ejs +16 -37
  101. package/hedhog/frontend/messages/en.json +241 -2
  102. package/hedhog/frontend/messages/pt.json +241 -2
  103. package/hedhog/table/mcp_api_key.yaml +30 -0
  104. package/hedhog/table/notification.yaml +72 -0
  105. package/hedhog/table/webhook_action_log.yaml +49 -0
  106. package/package.json +4 -4
  107. package/src/core.module.ts +3 -0
  108. package/src/index.ts +5 -0
  109. package/src/mcp/guards/mcp-auth.guard.ts +64 -32
  110. package/src/mcp/mcp-api-key.controller.ts +42 -0
  111. package/src/mcp/mcp-api-key.service.ts +66 -0
  112. package/src/mcp/mcp-registry.service.ts +75 -69
  113. package/src/mcp/mcp.module.ts +43 -36
  114. package/src/mcp-chat/mcp-chat.controller.ts +113 -60
  115. package/src/mcp-chat/mcp-chat.service.ts +494 -332
  116. package/src/notification/dto/create-notification.dto.ts +71 -0
  117. package/src/notification/dto/update-progress.dto.ts +16 -0
  118. package/src/notification/notification.controller.ts +71 -0
  119. package/src/notification/notification.module.ts +16 -0
  120. package/src/notification/notification.service.ts +124 -0
  121. package/src/setting/dto/update-setting-list.dto.ts +32 -0
  122. package/src/setting/setting.controller.ts +9 -0
  123. package/src/setting/setting.service.ts +91 -0
  124. package/src/task/task.service.ts +72 -0
  125. package/src/webhook-integration/webhook-integration.service.ts +934 -735
  126. package/src/webhook-integration/webhook-utils.ts +136 -79
@@ -1,1835 +1,2098 @@
1
- 'use client';
2
-
3
- import {
4
- EmptyState,
5
- Page,
6
- PageHeader,
7
- PaginationFooter,
8
- SearchBar,
9
- } from '@/components/entity-list';
10
- import { CopyButton } from '@/components/copy-button';
11
- import {
12
- AlertDialog,
13
- AlertDialogAction,
14
- AlertDialogCancel,
15
- AlertDialogContent,
16
- AlertDialogDescription,
17
- AlertDialogFooter,
18
- AlertDialogHeader,
19
- AlertDialogTitle,
20
- } from '@/components/ui/alert-dialog';
21
- import { Badge } from '@/components/ui/badge';
22
- import { Button } from '@/components/ui/button';
23
- import { Checkbox } from '@/components/ui/checkbox';
24
- import { Input } from '@/components/ui/input';
25
- import { Label } from '@/components/ui/label';
26
- import {
27
- Select,
28
- SelectContent,
29
- SelectItem,
30
- SelectTrigger,
31
- SelectValue,
32
- } from '@/components/ui/select';
33
- import {
34
- Sheet,
35
- SheetContent,
36
- SheetDescription,
37
- SheetHeader,
38
- SheetTitle,
39
- } from '@/components/ui/sheet';
40
- import { Switch } from '@/components/ui/switch';
41
- import {
42
- Table,
43
- TableBody,
44
- TableCell,
45
- TableHead,
46
- TableHeader,
47
- TableRow,
48
- } from '@/components/ui/table';
49
- import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
50
- import { Textarea } from '@/components/ui/textarea';
51
- import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
52
- import { usePagination } from '@/hooks/use-pagination';
53
- import { formatDate } from '@/lib/format-date';
54
- import { useApp, useQuery } from '@hed-hog/next-app-provider';
55
- import {
56
- Activity,
57
- BookOpenText,
58
- Pencil,
59
- Plus,
60
- RotateCcw,
61
- Send,
62
- Trash2,
63
- Webhook,
64
- } from 'lucide-react';
65
- import { useTranslations } from 'next-intl';
66
- import { type ReactNode, useMemo, useState } from 'react';
67
- import { toast } from 'sonner';
68
-
69
- type Status = 'active' | 'inactive';
70
-
71
- type EventCatalog = {
72
- id: number;
73
- slug: string;
74
- name: string;
75
- module: string;
76
- description?: string | null;
77
- status: Status;
78
- updated_at: string;
79
- };
80
-
81
- type IncomingAction = {
82
- id: number;
83
- type: 'email' | 'whatsapp_evolution';
84
- name: string;
85
- order: number;
86
- status: Status;
87
- mail_profile_id?: number | null;
88
- mail_id?: number | null;
89
- email_to?: string | null;
90
- email_cc?: string | null;
91
- email_bcc?: string | null;
92
- whatsapp_target_type?: 'phone' | 'group' | null;
93
- whatsapp_target?: string | null;
94
- whatsapp_template?: string | null;
95
- whatsapp_instance?: string | null;
96
- whatsapp_base_url?: string | null;
97
- whatsapp_token?: string | null;
98
- };
99
-
100
- type IncomingIntegration = {
101
- id: number;
102
- slug: string;
103
- name: string;
104
- description?: string | null;
105
- public_uuid: string;
106
- public_url?: string | null;
107
- status: Status;
108
- require_token: boolean;
109
- allowed_ips?: unknown;
110
- body_schema?: unknown;
111
- webhook_action?: IncomingAction[];
112
- updated_at: string;
113
- plainToken?: string | null;
114
- };
115
-
116
- type EventWebhook = {
117
- id: number;
118
- slug: string;
119
- name: string;
120
- description?: string | null;
121
- status: Status;
122
- priority: number;
123
- url: string;
124
- method: string;
125
- headers?: unknown;
126
- query?: unknown;
127
- payload?: unknown;
128
- timeout_ms: number;
129
- retry_count: number;
130
- event_webhook_event?: Array<{
131
- integration_event_catalog: EventCatalog;
132
- }>;
133
- updated_at: string;
134
- };
135
-
136
- type SelectOption = {
137
- id: number;
138
- slug: string;
139
- name?: string;
140
- subject?: string;
141
- provider?: string;
142
- module?: string;
143
- };
144
-
145
- const statusClass = {
146
- active: 'border-green-500/20 bg-green-500/10 text-green-600',
147
- inactive: 'border-slate-500/20 bg-slate-500/10 text-slate-600',
148
- };
149
-
150
- const defaultCatalogForm = {
151
- slug: '',
152
- name: '',
153
- module: '',
154
- description: '',
155
- status: 'active' as Status,
156
- };
157
-
158
- const defaultIncomingForm = {
159
- slug: '',
160
- name: '',
161
- description: '',
162
- status: 'active' as Status,
163
- require_token: false,
164
- allowed_ips: '',
165
- body_schema: '',
166
- };
167
-
168
- const defaultActionForm = {
169
- type: 'email' as IncomingAction['type'],
170
- name: '',
171
- order: 0,
172
- status: 'active' as Status,
173
- mail_profile_id: '',
174
- mail_id: '',
175
- email_to: '',
176
- email_cc: '',
177
- email_bcc: '',
178
- whatsapp_target_type: 'phone',
179
- whatsapp_target: '',
180
- whatsapp_template: '',
181
- whatsapp_instance: '',
182
- whatsapp_base_url: '',
183
- whatsapp_token: '',
184
- };
185
-
186
- const defaultEventWebhookForm = {
187
- slug: '',
188
- name: '',
189
- description: '',
190
- status: 'active' as Status,
191
- priority: 0,
192
- url: '',
193
- method: 'POST',
194
- headers: '{\n "Content-Type": "application/json"\n}',
195
- query: '{}',
196
- payload:
197
- '{\n "eventName": "{{eventName}}",\n "aggregateId": "{{aggregateId}}",\n "sourceModule": "{{sourceModule}}"\n}',
198
- timeout_ms: 10000,
199
- retry_count: 0,
200
- event_ids: [] as number[],
201
- };
202
-
203
- function stringifyJson(value: unknown) {
204
- if (!value) return '';
205
- if (typeof value === 'string') return value;
206
- return JSON.stringify(value, null, 2);
207
- }
208
-
209
- function parseJsonText(value: string) {
210
- const trimmed = value.trim();
211
- if (!trimmed) return null;
212
- return JSON.parse(trimmed);
213
- }
214
-
215
- function parseStringArrayText(value: string) {
216
- try {
217
- const parsed = parseJsonText(value);
218
- if (!Array.isArray(parsed)) return [];
219
- return parsed.filter((item): item is string => typeof item === 'string');
220
- } catch {
221
- return [];
222
- }
223
- }
224
-
225
- function stringifyStringArray(values: string[]) {
226
- const filtered = values.map((value) => value.trim()).filter(Boolean);
227
- return filtered.length ? JSON.stringify(filtered, null, 2) : '';
228
- }
229
-
230
- function getPublicWebhookUrl(uuid: string, publicUrl?: string | null) {
231
- if (publicUrl) return publicUrl;
232
- const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || '';
233
- return `${baseUrl.replace(/\/+$/, '')}/webhook/${uuid}`;
234
- }
235
-
236
- export default function IntegrationsWebhooksPage() {
237
- const t = useTranslations('core.IntegrationsWebhooksPage');
238
- const { request, currentLocaleCode, getSettingValue } = useApp();
239
-
240
- const catalog = usePagination({ url: '/integration-event-catalog' });
241
- const incoming = usePagination({ url: '/webhook-integration' });
242
- const eventWebhooks = usePagination({ url: '/event-webhook' });
243
-
244
- const [activeTab, setActiveTab] = useState('catalog');
245
- const [catalogOpen, setCatalogOpen] = useState(false);
246
- const [catalogEdit, setCatalogEdit] = useState<EventCatalog | null>(null);
247
- const [catalogForm, setCatalogForm] = useState(defaultCatalogForm);
248
-
249
- const [incomingOpen, setIncomingOpen] = useState(false);
250
- const [incomingEdit, setIncomingEdit] = useState<IncomingIntegration | null>(
251
- null,
252
- );
253
- const [incomingForm, setIncomingForm] = useState(defaultIncomingForm);
254
- const [oneTimeToken, setOneTimeToken] = useState<string | null>(null);
255
- const [allowedIpDraft, setAllowedIpDraft] = useState('');
256
- const [actionForm, setActionForm] = useState(defaultActionForm);
257
-
258
- const [eventWebhookOpen, setEventWebhookOpen] = useState(false);
259
- const [eventWebhookEdit, setEventWebhookEdit] = useState<EventWebhook | null>(
260
- null,
261
- );
262
- const [eventWebhookForm, setEventWebhookForm] = useState(
263
- defaultEventWebhookForm,
264
- );
265
-
266
- const [deleteTarget, setDeleteTarget] = useState<{
267
- kind: 'catalog' | 'incoming' | 'eventWebhook' | 'action';
268
- id: number;
269
- parentId?: number;
270
- name: string;
271
- } | null>(null);
272
-
273
- const { data: eventOptions = [], refetch: refetchEventOptions } = useQuery<
274
- SelectOption[]
275
- >({
276
- queryKey: ['integration-event-catalog-options', currentLocaleCode],
277
- queryFn: async () => {
278
- const response = await request<SelectOption[]>({
279
- url: '/integration-event-catalog/options',
280
- method: 'GET',
281
- });
282
- return response.data || [];
283
- },
284
- placeholderData: (previous) => previous ?? [],
285
- });
286
-
287
- const { data: mailProfiles = [] } = useQuery<SelectOption[]>({
288
- queryKey: ['webhook-mail-profiles', currentLocaleCode],
289
- queryFn: async () => {
290
- const response = await request<{ data: SelectOption[] }>({
291
- url: '/mail-profile?pageSize=100',
292
- method: 'GET',
293
- });
294
- return response.data.data || [];
295
- },
296
- placeholderData: (previous) => previous ?? [],
297
- });
298
-
299
- const { data: mailTemplates = [] } = useQuery<SelectOption[]>({
300
- queryKey: ['webhook-mail-templates', currentLocaleCode],
301
- queryFn: async () => {
302
- const response = await request<{ data: SelectOption[] }>({
303
- url: '/mail?pageSize=100',
304
- method: 'GET',
305
- });
306
- return response.data.data || [];
307
- },
308
- placeholderData: (previous) => previous ?? [],
309
- });
310
-
311
- const stats = useMemo(
312
- () => [
313
- {
314
- key: 'events',
315
- title: t('statsEvents'),
316
- value: catalog.totalItems,
317
- icon: BookOpenText,
318
- accentClassName: 'from-slate-500/20 via-slate-400/10 to-transparent',
319
- },
320
- {
321
- key: 'incoming',
322
- title: t('statsIncoming'),
323
- value: incoming.totalItems,
324
- icon: Webhook,
325
- accentClassName: 'from-blue-500/20 via-cyan-500/10 to-transparent',
326
- },
327
- {
328
- key: 'eventWebhooks',
329
- title: t('statsEventWebhooks'),
330
- value: eventWebhooks.totalItems,
331
- icon: Send,
332
- accentClassName: 'from-emerald-500/20 via-green-500/10 to-transparent',
333
- },
334
- ],
335
- [catalog.totalItems, incoming.totalItems, eventWebhooks.totalItems, t],
336
- );
337
- const allowedIpList = useMemo(
338
- () => parseStringArrayText(incomingForm.allowed_ips),
339
- [incomingForm.allowed_ips],
340
- );
341
-
342
- const openCatalogCreate = () => {
343
- setCatalogEdit(null);
344
- setCatalogForm(defaultCatalogForm);
345
- setCatalogOpen(true);
346
- };
347
-
348
- const openCatalogEdit = (item: EventCatalog) => {
349
- setCatalogEdit(item);
350
- setCatalogForm({
351
- slug: item.slug,
352
- name: item.name,
353
- module: item.module,
354
- description: item.description || '',
355
- status: item.status,
356
- });
357
- setCatalogOpen(true);
358
- };
359
-
360
- const saveCatalog = async () => {
361
- try {
362
- if (catalogEdit) {
363
- await request({
364
- url: `/integration-event-catalog/${catalogEdit.id}`,
365
- method: 'PATCH',
366
- data: catalogForm,
367
- });
368
- toast.success(t('updated'));
369
- } else {
370
- await request({
371
- url: '/integration-event-catalog',
372
- method: 'POST',
373
- data: catalogForm,
374
- });
375
- toast.success(t('created'));
376
- }
377
- setCatalogOpen(false);
378
- await Promise.all([catalog.refetch(), refetchEventOptions()]);
379
- } catch (error) {
380
- toast.error(t('saveError'));
381
- }
382
- };
383
-
384
- const openIncomingCreate = () => {
385
- setIncomingEdit(null);
386
- setIncomingForm(defaultIncomingForm);
387
- setOneTimeToken(null);
388
- setAllowedIpDraft('');
389
- setActionForm(defaultActionForm);
390
- setIncomingOpen(true);
391
- };
392
-
393
- const openIncomingEdit = async (item: IncomingIntegration) => {
394
- const response = await request<IncomingIntegration>({
395
- url: `/webhook-integration/${item.id}`,
396
- method: 'GET',
397
- });
398
- const data = response.data;
399
- setIncomingEdit(data);
400
- setIncomingForm({
401
- slug: data.slug,
402
- name: data.name,
403
- description: data.description || '',
404
- status: data.status,
405
- require_token: data.require_token,
406
- allowed_ips: stringifyJson(data.allowed_ips),
407
- body_schema: stringifyJson(data.body_schema),
408
- });
409
- if (data.plainToken) {
410
- setOneTimeToken(data.plainToken);
411
- }
412
- setAllowedIpDraft('');
413
- setActionForm(defaultActionForm);
414
- setIncomingOpen(true);
415
- };
416
-
417
- const setAllowedIpList = (items: string[]) => {
418
- setIncomingForm((current) => ({
419
- ...current,
420
- allowed_ips: stringifyStringArray(items),
421
- }));
422
- };
423
-
424
- const addAllowedIp = () => {
425
- const nextIp = allowedIpDraft.trim();
426
- if (!nextIp) return;
427
- setAllowedIpList([...allowedIpList, nextIp]);
428
- setAllowedIpDraft('');
429
- };
430
-
431
- const updateAllowedIp = (index: number, value: string) => {
432
- const items = [...allowedIpList];
433
- items[index] = value;
434
- setAllowedIpList(items);
435
- };
436
-
437
- const removeAllowedIp = (index: number) => {
438
- setAllowedIpList(
439
- allowedIpList.filter((_, itemIndex) => itemIndex !== index),
440
- );
441
- };
442
-
443
- const saveIncoming = async () => {
444
- try {
445
- const payload = {
446
- ...incomingForm,
447
- allowed_ips: parseJsonText(incomingForm.allowed_ips),
448
- body_schema: parseJsonText(incomingForm.body_schema),
449
- };
450
-
451
- const response = incomingEdit
452
- ? await request<IncomingIntegration>({
453
- url: `/webhook-integration/${incomingEdit.id}`,
454
- method: 'PATCH',
455
- data: payload,
456
- })
457
- : await request<IncomingIntegration>({
458
- url: '/webhook-integration',
459
- method: 'POST',
460
- data: payload,
461
- });
462
-
463
- if (response.data.plainToken) {
464
- setOneTimeToken(response.data.plainToken);
465
- toast.success(t('tokenGenerated'));
466
- } else {
467
- toast.success(incomingEdit ? t('updated') : t('created'));
468
- }
469
-
470
- if (!incomingEdit) {
471
- await openIncomingEdit(response.data);
472
- }
473
-
474
- await incoming.refetch();
475
- } catch (error) {
476
- toast.error(t('saveError'));
477
- }
478
- };
479
-
480
- const saveAction = async () => {
481
- if (!incomingEdit) return;
482
-
483
- try {
484
- await request({
485
- url: `/webhook-integration/${incomingEdit.id}/action`,
486
- method: 'POST',
487
- data: {
488
- ...actionForm,
489
- order: Number(actionForm.order || 0),
490
- mail_profile_id: actionForm.mail_profile_id || null,
491
- mail_id: actionForm.mail_id || null,
492
- },
493
- });
494
- toast.success(t('created'));
495
- setActionForm(defaultActionForm);
496
- await openIncomingEdit(incomingEdit);
497
- await incoming.refetch();
498
- } catch (error) {
499
- toast.error(t('saveError'));
500
- }
501
- };
502
-
503
- const regenerateUuid = async () => {
504
- if (!incomingEdit) return;
505
- const response = await request<IncomingIntegration>({
506
- url: `/webhook-integration/${incomingEdit.id}/regenerate-uuid`,
507
- method: 'POST',
508
- });
509
- toast.success(t('uuidRegenerated'));
510
- await openIncomingEdit(response.data);
511
- await incoming.refetch();
512
- };
513
-
514
- const regenerateToken = async () => {
515
- if (!incomingEdit) return;
516
- const response = await request<IncomingIntegration>({
517
- url: `/webhook-integration/${incomingEdit.id}/regenerate-token`,
518
- method: 'POST',
519
- });
520
- toast.success(
521
- response.data.plainToken ? t('tokenGenerated') : t('tokenGenerated'),
522
- );
523
- setOneTimeToken(response.data.plainToken || null);
524
- await openIncomingEdit(response.data);
525
- await incoming.refetch();
526
- };
527
-
528
- const openEventWebhookCreate = () => {
529
- setEventWebhookEdit(null);
530
- setEventWebhookForm(defaultEventWebhookForm);
531
- setEventWebhookOpen(true);
532
- };
533
-
534
- const openEventWebhookEdit = async (item: EventWebhook) => {
535
- const response = await request<EventWebhook>({
536
- url: `/event-webhook/${item.id}`,
537
- method: 'GET',
538
- });
539
- const data = response.data;
540
- setEventWebhookEdit(data);
541
- setEventWebhookForm({
542
- slug: data.slug,
543
- name: data.name,
544
- description: data.description || '',
545
- status: data.status,
546
- priority: data.priority || 0,
547
- url: data.url,
548
- method: data.method || 'POST',
549
- headers: stringifyJson(data.headers) || '{}',
550
- query: stringifyJson(data.query) || '{}',
551
- payload: stringifyJson(data.payload) || '{}',
552
- timeout_ms: data.timeout_ms || 10000,
553
- retry_count: data.retry_count || 0,
554
- event_ids:
555
- data.event_webhook_event?.map(
556
- (row) => row.integration_event_catalog.id,
557
- ) || [],
558
- });
559
- setEventWebhookOpen(true);
560
- };
561
-
562
- const saveEventWebhook = async () => {
563
- try {
564
- const payload = {
565
- ...eventWebhookForm,
566
- headers: parseJsonText(eventWebhookForm.headers),
567
- query: parseJsonText(eventWebhookForm.query),
568
- payload: parseJsonText(eventWebhookForm.payload),
569
- };
570
-
571
- if (eventWebhookEdit) {
572
- await request({
573
- url: `/event-webhook/${eventWebhookEdit.id}`,
574
- method: 'PATCH',
575
- data: payload,
576
- });
577
- toast.success(t('updated'));
578
- } else {
579
- await request({
580
- url: '/event-webhook',
581
- method: 'POST',
582
- data: payload,
583
- });
584
- toast.success(t('created'));
585
- }
586
- setEventWebhookOpen(false);
587
- await eventWebhooks.refetch();
588
- } catch (error) {
589
- toast.error(t('saveError'));
590
- }
591
- };
592
-
593
- const confirmDelete = async () => {
594
- if (!deleteTarget) return;
595
-
596
- try {
597
- if (deleteTarget.kind === 'catalog') {
598
- await request({
599
- url: '/integration-event-catalog',
600
- method: 'DELETE',
601
- data: { ids: [deleteTarget.id] },
602
- });
603
- await Promise.all([catalog.refetch(), refetchEventOptions()]);
604
- }
605
- if (deleteTarget.kind === 'incoming') {
606
- await request({
607
- url: '/webhook-integration',
608
- method: 'DELETE',
609
- data: { ids: [deleteTarget.id] },
610
- });
611
- await incoming.refetch();
612
- }
613
- if (deleteTarget.kind === 'eventWebhook') {
614
- await request({
615
- url: '/event-webhook',
616
- method: 'DELETE',
617
- data: { ids: [deleteTarget.id] },
618
- });
619
- await eventWebhooks.refetch();
620
- }
621
- if (deleteTarget.kind === 'action' && deleteTarget.parentId) {
622
- await request({
623
- url: `/webhook-integration/${deleteTarget.parentId}/action`,
624
- method: 'DELETE',
625
- data: { ids: [deleteTarget.id] },
626
- });
627
- if (incomingEdit) {
628
- await openIncomingEdit(incomingEdit);
629
- }
630
- await incoming.refetch();
631
- }
632
- toast.success(t('deleted'));
633
- setDeleteTarget(null);
634
- } catch (error) {
635
- toast.error(t('deleteError'));
636
- }
637
- };
638
-
639
- const renderStatus = (status: Status) => (
640
- <Badge variant="outline" className={statusClass[status]}>
641
- {t(status)}
642
- </Badge>
643
- );
644
-
645
- return (
646
- <Page>
647
- <PageHeader
648
- breadcrumbs={[
649
- { label: t('breadcrumbHome'), href: '/' },
650
- { label: t('breadcrumbManagement'), href: '/core/management' },
651
- { label: t('title') },
652
- ]}
653
- title={t('title')}
654
- description={t('description')}
655
- actions={[
656
- activeTab === 'catalog'
657
- ? {
658
- label: t('newEvent'),
659
- icon: <Plus className="h-4 w-4" />,
660
- onClick: openCatalogCreate,
661
- }
662
- : activeTab === 'incoming'
663
- ? {
664
- label: t('newIncoming'),
665
- icon: <Plus className="h-4 w-4" />,
666
- onClick: openIncomingCreate,
667
- }
668
- : {
669
- label: t('newEventWebhook'),
670
- icon: <Plus className="h-4 w-4" />,
671
- onClick: openEventWebhookCreate,
672
- },
673
- ]}
674
- />
675
-
676
- <KpiCardsGrid items={stats} />
677
-
678
- <Tabs value={activeTab} onValueChange={setActiveTab}>
679
- <TabsList>
680
- <TabsTrigger value="catalog">
681
- <BookOpenText className="h-4 w-4" />
682
- {t('tabCatalog')}
683
- </TabsTrigger>
684
- <TabsTrigger value="incoming">
685
- <Webhook className="h-4 w-4" />
686
- {t('tabIncoming')}
687
- </TabsTrigger>
688
- <TabsTrigger value="eventWebhooks">
689
- <Send className="h-4 w-4" />
690
- {t('tabEventWebhooks')}
691
- </TabsTrigger>
692
- </TabsList>
693
-
694
- <TabsContent value="catalog" className="space-y-4">
695
- <SearchBar
696
- searchQuery={catalog.search}
697
- onSearchChange={(value) => {
698
- catalog.setSearch(value);
699
- catalog.setPage(1);
700
- }}
701
- onSearch={() => catalog.setPage(1)}
702
- placeholder={t('searchEvents')}
703
- />
704
-
705
- {catalog.items.length === 0 ? (
706
- <EmptyState
707
- icon={<BookOpenText className="h-12 w-12" />}
708
- title={t('emptyEvents')}
709
- description={t('emptyEventsDescription')}
710
- actionLabel={t('newEvent')}
711
- onAction={openCatalogCreate}
712
- />
713
- ) : (
714
- <div className="overflow-x-auto">
715
- <Table>
716
- <TableHeader>
717
- <TableRow>
718
- <TableHead>{t('eventName')}</TableHead>
719
- <TableHead>{t('module')}</TableHead>
720
- <TableHead>{t('descriptionColumn')}</TableHead>
721
- <TableHead>{t('status')}</TableHead>
722
- <TableHead>{t('updatedAt')}</TableHead>
723
- <TableHead className="text-right">{t('actions')}</TableHead>
724
- </TableRow>
725
- </TableHeader>
726
- <TableBody>
727
- {(catalog.items as EventCatalog[]).map((item) => (
728
- <TableRow
729
- key={item.id}
730
- className="cursor-pointer"
731
- onDoubleClick={() => openCatalogEdit(item)}
732
- >
733
- <TableCell>
734
- <div className="font-medium">{item.name}</div>
735
- <div className="font-mono text-xs text-muted-foreground">
736
- {item.slug}
737
- </div>
738
- </TableCell>
739
- <TableCell>{item.module}</TableCell>
740
- <TableCell className="max-w-md truncate">
741
- {item.description || '-'}
742
- </TableCell>
743
- <TableCell>{renderStatus(item.status)}</TableCell>
744
- <TableCell className="text-muted-foreground">
745
- {formatDate(
746
- item.updated_at,
747
- getSettingValue,
748
- currentLocaleCode,
749
- )}
750
- </TableCell>
751
- <TableCell className="text-right">
752
- <Button
753
- variant="ghost"
754
- size="icon"
755
- onClick={() => openCatalogEdit(item)}
756
- >
757
- <Pencil className="h-4 w-4" />
758
- </Button>
759
- <Button
760
- variant="ghost"
761
- size="icon"
762
- onClick={() =>
763
- setDeleteTarget({
764
- kind: 'catalog',
765
- id: item.id,
766
- name: item.name,
767
- })
768
- }
769
- >
770
- <Trash2 className="h-4 w-4 text-destructive" />
771
- </Button>
772
- </TableCell>
773
- </TableRow>
774
- ))}
775
- </TableBody>
776
- </Table>
777
- </div>
778
- )}
779
-
780
- <PaginationFooter
781
- currentPage={catalog.page}
782
- pageSize={catalog.pageSize}
783
- totalItems={catalog.totalItems}
784
- onPageChange={catalog.setPage}
785
- onPageSizeChange={(size) => {
786
- catalog.setPageSize(size);
787
- catalog.setPage(1);
788
- }}
789
- />
790
- </TabsContent>
791
-
792
- <TabsContent value="incoming" className="space-y-4">
793
- <SearchBar
794
- searchQuery={incoming.search}
795
- onSearchChange={(value) => {
796
- incoming.setSearch(value);
797
- incoming.setPage(1);
798
- }}
799
- onSearch={() => incoming.setPage(1)}
800
- placeholder={t('searchIncoming')}
801
- />
802
-
803
- {incoming.items.length === 0 ? (
804
- <EmptyState
805
- icon={<Webhook className="h-12 w-12" />}
806
- title={t('emptyIncoming')}
807
- description={t('emptyIncomingDescription')}
808
- actionLabel={t('newIncoming')}
809
- onAction={openIncomingCreate}
810
- />
811
- ) : (
812
- <div className="overflow-x-auto">
813
- <Table>
814
- <TableHeader>
815
- <TableRow>
816
- <TableHead>{t('name')}</TableHead>
817
- <TableHead>{t('publicUrl')}</TableHead>
818
- <TableHead>{t('security')}</TableHead>
819
- <TableHead>{t('actionsCount')}</TableHead>
820
- <TableHead>{t('status')}</TableHead>
821
- <TableHead className="text-right">{t('actions')}</TableHead>
822
- </TableRow>
823
- </TableHeader>
824
- <TableBody>
825
- {(incoming.items as IncomingIntegration[]).map((item) => (
826
- <TableRow
827
- key={item.id}
828
- className="cursor-pointer"
829
- onDoubleClick={() => openIncomingEdit(item)}
830
- >
831
- <TableCell>
832
- <div className="font-medium">{item.name}</div>
833
- <div className="font-mono text-xs text-muted-foreground">
834
- {item.slug}
835
- </div>
836
- </TableCell>
837
- <TableCell>
838
- <div className="flex max-w-md items-center gap-2">
839
- <span className="truncate font-mono text-xs">
840
- {getPublicWebhookUrl(
841
- item.public_uuid,
842
- item.public_url,
843
- )}
844
- </span>
845
- <CopyButton
846
- value={getPublicWebhookUrl(
847
- item.public_uuid,
848
- item.public_url,
849
- )}
850
- />
851
- </div>
852
- </TableCell>
853
- <TableCell>
854
- {item.require_token ? (
855
- <Badge variant="secondary">{t('token')}</Badge>
856
- ) : (
857
- <Badge variant="outline">UUID</Badge>
858
- )}
859
- </TableCell>
860
- <TableCell>{item.webhook_action?.length || 0}</TableCell>
861
- <TableCell>{renderStatus(item.status)}</TableCell>
862
- <TableCell className="text-right">
863
- <Button
864
- variant="ghost"
865
- size="icon"
866
- onClick={() => openIncomingEdit(item)}
867
- >
868
- <Pencil className="h-4 w-4" />
869
- </Button>
870
- <Button
871
- variant="ghost"
872
- size="icon"
873
- onClick={() =>
874
- setDeleteTarget({
875
- kind: 'incoming',
876
- id: item.id,
877
- name: item.name,
878
- })
879
- }
880
- >
881
- <Trash2 className="h-4 w-4 text-destructive" />
882
- </Button>
883
- </TableCell>
884
- </TableRow>
885
- ))}
886
- </TableBody>
887
- </Table>
888
- </div>
889
- )}
890
-
891
- <PaginationFooter
892
- currentPage={incoming.page}
893
- pageSize={incoming.pageSize}
894
- totalItems={incoming.totalItems}
895
- onPageChange={incoming.setPage}
896
- onPageSizeChange={(size) => {
897
- incoming.setPageSize(size);
898
- incoming.setPage(1);
899
- }}
900
- />
901
- </TabsContent>
902
-
903
- <TabsContent value="eventWebhooks" className="space-y-4">
904
- <SearchBar
905
- searchQuery={eventWebhooks.search}
906
- onSearchChange={(value) => {
907
- eventWebhooks.setSearch(value);
908
- eventWebhooks.setPage(1);
909
- }}
910
- onSearch={() => eventWebhooks.setPage(1)}
911
- placeholder={t('searchEventWebhooks')}
912
- />
913
-
914
- {eventWebhooks.items.length === 0 ? (
915
- <EmptyState
916
- icon={<Send className="h-12 w-12" />}
917
- title={t('emptyEventWebhooks')}
918
- description={t('emptyEventWebhooksDescription')}
919
- actionLabel={t('newEventWebhook')}
920
- onAction={openEventWebhookCreate}
921
- />
922
- ) : (
923
- <div className="overflow-x-auto">
924
- <Table>
925
- <TableHeader>
926
- <TableRow>
927
- <TableHead>{t('name')}</TableHead>
928
- <TableHead>{t('events')}</TableHead>
929
- <TableHead>{t('httpTarget')}</TableHead>
930
- <TableHead>{t('retry')}</TableHead>
931
- <TableHead>{t('status')}</TableHead>
932
- <TableHead className="text-right">{t('actions')}</TableHead>
933
- </TableRow>
934
- </TableHeader>
935
- <TableBody>
936
- {(eventWebhooks.items as EventWebhook[]).map((item) => (
937
- <TableRow
938
- key={item.id}
939
- className="cursor-pointer"
940
- onDoubleClick={() => openEventWebhookEdit(item)}
941
- >
942
- <TableCell>
943
- <div className="font-medium">{item.name}</div>
944
- <div className="font-mono text-xs text-muted-foreground">
945
- {item.slug}
946
- </div>
947
- </TableCell>
948
- <TableCell>
949
- <div className="flex flex-wrap gap-1">
950
- {item.event_webhook_event?.map((row) => (
951
- <Badge
952
- key={row.integration_event_catalog.id}
953
- variant="secondary"
954
- className="font-mono text-xs"
955
- >
956
- {row.integration_event_catalog.slug}
957
- </Badge>
958
- ))}
959
- </div>
960
- </TableCell>
961
- <TableCell>
962
- <div className="flex items-center gap-2">
963
- <Badge variant="outline">{item.method}</Badge>
964
- <span className="max-w-md truncate font-mono text-xs">
965
- {item.url}
966
- </span>
967
- </div>
968
- </TableCell>
969
- <TableCell>{item.retry_count}</TableCell>
970
- <TableCell>{renderStatus(item.status)}</TableCell>
971
- <TableCell className="text-right">
972
- <Button
973
- variant="ghost"
974
- size="icon"
975
- onClick={() => openEventWebhookEdit(item)}
976
- >
977
- <Pencil className="h-4 w-4" />
978
- </Button>
979
- <Button
980
- variant="ghost"
981
- size="icon"
982
- onClick={() =>
983
- setDeleteTarget({
984
- kind: 'eventWebhook',
985
- id: item.id,
986
- name: item.name,
987
- })
988
- }
989
- >
990
- <Trash2 className="h-4 w-4 text-destructive" />
991
- </Button>
992
- </TableCell>
993
- </TableRow>
994
- ))}
995
- </TableBody>
996
- </Table>
997
- </div>
998
- )}
999
-
1000
- <PaginationFooter
1001
- currentPage={eventWebhooks.page}
1002
- pageSize={eventWebhooks.pageSize}
1003
- totalItems={eventWebhooks.totalItems}
1004
- onPageChange={eventWebhooks.setPage}
1005
- onPageSizeChange={(size) => {
1006
- eventWebhooks.setPageSize(size);
1007
- eventWebhooks.setPage(1);
1008
- }}
1009
- />
1010
- </TabsContent>
1011
- </Tabs>
1012
-
1013
- <Sheet open={catalogOpen} onOpenChange={setCatalogOpen}>
1014
- <SheetContent className="w-full overflow-y-auto sm:max-w-2xl">
1015
- <SheetHeader>
1016
- <SheetTitle>
1017
- {catalogEdit ? t('editEvent') : t('newEvent')}
1018
- </SheetTitle>
1019
- <SheetDescription>{t('eventFormDescription')}</SheetDescription>
1020
- </SheetHeader>
1021
- <div className="space-y-4 px-3 pb-8 sm:px-4">
1022
- <Field label={t('eventName')}>
1023
- <Input
1024
- value={catalogForm.name}
1025
- onChange={(event) =>
1026
- setCatalogForm({ ...catalogForm, name: event.target.value })
1027
- }
1028
- />
1029
- </Field>
1030
- <Field label={t('technicalName')}>
1031
- <Input
1032
- value={catalogForm.slug}
1033
- onChange={(event) =>
1034
- setCatalogForm({ ...catalogForm, slug: event.target.value })
1035
- }
1036
- placeholder="module.entity.changed"
1037
- />
1038
- </Field>
1039
- <Field label={t('module')}>
1040
- <Input
1041
- value={catalogForm.module}
1042
- onChange={(event) =>
1043
- setCatalogForm({ ...catalogForm, module: event.target.value })
1044
- }
1045
- />
1046
- </Field>
1047
- <Field label={t('descriptionColumn')}>
1048
- <Textarea
1049
- value={catalogForm.description}
1050
- onChange={(event) =>
1051
- setCatalogForm({
1052
- ...catalogForm,
1053
- description: event.target.value,
1054
- })
1055
- }
1056
- />
1057
- </Field>
1058
- <StatusSelect
1059
- value={catalogForm.status}
1060
- onValueChange={(status) =>
1061
- setCatalogForm({ ...catalogForm, status })
1062
- }
1063
- t={t}
1064
- />
1065
- <SheetActions
1066
- onSave={saveCatalog}
1067
- onCancel={() => setCatalogOpen(false)}
1068
- t={t}
1069
- />
1070
- </div>
1071
- </SheetContent>
1072
- </Sheet>
1073
-
1074
- <Sheet
1075
- open={incomingOpen}
1076
- onOpenChange={(open) => {
1077
- setIncomingOpen(open);
1078
- if (!open) {
1079
- setOneTimeToken(null);
1080
- }
1081
- }}
1082
- >
1083
- <SheetContent className="w-full overflow-y-auto sm:max-w-5xl">
1084
- <SheetHeader>
1085
- <SheetTitle>
1086
- {incomingEdit ? t('editIncoming') : t('newIncoming')}
1087
- </SheetTitle>
1088
- <SheetDescription>{t('incomingFormDescription')}</SheetDescription>
1089
- </SheetHeader>
1090
- <div className="space-y-6 px-3 pb-8 sm:px-4">
1091
- <div className="grid gap-4 md:grid-cols-2">
1092
- <Field label={t('name')}>
1093
- <Input
1094
- value={incomingForm.name}
1095
- onChange={(event) =>
1096
- setIncomingForm({
1097
- ...incomingForm,
1098
- name: event.target.value,
1099
- })
1100
- }
1101
- />
1102
- </Field>
1103
- <Field label={t('technicalName')}>
1104
- <Input
1105
- value={incomingForm.slug}
1106
- onChange={(event) =>
1107
- setIncomingForm({
1108
- ...incomingForm,
1109
- slug: event.target.value,
1110
- })
1111
- }
1112
- />
1113
- </Field>
1114
- </div>
1115
- <Field label={t('descriptionColumn')}>
1116
- <Textarea
1117
- value={incomingForm.description}
1118
- onChange={(event) =>
1119
- setIncomingForm({
1120
- ...incomingForm,
1121
- description: event.target.value,
1122
- })
1123
- }
1124
- />
1125
- </Field>
1126
- <div className="grid gap-4 md:grid-cols-3">
1127
- <StatusSelect
1128
- value={incomingForm.status}
1129
- onValueChange={(status) =>
1130
- setIncomingForm({ ...incomingForm, status })
1131
- }
1132
- t={t}
1133
- />
1134
- <div className="flex items-center justify-between rounded-md border px-4 py-3 md:col-span-2">
1135
- <div>
1136
- <p className="text-sm font-medium">{t('requireToken')}</p>
1137
- <p className="text-xs text-muted-foreground">
1138
- {t('requireTokenHelp')}
1139
- </p>
1140
- </div>
1141
- <Switch
1142
- checked={incomingForm.require_token}
1143
- onCheckedChange={(checked) =>
1144
- setIncomingForm({
1145
- ...incomingForm,
1146
- require_token: checked,
1147
- })
1148
- }
1149
- />
1150
- </div>
1151
- </div>
1152
- {oneTimeToken ? (
1153
- <div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4">
1154
- <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
1155
- <div className="min-w-0 space-y-1">
1156
- <Label>{t('oneTimeTokenTitle')}</Label>
1157
- <p className="text-xs text-muted-foreground">
1158
- {t('oneTimeTokenDescription')}
1159
- </p>
1160
- <div className="mt-2 flex min-w-0 items-center gap-2 rounded-md border bg-background px-3 py-2">
1161
- <code className="min-w-0 flex-1 truncate text-xs">
1162
- {oneTimeToken}
1163
- </code>
1164
- <CopyButton value={oneTimeToken} className="h-7 w-7" />
1165
- </div>
1166
- </div>
1167
- </div>
1168
- </div>
1169
- ) : incomingForm.require_token ? (
1170
- <div className="rounded-md border p-4">
1171
- <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
1172
- <div>
1173
- <Label>{t('tokenConfiguredTitle')}</Label>
1174
- <p className="text-xs text-muted-foreground">
1175
- {incomingEdit
1176
- ? t('tokenConfiguredDescription')
1177
- : t('tokenWillBeCreatedDescription')}
1178
- </p>
1179
- </div>
1180
- {incomingEdit ? (
1181
- <Button
1182
- variant="outline"
1183
- size="sm"
1184
- onClick={regenerateToken}
1185
- >
1186
- <RotateCcw className="mr-2 h-4 w-4" />
1187
- {t('regenerateToken')}
1188
- </Button>
1189
- ) : null}
1190
- </div>
1191
- </div>
1192
- ) : null}
1193
- <div className="grid gap-4 md:grid-cols-2">
1194
- <Field label={t('allowedIps')}>
1195
- <div className="space-y-3">
1196
- <div className="rounded-md border">
1197
- <Table>
1198
- <TableHeader>
1199
- <TableRow>
1200
- <TableHead>{t('ipAddress')}</TableHead>
1201
- <TableHead className="w-[72px] text-right">
1202
- {t('actions')}
1203
- </TableHead>
1204
- </TableRow>
1205
- </TableHeader>
1206
- <TableBody>
1207
- {allowedIpList.length === 0 ? (
1208
- <TableRow>
1209
- <TableCell
1210
- colSpan={2}
1211
- className="h-16 text-center text-sm text-muted-foreground"
1212
- >
1213
- {t('emptyAllowedIps')}
1214
- </TableCell>
1215
- </TableRow>
1216
- ) : (
1217
- allowedIpList.map((ip, index) => (
1218
- <TableRow key={`${ip}-${index}`}>
1219
- <TableCell>
1220
- <Input
1221
- value={ip}
1222
- onChange={(event) =>
1223
- updateAllowedIp(index, event.target.value)
1224
- }
1225
- placeholder="127.0.0.1"
1226
- />
1227
- </TableCell>
1228
- <TableCell className="text-right">
1229
- <Button
1230
- type="button"
1231
- variant="ghost"
1232
- size="icon"
1233
- onClick={() => removeAllowedIp(index)}
1234
- >
1235
- <Trash2 className="h-4 w-4" />
1236
- </Button>
1237
- </TableCell>
1238
- </TableRow>
1239
- ))
1240
- )}
1241
- </TableBody>
1242
- </Table>
1243
- </div>
1244
- <div className="flex gap-2">
1245
- <Input
1246
- value={allowedIpDraft}
1247
- onChange={(event) =>
1248
- setAllowedIpDraft(event.target.value)
1249
- }
1250
- onKeyDown={(event) => {
1251
- if (event.key === 'Enter') {
1252
- event.preventDefault();
1253
- addAllowedIp();
1254
- }
1255
- }}
1256
- placeholder="127.0.0.1"
1257
- />
1258
- <Button
1259
- type="button"
1260
- variant="outline"
1261
- onClick={addAllowedIp}
1262
- >
1263
- <Plus className="mr-2 h-4 w-4" />
1264
- {t('addIp')}
1265
- </Button>
1266
- </div>
1267
- </div>
1268
- </Field>
1269
- <Field label={t('bodySchema')}>
1270
- <Textarea
1271
- value={incomingForm.body_schema}
1272
- onChange={(event) =>
1273
- setIncomingForm({
1274
- ...incomingForm,
1275
- body_schema: event.target.value,
1276
- })
1277
- }
1278
- placeholder='{"type":"object"}'
1279
- />
1280
- </Field>
1281
- </div>
1282
- {incomingEdit && (
1283
- <div className="rounded-md border p-4">
1284
- <div className="mb-3 flex items-center justify-between gap-3">
1285
- <div className="min-w-0">
1286
- <Label>{t('publicUrl')}</Label>
1287
- <div className="mt-1 flex items-center gap-2">
1288
- <span className="truncate font-mono text-xs">
1289
- {getPublicWebhookUrl(
1290
- incomingEdit.public_uuid,
1291
- incomingEdit.public_url,
1292
- )}
1293
- </span>
1294
- <CopyButton
1295
- value={getPublicWebhookUrl(
1296
- incomingEdit.public_uuid,
1297
- incomingEdit.public_url,
1298
- )}
1299
- />
1300
- </div>
1301
- </div>
1302
- <div className="flex gap-2">
1303
- <Button
1304
- variant="outline"
1305
- size="sm"
1306
- onClick={regenerateUuid}
1307
- >
1308
- <RotateCcw className="mr-2 h-4 w-4" />
1309
- {t('regenerateUuid')}
1310
- </Button>
1311
- </div>
1312
- </div>
1313
- </div>
1314
- )}
1315
- <SheetActions
1316
- onSave={saveIncoming}
1317
- onCancel={() => setIncomingOpen(false)}
1318
- t={t}
1319
- />
1320
-
1321
- {incomingEdit && (
1322
- <div className="space-y-4 border-t pt-5">
1323
- <div className="flex items-center gap-2">
1324
- <Activity className="h-4 w-4" />
1325
- <h3 className="text-sm font-semibold">
1326
- {t('actionSection')}
1327
- </h3>
1328
- </div>
1329
- <div className="grid gap-3 md:grid-cols-3">
1330
- <Field label={t('actionType')}>
1331
- <Select
1332
- value={actionForm.type}
1333
- onValueChange={(value: IncomingAction['type']) =>
1334
- setActionForm({ ...actionForm, type: value })
1335
- }
1336
- >
1337
- <SelectTrigger className="w-full">
1338
- <SelectValue />
1339
- </SelectTrigger>
1340
- <SelectContent>
1341
- <SelectItem value="email">{t('email')}</SelectItem>
1342
- <SelectItem value="whatsapp_evolution">
1343
- {t('whatsapp')}
1344
- </SelectItem>
1345
- </SelectContent>
1346
- </Select>
1347
- </Field>
1348
- <Field label={t('name')}>
1349
- <Input
1350
- value={actionForm.name}
1351
- onChange={(event) =>
1352
- setActionForm({
1353
- ...actionForm,
1354
- name: event.target.value,
1355
- })
1356
- }
1357
- />
1358
- </Field>
1359
- <Field label={t('order')}>
1360
- <Input
1361
- type="number"
1362
- value={actionForm.order}
1363
- onChange={(event) =>
1364
- setActionForm({
1365
- ...actionForm,
1366
- order: Number(event.target.value),
1367
- })
1368
- }
1369
- />
1370
- </Field>
1371
- </div>
1372
- {actionForm.type === 'email' ? (
1373
- <div className="grid gap-3 md:grid-cols-2">
1374
- <Field label={t('mailProfile')}>
1375
- <Select
1376
- value={actionForm.mail_profile_id}
1377
- onValueChange={(value) =>
1378
- setActionForm({
1379
- ...actionForm,
1380
- mail_profile_id: value,
1381
- })
1382
- }
1383
- >
1384
- <SelectTrigger className="w-full">
1385
- <SelectValue placeholder={t('select')} />
1386
- </SelectTrigger>
1387
- <SelectContent>
1388
- {mailProfiles.map((profile) => (
1389
- <SelectItem
1390
- key={profile.id}
1391
- value={String(profile.id)}
1392
- >
1393
- {profile.name || profile.slug}
1394
- </SelectItem>
1395
- ))}
1396
- </SelectContent>
1397
- </Select>
1398
- </Field>
1399
- <Field label={t('mailTemplate')}>
1400
- <Select
1401
- value={actionForm.mail_id}
1402
- onValueChange={(value) =>
1403
- setActionForm({ ...actionForm, mail_id: value })
1404
- }
1405
- >
1406
- <SelectTrigger className="w-full">
1407
- <SelectValue placeholder={t('select')} />
1408
- </SelectTrigger>
1409
- <SelectContent>
1410
- {mailTemplates.map((template) => (
1411
- <SelectItem
1412
- key={template.id}
1413
- value={String(template.id)}
1414
- >
1415
- {template.slug}
1416
- </SelectItem>
1417
- ))}
1418
- </SelectContent>
1419
- </Select>
1420
- </Field>
1421
- <Field label={t('emailTo')}>
1422
- <Input
1423
- value={actionForm.email_to}
1424
- onChange={(event) =>
1425
- setActionForm({
1426
- ...actionForm,
1427
- email_to: event.target.value,
1428
- })
1429
- }
1430
- placeholder="{{email}}"
1431
- />
1432
- </Field>
1433
- <Field label={t('emailCc')}>
1434
- <Input
1435
- value={actionForm.email_cc}
1436
- onChange={(event) =>
1437
- setActionForm({
1438
- ...actionForm,
1439
- email_cc: event.target.value,
1440
- })
1441
- }
1442
- />
1443
- </Field>
1444
- </div>
1445
- ) : (
1446
- <div className="grid gap-3 md:grid-cols-2">
1447
- <Field label={t('whatsappTargetType')}>
1448
- <Select
1449
- value={actionForm.whatsapp_target_type}
1450
- onValueChange={(value) =>
1451
- setActionForm({
1452
- ...actionForm,
1453
- whatsapp_target_type: value,
1454
- })
1455
- }
1456
- >
1457
- <SelectTrigger className="w-full">
1458
- <SelectValue />
1459
- </SelectTrigger>
1460
- <SelectContent>
1461
- <SelectItem value="phone">{t('phone')}</SelectItem>
1462
- <SelectItem value="group">{t('group')}</SelectItem>
1463
- </SelectContent>
1464
- </Select>
1465
- </Field>
1466
- <Field label={t('whatsappTarget')}>
1467
- <Input
1468
- value={actionForm.whatsapp_target}
1469
- onChange={(event) =>
1470
- setActionForm({
1471
- ...actionForm,
1472
- whatsapp_target: event.target.value,
1473
- })
1474
- }
1475
- placeholder="{{phone}}"
1476
- />
1477
- </Field>
1478
- <Field label={t('whatsappInstance')}>
1479
- <Input
1480
- value={actionForm.whatsapp_instance}
1481
- onChange={(event) =>
1482
- setActionForm({
1483
- ...actionForm,
1484
- whatsapp_instance: event.target.value,
1485
- })
1486
- }
1487
- />
1488
- </Field>
1489
- <Field label={t('whatsappBaseUrl')}>
1490
- <Input
1491
- value={actionForm.whatsapp_base_url}
1492
- onChange={(event) =>
1493
- setActionForm({
1494
- ...actionForm,
1495
- whatsapp_base_url: event.target.value,
1496
- })
1497
- }
1498
- />
1499
- </Field>
1500
- <Field label={t('whatsappToken')}>
1501
- <Input
1502
- type="password"
1503
- value={actionForm.whatsapp_token}
1504
- onChange={(event) =>
1505
- setActionForm({
1506
- ...actionForm,
1507
- whatsapp_token: event.target.value,
1508
- })
1509
- }
1510
- />
1511
- </Field>
1512
- <Field label={t('whatsappTemplate')}>
1513
- <Textarea
1514
- value={actionForm.whatsapp_template}
1515
- onChange={(event) =>
1516
- setActionForm({
1517
- ...actionForm,
1518
- whatsapp_template: event.target.value,
1519
- })
1520
- }
1521
- placeholder="Olá {{name}}"
1522
- />
1523
- </Field>
1524
- </div>
1525
- )}
1526
- <div className="flex justify-end">
1527
- <Button onClick={saveAction}>{t('addAction')}</Button>
1528
- </div>
1529
- <div className="rounded-md border">
1530
- <Table>
1531
- <TableHeader>
1532
- <TableRow>
1533
- <TableHead>{t('name')}</TableHead>
1534
- <TableHead>{t('actionType')}</TableHead>
1535
- <TableHead>{t('order')}</TableHead>
1536
- <TableHead className="text-right">
1537
- {t('actions')}
1538
- </TableHead>
1539
- </TableRow>
1540
- </TableHeader>
1541
- <TableBody>
1542
- {incomingEdit.webhook_action?.map((action) => (
1543
- <TableRow key={action.id}>
1544
- <TableCell>{action.name}</TableCell>
1545
- <TableCell>
1546
- <Badge variant="secondary">{action.type}</Badge>
1547
- </TableCell>
1548
- <TableCell>{action.order}</TableCell>
1549
- <TableCell className="text-right">
1550
- <Button
1551
- variant="ghost"
1552
- size="icon"
1553
- onClick={() =>
1554
- setDeleteTarget({
1555
- kind: 'action',
1556
- id: action.id,
1557
- parentId: incomingEdit.id,
1558
- name: action.name,
1559
- })
1560
- }
1561
- >
1562
- <Trash2 className="h-4 w-4 text-destructive" />
1563
- </Button>
1564
- </TableCell>
1565
- </TableRow>
1566
- ))}
1567
- </TableBody>
1568
- </Table>
1569
- </div>
1570
- </div>
1571
- )}
1572
- </div>
1573
- </SheetContent>
1574
- </Sheet>
1575
-
1576
- <Sheet open={eventWebhookOpen} onOpenChange={setEventWebhookOpen}>
1577
- <SheetContent className="w-full overflow-y-auto sm:max-w-5xl">
1578
- <SheetHeader>
1579
- <SheetTitle>
1580
- {eventWebhookEdit ? t('editEventWebhook') : t('newEventWebhook')}
1581
- </SheetTitle>
1582
- <SheetDescription>
1583
- {t('eventWebhookFormDescription')}
1584
- </SheetDescription>
1585
- </SheetHeader>
1586
- <div className="space-y-5 px-3 pb-8 sm:px-4">
1587
- <div className="grid gap-4 md:grid-cols-2">
1588
- <Field label={t('name')}>
1589
- <Input
1590
- value={eventWebhookForm.name}
1591
- onChange={(event) =>
1592
- setEventWebhookForm({
1593
- ...eventWebhookForm,
1594
- name: event.target.value,
1595
- })
1596
- }
1597
- />
1598
- </Field>
1599
- <Field label={t('technicalName')}>
1600
- <Input
1601
- value={eventWebhookForm.slug}
1602
- onChange={(event) =>
1603
- setEventWebhookForm({
1604
- ...eventWebhookForm,
1605
- slug: event.target.value,
1606
- })
1607
- }
1608
- />
1609
- </Field>
1610
- </div>
1611
- <Field label={t('events')}>
1612
- <div className="grid max-h-52 gap-2 overflow-y-auto rounded-md border p-3 md:grid-cols-2">
1613
- {eventOptions.map((event) => {
1614
- const checked = eventWebhookForm.event_ids.includes(event.id);
1615
- return (
1616
- <label
1617
- key={event.id}
1618
- className="flex cursor-pointer items-start gap-2 rounded-md p-2 hover:bg-muted"
1619
- >
1620
- <Checkbox
1621
- checked={checked}
1622
- onCheckedChange={(nextChecked) => {
1623
- setEventWebhookForm({
1624
- ...eventWebhookForm,
1625
- event_ids: nextChecked
1626
- ? [...eventWebhookForm.event_ids, event.id]
1627
- : eventWebhookForm.event_ids.filter(
1628
- (id) => id !== event.id,
1629
- ),
1630
- });
1631
- }}
1632
- />
1633
- <span>
1634
- <span className="block font-mono text-xs">
1635
- {event.slug}
1636
- </span>
1637
- <span className="text-xs text-muted-foreground">
1638
- {event.module}
1639
- </span>
1640
- </span>
1641
- </label>
1642
- );
1643
- })}
1644
- </div>
1645
- </Field>
1646
- <div className="grid gap-4 md:grid-cols-[160px_1fr]">
1647
- <Field label={t('method')}>
1648
- <Select
1649
- value={eventWebhookForm.method}
1650
- onValueChange={(method) =>
1651
- setEventWebhookForm({ ...eventWebhookForm, method })
1652
- }
1653
- >
1654
- <SelectTrigger className="w-full">
1655
- <SelectValue />
1656
- </SelectTrigger>
1657
- <SelectContent>
1658
- {['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].map((method) => (
1659
- <SelectItem key={method} value={method}>
1660
- {method}
1661
- </SelectItem>
1662
- ))}
1663
- </SelectContent>
1664
- </Select>
1665
- </Field>
1666
- <Field label={t('url')}>
1667
- <Input
1668
- value={eventWebhookForm.url}
1669
- onChange={(event) =>
1670
- setEventWebhookForm({
1671
- ...eventWebhookForm,
1672
- url: event.target.value,
1673
- })
1674
- }
1675
- placeholder="https://api.example.com/webhook"
1676
- />
1677
- </Field>
1678
- </div>
1679
- <div className="grid gap-4 md:grid-cols-3">
1680
- <StatusSelect
1681
- value={eventWebhookForm.status}
1682
- onValueChange={(status) =>
1683
- setEventWebhookForm({ ...eventWebhookForm, status })
1684
- }
1685
- t={t}
1686
- />
1687
- <Field label={t('timeout')}>
1688
- <Input
1689
- type="number"
1690
- value={eventWebhookForm.timeout_ms}
1691
- onChange={(event) =>
1692
- setEventWebhookForm({
1693
- ...eventWebhookForm,
1694
- timeout_ms: Number(event.target.value),
1695
- })
1696
- }
1697
- />
1698
- </Field>
1699
- <Field label={t('retry')}>
1700
- <Input
1701
- type="number"
1702
- value={eventWebhookForm.retry_count}
1703
- onChange={(event) =>
1704
- setEventWebhookForm({
1705
- ...eventWebhookForm,
1706
- retry_count: Number(event.target.value),
1707
- })
1708
- }
1709
- />
1710
- </Field>
1711
- </div>
1712
- <div className="grid gap-4 md:grid-cols-3">
1713
- <Field label={t('headers')}>
1714
- <Textarea
1715
- className="min-h-48 font-mono text-xs"
1716
- value={eventWebhookForm.headers}
1717
- onChange={(event) =>
1718
- setEventWebhookForm({
1719
- ...eventWebhookForm,
1720
- headers: event.target.value,
1721
- })
1722
- }
1723
- />
1724
- </Field>
1725
- <Field label={t('query')}>
1726
- <Textarea
1727
- className="min-h-48 font-mono text-xs"
1728
- value={eventWebhookForm.query}
1729
- onChange={(event) =>
1730
- setEventWebhookForm({
1731
- ...eventWebhookForm,
1732
- query: event.target.value,
1733
- })
1734
- }
1735
- />
1736
- </Field>
1737
- <Field label={t('payload')}>
1738
- <Textarea
1739
- className="min-h-48 font-mono text-xs"
1740
- value={eventWebhookForm.payload}
1741
- onChange={(event) =>
1742
- setEventWebhookForm({
1743
- ...eventWebhookForm,
1744
- payload: event.target.value,
1745
- })
1746
- }
1747
- />
1748
- </Field>
1749
- </div>
1750
- <SheetActions
1751
- onSave={saveEventWebhook}
1752
- onCancel={() => setEventWebhookOpen(false)}
1753
- t={t}
1754
- />
1755
- </div>
1756
- </SheetContent>
1757
- </Sheet>
1758
-
1759
- <AlertDialog
1760
- open={Boolean(deleteTarget)}
1761
- onOpenChange={(open) => !open && setDeleteTarget(null)}
1762
- >
1763
- <AlertDialogContent>
1764
- <AlertDialogHeader>
1765
- <AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
1766
- <AlertDialogDescription>
1767
- {t('deleteDescription', { name: deleteTarget?.name || '' })}
1768
- </AlertDialogDescription>
1769
- </AlertDialogHeader>
1770
- <AlertDialogFooter>
1771
- <AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
1772
- <AlertDialogAction
1773
- onClick={confirmDelete}
1774
- className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
1775
- >
1776
- {t('delete')}
1777
- </AlertDialogAction>
1778
- </AlertDialogFooter>
1779
- </AlertDialogContent>
1780
- </AlertDialog>
1781
- </Page>
1782
- );
1783
- }
1784
-
1785
- function Field({ label, children }: { label: string; children: ReactNode }) {
1786
- return (
1787
- <div className="space-y-2">
1788
- <Label>{label}</Label>
1789
- {children}
1790
- </div>
1791
- );
1792
- }
1793
-
1794
- function StatusSelect({
1795
- value,
1796
- onValueChange,
1797
- t,
1798
- }: {
1799
- value: Status;
1800
- onValueChange: (value: Status) => void;
1801
- t: (key: string) => string;
1802
- }) {
1803
- return (
1804
- <Field label={t('status')}>
1805
- <Select value={value} onValueChange={onValueChange}>
1806
- <SelectTrigger className="w-full">
1807
- <SelectValue />
1808
- </SelectTrigger>
1809
- <SelectContent>
1810
- <SelectItem value="active">{t('active')}</SelectItem>
1811
- <SelectItem value="inactive">{t('inactive')}</SelectItem>
1812
- </SelectContent>
1813
- </Select>
1814
- </Field>
1815
- );
1816
- }
1817
-
1818
- function SheetActions({
1819
- onSave,
1820
- onCancel,
1821
- t,
1822
- }: {
1823
- onSave: () => void;
1824
- onCancel: () => void;
1825
- t: (key: string) => string;
1826
- }) {
1827
- return (
1828
- <div className="flex justify-end gap-3 border-t pt-4">
1829
- <Button variant="outline" onClick={onCancel}>
1830
- {t('cancel')}
1831
- </Button>
1832
- <Button onClick={onSave}>{t('save')}</Button>
1833
- </div>
1834
- );
1835
- }
1
+ 'use client';
2
+
3
+ import { CopyButton } from '@/components/copy-button';
4
+ import {
5
+ EmptyState,
6
+ Page,
7
+ PageHeader,
8
+ PaginationFooter,
9
+ SearchBar,
10
+ } from '@/components/entity-list';
11
+ import {
12
+ AlertDialog,
13
+ AlertDialogAction,
14
+ AlertDialogCancel,
15
+ AlertDialogContent,
16
+ AlertDialogDescription,
17
+ AlertDialogFooter,
18
+ AlertDialogHeader,
19
+ AlertDialogTitle,
20
+ } from '@/components/ui/alert-dialog';
21
+ import { Badge } from '@/components/ui/badge';
22
+ import { Button } from '@/components/ui/button';
23
+ import { Checkbox } from '@/components/ui/checkbox';
24
+ import { Input } from '@/components/ui/input';
25
+ import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
26
+ import { Label } from '@/components/ui/label';
27
+ import {
28
+ Select,
29
+ SelectContent,
30
+ SelectItem,
31
+ SelectTrigger,
32
+ SelectValue,
33
+ } from '@/components/ui/select';
34
+ import {
35
+ Sheet,
36
+ SheetContent,
37
+ SheetDescription,
38
+ SheetHeader,
39
+ SheetTitle,
40
+ } from '@/components/ui/sheet';
41
+ import { Switch } from '@/components/ui/switch';
42
+ import {
43
+ Table,
44
+ TableBody,
45
+ TableCell,
46
+ TableHead,
47
+ TableHeader,
48
+ TableRow,
49
+ } from '@/components/ui/table';
50
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
51
+ import { Textarea } from '@/components/ui/textarea';
52
+ import { usePagination } from '@/hooks/use-pagination';
53
+ import { formatDate } from '@/lib/format-date';
54
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
55
+ import {
56
+ Activity,
57
+ BookOpenText,
58
+ Clock3,
59
+ Pencil,
60
+ Plus,
61
+ RotateCcw,
62
+ Send,
63
+ Trash2,
64
+ Webhook,
65
+ } from 'lucide-react';
66
+ import { useTranslations } from 'next-intl';
67
+ import { type ReactNode, useMemo, useState } from 'react';
68
+ import { toast } from 'sonner';
69
+
70
+ type Status = 'active' | 'inactive';
71
+
72
+ type EventCatalog = {
73
+ id: number;
74
+ slug: string;
75
+ name: string;
76
+ module: string;
77
+ description?: string | null;
78
+ status: Status;
79
+ updated_at: string;
80
+ };
81
+
82
+ type IncomingAction = {
83
+ id: number;
84
+ type: 'email' | 'whatsapp_evolution';
85
+ name: string;
86
+ order: number;
87
+ status: Status;
88
+ mail_profile_id?: number | null;
89
+ mail_id?: number | null;
90
+ email_to?: string | null;
91
+ email_cc?: string | null;
92
+ email_bcc?: string | null;
93
+ whatsapp_target_type?: 'phone' | 'group' | null;
94
+ whatsapp_target?: string | null;
95
+ whatsapp_template?: string | null;
96
+ whatsapp_instance?: string | null;
97
+ whatsapp_base_url?: string | null;
98
+ whatsapp_token?: string | null;
99
+ };
100
+
101
+ type IncomingIntegration = {
102
+ id: number;
103
+ slug: string;
104
+ name: string;
105
+ description?: string | null;
106
+ public_uuid: string;
107
+ public_url?: string | null;
108
+ status: Status;
109
+ require_token: boolean;
110
+ allowed_ips?: unknown;
111
+ body_schema?: unknown;
112
+ webhook_action?: IncomingAction[];
113
+ updated_at: string;
114
+ plainToken?: string | null;
115
+ };
116
+
117
+ type IncomingActionLog = {
118
+ id: number;
119
+ action_name: string;
120
+ action_type: IncomingAction['type'];
121
+ status: 'success' | 'failed';
122
+ duration_ms: number;
123
+ request_payload?: unknown;
124
+ response_payload?: unknown;
125
+ error_message?: string | null;
126
+ created_at: string;
127
+ };
128
+
129
+ type IncomingCallLog = {
130
+ id: number;
131
+ public_uuid: string;
132
+ status: 'success' | 'failed';
133
+ remote_ip?: string | null;
134
+ duration_ms: number;
135
+ error_message?: string | null;
136
+ payload_summary?: unknown;
137
+ response_summary?: unknown;
138
+ created_at: string;
139
+ webhook_action_log?: IncomingActionLog[];
140
+ };
141
+
142
+ type EventWebhook = {
143
+ id: number;
144
+ slug: string;
145
+ name: string;
146
+ description?: string | null;
147
+ status: Status;
148
+ priority: number;
149
+ url: string;
150
+ method: string;
151
+ headers?: unknown;
152
+ query?: unknown;
153
+ payload?: unknown;
154
+ timeout_ms: number;
155
+ retry_count: number;
156
+ event_webhook_event?: Array<{
157
+ integration_event_catalog: EventCatalog;
158
+ }>;
159
+ updated_at: string;
160
+ };
161
+
162
+ type SelectOption = {
163
+ id: number;
164
+ slug: string;
165
+ name?: string;
166
+ subject?: string;
167
+ provider?: string;
168
+ module?: string;
169
+ };
170
+
171
+ type PaginatedResponse<T> = {
172
+ data: T[];
173
+ totalItems: number;
174
+ currentPage: number;
175
+ pageSize: number;
176
+ };
177
+
178
+ const statusClass = {
179
+ active: 'border-green-500/20 bg-green-500/10 text-green-600',
180
+ inactive: 'border-slate-500/20 bg-slate-500/10 text-slate-600',
181
+ };
182
+
183
+ const defaultCatalogForm = {
184
+ slug: '',
185
+ name: '',
186
+ module: '',
187
+ description: '',
188
+ status: 'active' as Status,
189
+ };
190
+
191
+ const defaultIncomingForm = {
192
+ slug: '',
193
+ name: '',
194
+ description: '',
195
+ status: 'active' as Status,
196
+ require_token: false,
197
+ allowed_ips: '',
198
+ body_schema: '',
199
+ };
200
+
201
+ const defaultActionForm = {
202
+ type: 'email' as IncomingAction['type'],
203
+ name: '',
204
+ order: 0,
205
+ status: 'active' as Status,
206
+ mail_profile_id: '',
207
+ mail_id: '',
208
+ email_to: '',
209
+ email_cc: '',
210
+ email_bcc: '',
211
+ whatsapp_target_type: 'phone',
212
+ whatsapp_target: '',
213
+ whatsapp_template: '',
214
+ whatsapp_instance: '',
215
+ whatsapp_base_url: '',
216
+ whatsapp_token: '',
217
+ };
218
+
219
+ const defaultEventWebhookForm = {
220
+ slug: '',
221
+ name: '',
222
+ description: '',
223
+ status: 'active' as Status,
224
+ priority: 0,
225
+ url: '',
226
+ method: 'POST',
227
+ headers: '{\n "Content-Type": "application/json"\n}',
228
+ query: '{}',
229
+ payload:
230
+ '{\n "eventName": "{{eventName}}",\n "aggregateId": "{{aggregateId}}",\n "sourceModule": "{{sourceModule}}"\n}',
231
+ timeout_ms: 10000,
232
+ retry_count: 0,
233
+ event_ids: [] as number[],
234
+ };
235
+
236
+ function stringifyJson(value: unknown) {
237
+ if (!value) return '';
238
+ if (typeof value === 'string') return value;
239
+ return JSON.stringify(value, null, 2);
240
+ }
241
+
242
+ function parseJsonText(value: string) {
243
+ const trimmed = value.trim();
244
+ if (!trimmed) return null;
245
+ return JSON.parse(trimmed);
246
+ }
247
+
248
+ function parseStringArrayText(value: string) {
249
+ try {
250
+ const parsed = parseJsonText(value);
251
+ if (!Array.isArray(parsed)) return [];
252
+ return parsed.filter((item): item is string => typeof item === 'string');
253
+ } catch {
254
+ return [];
255
+ }
256
+ }
257
+
258
+ function stringifyStringArray(values: string[]) {
259
+ const filtered = values.map((value) => value.trim()).filter(Boolean);
260
+ return filtered.length ? JSON.stringify(filtered, null, 2) : '';
261
+ }
262
+
263
+ function getPublicWebhookUrl(uuid: string, publicUrl?: string | null) {
264
+ if (publicUrl) return publicUrl;
265
+ const baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || '';
266
+ return `${baseUrl.replace(/\/+$/, '')}/webhook/${uuid}`;
267
+ }
268
+
269
+ export default function IntegrationsWebhooksPage() {
270
+ const t = useTranslations('core.IntegrationsWebhooksPage');
271
+ const { request, currentLocaleCode, getSettingValue } = useApp();
272
+ const pageSizeOptions = [6, 12, 24, 48] as const;
273
+
274
+ const catalog = usePagination({
275
+ url: '/integration-event-catalog',
276
+ paginationOptions: { pageSizeOptions },
277
+ });
278
+ const incoming = usePagination({
279
+ url: '/webhook-integration',
280
+ paginationOptions: { pageSizeOptions },
281
+ });
282
+ const eventWebhooks = usePagination({
283
+ url: '/event-webhook',
284
+ paginationOptions: { pageSizeOptions },
285
+ });
286
+
287
+ const [activeTab, setActiveTab] = useState('incoming');
288
+ const [catalogOpen, setCatalogOpen] = useState(false);
289
+ const [catalogEdit, setCatalogEdit] = useState<EventCatalog | null>(null);
290
+ const [catalogForm, setCatalogForm] = useState(defaultCatalogForm);
291
+
292
+ const [incomingOpen, setIncomingOpen] = useState(false);
293
+ const [incomingEdit, setIncomingEdit] = useState<IncomingIntegration | null>(
294
+ null
295
+ );
296
+ const [incomingForm, setIncomingForm] = useState(defaultIncomingForm);
297
+ const [incomingSheetTab, setIncomingSheetTab] = useState('settings');
298
+ const [oneTimeToken, setOneTimeToken] = useState<string | null>(null);
299
+ const [allowedIpDraft, setAllowedIpDraft] = useState('');
300
+ const [actionForm, setActionForm] = useState(defaultActionForm);
301
+ const [incomingLogsPage, setIncomingLogsPage] = useState(1);
302
+ const [incomingLogsPageSize, setIncomingLogsPageSize] = useState(12);
303
+
304
+ const [eventWebhookOpen, setEventWebhookOpen] = useState(false);
305
+ const [eventWebhookEdit, setEventWebhookEdit] = useState<EventWebhook | null>(
306
+ null
307
+ );
308
+ const [eventWebhookForm, setEventWebhookForm] = useState(
309
+ defaultEventWebhookForm
310
+ );
311
+
312
+ const [deleteTarget, setDeleteTarget] = useState<{
313
+ kind: 'catalog' | 'incoming' | 'eventWebhook' | 'action';
314
+ id: number;
315
+ parentId?: number;
316
+ name: string;
317
+ } | null>(null);
318
+
319
+ const { data: eventOptions = [], refetch: refetchEventOptions } = useQuery<
320
+ SelectOption[]
321
+ >({
322
+ queryKey: ['integration-event-catalog-options', currentLocaleCode],
323
+ queryFn: async () => {
324
+ const response = await request<SelectOption[]>({
325
+ url: '/integration-event-catalog/options',
326
+ method: 'GET',
327
+ });
328
+ return response.data || [];
329
+ },
330
+ placeholderData: (previous) => previous ?? [],
331
+ });
332
+
333
+ const { data: mailProfiles = [] } = useQuery<SelectOption[]>({
334
+ queryKey: ['webhook-mail-profiles', currentLocaleCode],
335
+ queryFn: async () => {
336
+ const response = await request<{ data: SelectOption[] }>({
337
+ url: '/mail-profile?pageSize=100',
338
+ method: 'GET',
339
+ });
340
+ return response.data.data || [];
341
+ },
342
+ placeholderData: (previous) => previous ?? [],
343
+ });
344
+
345
+ const { data: mailTemplates = [] } = useQuery<SelectOption[]>({
346
+ queryKey: ['webhook-mail-templates', currentLocaleCode],
347
+ queryFn: async () => {
348
+ const response = await request<{ data: SelectOption[] }>({
349
+ url: '/mail?pageSize=100',
350
+ method: 'GET',
351
+ });
352
+ return response.data.data || [];
353
+ },
354
+ placeholderData: (previous) => previous ?? [],
355
+ });
356
+
357
+ const { data: incomingLogsResult, isFetching: isFetchingIncomingLogs } =
358
+ useQuery<PaginatedResponse<IncomingCallLog>>({
359
+ queryKey: [
360
+ 'incoming-webhook-logs',
361
+ incomingEdit?.id,
362
+ incomingLogsPage,
363
+ incomingLogsPageSize,
364
+ ],
365
+ queryFn: async () => {
366
+ if (!incomingEdit?.id) {
367
+ return {
368
+ data: [],
369
+ totalItems: 0,
370
+ currentPage: 1,
371
+ pageSize: incomingLogsPageSize,
372
+ };
373
+ }
374
+
375
+ const response = await request<PaginatedResponse<IncomingCallLog>>({
376
+ url: `/webhook-integration/${incomingEdit.id}/log?page=${incomingLogsPage}&pageSize=${incomingLogsPageSize}`,
377
+ method: 'GET',
378
+ });
379
+
380
+ return (
381
+ response.data || {
382
+ data: [],
383
+ totalItems: 0,
384
+ currentPage: 1,
385
+ pageSize: incomingLogsPageSize,
386
+ }
387
+ );
388
+ },
389
+ placeholderData: (previous) =>
390
+ previous || {
391
+ data: [],
392
+ totalItems: 0,
393
+ currentPage: 1,
394
+ pageSize: incomingLogsPageSize,
395
+ },
396
+ });
397
+
398
+ const stats = useMemo(
399
+ () => [
400
+ {
401
+ key: 'events',
402
+ title: t('statsEvents'),
403
+ value: catalog.totalItems,
404
+ icon: BookOpenText,
405
+ accentClassName: 'from-slate-500/20 via-slate-400/10 to-transparent',
406
+ },
407
+ {
408
+ key: 'incoming',
409
+ title: t('statsIncoming'),
410
+ value: incoming.totalItems,
411
+ icon: Webhook,
412
+ accentClassName: 'from-blue-500/20 via-cyan-500/10 to-transparent',
413
+ },
414
+ {
415
+ key: 'eventWebhooks',
416
+ title: t('statsEventWebhooks'),
417
+ value: eventWebhooks.totalItems,
418
+ icon: Send,
419
+ accentClassName: 'from-emerald-500/20 via-green-500/10 to-transparent',
420
+ },
421
+ ],
422
+ [catalog.totalItems, incoming.totalItems, eventWebhooks.totalItems, t]
423
+ );
424
+ const allowedIpList = useMemo(
425
+ () => parseStringArrayText(incomingForm.allowed_ips),
426
+ [incomingForm.allowed_ips]
427
+ );
428
+ const incomingLogItems = incomingLogsResult?.data || [];
429
+ const incomingLogsTotalItems = incomingLogsResult?.totalItems || 0;
430
+
431
+ const openCatalogCreate = () => {
432
+ setCatalogEdit(null);
433
+ setCatalogForm(defaultCatalogForm);
434
+ setCatalogOpen(true);
435
+ };
436
+
437
+ const openCatalogEdit = (item: EventCatalog) => {
438
+ setCatalogEdit(item);
439
+ setCatalogForm({
440
+ slug: item.slug,
441
+ name: item.name,
442
+ module: item.module,
443
+ description: item.description || '',
444
+ status: item.status,
445
+ });
446
+ setCatalogOpen(true);
447
+ };
448
+
449
+ const saveCatalog = async () => {
450
+ try {
451
+ if (catalogEdit) {
452
+ await request({
453
+ url: `/integration-event-catalog/${catalogEdit.id}`,
454
+ method: 'PATCH',
455
+ data: catalogForm,
456
+ });
457
+ toast.success(t('updated'));
458
+ } else {
459
+ await request({
460
+ url: '/integration-event-catalog',
461
+ method: 'POST',
462
+ data: catalogForm,
463
+ });
464
+ toast.success(t('created'));
465
+ }
466
+ setCatalogOpen(false);
467
+ await Promise.all([catalog.refetch(), refetchEventOptions()]);
468
+ } catch {
469
+ toast.error(t('saveError'));
470
+ }
471
+ };
472
+
473
+ const openIncomingCreate = () => {
474
+ setIncomingEdit(null);
475
+ setIncomingSheetTab('settings');
476
+ setIncomingLogsPage(1);
477
+ setIncomingForm(defaultIncomingForm);
478
+ setOneTimeToken(null);
479
+ setAllowedIpDraft('');
480
+ setActionForm(defaultActionForm);
481
+ setIncomingOpen(true);
482
+ };
483
+
484
+ const openIncomingEdit = async (item: IncomingIntegration) => {
485
+ const response = await request<IncomingIntegration>({
486
+ url: `/webhook-integration/${item.id}`,
487
+ method: 'GET',
488
+ });
489
+ const data = response.data;
490
+ setIncomingEdit(data);
491
+ setIncomingForm({
492
+ slug: data.slug,
493
+ name: data.name,
494
+ description: data.description || '',
495
+ status: data.status,
496
+ require_token: data.require_token,
497
+ allowed_ips: stringifyJson(data.allowed_ips),
498
+ body_schema: stringifyJson(data.body_schema),
499
+ });
500
+ if (data.plainToken) {
501
+ setOneTimeToken(data.plainToken);
502
+ }
503
+ setAllowedIpDraft('');
504
+ setActionForm(defaultActionForm);
505
+ setIncomingSheetTab('settings');
506
+ setIncomingLogsPage(1);
507
+ setIncomingOpen(true);
508
+ };
509
+
510
+ const setAllowedIpList = (items: string[]) => {
511
+ setIncomingForm((current) => ({
512
+ ...current,
513
+ allowed_ips: stringifyStringArray(items),
514
+ }));
515
+ };
516
+
517
+ const addAllowedIp = () => {
518
+ const nextIp = allowedIpDraft.trim();
519
+ if (!nextIp) return;
520
+ setAllowedIpList([...allowedIpList, nextIp]);
521
+ setAllowedIpDraft('');
522
+ };
523
+
524
+ const updateAllowedIp = (index: number, value: string) => {
525
+ const items = [...allowedIpList];
526
+ items[index] = value;
527
+ setAllowedIpList(items);
528
+ };
529
+
530
+ const removeAllowedIp = (index: number) => {
531
+ setAllowedIpList(
532
+ allowedIpList.filter((_, itemIndex) => itemIndex !== index)
533
+ );
534
+ };
535
+
536
+ const saveIncoming = async () => {
537
+ try {
538
+ const payload = {
539
+ ...incomingForm,
540
+ allowed_ips: parseJsonText(incomingForm.allowed_ips),
541
+ body_schema: parseJsonText(incomingForm.body_schema),
542
+ };
543
+
544
+ const response = incomingEdit
545
+ ? await request<IncomingIntegration>({
546
+ url: `/webhook-integration/${incomingEdit.id}`,
547
+ method: 'PATCH',
548
+ data: payload,
549
+ })
550
+ : await request<IncomingIntegration>({
551
+ url: '/webhook-integration',
552
+ method: 'POST',
553
+ data: payload,
554
+ });
555
+
556
+ if (response.data.plainToken) {
557
+ setOneTimeToken(response.data.plainToken);
558
+ toast.success(t('tokenGenerated'));
559
+ } else {
560
+ toast.success(incomingEdit ? t('updated') : t('created'));
561
+ }
562
+
563
+ if (!incomingEdit) {
564
+ await openIncomingEdit(response.data);
565
+ }
566
+
567
+ await incoming.refetch();
568
+ } catch {
569
+ toast.error(t('saveError'));
570
+ }
571
+ };
572
+
573
+ const saveAction = async () => {
574
+ if (!incomingEdit) return;
575
+
576
+ try {
577
+ await request({
578
+ url: `/webhook-integration/${incomingEdit.id}/action`,
579
+ method: 'POST',
580
+ data: {
581
+ ...actionForm,
582
+ order: Number(actionForm.order || 0),
583
+ mail_profile_id: actionForm.mail_profile_id || null,
584
+ mail_id: actionForm.mail_id || null,
585
+ },
586
+ });
587
+ toast.success(t('created'));
588
+ setActionForm(defaultActionForm);
589
+ await openIncomingEdit(incomingEdit);
590
+ await incoming.refetch();
591
+ } catch {
592
+ toast.error(t('saveError'));
593
+ }
594
+ };
595
+
596
+ const regenerateUuid = async () => {
597
+ if (!incomingEdit) return;
598
+ const response = await request<IncomingIntegration>({
599
+ url: `/webhook-integration/${incomingEdit.id}/regenerate-uuid`,
600
+ method: 'POST',
601
+ });
602
+ toast.success(t('uuidRegenerated'));
603
+ await openIncomingEdit(response.data);
604
+ await incoming.refetch();
605
+ };
606
+
607
+ const regenerateToken = async () => {
608
+ if (!incomingEdit) return;
609
+ const response = await request<IncomingIntegration>({
610
+ url: `/webhook-integration/${incomingEdit.id}/regenerate-token`,
611
+ method: 'POST',
612
+ });
613
+ toast.success(
614
+ response.data.plainToken ? t('tokenGenerated') : t('tokenGenerated')
615
+ );
616
+ setOneTimeToken(response.data.plainToken || null);
617
+ await openIncomingEdit(response.data);
618
+ await incoming.refetch();
619
+ };
620
+
621
+ const openEventWebhookCreate = () => {
622
+ setEventWebhookEdit(null);
623
+ setEventWebhookForm(defaultEventWebhookForm);
624
+ setEventWebhookOpen(true);
625
+ };
626
+
627
+ const openEventWebhookEdit = async (item: EventWebhook) => {
628
+ const response = await request<EventWebhook>({
629
+ url: `/event-webhook/${item.id}`,
630
+ method: 'GET',
631
+ });
632
+ const data = response.data;
633
+ setEventWebhookEdit(data);
634
+ setEventWebhookForm({
635
+ slug: data.slug,
636
+ name: data.name,
637
+ description: data.description || '',
638
+ status: data.status,
639
+ priority: data.priority || 0,
640
+ url: data.url,
641
+ method: data.method || 'POST',
642
+ headers: stringifyJson(data.headers) || '{}',
643
+ query: stringifyJson(data.query) || '{}',
644
+ payload: stringifyJson(data.payload) || '{}',
645
+ timeout_ms: data.timeout_ms || 10000,
646
+ retry_count: data.retry_count || 0,
647
+ event_ids:
648
+ data.event_webhook_event?.map(
649
+ (row) => row.integration_event_catalog.id
650
+ ) || [],
651
+ });
652
+ setEventWebhookOpen(true);
653
+ };
654
+
655
+ const saveEventWebhook = async () => {
656
+ try {
657
+ const payload = {
658
+ ...eventWebhookForm,
659
+ headers: parseJsonText(eventWebhookForm.headers),
660
+ query: parseJsonText(eventWebhookForm.query),
661
+ payload: parseJsonText(eventWebhookForm.payload),
662
+ };
663
+
664
+ if (eventWebhookEdit) {
665
+ await request({
666
+ url: `/event-webhook/${eventWebhookEdit.id}`,
667
+ method: 'PATCH',
668
+ data: payload,
669
+ });
670
+ toast.success(t('updated'));
671
+ } else {
672
+ await request({
673
+ url: '/event-webhook',
674
+ method: 'POST',
675
+ data: payload,
676
+ });
677
+ toast.success(t('created'));
678
+ }
679
+ setEventWebhookOpen(false);
680
+ await eventWebhooks.refetch();
681
+ } catch {
682
+ toast.error(t('saveError'));
683
+ }
684
+ };
685
+
686
+ const confirmDelete = async () => {
687
+ if (!deleteTarget) return;
688
+
689
+ try {
690
+ if (deleteTarget.kind === 'catalog') {
691
+ await request({
692
+ url: '/integration-event-catalog',
693
+ method: 'DELETE',
694
+ data: { ids: [deleteTarget.id] },
695
+ });
696
+ await Promise.all([catalog.refetch(), refetchEventOptions()]);
697
+ }
698
+ if (deleteTarget.kind === 'incoming') {
699
+ await request({
700
+ url: '/webhook-integration',
701
+ method: 'DELETE',
702
+ data: { ids: [deleteTarget.id] },
703
+ });
704
+ await incoming.refetch();
705
+ }
706
+ if (deleteTarget.kind === 'eventWebhook') {
707
+ await request({
708
+ url: '/event-webhook',
709
+ method: 'DELETE',
710
+ data: { ids: [deleteTarget.id] },
711
+ });
712
+ await eventWebhooks.refetch();
713
+ }
714
+ if (deleteTarget.kind === 'action' && deleteTarget.parentId) {
715
+ await request({
716
+ url: `/webhook-integration/${deleteTarget.parentId}/action`,
717
+ method: 'DELETE',
718
+ data: { ids: [deleteTarget.id] },
719
+ });
720
+ if (incomingEdit) {
721
+ await openIncomingEdit(incomingEdit);
722
+ }
723
+ await incoming.refetch();
724
+ }
725
+ toast.success(t('deleted'));
726
+ setDeleteTarget(null);
727
+ } catch {
728
+ toast.error(t('deleteError'));
729
+ }
730
+ };
731
+
732
+ const renderStatus = (status: Status) => (
733
+ <Badge variant="outline" className={statusClass[status]}>
734
+ {t(status)}
735
+ </Badge>
736
+ );
737
+
738
+ return (
739
+ <Page>
740
+ <PageHeader
741
+ breadcrumbs={[
742
+ { label: t('breadcrumbHome'), href: '/' },
743
+ { label: t('breadcrumbManagement'), href: '/core/management' },
744
+ { label: t('title') },
745
+ ]}
746
+ title={t('title')}
747
+ description={t('description')}
748
+ actions={[
749
+ activeTab === 'catalog'
750
+ ? {
751
+ label: t('newEvent'),
752
+ icon: <Plus className="h-4 w-4" />,
753
+ onClick: openCatalogCreate,
754
+ }
755
+ : activeTab === 'incoming'
756
+ ? {
757
+ label: t('newIncoming'),
758
+ icon: <Plus className="h-4 w-4" />,
759
+ onClick: openIncomingCreate,
760
+ }
761
+ : {
762
+ label: t('newEventWebhook'),
763
+ icon: <Plus className="h-4 w-4" />,
764
+ onClick: openEventWebhookCreate,
765
+ },
766
+ ]}
767
+ />
768
+
769
+ <KpiCardsGrid items={stats} />
770
+
771
+ <Tabs value={activeTab} onValueChange={setActiveTab}>
772
+ <TabsList>
773
+ <TabsTrigger value="incoming">
774
+ <Webhook className="h-4 w-4" />
775
+ {t('tabIncoming')}
776
+ </TabsTrigger>
777
+ <TabsTrigger value="eventWebhooks">
778
+ <Send className="h-4 w-4" />
779
+ {t('tabEventWebhooks')}
780
+ </TabsTrigger>
781
+ <TabsTrigger value="catalog">
782
+ <BookOpenText className="h-4 w-4" />
783
+ {t('tabCatalog')}
784
+ </TabsTrigger>
785
+ </TabsList>
786
+
787
+ <TabsContent value="catalog" className="space-y-4">
788
+ <SearchBar
789
+ searchQuery={catalog.search}
790
+ onSearchChange={(value) => {
791
+ catalog.setSearch(value);
792
+ catalog.setPage(1);
793
+ }}
794
+ onSearch={() => catalog.setPage(1)}
795
+ placeholder={t('searchEvents')}
796
+ />
797
+
798
+ {catalog.items.length === 0 ? (
799
+ <EmptyState
800
+ icon={<BookOpenText className="h-12 w-12" />}
801
+ title={t('emptyEvents')}
802
+ description={t('emptyEventsDescription')}
803
+ actionLabel={t('newEvent')}
804
+ onAction={openCatalogCreate}
805
+ />
806
+ ) : (
807
+ <div className="overflow-x-auto">
808
+ <Table>
809
+ <TableHeader>
810
+ <TableRow>
811
+ <TableHead>{t('eventName')}</TableHead>
812
+ <TableHead>{t('module')}</TableHead>
813
+ <TableHead>{t('descriptionColumn')}</TableHead>
814
+ <TableHead>{t('status')}</TableHead>
815
+ <TableHead>{t('updatedAt')}</TableHead>
816
+ <TableHead className="text-right">{t('actions')}</TableHead>
817
+ </TableRow>
818
+ </TableHeader>
819
+ <TableBody>
820
+ {(catalog.items as EventCatalog[]).map((item) => (
821
+ <TableRow
822
+ key={item.id}
823
+ className="cursor-pointer"
824
+ onDoubleClick={() => openCatalogEdit(item)}
825
+ >
826
+ <TableCell>
827
+ <div className="font-medium">{item.name}</div>
828
+ <div className="font-mono text-xs text-muted-foreground">
829
+ {item.slug}
830
+ </div>
831
+ </TableCell>
832
+ <TableCell>{item.module}</TableCell>
833
+ <TableCell className="max-w-md truncate">
834
+ {item.description || '-'}
835
+ </TableCell>
836
+ <TableCell>{renderStatus(item.status)}</TableCell>
837
+ <TableCell className="text-muted-foreground">
838
+ {formatDate(
839
+ item.updated_at,
840
+ getSettingValue,
841
+ currentLocaleCode
842
+ )}
843
+ </TableCell>
844
+ <TableCell className="text-right">
845
+ <Button
846
+ variant="ghost"
847
+ size="icon"
848
+ onClick={() => openCatalogEdit(item)}
849
+ >
850
+ <Pencil className="h-4 w-4" />
851
+ </Button>
852
+ <Button
853
+ variant="ghost"
854
+ size="icon"
855
+ onClick={() =>
856
+ setDeleteTarget({
857
+ kind: 'catalog',
858
+ id: item.id,
859
+ name: item.name,
860
+ })
861
+ }
862
+ >
863
+ <Trash2 className="h-4 w-4 text-destructive" />
864
+ </Button>
865
+ </TableCell>
866
+ </TableRow>
867
+ ))}
868
+ </TableBody>
869
+ </Table>
870
+ </div>
871
+ )}
872
+
873
+ <PaginationFooter
874
+ currentPage={catalog.page}
875
+ pageSize={catalog.pageSize}
876
+ totalItems={catalog.totalItems}
877
+ onPageChange={catalog.setPage}
878
+ onPageSizeChange={(size) => {
879
+ catalog.setPageSize(size);
880
+ catalog.setPage(1);
881
+ }}
882
+ />
883
+ </TabsContent>
884
+
885
+ <TabsContent value="incoming" className="space-y-4">
886
+ <SearchBar
887
+ searchQuery={incoming.search}
888
+ onSearchChange={(value) => {
889
+ incoming.setSearch(value);
890
+ incoming.setPage(1);
891
+ }}
892
+ onSearch={() => incoming.setPage(1)}
893
+ placeholder={t('searchIncoming')}
894
+ />
895
+
896
+ {incoming.items.length === 0 ? (
897
+ <EmptyState
898
+ icon={<Webhook className="h-12 w-12" />}
899
+ title={t('emptyIncoming')}
900
+ description={t('emptyIncomingDescription')}
901
+ actionLabel={t('newIncoming')}
902
+ onAction={openIncomingCreate}
903
+ />
904
+ ) : (
905
+ <div className="overflow-x-auto">
906
+ <Table>
907
+ <TableHeader>
908
+ <TableRow>
909
+ <TableHead>{t('name')}</TableHead>
910
+ <TableHead>{t('publicUrl')}</TableHead>
911
+ <TableHead>{t('security')}</TableHead>
912
+ <TableHead>{t('actionsCount')}</TableHead>
913
+ <TableHead>{t('status')}</TableHead>
914
+ <TableHead className="text-right">{t('actions')}</TableHead>
915
+ </TableRow>
916
+ </TableHeader>
917
+ <TableBody>
918
+ {(incoming.items as IncomingIntegration[]).map((item) => (
919
+ <TableRow
920
+ key={item.id}
921
+ className="cursor-pointer"
922
+ onDoubleClick={() => openIncomingEdit(item)}
923
+ >
924
+ <TableCell>
925
+ <div className="font-medium">{item.name}</div>
926
+ <div className="font-mono text-xs text-muted-foreground">
927
+ {item.slug}
928
+ </div>
929
+ </TableCell>
930
+ <TableCell>
931
+ <div className="flex max-w-md items-center gap-2">
932
+ <span className="truncate font-mono text-xs">
933
+ {getPublicWebhookUrl(
934
+ item.public_uuid,
935
+ item.public_url
936
+ )}
937
+ </span>
938
+ <CopyButton
939
+ value={getPublicWebhookUrl(
940
+ item.public_uuid,
941
+ item.public_url
942
+ )}
943
+ />
944
+ </div>
945
+ </TableCell>
946
+ <TableCell>
947
+ {item.require_token ? (
948
+ <Badge variant="secondary">{t('token')}</Badge>
949
+ ) : (
950
+ <Badge variant="outline">{t('uuid')}</Badge>
951
+ )}
952
+ </TableCell>
953
+ <TableCell>{item.webhook_action?.length || 0}</TableCell>
954
+ <TableCell>{renderStatus(item.status)}</TableCell>
955
+ <TableCell className="text-right">
956
+ <Button
957
+ variant="ghost"
958
+ size="icon"
959
+ onClick={() => openIncomingEdit(item)}
960
+ >
961
+ <Pencil className="h-4 w-4" />
962
+ </Button>
963
+ <Button
964
+ variant="ghost"
965
+ size="icon"
966
+ onClick={() =>
967
+ setDeleteTarget({
968
+ kind: 'incoming',
969
+ id: item.id,
970
+ name: item.name,
971
+ })
972
+ }
973
+ >
974
+ <Trash2 className="h-4 w-4 text-destructive" />
975
+ </Button>
976
+ </TableCell>
977
+ </TableRow>
978
+ ))}
979
+ </TableBody>
980
+ </Table>
981
+ </div>
982
+ )}
983
+
984
+ <PaginationFooter
985
+ currentPage={incoming.page}
986
+ pageSize={incoming.pageSize}
987
+ totalItems={incoming.totalItems}
988
+ onPageChange={incoming.setPage}
989
+ onPageSizeChange={(size) => {
990
+ incoming.setPageSize(size);
991
+ incoming.setPage(1);
992
+ }}
993
+ />
994
+ </TabsContent>
995
+
996
+ <TabsContent value="eventWebhooks" className="space-y-4">
997
+ <SearchBar
998
+ searchQuery={eventWebhooks.search}
999
+ onSearchChange={(value) => {
1000
+ eventWebhooks.setSearch(value);
1001
+ eventWebhooks.setPage(1);
1002
+ }}
1003
+ onSearch={() => eventWebhooks.setPage(1)}
1004
+ placeholder={t('searchEventWebhooks')}
1005
+ />
1006
+
1007
+ {eventWebhooks.items.length === 0 ? (
1008
+ <EmptyState
1009
+ icon={<Send className="h-12 w-12" />}
1010
+ title={t('emptyEventWebhooks')}
1011
+ description={t('emptyEventWebhooksDescription')}
1012
+ actionLabel={t('newEventWebhook')}
1013
+ onAction={openEventWebhookCreate}
1014
+ />
1015
+ ) : (
1016
+ <div className="overflow-x-auto">
1017
+ <Table>
1018
+ <TableHeader>
1019
+ <TableRow>
1020
+ <TableHead>{t('name')}</TableHead>
1021
+ <TableHead>{t('events')}</TableHead>
1022
+ <TableHead>{t('httpTarget')}</TableHead>
1023
+ <TableHead>{t('retry')}</TableHead>
1024
+ <TableHead>{t('status')}</TableHead>
1025
+ <TableHead className="text-right">{t('actions')}</TableHead>
1026
+ </TableRow>
1027
+ </TableHeader>
1028
+ <TableBody>
1029
+ {(eventWebhooks.items as EventWebhook[]).map((item) => (
1030
+ <TableRow
1031
+ key={item.id}
1032
+ className="cursor-pointer"
1033
+ onDoubleClick={() => openEventWebhookEdit(item)}
1034
+ >
1035
+ <TableCell>
1036
+ <div className="font-medium">{item.name}</div>
1037
+ <div className="font-mono text-xs text-muted-foreground">
1038
+ {item.slug}
1039
+ </div>
1040
+ </TableCell>
1041
+ <TableCell>
1042
+ <div className="flex flex-wrap gap-1">
1043
+ {item.event_webhook_event?.map((row) => (
1044
+ <Badge
1045
+ key={row.integration_event_catalog.id}
1046
+ variant="secondary"
1047
+ className="font-mono text-xs"
1048
+ >
1049
+ {row.integration_event_catalog.slug}
1050
+ </Badge>
1051
+ ))}
1052
+ </div>
1053
+ </TableCell>
1054
+ <TableCell>
1055
+ <div className="flex items-center gap-2">
1056
+ <Badge variant="outline">{item.method}</Badge>
1057
+ <span className="max-w-md truncate font-mono text-xs">
1058
+ {item.url}
1059
+ </span>
1060
+ </div>
1061
+ </TableCell>
1062
+ <TableCell>{item.retry_count}</TableCell>
1063
+ <TableCell>{renderStatus(item.status)}</TableCell>
1064
+ <TableCell className="text-right">
1065
+ <Button
1066
+ variant="ghost"
1067
+ size="icon"
1068
+ onClick={() => openEventWebhookEdit(item)}
1069
+ >
1070
+ <Pencil className="h-4 w-4" />
1071
+ </Button>
1072
+ <Button
1073
+ variant="ghost"
1074
+ size="icon"
1075
+ onClick={() =>
1076
+ setDeleteTarget({
1077
+ kind: 'eventWebhook',
1078
+ id: item.id,
1079
+ name: item.name,
1080
+ })
1081
+ }
1082
+ >
1083
+ <Trash2 className="h-4 w-4 text-destructive" />
1084
+ </Button>
1085
+ </TableCell>
1086
+ </TableRow>
1087
+ ))}
1088
+ </TableBody>
1089
+ </Table>
1090
+ </div>
1091
+ )}
1092
+
1093
+ <PaginationFooter
1094
+ currentPage={eventWebhooks.page}
1095
+ pageSize={eventWebhooks.pageSize}
1096
+ totalItems={eventWebhooks.totalItems}
1097
+ onPageChange={eventWebhooks.setPage}
1098
+ onPageSizeChange={(size) => {
1099
+ eventWebhooks.setPageSize(size);
1100
+ eventWebhooks.setPage(1);
1101
+ }}
1102
+ />
1103
+ </TabsContent>
1104
+ </Tabs>
1105
+
1106
+ <Sheet open={catalogOpen} onOpenChange={setCatalogOpen}>
1107
+ <SheetContent className="w-full overflow-y-auto sm:max-w-2xl">
1108
+ <SheetHeader>
1109
+ <SheetTitle>
1110
+ {catalogEdit ? t('editEvent') : t('newEvent')}
1111
+ </SheetTitle>
1112
+ <SheetDescription>{t('eventFormDescription')}</SheetDescription>
1113
+ </SheetHeader>
1114
+ <div className="space-y-4 px-3 pb-8 sm:px-4">
1115
+ <Field label={t('eventName')}>
1116
+ <Input
1117
+ value={catalogForm.name}
1118
+ onChange={(event) =>
1119
+ setCatalogForm({ ...catalogForm, name: event.target.value })
1120
+ }
1121
+ />
1122
+ </Field>
1123
+ <Field label={t('technicalName')}>
1124
+ <Input
1125
+ value={catalogForm.slug}
1126
+ onChange={(event) =>
1127
+ setCatalogForm({ ...catalogForm, slug: event.target.value })
1128
+ }
1129
+ placeholder="module.entity.changed"
1130
+ />
1131
+ </Field>
1132
+ <Field label={t('module')}>
1133
+ <Input
1134
+ value={catalogForm.module}
1135
+ onChange={(event) =>
1136
+ setCatalogForm({ ...catalogForm, module: event.target.value })
1137
+ }
1138
+ />
1139
+ </Field>
1140
+ <Field label={t('descriptionColumn')}>
1141
+ <Textarea
1142
+ value={catalogForm.description}
1143
+ onChange={(event) =>
1144
+ setCatalogForm({
1145
+ ...catalogForm,
1146
+ description: event.target.value,
1147
+ })
1148
+ }
1149
+ />
1150
+ </Field>
1151
+ <StatusSelect
1152
+ value={catalogForm.status}
1153
+ onValueChange={(status) =>
1154
+ setCatalogForm({ ...catalogForm, status })
1155
+ }
1156
+ t={t}
1157
+ />
1158
+ <SheetActions
1159
+ onSave={saveCatalog}
1160
+ onCancel={() => setCatalogOpen(false)}
1161
+ t={t}
1162
+ />
1163
+ </div>
1164
+ </SheetContent>
1165
+ </Sheet>
1166
+
1167
+ <Sheet
1168
+ open={incomingOpen}
1169
+ onOpenChange={(open) => {
1170
+ setIncomingOpen(open);
1171
+ if (!open) {
1172
+ setOneTimeToken(null);
1173
+ setIncomingSheetTab('settings');
1174
+ setIncomingLogsPage(1);
1175
+ }
1176
+ }}
1177
+ >
1178
+ <SheetContent className="w-full overflow-y-auto sm:max-w-5xl">
1179
+ <SheetHeader>
1180
+ <SheetTitle>
1181
+ {incomingEdit ? t('editIncoming') : t('newIncoming')}
1182
+ </SheetTitle>
1183
+ <SheetDescription>{t('incomingFormDescription')}</SheetDescription>
1184
+ </SheetHeader>
1185
+ <Tabs
1186
+ value={incomingSheetTab}
1187
+ onValueChange={setIncomingSheetTab}
1188
+ className="mt-4"
1189
+ >
1190
+ <div className="px-3 sm:px-4">
1191
+ <TabsList className="grid w-full grid-cols-2">
1192
+ <TabsTrigger value="settings">
1193
+ <Webhook className="mr-2 h-4 w-4" />
1194
+ {t('incomingSheetTabSettings')}
1195
+ </TabsTrigger>
1196
+ <TabsTrigger value="logs" disabled={!incomingEdit}>
1197
+ <Clock3 className="mr-2 h-4 w-4" />
1198
+ {t('incomingSheetTabLogs')}
1199
+ </TabsTrigger>
1200
+ </TabsList>
1201
+ </div>
1202
+ <TabsContent value="settings" className="mt-0">
1203
+ <div className="space-y-6 px-3 pb-8 sm:px-4">
1204
+ <div className="grid gap-4 md:grid-cols-2">
1205
+ <Field label={t('name')}>
1206
+ <Input
1207
+ value={incomingForm.name}
1208
+ onChange={(event) =>
1209
+ setIncomingForm({
1210
+ ...incomingForm,
1211
+ name: event.target.value,
1212
+ })
1213
+ }
1214
+ />
1215
+ </Field>
1216
+ <Field label={t('technicalName')}>
1217
+ <Input
1218
+ value={incomingForm.slug}
1219
+ onChange={(event) =>
1220
+ setIncomingForm({
1221
+ ...incomingForm,
1222
+ slug: event.target.value,
1223
+ })
1224
+ }
1225
+ />
1226
+ </Field>
1227
+ </div>
1228
+ <Field label={t('descriptionColumn')}>
1229
+ <Textarea
1230
+ value={incomingForm.description}
1231
+ onChange={(event) =>
1232
+ setIncomingForm({
1233
+ ...incomingForm,
1234
+ description: event.target.value,
1235
+ })
1236
+ }
1237
+ />
1238
+ </Field>
1239
+ <div className="grid gap-4 md:grid-cols-3">
1240
+ <StatusSelect
1241
+ value={incomingForm.status}
1242
+ onValueChange={(status) =>
1243
+ setIncomingForm({ ...incomingForm, status })
1244
+ }
1245
+ t={t}
1246
+ />
1247
+ <div className="flex items-center justify-between rounded-md border px-4 py-3 md:col-span-2">
1248
+ <div>
1249
+ <p className="text-sm font-medium">{t('requireToken')}</p>
1250
+ <p className="text-xs text-muted-foreground">
1251
+ {t('requireTokenHelp')}
1252
+ </p>
1253
+ </div>
1254
+ <Switch
1255
+ checked={incomingForm.require_token}
1256
+ onCheckedChange={(checked) =>
1257
+ setIncomingForm({
1258
+ ...incomingForm,
1259
+ require_token: checked,
1260
+ })
1261
+ }
1262
+ />
1263
+ </div>
1264
+ </div>
1265
+ {oneTimeToken ? (
1266
+ <div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-4">
1267
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
1268
+ <div className="min-w-0 space-y-1">
1269
+ <Label>{t('oneTimeTokenTitle')}</Label>
1270
+ <p className="text-xs text-muted-foreground">
1271
+ {t('oneTimeTokenDescription')}
1272
+ </p>
1273
+ <div className="mt-2 flex min-w-0 items-center gap-2 rounded-md border bg-background px-3 py-2">
1274
+ <code className="min-w-0 flex-1 truncate text-xs">
1275
+ {oneTimeToken}
1276
+ </code>
1277
+ <CopyButton
1278
+ value={oneTimeToken}
1279
+ className="h-7 w-7"
1280
+ />
1281
+ </div>
1282
+ </div>
1283
+ </div>
1284
+ </div>
1285
+ ) : incomingForm.require_token ? (
1286
+ <div className="rounded-md border p-4">
1287
+ <div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
1288
+ <div>
1289
+ <Label>{t('tokenConfiguredTitle')}</Label>
1290
+ <p className="text-xs text-muted-foreground">
1291
+ {incomingEdit
1292
+ ? t('tokenConfiguredDescription')
1293
+ : t('tokenWillBeCreatedDescription')}
1294
+ </p>
1295
+ </div>
1296
+ {incomingEdit ? (
1297
+ <Button
1298
+ variant="outline"
1299
+ size="sm"
1300
+ onClick={regenerateToken}
1301
+ >
1302
+ <RotateCcw className="mr-2 h-4 w-4" />
1303
+ {t('regenerateToken')}
1304
+ </Button>
1305
+ ) : null}
1306
+ </div>
1307
+ </div>
1308
+ ) : null}
1309
+ <div className="grid gap-4 md:grid-cols-2">
1310
+ <Field label={t('allowedIps')}>
1311
+ <div className="space-y-3">
1312
+ <div className="rounded-md border">
1313
+ <Table>
1314
+ <TableHeader>
1315
+ <TableRow>
1316
+ <TableHead>{t('ipAddress')}</TableHead>
1317
+ <TableHead className="w-18 text-right">
1318
+ {t('actions')}
1319
+ </TableHead>
1320
+ </TableRow>
1321
+ </TableHeader>
1322
+ <TableBody>
1323
+ {allowedIpList.length === 0 ? (
1324
+ <TableRow>
1325
+ <TableCell
1326
+ colSpan={2}
1327
+ className="h-16 text-center text-sm text-muted-foreground"
1328
+ >
1329
+ {t('emptyAllowedIps')}
1330
+ </TableCell>
1331
+ </TableRow>
1332
+ ) : (
1333
+ allowedIpList.map((ip, index) => (
1334
+ <TableRow key={`${ip}-${index}`}>
1335
+ <TableCell>
1336
+ <Input
1337
+ value={ip}
1338
+ onChange={(event) =>
1339
+ updateAllowedIp(
1340
+ index,
1341
+ event.target.value
1342
+ )
1343
+ }
1344
+ placeholder="127.0.0.1"
1345
+ />
1346
+ </TableCell>
1347
+ <TableCell className="text-right">
1348
+ <Button
1349
+ type="button"
1350
+ variant="ghost"
1351
+ size="icon"
1352
+ onClick={() => removeAllowedIp(index)}
1353
+ >
1354
+ <Trash2 className="h-4 w-4" />
1355
+ </Button>
1356
+ </TableCell>
1357
+ </TableRow>
1358
+ ))
1359
+ )}
1360
+ </TableBody>
1361
+ </Table>
1362
+ </div>
1363
+ <div className="flex gap-2">
1364
+ <Input
1365
+ value={allowedIpDraft}
1366
+ onChange={(event) =>
1367
+ setAllowedIpDraft(event.target.value)
1368
+ }
1369
+ onKeyDown={(event) => {
1370
+ if (event.key === 'Enter') {
1371
+ event.preventDefault();
1372
+ addAllowedIp();
1373
+ }
1374
+ }}
1375
+ placeholder="127.0.0.1"
1376
+ />
1377
+ <Button
1378
+ type="button"
1379
+ variant="outline"
1380
+ onClick={addAllowedIp}
1381
+ >
1382
+ <Plus className="mr-2 h-4 w-4" />
1383
+ {t('addIp')}
1384
+ </Button>
1385
+ </div>
1386
+ </div>
1387
+ </Field>
1388
+ <Field label={t('bodySchema')}>
1389
+ <Textarea
1390
+ value={incomingForm.body_schema}
1391
+ onChange={(event) =>
1392
+ setIncomingForm({
1393
+ ...incomingForm,
1394
+ body_schema: event.target.value,
1395
+ })
1396
+ }
1397
+ placeholder='{"type":"object"}'
1398
+ />
1399
+ </Field>
1400
+ </div>
1401
+ {incomingEdit && (
1402
+ <div className="rounded-md border p-4">
1403
+ <div className="mb-3 flex items-center justify-between gap-3">
1404
+ <div className="min-w-0">
1405
+ <Label>{t('publicUrl')}</Label>
1406
+ <div className="mt-1 flex items-center gap-2">
1407
+ <span className="truncate font-mono text-xs">
1408
+ {getPublicWebhookUrl(
1409
+ incomingEdit.public_uuid,
1410
+ incomingEdit.public_url
1411
+ )}
1412
+ </span>
1413
+ <CopyButton
1414
+ value={getPublicWebhookUrl(
1415
+ incomingEdit.public_uuid,
1416
+ incomingEdit.public_url
1417
+ )}
1418
+ />
1419
+ </div>
1420
+ </div>
1421
+ <div className="flex gap-2">
1422
+ <Button
1423
+ variant="outline"
1424
+ size="sm"
1425
+ onClick={regenerateUuid}
1426
+ >
1427
+ <RotateCcw className="mr-2 h-4 w-4" />
1428
+ {t('regenerateUuid')}
1429
+ </Button>
1430
+ </div>
1431
+ </div>
1432
+ </div>
1433
+ )}
1434
+ <SheetActions
1435
+ onSave={saveIncoming}
1436
+ onCancel={() => setIncomingOpen(false)}
1437
+ t={t}
1438
+ />
1439
+
1440
+ {incomingEdit && (
1441
+ <div className="space-y-4 border-t pt-5">
1442
+ <div className="flex items-center gap-2">
1443
+ <Activity className="h-4 w-4" />
1444
+ <h3 className="text-sm font-semibold">
1445
+ {t('actionSection')}
1446
+ </h3>
1447
+ </div>
1448
+ <div className="grid gap-3 md:grid-cols-3">
1449
+ <Field label={t('actionType')}>
1450
+ <Select
1451
+ value={actionForm.type}
1452
+ onValueChange={(value: IncomingAction['type']) =>
1453
+ setActionForm({ ...actionForm, type: value })
1454
+ }
1455
+ >
1456
+ <SelectTrigger className="w-full">
1457
+ <SelectValue />
1458
+ </SelectTrigger>
1459
+ <SelectContent>
1460
+ <SelectItem value="email">{t('email')}</SelectItem>
1461
+ <SelectItem value="whatsapp_evolution">
1462
+ {t('whatsapp')}
1463
+ </SelectItem>
1464
+ </SelectContent>
1465
+ </Select>
1466
+ </Field>
1467
+ <Field label={t('name')}>
1468
+ <Input
1469
+ value={actionForm.name}
1470
+ onChange={(event) =>
1471
+ setActionForm({
1472
+ ...actionForm,
1473
+ name: event.target.value,
1474
+ })
1475
+ }
1476
+ />
1477
+ </Field>
1478
+ <Field label={t('order')}>
1479
+ <Input
1480
+ type="number"
1481
+ value={actionForm.order}
1482
+ onChange={(event) =>
1483
+ setActionForm({
1484
+ ...actionForm,
1485
+ order: Number(event.target.value),
1486
+ })
1487
+ }
1488
+ />
1489
+ </Field>
1490
+ </div>
1491
+ {actionForm.type === 'email' ? (
1492
+ <div className="grid gap-3 md:grid-cols-2">
1493
+ <Field label={t('mailProfile')}>
1494
+ <Select
1495
+ value={actionForm.mail_profile_id}
1496
+ onValueChange={(value) =>
1497
+ setActionForm({
1498
+ ...actionForm,
1499
+ mail_profile_id: value,
1500
+ })
1501
+ }
1502
+ >
1503
+ <SelectTrigger className="w-full">
1504
+ <SelectValue placeholder={t('select')} />
1505
+ </SelectTrigger>
1506
+ <SelectContent>
1507
+ {mailProfiles.map((profile) => (
1508
+ <SelectItem
1509
+ key={profile.id}
1510
+ value={String(profile.id)}
1511
+ >
1512
+ {profile.name || profile.slug}
1513
+ </SelectItem>
1514
+ ))}
1515
+ </SelectContent>
1516
+ </Select>
1517
+ </Field>
1518
+ <Field label={t('mailTemplate')}>
1519
+ <Select
1520
+ value={actionForm.mail_id}
1521
+ onValueChange={(value) =>
1522
+ setActionForm({ ...actionForm, mail_id: value })
1523
+ }
1524
+ >
1525
+ <SelectTrigger className="w-full">
1526
+ <SelectValue placeholder={t('select')} />
1527
+ </SelectTrigger>
1528
+ <SelectContent>
1529
+ {mailTemplates.map((template) => (
1530
+ <SelectItem
1531
+ key={template.id}
1532
+ value={String(template.id)}
1533
+ >
1534
+ {template.slug}
1535
+ </SelectItem>
1536
+ ))}
1537
+ </SelectContent>
1538
+ </Select>
1539
+ </Field>
1540
+ <Field label={t('emailTo')}>
1541
+ <Input
1542
+ value={actionForm.email_to}
1543
+ onChange={(event) =>
1544
+ setActionForm({
1545
+ ...actionForm,
1546
+ email_to: event.target.value,
1547
+ })
1548
+ }
1549
+ placeholder="{{email}}"
1550
+ />
1551
+ </Field>
1552
+ <Field label={t('emailCc')}>
1553
+ <Input
1554
+ value={actionForm.email_cc}
1555
+ onChange={(event) =>
1556
+ setActionForm({
1557
+ ...actionForm,
1558
+ email_cc: event.target.value,
1559
+ })
1560
+ }
1561
+ />
1562
+ </Field>
1563
+ </div>
1564
+ ) : (
1565
+ <div className="grid gap-3 md:grid-cols-2">
1566
+ <Field label={t('whatsappTargetType')}>
1567
+ <Select
1568
+ value={actionForm.whatsapp_target_type}
1569
+ onValueChange={(value) =>
1570
+ setActionForm({
1571
+ ...actionForm,
1572
+ whatsapp_target_type: value,
1573
+ })
1574
+ }
1575
+ >
1576
+ <SelectTrigger className="w-full">
1577
+ <SelectValue />
1578
+ </SelectTrigger>
1579
+ <SelectContent>
1580
+ <SelectItem value="phone">
1581
+ {t('phone')}
1582
+ </SelectItem>
1583
+ <SelectItem value="group">
1584
+ {t('group')}
1585
+ </SelectItem>
1586
+ </SelectContent>
1587
+ </Select>
1588
+ </Field>
1589
+ <Field label={t('whatsappTarget')}>
1590
+ <Input
1591
+ value={actionForm.whatsapp_target}
1592
+ onChange={(event) =>
1593
+ setActionForm({
1594
+ ...actionForm,
1595
+ whatsapp_target: event.target.value,
1596
+ })
1597
+ }
1598
+ placeholder="{{phone}}"
1599
+ />
1600
+ </Field>
1601
+ <Field label={t('whatsappInstance')}>
1602
+ <Input
1603
+ value={actionForm.whatsapp_instance}
1604
+ onChange={(event) =>
1605
+ setActionForm({
1606
+ ...actionForm,
1607
+ whatsapp_instance: event.target.value,
1608
+ })
1609
+ }
1610
+ />
1611
+ </Field>
1612
+ <Field label={t('whatsappBaseUrl')}>
1613
+ <Input
1614
+ value={actionForm.whatsapp_base_url}
1615
+ onChange={(event) =>
1616
+ setActionForm({
1617
+ ...actionForm,
1618
+ whatsapp_base_url: event.target.value,
1619
+ })
1620
+ }
1621
+ />
1622
+ </Field>
1623
+ <Field label={t('whatsappToken')}>
1624
+ <Input
1625
+ type="password"
1626
+ value={actionForm.whatsapp_token}
1627
+ onChange={(event) =>
1628
+ setActionForm({
1629
+ ...actionForm,
1630
+ whatsapp_token: event.target.value,
1631
+ })
1632
+ }
1633
+ />
1634
+ </Field>
1635
+ <Field label={t('whatsappTemplate')}>
1636
+ <Textarea
1637
+ value={actionForm.whatsapp_template}
1638
+ onChange={(event) =>
1639
+ setActionForm({
1640
+ ...actionForm,
1641
+ whatsapp_template: event.target.value,
1642
+ })
1643
+ }
1644
+ placeholder="Olá {{name}}"
1645
+ />
1646
+ </Field>
1647
+ </div>
1648
+ )}
1649
+ <div className="flex justify-end">
1650
+ <Button onClick={saveAction}>{t('addAction')}</Button>
1651
+ </div>
1652
+ <div className="rounded-md border">
1653
+ <Table>
1654
+ <TableHeader>
1655
+ <TableRow>
1656
+ <TableHead>{t('name')}</TableHead>
1657
+ <TableHead>{t('actionType')}</TableHead>
1658
+ <TableHead>{t('order')}</TableHead>
1659
+ <TableHead className="text-right">
1660
+ {t('actions')}
1661
+ </TableHead>
1662
+ </TableRow>
1663
+ </TableHeader>
1664
+ <TableBody>
1665
+ {incomingEdit.webhook_action?.map((action) => (
1666
+ <TableRow key={action.id}>
1667
+ <TableCell>{action.name}</TableCell>
1668
+ <TableCell>
1669
+ <Badge variant="secondary">
1670
+ {action.type === 'email'
1671
+ ? t('email')
1672
+ : t('whatsapp')}
1673
+ </Badge>
1674
+ </TableCell>
1675
+ <TableCell>{action.order}</TableCell>
1676
+ <TableCell className="text-right">
1677
+ <Button
1678
+ variant="ghost"
1679
+ size="icon"
1680
+ onClick={() =>
1681
+ setDeleteTarget({
1682
+ kind: 'action',
1683
+ id: action.id,
1684
+ parentId: incomingEdit.id,
1685
+ name: action.name,
1686
+ })
1687
+ }
1688
+ >
1689
+ <Trash2 className="h-4 w-4 text-destructive" />
1690
+ </Button>
1691
+ </TableCell>
1692
+ </TableRow>
1693
+ ))}
1694
+ </TableBody>
1695
+ </Table>
1696
+ </div>
1697
+ </div>
1698
+ )}
1699
+ </div>
1700
+ </TabsContent>
1701
+
1702
+ <TabsContent
1703
+ value="logs"
1704
+ className="mt-0 space-y-4 px-3 pb-8 sm:px-4"
1705
+ >
1706
+ {!incomingEdit ? (
1707
+ <EmptyState
1708
+ icon={<Clock3 className="h-12 w-12" />}
1709
+ title={t('emptyIncomingLogs')}
1710
+ description={t('emptyIncomingLogsDescription')}
1711
+ />
1712
+ ) : incomingLogItems.length === 0 ? (
1713
+ <EmptyState
1714
+ icon={<Clock3 className="h-12 w-12" />}
1715
+ title={t('emptyIncomingLogs')}
1716
+ description={t('emptyIncomingLogsDescription')}
1717
+ />
1718
+ ) : (
1719
+ <div className="space-y-4">
1720
+ {incomingLogItems.map((log) => (
1721
+ <div key={log.id} className="rounded-md border p-4">
1722
+ <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
1723
+ <div className="flex items-center gap-2">
1724
+ <Badge
1725
+ variant="outline"
1726
+ className={
1727
+ log.status === 'success'
1728
+ ? 'border-green-500/20 bg-green-500/10 text-green-600'
1729
+ : 'border-red-500/20 bg-red-500/10 text-red-600'
1730
+ }
1731
+ >
1732
+ {log.status === 'success'
1733
+ ? t('logStatusSuccess')
1734
+ : t('logStatusFailed')}
1735
+ </Badge>
1736
+ <span className="text-xs text-muted-foreground">
1737
+ {formatDate(
1738
+ log.created_at,
1739
+ getSettingValue,
1740
+ currentLocaleCode
1741
+ )}
1742
+ </span>
1743
+ </div>
1744
+ <div className="flex flex-wrap gap-2 text-xs text-muted-foreground">
1745
+ <span>
1746
+ {t('logDuration')}: {log.duration_ms}ms
1747
+ </span>
1748
+ <span>
1749
+ {t('logRemoteIp')}: {log.remote_ip || '-'}
1750
+ </span>
1751
+ </div>
1752
+ </div>
1753
+
1754
+ {log.error_message ? (
1755
+ <p className="mt-3 text-xs text-destructive">
1756
+ {log.error_message}
1757
+ </p>
1758
+ ) : null}
1759
+
1760
+ {log.webhook_action_log?.length ? (
1761
+ <div className="mt-4 rounded-md border">
1762
+ <Table>
1763
+ <TableHeader>
1764
+ <TableRow>
1765
+ <TableHead>{t('actionType')}</TableHead>
1766
+ <TableHead>{t('name')}</TableHead>
1767
+ <TableHead>{t('status')}</TableHead>
1768
+ <TableHead>{t('logDuration')}</TableHead>
1769
+ <TableHead>{t('logResponse')}</TableHead>
1770
+ </TableRow>
1771
+ </TableHeader>
1772
+ <TableBody>
1773
+ {log.webhook_action_log.map((actionLog) => (
1774
+ <TableRow key={actionLog.id}>
1775
+ <TableCell>
1776
+ {actionLog.action_type === 'email'
1777
+ ? t('email')
1778
+ : t('whatsapp')}
1779
+ </TableCell>
1780
+ <TableCell>{actionLog.action_name}</TableCell>
1781
+ <TableCell>
1782
+ <Badge
1783
+ variant="outline"
1784
+ className={
1785
+ actionLog.status === 'success'
1786
+ ? 'border-green-500/20 bg-green-500/10 text-green-600'
1787
+ : 'border-red-500/20 bg-red-500/10 text-red-600'
1788
+ }
1789
+ >
1790
+ {actionLog.status === 'success'
1791
+ ? t('logStatusSuccess')
1792
+ : t('logStatusFailed')}
1793
+ </Badge>
1794
+ </TableCell>
1795
+ <TableCell>
1796
+ {actionLog.duration_ms}ms
1797
+ </TableCell>
1798
+ <TableCell>
1799
+ <div className="max-h-24 overflow-auto rounded border bg-muted/30 p-2 font-mono text-xs">
1800
+ {actionLog.error_message ||
1801
+ stringifyJson(
1802
+ actionLog.response_payload
1803
+ ) ||
1804
+ '-'}
1805
+ </div>
1806
+ </TableCell>
1807
+ </TableRow>
1808
+ ))}
1809
+ </TableBody>
1810
+ </Table>
1811
+ </div>
1812
+ ) : null}
1813
+ </div>
1814
+ ))}
1815
+ </div>
1816
+ )}
1817
+
1818
+ <PaginationFooter
1819
+ currentPage={incomingLogsPage}
1820
+ pageSize={incomingLogsPageSize}
1821
+ totalItems={incomingLogsTotalItems}
1822
+ onPageChange={setIncomingLogsPage}
1823
+ onPageSizeChange={(size) => {
1824
+ setIncomingLogsPageSize(size);
1825
+ setIncomingLogsPage(1);
1826
+ }}
1827
+ />
1828
+
1829
+ {isFetchingIncomingLogs ? (
1830
+ <p className="text-xs text-muted-foreground">
1831
+ {t('incomingLogsLoading')}
1832
+ </p>
1833
+ ) : null}
1834
+ </TabsContent>
1835
+ </Tabs>
1836
+ </SheetContent>
1837
+ </Sheet>
1838
+
1839
+ <Sheet open={eventWebhookOpen} onOpenChange={setEventWebhookOpen}>
1840
+ <SheetContent className="w-full overflow-y-auto sm:max-w-5xl">
1841
+ <SheetHeader>
1842
+ <SheetTitle>
1843
+ {eventWebhookEdit ? t('editEventWebhook') : t('newEventWebhook')}
1844
+ </SheetTitle>
1845
+ <SheetDescription>
1846
+ {t('eventWebhookFormDescription')}
1847
+ </SheetDescription>
1848
+ </SheetHeader>
1849
+ <div className="space-y-5 px-3 pb-8 sm:px-4">
1850
+ <div className="grid gap-4 md:grid-cols-2">
1851
+ <Field label={t('name')}>
1852
+ <Input
1853
+ value={eventWebhookForm.name}
1854
+ onChange={(event) =>
1855
+ setEventWebhookForm({
1856
+ ...eventWebhookForm,
1857
+ name: event.target.value,
1858
+ })
1859
+ }
1860
+ />
1861
+ </Field>
1862
+ <Field label={t('technicalName')}>
1863
+ <Input
1864
+ value={eventWebhookForm.slug}
1865
+ onChange={(event) =>
1866
+ setEventWebhookForm({
1867
+ ...eventWebhookForm,
1868
+ slug: event.target.value,
1869
+ })
1870
+ }
1871
+ />
1872
+ </Field>
1873
+ </div>
1874
+ <Field label={t('events')}>
1875
+ <div className="grid max-h-52 gap-2 overflow-y-auto rounded-md border p-3 md:grid-cols-2">
1876
+ {eventOptions.map((event) => {
1877
+ const checked = eventWebhookForm.event_ids.includes(event.id);
1878
+ return (
1879
+ <label
1880
+ key={event.id}
1881
+ className="flex cursor-pointer items-start gap-2 rounded-md p-2 hover:bg-muted"
1882
+ >
1883
+ <Checkbox
1884
+ checked={checked}
1885
+ onCheckedChange={(nextChecked) => {
1886
+ setEventWebhookForm({
1887
+ ...eventWebhookForm,
1888
+ event_ids: nextChecked
1889
+ ? [...eventWebhookForm.event_ids, event.id]
1890
+ : eventWebhookForm.event_ids.filter(
1891
+ (id) => id !== event.id
1892
+ ),
1893
+ });
1894
+ }}
1895
+ />
1896
+ <span>
1897
+ <span className="block font-mono text-xs">
1898
+ {event.slug}
1899
+ </span>
1900
+ <span className="text-xs text-muted-foreground">
1901
+ {event.module}
1902
+ </span>
1903
+ </span>
1904
+ </label>
1905
+ );
1906
+ })}
1907
+ </div>
1908
+ </Field>
1909
+ <div className="grid gap-4 md:grid-cols-[160px_1fr]">
1910
+ <Field label={t('method')}>
1911
+ <Select
1912
+ value={eventWebhookForm.method}
1913
+ onValueChange={(method) =>
1914
+ setEventWebhookForm({ ...eventWebhookForm, method })
1915
+ }
1916
+ >
1917
+ <SelectTrigger className="w-full">
1918
+ <SelectValue />
1919
+ </SelectTrigger>
1920
+ <SelectContent>
1921
+ {['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].map((method) => (
1922
+ <SelectItem key={method} value={method}>
1923
+ {method}
1924
+ </SelectItem>
1925
+ ))}
1926
+ </SelectContent>
1927
+ </Select>
1928
+ </Field>
1929
+ <Field label={t('url')}>
1930
+ <Input
1931
+ value={eventWebhookForm.url}
1932
+ onChange={(event) =>
1933
+ setEventWebhookForm({
1934
+ ...eventWebhookForm,
1935
+ url: event.target.value,
1936
+ })
1937
+ }
1938
+ placeholder="https://api.example.com/webhook"
1939
+ />
1940
+ </Field>
1941
+ </div>
1942
+ <div className="grid gap-4 md:grid-cols-3">
1943
+ <StatusSelect
1944
+ value={eventWebhookForm.status}
1945
+ onValueChange={(status) =>
1946
+ setEventWebhookForm({ ...eventWebhookForm, status })
1947
+ }
1948
+ t={t}
1949
+ />
1950
+ <Field label={t('timeout')}>
1951
+ <Input
1952
+ type="number"
1953
+ value={eventWebhookForm.timeout_ms}
1954
+ onChange={(event) =>
1955
+ setEventWebhookForm({
1956
+ ...eventWebhookForm,
1957
+ timeout_ms: Number(event.target.value),
1958
+ })
1959
+ }
1960
+ />
1961
+ </Field>
1962
+ <Field label={t('retry')}>
1963
+ <Input
1964
+ type="number"
1965
+ value={eventWebhookForm.retry_count}
1966
+ onChange={(event) =>
1967
+ setEventWebhookForm({
1968
+ ...eventWebhookForm,
1969
+ retry_count: Number(event.target.value),
1970
+ })
1971
+ }
1972
+ />
1973
+ </Field>
1974
+ </div>
1975
+ <div className="grid gap-4 md:grid-cols-3">
1976
+ <Field label={t('headers')}>
1977
+ <Textarea
1978
+ className="min-h-48 font-mono text-xs"
1979
+ value={eventWebhookForm.headers}
1980
+ onChange={(event) =>
1981
+ setEventWebhookForm({
1982
+ ...eventWebhookForm,
1983
+ headers: event.target.value,
1984
+ })
1985
+ }
1986
+ />
1987
+ </Field>
1988
+ <Field label={t('query')}>
1989
+ <Textarea
1990
+ className="min-h-48 font-mono text-xs"
1991
+ value={eventWebhookForm.query}
1992
+ onChange={(event) =>
1993
+ setEventWebhookForm({
1994
+ ...eventWebhookForm,
1995
+ query: event.target.value,
1996
+ })
1997
+ }
1998
+ />
1999
+ </Field>
2000
+ <Field label={t('payload')}>
2001
+ <Textarea
2002
+ className="min-h-48 font-mono text-xs"
2003
+ value={eventWebhookForm.payload}
2004
+ onChange={(event) =>
2005
+ setEventWebhookForm({
2006
+ ...eventWebhookForm,
2007
+ payload: event.target.value,
2008
+ })
2009
+ }
2010
+ />
2011
+ </Field>
2012
+ </div>
2013
+ <SheetActions
2014
+ onSave={saveEventWebhook}
2015
+ onCancel={() => setEventWebhookOpen(false)}
2016
+ t={t}
2017
+ />
2018
+ </div>
2019
+ </SheetContent>
2020
+ </Sheet>
2021
+
2022
+ <AlertDialog
2023
+ open={Boolean(deleteTarget)}
2024
+ onOpenChange={(open) => !open && setDeleteTarget(null)}
2025
+ >
2026
+ <AlertDialogContent>
2027
+ <AlertDialogHeader>
2028
+ <AlertDialogTitle>{t('deleteTitle')}</AlertDialogTitle>
2029
+ <AlertDialogDescription>
2030
+ {t('deleteDescription', { name: deleteTarget?.name || '' })}
2031
+ </AlertDialogDescription>
2032
+ </AlertDialogHeader>
2033
+ <AlertDialogFooter>
2034
+ <AlertDialogCancel>{t('cancel')}</AlertDialogCancel>
2035
+ <AlertDialogAction
2036
+ onClick={confirmDelete}
2037
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
2038
+ >
2039
+ {t('delete')}
2040
+ </AlertDialogAction>
2041
+ </AlertDialogFooter>
2042
+ </AlertDialogContent>
2043
+ </AlertDialog>
2044
+ </Page>
2045
+ );
2046
+ }
2047
+
2048
+ function Field({ label, children }: { label: string; children: ReactNode }) {
2049
+ return (
2050
+ <div className="space-y-2">
2051
+ <Label>{label}</Label>
2052
+ {children}
2053
+ </div>
2054
+ );
2055
+ }
2056
+
2057
+ function StatusSelect({
2058
+ value,
2059
+ onValueChange,
2060
+ t,
2061
+ }: {
2062
+ value: Status;
2063
+ onValueChange: (value: Status) => void;
2064
+ t: (key: string) => string;
2065
+ }) {
2066
+ return (
2067
+ <Field label={t('status')}>
2068
+ <Select value={value} onValueChange={onValueChange}>
2069
+ <SelectTrigger className="w-full">
2070
+ <SelectValue />
2071
+ </SelectTrigger>
2072
+ <SelectContent>
2073
+ <SelectItem value="active">{t('active')}</SelectItem>
2074
+ <SelectItem value="inactive">{t('inactive')}</SelectItem>
2075
+ </SelectContent>
2076
+ </Select>
2077
+ </Field>
2078
+ );
2079
+ }
2080
+
2081
+ function SheetActions({
2082
+ onSave,
2083
+ onCancel,
2084
+ t,
2085
+ }: {
2086
+ onSave: () => void;
2087
+ onCancel: () => void;
2088
+ t: (key: string) => string;
2089
+ }) {
2090
+ return (
2091
+ <div className="flex justify-end gap-3 border-t pt-4">
2092
+ <Button variant="outline" onClick={onCancel}>
2093
+ {t('cancel')}
2094
+ </Button>
2095
+ <Button onClick={onSave}>{t('save')}</Button>
2096
+ </div>
2097
+ );
2098
+ }