@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
@@ -0,0 +1,649 @@
1
+ 'use client';
2
+
3
+ import {
4
+ EmptyState,
5
+ Page,
6
+ PageHeader,
7
+ SearchBar,
8
+ } from '@/components/entity-list';
9
+ import { Badge } from '@/components/ui/badge';
10
+ import { Button } from '@/components/ui/button';
11
+ import {
12
+ Card,
13
+ CardContent,
14
+ CardDescription,
15
+ CardHeader,
16
+ CardTitle,
17
+ } from '@/components/ui/card';
18
+ import { Input } from '@/components/ui/input';
19
+ import { KpiCardsGrid } from '@/components/ui/kpi-cards-grid';
20
+ import { PaginatedResult } from '@/types/pagination-result';
21
+ import {
22
+ Setting,
23
+ SettingComponentEnum,
24
+ SettingGroup,
25
+ SettingSubgroup,
26
+ } from '@hed-hog/api-types';
27
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
28
+ import {
29
+ ListTree,
30
+ Plus,
31
+ Save,
32
+ Settings2,
33
+ SlidersHorizontal,
34
+ Trash2,
35
+ } from 'lucide-react';
36
+ import { useTranslations } from 'next-intl';
37
+ import { useEffect, useMemo, useState } from 'react';
38
+ import { SettingField } from '../configurations/[slug]/components/setting-field';
39
+
40
+ type SettingWithList = Setting & {
41
+ subgroup_id?: number | null;
42
+ setting_subgroup?: SettingSubgroup | null;
43
+ setting_list?: Array<{
44
+ id: number;
45
+ order?: number;
46
+ value: string;
47
+ }>;
48
+ };
49
+
50
+ type SubgroupSection = {
51
+ key: string;
52
+ slug: string;
53
+ name: string;
54
+ description?: string;
55
+ settings: SettingWithList[];
56
+ };
57
+
58
+ const createEmptyPage = <T,>(pageSize: number): PaginatedResult<T> => ({
59
+ total: 0,
60
+ lastPage: 0,
61
+ page: 1,
62
+ pageSize,
63
+ prev: null,
64
+ next: null,
65
+ data: [],
66
+ });
67
+
68
+ export default function CoreSettingListPage() {
69
+ const t = useTranslations('core.SettingListPage');
70
+ const { request, currentLocaleCode, showToastHandler } = useApp();
71
+ const [search, setSearch] = useState('');
72
+ const [activeGroupSlug, setActiveGroupSlug] = useState<string>('all');
73
+ const [activeSubgroupSlug, setActiveSubgroupSlug] = useState<string>('all');
74
+ const [optionsDraftBySettingId, setOptionsDraftBySettingId] = useState<
75
+ Record<number, string[]>
76
+ >({});
77
+ const [savingOptionBySettingId, setSavingOptionBySettingId] = useState<
78
+ Record<number, boolean>
79
+ >({});
80
+
81
+ const { data: groupsPage } = useQuery<PaginatedResult<SettingGroup>>({
82
+ queryKey: ['core-setting-list-groups', currentLocaleCode],
83
+ queryFn: async () => {
84
+ const response = await request<PaginatedResult<SettingGroup>>({
85
+ url: '/setting/group?page=1&pageSize=100',
86
+ method: 'GET',
87
+ });
88
+ return response.data;
89
+ },
90
+ placeholderData: (previous) =>
91
+ previous ?? createEmptyPage<SettingGroup>(100),
92
+ });
93
+
94
+ const groups = useMemo<SettingGroup[]>(
95
+ () => groupsPage?.data ?? [],
96
+ [groupsPage]
97
+ );
98
+
99
+ useEffect(() => {
100
+ if (!groups.length) return;
101
+
102
+ if (activeGroupSlug === 'all') {
103
+ return;
104
+ }
105
+
106
+ const groupExists = groups.some(
107
+ (group: SettingGroup) => group.slug === activeGroupSlug
108
+ );
109
+ if (!groupExists) {
110
+ setActiveGroupSlug('all');
111
+ }
112
+ }, [activeGroupSlug, groups]);
113
+
114
+ useEffect(() => {
115
+ setActiveSubgroupSlug('all');
116
+ }, [activeGroupSlug]);
117
+
118
+ const {
119
+ data: settingsPage,
120
+ isLoading,
121
+ refetch,
122
+ } = useQuery<PaginatedResult<SettingWithList>>({
123
+ queryKey: ['core-setting-list-items', activeGroupSlug, currentLocaleCode],
124
+ queryFn: async () => {
125
+ const url =
126
+ activeGroupSlug === 'all'
127
+ ? '/setting?page=1&pageSize=500'
128
+ : `/setting/group/${activeGroupSlug}?page=1&pageSize=200`;
129
+
130
+ const response = await request<PaginatedResult<SettingWithList>>({
131
+ url,
132
+ method: 'GET',
133
+ });
134
+ return response.data;
135
+ },
136
+ placeholderData: (previous) =>
137
+ previous ?? createEmptyPage<SettingWithList>(200),
138
+ });
139
+
140
+ const allSettings = useMemo<SettingWithList[]>(
141
+ () => settingsPage?.data ?? [],
142
+ [settingsPage]
143
+ );
144
+
145
+ const listTypeSettings = useMemo<SettingWithList[]>(
146
+ () =>
147
+ allSettings.filter((setting) => {
148
+ const component = setting.component;
149
+ const isListComponent =
150
+ component === SettingComponentEnum.COMBOBOX ||
151
+ component === SettingComponentEnum.CHECKBOX;
152
+
153
+ return isListComponent;
154
+ }),
155
+ [allSettings]
156
+ );
157
+
158
+ useEffect(() => {
159
+ setOptionsDraftBySettingId((previous) => {
160
+ const next = { ...previous };
161
+ for (const setting of listTypeSettings) {
162
+ if (!setting.id) {
163
+ continue;
164
+ }
165
+
166
+ if (next[setting.id] === undefined) {
167
+ next[setting.id] = [...(setting.setting_list ?? [])]
168
+ .sort((left, right) => (left.order ?? 0) - (right.order ?? 0))
169
+ .map((option) => option.value);
170
+ }
171
+ }
172
+
173
+ return next;
174
+ });
175
+ }, [listTypeSettings]);
176
+
177
+ const subgroupOptions = useMemo(() => {
178
+ const map = new Map<
179
+ string,
180
+ { value: string; label: string; description?: string }
181
+ >();
182
+
183
+ listTypeSettings.forEach((setting) => {
184
+ const subgroup = setting.setting_subgroup;
185
+ if (!subgroup?.slug) {
186
+ return;
187
+ }
188
+
189
+ map.set(subgroup.slug, {
190
+ value: subgroup.slug,
191
+ label: subgroup.name?.toString() || subgroup.slug,
192
+ description: subgroup.description?.toString() || undefined,
193
+ });
194
+ });
195
+
196
+ return Array.from(map.values()).sort((left, right) =>
197
+ left.label.localeCompare(right.label)
198
+ );
199
+ }, [listTypeSettings]);
200
+
201
+ const visibleSettings = useMemo(() => {
202
+ if (activeSubgroupSlug === 'all') {
203
+ return listTypeSettings;
204
+ }
205
+
206
+ return listTypeSettings.filter(
207
+ (setting) => setting.setting_subgroup?.slug === activeSubgroupSlug
208
+ );
209
+ }, [activeSubgroupSlug, listTypeSettings]);
210
+
211
+ const currentGroup = useMemo(
212
+ () => groups.find((group) => group.slug === activeGroupSlug) ?? null,
213
+ [activeGroupSlug, groups]
214
+ );
215
+
216
+ const filteredSettings = useMemo(() => {
217
+ const normalizedQuery = search.trim().toLowerCase();
218
+
219
+ if (!normalizedQuery) {
220
+ return visibleSettings;
221
+ }
222
+
223
+ return visibleSettings.filter((setting: SettingWithList) => {
224
+ const options = (setting.setting_list ?? []).map(
225
+ (option: { value: string }) => option.value
226
+ );
227
+ const text = [
228
+ setting.name,
229
+ setting.slug,
230
+ setting.description,
231
+ setting.setting_subgroup?.name,
232
+ setting.setting_subgroup?.slug,
233
+ options.join(' '),
234
+ ]
235
+ .filter(Boolean)
236
+ .join(' ')
237
+ .toLowerCase();
238
+
239
+ return text.includes(normalizedQuery);
240
+ });
241
+ }, [search, visibleSettings]);
242
+
243
+ const groupedSections = useMemo<SubgroupSection[]>(() => {
244
+ const sectionsMap = new Map<string, SubgroupSection>();
245
+
246
+ filteredSettings.forEach((setting) => {
247
+ const subgroup = setting.setting_subgroup ?? null;
248
+ const subgroupSlug = subgroup?.slug ?? '__ungrouped__';
249
+ const sectionKey = subgroupSlug;
250
+
251
+ if (!sectionsMap.has(sectionKey)) {
252
+ sectionsMap.set(sectionKey, {
253
+ key: sectionKey,
254
+ slug: subgroupSlug,
255
+ name:
256
+ subgroup?.name?.toString() ||
257
+ (subgroup ? subgroup.slug : t('sections.withoutSubgroup')),
258
+ description: subgroup?.description?.toString() || undefined,
259
+ settings: [],
260
+ });
261
+ }
262
+
263
+ sectionsMap.get(sectionKey)?.settings.push(setting);
264
+ });
265
+
266
+ const sections = Array.from(sectionsMap.values());
267
+ sections.forEach((section) => {
268
+ section.settings.sort((left, right) =>
269
+ left.slug.localeCompare(right.slug)
270
+ );
271
+ });
272
+
273
+ sections.sort((left, right) => {
274
+ if (left.slug === '__ungrouped__') return 1;
275
+ if (right.slug === '__ungrouped__') return -1;
276
+ return left.name.localeCompare(right.name);
277
+ });
278
+
279
+ return sections;
280
+ }, [filteredSettings, t]);
281
+
282
+ const settingsWithOptionsCount = useMemo(
283
+ () => listTypeSettings.length,
284
+ [listTypeSettings]
285
+ );
286
+
287
+ const totalOptions = useMemo(
288
+ () =>
289
+ listTypeSettings.reduce(
290
+ (acc: number, setting: SettingWithList) =>
291
+ acc + (setting.setting_list?.length ?? 0),
292
+ 0
293
+ ),
294
+ [listTypeSettings]
295
+ );
296
+
297
+ const controls = useMemo(
298
+ () => [
299
+ {
300
+ id: 'group',
301
+ type: 'select' as const,
302
+ value: activeGroupSlug,
303
+ onChange: (value: string) => setActiveGroupSlug(value),
304
+ placeholder: t('filters.groupPlaceholder'),
305
+ options: [
306
+ { value: 'all', label: t('filters.allGroups') },
307
+ ...groups.map((group: SettingGroup) => ({
308
+ value: group.slug,
309
+ label: group.name || group.slug,
310
+ })),
311
+ ],
312
+ },
313
+ {
314
+ id: 'subgroup',
315
+ type: 'select' as const,
316
+ value: activeSubgroupSlug,
317
+ onChange: (value: string) => setActiveSubgroupSlug(value),
318
+ placeholder: t('filters.subgroupPlaceholder'),
319
+ options: [
320
+ { value: 'all', label: t('filters.allSubgroups') },
321
+ ...subgroupOptions.map((subgroup) => ({
322
+ value: subgroup.value,
323
+ label: subgroup.label,
324
+ })),
325
+ ],
326
+ },
327
+ ],
328
+ [activeGroupSlug, activeSubgroupSlug, groups, subgroupOptions, t]
329
+ );
330
+
331
+ const updateOptionDraft = (
332
+ settingId: number,
333
+ nextValues: string[] | ((previous: string[]) => string[])
334
+ ) => {
335
+ setOptionsDraftBySettingId((previous) => {
336
+ const currentValues = previous[settingId] ?? [];
337
+ const values =
338
+ typeof nextValues === 'function'
339
+ ? nextValues(currentValues)
340
+ : nextValues;
341
+
342
+ return {
343
+ ...previous,
344
+ [settingId]: values,
345
+ };
346
+ });
347
+ };
348
+
349
+ const saveSettingOptions = async (
350
+ setting: SettingWithList,
351
+ overrideValues?: string[]
352
+ ) => {
353
+ const settingId = setting.id;
354
+ if (!settingId) {
355
+ return;
356
+ }
357
+
358
+ const values = (
359
+ overrideValues ??
360
+ optionsDraftBySettingId[settingId] ??
361
+ []
362
+ ).map((value) => value.trim());
363
+
364
+ setSavingOptionBySettingId((previous) => ({
365
+ ...previous,
366
+ [settingId]: true,
367
+ }));
368
+
369
+ try {
370
+ await request({
371
+ url: `/setting/${settingId}/options`,
372
+ method: 'PUT',
373
+ data: {
374
+ options: values.map((value, index) => ({
375
+ value,
376
+ order: index + 1,
377
+ })),
378
+ },
379
+ });
380
+
381
+ showToastHandler('success', t('optionsSavedSuccess'));
382
+ await refetch();
383
+
384
+ setOptionsDraftBySettingId((previous) => {
385
+ const updatedValues = values.filter((value) => value.length > 0);
386
+ return {
387
+ ...previous,
388
+ [settingId]: updatedValues,
389
+ };
390
+ });
391
+ } catch (_error) {
392
+ showToastHandler('error', t('optionsSavedError'));
393
+ } finally {
394
+ setSavingOptionBySettingId((previous) => ({
395
+ ...previous,
396
+ [settingId]: false,
397
+ }));
398
+ }
399
+ };
400
+
401
+ return (
402
+ <Page>
403
+ <PageHeader
404
+ breadcrumbs={[
405
+ { label: t('breadcrumbs.home'), href: '/' },
406
+ { label: t('breadcrumbs.core') },
407
+ { label: t('title') },
408
+ ]}
409
+ title={t('title')}
410
+ description={t('description')}
411
+ />
412
+
413
+ <KpiCardsGrid
414
+ items={[
415
+ {
416
+ key: 'groups',
417
+ title: t('kpis.groups'),
418
+ value: groups.length,
419
+ icon: Settings2,
420
+ iconContainerClassName: 'bg-sky-50 text-sky-700',
421
+ accentClassName: 'from-sky-500/20 via-sky-500/10 to-transparent',
422
+ },
423
+ {
424
+ key: 'settings',
425
+ title: t('kpis.settingsTotal'),
426
+ value: settingsWithOptionsCount,
427
+ icon: ListTree,
428
+ iconContainerClassName: 'bg-emerald-50 text-emerald-700',
429
+ accentClassName:
430
+ 'from-emerald-500/20 via-emerald-500/10 to-transparent',
431
+ },
432
+ {
433
+ key: 'subgroups',
434
+ title: t('kpis.subgroups'),
435
+ value: subgroupOptions.length,
436
+ icon: SlidersHorizontal,
437
+ iconContainerClassName: 'bg-amber-50 text-amber-700',
438
+ accentClassName:
439
+ 'from-amber-500/20 via-amber-500/10 to-transparent',
440
+ },
441
+ {
442
+ key: 'options',
443
+ title: t('kpis.totalOptions'),
444
+ value: totalOptions,
445
+ icon: SlidersHorizontal,
446
+ iconContainerClassName: 'bg-violet-50 text-violet-700',
447
+ accentClassName:
448
+ 'from-violet-500/20 via-violet-500/10 to-transparent',
449
+ },
450
+ ]}
451
+ />
452
+
453
+ <Card>
454
+ <CardContent className="flex flex-wrap items-center justify-between gap-3 py-3">
455
+ <div className="space-y-0.5">
456
+ <p className="text-sm font-medium text-foreground">
457
+ {activeGroupSlug === 'all'
458
+ ? t('filters.allGroups')
459
+ : currentGroup?.name || activeGroupSlug}
460
+ </p>
461
+ {activeGroupSlug !== 'all' && currentGroup?.description ? (
462
+ <p className="text-xs text-muted-foreground">
463
+ {currentGroup.description}
464
+ </p>
465
+ ) : null}
466
+ </div>
467
+ <Badge variant="secondary">
468
+ {t('kpis.options')}: {totalOptions}
469
+ </Badge>
470
+ </CardContent>
471
+ </Card>
472
+
473
+ <SearchBar
474
+ searchQuery={search}
475
+ onSearchChange={setSearch}
476
+ onSearch={() => undefined}
477
+ placeholder={t('searchPlaceholder')}
478
+ controls={controls}
479
+ />
480
+
481
+ {isLoading ? (
482
+ <Card>
483
+ <CardContent className="py-8 text-sm text-muted-foreground">
484
+ {t('loading')}
485
+ </CardContent>
486
+ </Card>
487
+ ) : filteredSettings.length === 0 ? (
488
+ <EmptyState
489
+ icon={<ListTree className="h-10 w-10" />}
490
+ title={t('emptyTitle')}
491
+ description={
492
+ listTypeSettings.length === 0
493
+ ? t('emptyDescriptionNoSettings')
494
+ : t('emptyDescriptionFiltered')
495
+ }
496
+ />
497
+ ) : (
498
+ <div className="grid gap-3">
499
+ {groupedSections.map((section) => (
500
+ <Card key={section.key}>
501
+ <CardHeader className="pb-3">
502
+ <CardTitle className="text-base">{section.name}</CardTitle>
503
+ {section.description ? (
504
+ <CardDescription>{section.description}</CardDescription>
505
+ ) : null}
506
+ </CardHeader>
507
+
508
+ <CardContent className="space-y-4">
509
+ {section.settings.map((setting: SettingWithList) => {
510
+ const settingId = setting.id;
511
+ const options = [...(setting.setting_list ?? [])].sort(
512
+ (left, right) => (left.order ?? 0) - (right.order ?? 0)
513
+ );
514
+ const optionDraft =
515
+ settingId != null
516
+ ? (optionsDraftBySettingId[settingId] ??
517
+ options.map((option) => option.value))
518
+ : options.map((option) => option.value);
519
+ const isSaving =
520
+ settingId != null
521
+ ? savingOptionBySettingId[settingId] === true
522
+ : false;
523
+
524
+ return (
525
+ <div
526
+ key={setting.id}
527
+ className="space-y-2 border-b border-border/60 pb-4 last:border-0 last:pb-0"
528
+ >
529
+ <div className="flex flex-wrap items-start justify-between gap-3">
530
+ <div className="min-w-0 flex-1 space-y-1">
531
+ <p className="text-sm font-semibold text-foreground">
532
+ {setting.name}
533
+ </p>
534
+ <p className="text-sm text-muted-foreground">
535
+ {setting.description}
536
+ </p>
537
+ <Badge
538
+ variant="outline"
539
+ className="font-mono text-xs"
540
+ >
541
+ {setting.slug}
542
+ </Badge>
543
+ </div>
544
+
545
+ <div className="w-full sm:w-auto sm:min-w-80">
546
+ <SettingField setting={setting} className="w-full" />
547
+ </div>
548
+ </div>
549
+
550
+ <div className="space-y-2">
551
+ <div className="flex items-center justify-between gap-2">
552
+ <p className="text-xs font-medium text-muted-foreground">
553
+ {t('optionsSectionTitle')}
554
+ </p>
555
+ <Button
556
+ type="button"
557
+ size="sm"
558
+ variant="outline"
559
+ onClick={() => {
560
+ if (settingId == null) return;
561
+ updateOptionDraft(settingId, (previous) => [
562
+ ...previous,
563
+ '',
564
+ ]);
565
+ }}
566
+ >
567
+ <Plus className="mr-1 h-3.5 w-3.5" />
568
+ {t('actions.addOption')}
569
+ </Button>
570
+ </div>
571
+
572
+ {optionDraft.length === 0 ? (
573
+ <p className="text-xs text-muted-foreground">
574
+ {t('emptyOptions')}
575
+ </p>
576
+ ) : (
577
+ <div className="space-y-2">
578
+ {optionDraft.map((optionValue, index) => (
579
+ <div
580
+ key={`${settingId ?? 'setting'}-option-${index}`}
581
+ className="flex items-center gap-2"
582
+ >
583
+ <Input
584
+ value={optionValue}
585
+ onChange={(event) => {
586
+ if (settingId == null) return;
587
+ const nextValue = event.target.value;
588
+ updateOptionDraft(settingId, (previous) =>
589
+ previous.map((value, currentIndex) =>
590
+ currentIndex === index
591
+ ? nextValue
592
+ : value
593
+ )
594
+ );
595
+ }}
596
+ placeholder={t('optionPlaceholder')}
597
+ className="bg-background"
598
+ />
599
+ <Button
600
+ type="button"
601
+ size="icon"
602
+ variant="ghost"
603
+ onClick={async () => {
604
+ if (settingId == null) return;
605
+ const nextValues = optionDraft.filter(
606
+ (_, currentIndex) =>
607
+ currentIndex !== index
608
+ );
609
+ updateOptionDraft(settingId, nextValues);
610
+ await saveSettingOptions(
611
+ setting,
612
+ nextValues
613
+ );
614
+ }}
615
+ disabled={isSaving}
616
+ aria-label={t('actions.removeOption')}
617
+ >
618
+ <Trash2 className="h-4 w-4" />
619
+ </Button>
620
+ </div>
621
+ ))}
622
+ </div>
623
+ )}
624
+
625
+ <div className="flex justify-end">
626
+ <Button
627
+ type="button"
628
+ size="sm"
629
+ onClick={() => saveSettingOptions(setting)}
630
+ disabled={isSaving || settingId == null}
631
+ >
632
+ <Save className="mr-1 h-3.5 w-3.5" />
633
+ {isSaving
634
+ ? t('actions.saving')
635
+ : t('actions.saveOptions')}
636
+ </Button>
637
+ </div>
638
+ </div>
639
+ </div>
640
+ );
641
+ })}
642
+ </CardContent>
643
+ </Card>
644
+ ))}
645
+ </div>
646
+ )}
647
+ </Page>
648
+ );
649
+ }