@hed-hog/core 0.0.325 → 0.0.326

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 (100) hide show
  1. package/dist/core.module.d.ts.map +1 -1
  2. package/dist/core.module.js +3 -0
  3. package/dist/core.module.js.map +1 -1
  4. package/dist/index.d.ts +4 -0
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +4 -0
  7. package/dist/index.js.map +1 -1
  8. package/dist/integration/services/outbox.processor.d.ts.map +1 -1
  9. package/dist/integration/services/outbox.processor.js +1 -0
  10. package/dist/integration/services/outbox.processor.js.map +1 -1
  11. package/dist/integration/types/event.types.d.ts +1 -0
  12. package/dist/integration/types/event.types.d.ts.map +1 -1
  13. package/dist/mail/mail.controller.d.ts +2 -0
  14. package/dist/mail/mail.controller.d.ts.map +1 -1
  15. package/dist/mail/mail.module.d.ts.map +1 -1
  16. package/dist/mail/mail.module.js +33 -53
  17. package/dist/mail/mail.module.js.map +1 -1
  18. package/dist/mail/mail.service.d.ts +5 -1
  19. package/dist/mail/mail.service.d.ts.map +1 -1
  20. package/dist/mail/mail.service.js +64 -84
  21. package/dist/mail/mail.service.js.map +1 -1
  22. package/dist/role/role.service.d.ts +3 -1
  23. package/dist/role/role.service.d.ts.map +1 -1
  24. package/dist/role/role.service.js +33 -5
  25. package/dist/role/role.service.js.map +1 -1
  26. package/dist/setting/setting.service.d.ts.map +1 -1
  27. package/dist/setting/setting.service.js +1 -0
  28. package/dist/setting/setting.service.js.map +1 -1
  29. package/dist/user/user.service.d.ts +3 -1
  30. package/dist/user/user.service.d.ts.map +1 -1
  31. package/dist/user/user.service.js +31 -4
  32. package/dist/user/user.service.js.map +1 -1
  33. package/dist/webhook-integration/event-webhook.controller.d.ts +29 -0
  34. package/dist/webhook-integration/event-webhook.controller.d.ts.map +1 -0
  35. package/dist/webhook-integration/event-webhook.controller.js +93 -0
  36. package/dist/webhook-integration/event-webhook.controller.js.map +1 -0
  37. package/dist/webhook-integration/event-webhook.service.d.ts +54 -0
  38. package/dist/webhook-integration/event-webhook.service.d.ts.map +1 -0
  39. package/dist/webhook-integration/event-webhook.service.js +334 -0
  40. package/dist/webhook-integration/event-webhook.service.js.map +1 -0
  41. package/dist/webhook-integration/integration-event-catalog.controller.d.ts +21 -0
  42. package/dist/webhook-integration/integration-event-catalog.controller.d.ts.map +1 -0
  43. package/dist/webhook-integration/integration-event-catalog.controller.js +91 -0
  44. package/dist/webhook-integration/integration-event-catalog.controller.js.map +1 -0
  45. package/dist/webhook-integration/integration-event-catalog.service.d.ts +26 -0
  46. package/dist/webhook-integration/integration-event-catalog.service.d.ts.map +1 -0
  47. package/dist/webhook-integration/integration-event-catalog.service.js +112 -0
  48. package/dist/webhook-integration/integration-event-catalog.service.js.map +1 -0
  49. package/dist/webhook-integration/webhook-integration.controller.d.ts +44 -0
  50. package/dist/webhook-integration/webhook-integration.controller.d.ts.map +1 -0
  51. package/dist/webhook-integration/webhook-integration.controller.js +181 -0
  52. package/dist/webhook-integration/webhook-integration.controller.js.map +1 -0
  53. package/dist/webhook-integration/webhook-integration.module.d.ts +3 -0
  54. package/dist/webhook-integration/webhook-integration.module.d.ts.map +1 -0
  55. package/dist/webhook-integration/webhook-integration.module.js +54 -0
  56. package/dist/webhook-integration/webhook-integration.module.js.map +1 -0
  57. package/dist/webhook-integration/webhook-integration.service.d.ts +73 -0
  58. package/dist/webhook-integration/webhook-integration.service.d.ts.map +1 -0
  59. package/dist/webhook-integration/webhook-integration.service.js +569 -0
  60. package/dist/webhook-integration/webhook-integration.service.js.map +1 -0
  61. package/dist/webhook-integration/webhook-utils.d.ts +7 -0
  62. package/dist/webhook-integration/webhook-utils.d.ts.map +1 -0
  63. package/dist/webhook-integration/webhook-utils.js +104 -0
  64. package/dist/webhook-integration/webhook-utils.js.map +1 -0
  65. package/hedhog/data/integration_event_catalog.yaml +118 -0
  66. package/hedhog/data/mail_profile.yaml +18 -0
  67. package/hedhog/data/menu.yaml +26 -12
  68. package/hedhog/data/route.yaml +152 -8
  69. package/hedhog/data/setting_group.yaml +7 -156
  70. package/hedhog/data/setting_subgroup.yaml +0 -32
  71. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +205 -0
  72. package/hedhog/frontend/app/integrations-webhooks/page.tsx.ejs +1835 -0
  73. package/hedhog/frontend/messages/en.json +2 -1
  74. package/hedhog/frontend/messages/pt.json +2 -1
  75. package/hedhog/table/event_webhook.yaml +55 -0
  76. package/hedhog/table/event_webhook_delivery_log.yaml +66 -0
  77. package/hedhog/table/event_webhook_event.yaml +26 -0
  78. package/hedhog/table/integration_event_catalog.yaml +41 -0
  79. package/hedhog/table/setting.yaml +1 -0
  80. package/hedhog/table/webhook_action.yaml +85 -0
  81. package/hedhog/table/webhook_call_log.yaml +46 -0
  82. package/hedhog/table/webhook_integration.yaml +43 -0
  83. package/package.json +4 -4
  84. package/src/core.module.ts +10 -7
  85. package/src/index.ts +10 -5
  86. package/src/integration/services/outbox.processor.ts +5 -4
  87. package/src/integration/types/event.types.ts +4 -3
  88. package/src/mail/mail.module.ts +36 -59
  89. package/src/mail/mail.service.ts +84 -89
  90. package/src/role/role.service.ts +41 -8
  91. package/src/setting/setting.service.ts +7 -6
  92. package/src/user/user.service.ts +34 -3
  93. package/src/webhook-integration/event-webhook.controller.ts +50 -0
  94. package/src/webhook-integration/event-webhook.service.ts +384 -0
  95. package/src/webhook-integration/integration-event-catalog.controller.ts +50 -0
  96. package/src/webhook-integration/integration-event-catalog.service.ts +117 -0
  97. package/src/webhook-integration/webhook-integration.controller.ts +103 -0
  98. package/src/webhook-integration/webhook-integration.module.ts +45 -0
  99. package/src/webhook-integration/webhook-integration.service.ts +735 -0
  100. package/src/webhook-integration/webhook-utils.ts +79 -0
@@ -0,0 +1,1835 @@
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
+ }