@hed-hog/core 0.0.185 → 0.0.190
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hedhog/frontend/app/account/2fa/page.tsx.ejs +5 -0
- package/hedhog/frontend/app/account/accounts/page.tsx.ejs +5 -0
- package/hedhog/frontend/app/account/components/active-sessions.tsx.ejs +356 -0
- package/hedhog/frontend/app/account/components/change-email-form.tsx.ejs +379 -0
- package/hedhog/frontend/app/account/components/change-password-form.tsx.ejs +184 -0
- package/hedhog/frontend/app/account/components/connected-accounts.tsx.ejs +144 -0
- package/hedhog/frontend/app/account/components/email-request-dialog.tsx.ejs +96 -0
- package/hedhog/frontend/app/account/components/mfa-add-buttons.tsx.ejs +43 -0
- package/hedhog/frontend/app/account/components/mfa-method-card.tsx.ejs +115 -0
- package/hedhog/frontend/app/account/components/mfa-setup-dialog.tsx.ejs +236 -0
- package/hedhog/frontend/app/account/components/profile-form.tsx.ejs +209 -0
- package/hedhog/frontend/app/account/components/recovery-codes-dialog.tsx.ejs +192 -0
- package/hedhog/frontend/app/account/components/regenerate-codes-dialog.tsx.ejs +372 -0
- package/hedhog/frontend/app/account/components/remove-mfa-dialog.tsx.ejs +337 -0
- package/hedhog/frontend/app/account/components/two-factor-auth.tsx.ejs +393 -0
- package/hedhog/frontend/app/account/components/verify-before-add-dialog.tsx.ejs +332 -0
- package/hedhog/frontend/app/account/email/page.tsx.ejs +5 -0
- package/hedhog/frontend/app/account/hooks/use-mfa-methods.ts.ejs +27 -0
- package/hedhog/frontend/app/account/hooks/use-mfa-setup.ts.ejs +461 -0
- package/hedhog/frontend/app/account/layout.tsx.ejs +105 -0
- package/hedhog/frontend/app/account/lib/mfa-utils.tsx.ejs +37 -0
- package/hedhog/frontend/app/account/page.tsx.ejs +5 -0
- package/hedhog/frontend/app/account/password/page.tsx.ejs +5 -0
- package/hedhog/frontend/app/account/profile/page.tsx.ejs +5 -0
- package/hedhog/frontend/app/account/sessions/page.tsx.ejs +5 -0
- package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +490 -0
- package/hedhog/frontend/app/configurations/[slug]/page.tsx.ejs +62 -0
- package/hedhog/frontend/app/configurations/layout.tsx.ejs +316 -0
- package/hedhog/frontend/app/configurations/page.tsx.ejs +35 -0
- package/hedhog/frontend/app/dashboard/[slug]/dashboard-content.tsx.ejs +351 -0
- package/hedhog/frontend/app/dashboard/[slug]/page.tsx.ejs +11 -0
- package/hedhog/frontend/app/dashboard/[slug]/types.ts.ejs +62 -0
- package/hedhog/frontend/app/dashboard/[slug]/widget-renderer.tsx.ejs +45 -0
- package/hedhog/frontend/app/dashboard/dashboard.css.ejs +196 -0
- package/hedhog/frontend/app/dashboard/management/page.tsx.ejs +63 -0
- package/hedhog/frontend/app/dashboard/management/tabs/component-roles-tab.tsx.ejs +516 -0
- package/hedhog/frontend/app/dashboard/management/tabs/components-tab.tsx.ejs +753 -0
- package/hedhog/frontend/app/dashboard/management/tabs/dashboard-roles-tab.tsx.ejs +516 -0
- package/hedhog/frontend/app/dashboard/management/tabs/dashboards-tab.tsx.ejs +489 -0
- package/hedhog/frontend/app/dashboard/management/tabs/items-tab.tsx.ejs +621 -0
- package/hedhog/frontend/app/dashboard/page.tsx.ejs +14 -0
- package/hedhog/frontend/app/mail/log/page.tsx.ejs +312 -0
- package/hedhog/frontend/app/mail/template/page.tsx.ejs +1177 -0
- package/hedhog/frontend/app/preferences/page.tsx.ejs +448 -0
- package/hedhog/frontend/app/roles/menus.tsx.ejs +504 -0
- package/hedhog/frontend/app/roles/page.tsx.ejs +814 -0
- package/hedhog/frontend/app/roles/routes.tsx.ejs +397 -0
- package/hedhog/frontend/app/roles/users.tsx.ejs +306 -0
- package/hedhog/frontend/app/users/active-session.tsx.ejs +159 -0
- package/hedhog/frontend/app/users/identifiers.tsx.ejs +279 -0
- package/hedhog/frontend/app/users/page.tsx.ejs +1257 -0
- package/hedhog/frontend/app/users/permissions.tsx.ejs +155 -0
- package/hedhog/frontend/messages/en.json +1080 -0
- package/hedhog/frontend/messages/pt.json +1135 -0
- package/package.json +4 -4
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { PageHeader } from '@/components/entity-list';
|
|
4
|
+
import { Button } from '@/components/ui/button';
|
|
5
|
+
import { Checkbox } from '@/components/ui/checkbox';
|
|
6
|
+
import {
|
|
7
|
+
Dialog,
|
|
8
|
+
DialogClose,
|
|
9
|
+
DialogContent,
|
|
10
|
+
DialogDescription,
|
|
11
|
+
DialogFooter,
|
|
12
|
+
DialogHeader,
|
|
13
|
+
DialogTitle,
|
|
14
|
+
} from '@/components/ui/dialog';
|
|
15
|
+
import {
|
|
16
|
+
DropdownMenu,
|
|
17
|
+
DropdownMenuContent,
|
|
18
|
+
DropdownMenuItem,
|
|
19
|
+
DropdownMenuTrigger,
|
|
20
|
+
} from '@/components/ui/dropdown-menu';
|
|
21
|
+
import { cn } from '@/lib/utils';
|
|
22
|
+
import { PaginatedResult } from '@hed-hog/api-pagination';
|
|
23
|
+
import { SettingGroup } from '@hed-hog/api-types';
|
|
24
|
+
import { useApp, useQuery } from '@hed-hog/next-app-provider';
|
|
25
|
+
import { Download, MenuIcon, Upload } from 'lucide-react';
|
|
26
|
+
import { useTranslations } from 'next-intl';
|
|
27
|
+
import Link from 'next/link';
|
|
28
|
+
import { usePathname } from 'next/navigation';
|
|
29
|
+
import { useCallback, useRef, useState } from 'react';
|
|
30
|
+
|
|
31
|
+
interface SettingsValidation {
|
|
32
|
+
totalSettings: number;
|
|
33
|
+
validSettings: number;
|
|
34
|
+
invalidSlugs: string[];
|
|
35
|
+
validSlugs: string[];
|
|
36
|
+
fileData: any[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface IProps {
|
|
40
|
+
children: React.ReactNode;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export default function ConfigurationsLayout({ children }: IProps) {
|
|
44
|
+
const t = useTranslations('core.Configurations');
|
|
45
|
+
const pathname = usePathname();
|
|
46
|
+
const { request, currentLocaleCode, showToastHandler } = useApp();
|
|
47
|
+
|
|
48
|
+
const { data: settingGroups, refetch } = useQuery<
|
|
49
|
+
PaginatedResult<SettingGroup>
|
|
50
|
+
>({
|
|
51
|
+
queryKey: ['setting-groups', currentLocaleCode],
|
|
52
|
+
queryFn: async () => {
|
|
53
|
+
const response = await request<PaginatedResult<SettingGroup>>({
|
|
54
|
+
url: '/setting/group',
|
|
55
|
+
});
|
|
56
|
+
return response.data;
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
const [exportDialogOpen, setExportDialogOpen] = useState(false);
|
|
61
|
+
const [includeSecrets, setIncludeSecrets] = useState(false);
|
|
62
|
+
const [importDialogOpen, setImportDialogOpen] = useState(false);
|
|
63
|
+
const [importData, setImportData] = useState<SettingsValidation | null>(null);
|
|
64
|
+
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
65
|
+
|
|
66
|
+
const handleExport = (includeSecrets: boolean) => {
|
|
67
|
+
request({
|
|
68
|
+
url: `/setting/export?secrets=${includeSecrets}`,
|
|
69
|
+
responseType: 'blob',
|
|
70
|
+
})
|
|
71
|
+
.then((response) => {
|
|
72
|
+
const blob = new Blob([response.data], {
|
|
73
|
+
type: 'application/octet-stream',
|
|
74
|
+
});
|
|
75
|
+
const url = URL.createObjectURL(blob);
|
|
76
|
+
const a = document.createElement('a');
|
|
77
|
+
a.href = url;
|
|
78
|
+
a.download = 'settings.hedhog';
|
|
79
|
+
document.body.appendChild(a);
|
|
80
|
+
a.click();
|
|
81
|
+
document.body.removeChild(a);
|
|
82
|
+
URL.revokeObjectURL(url);
|
|
83
|
+
})
|
|
84
|
+
.catch(() => {
|
|
85
|
+
showToastHandler('error', t('exportFailed'));
|
|
86
|
+
});
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const handleImportValidate = async (
|
|
90
|
+
event: React.ChangeEvent<HTMLInputElement>
|
|
91
|
+
) => {
|
|
92
|
+
const file = event.target.files?.[0];
|
|
93
|
+
if (!file) return;
|
|
94
|
+
|
|
95
|
+
event.target.value = '';
|
|
96
|
+
const formData = new FormData();
|
|
97
|
+
formData.append('file', file);
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const response = await request<{
|
|
101
|
+
totalSettings: number;
|
|
102
|
+
validSettings: number;
|
|
103
|
+
invalidSlugs: string[];
|
|
104
|
+
validSlugs: string[];
|
|
105
|
+
fileData: any[];
|
|
106
|
+
}>({
|
|
107
|
+
url: '/setting/import',
|
|
108
|
+
method: 'POST',
|
|
109
|
+
data: formData,
|
|
110
|
+
headers: {
|
|
111
|
+
'Content-Type': 'multipart/form-data',
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
setImportData(response.data);
|
|
116
|
+
setImportDialogOpen(true);
|
|
117
|
+
} catch (error) {
|
|
118
|
+
showToastHandler('error', t('importValidateFailed'));
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const handleConfirmImport = useCallback(async () => {
|
|
123
|
+
if (!importData) return;
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
await request({
|
|
127
|
+
url: '/setting/import/confirm',
|
|
128
|
+
method: 'POST',
|
|
129
|
+
data: {
|
|
130
|
+
settings: importData.fileData,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
refetch().then(() => {
|
|
134
|
+
showToastHandler('success', t('importSuccess'));
|
|
135
|
+
setImportDialogOpen(false);
|
|
136
|
+
setImportData(null);
|
|
137
|
+
if (typeof window !== 'undefined') {
|
|
138
|
+
window.location.reload();
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
} catch (error) {
|
|
142
|
+
showToastHandler('error', t('importFailed'));
|
|
143
|
+
}
|
|
144
|
+
}, [importData, request, refetch, showToastHandler, t]);
|
|
145
|
+
|
|
146
|
+
return (
|
|
147
|
+
<div className="flex flex-col h-screen px-4">
|
|
148
|
+
<PageHeader
|
|
149
|
+
breadcrumbs={[
|
|
150
|
+
{ label: t('breadcrumbHome'), href: '/' },
|
|
151
|
+
{ label: t('breadcrumbTitle') },
|
|
152
|
+
]}
|
|
153
|
+
title={t('title')}
|
|
154
|
+
description={t('description')}
|
|
155
|
+
extraContent={
|
|
156
|
+
<>
|
|
157
|
+
<DropdownMenu>
|
|
158
|
+
<DropdownMenuTrigger asChild>
|
|
159
|
+
<Button variant="outline" size="icon">
|
|
160
|
+
<MenuIcon />
|
|
161
|
+
</Button>
|
|
162
|
+
</DropdownMenuTrigger>
|
|
163
|
+
<DropdownMenuContent align="end">
|
|
164
|
+
<DropdownMenuItem onClick={() => setExportDialogOpen(true)}>
|
|
165
|
+
<span className="flex items-center gap-2">
|
|
166
|
+
<Download size={16} /> {t('menuExport')}
|
|
167
|
+
</span>
|
|
168
|
+
</DropdownMenuItem>
|
|
169
|
+
<DropdownMenuItem onClick={() => fileInputRef.current?.click()}>
|
|
170
|
+
<span className="flex items-center gap-2">
|
|
171
|
+
<Upload size={16} /> {t('menuImport')}
|
|
172
|
+
</span>
|
|
173
|
+
</DropdownMenuItem>
|
|
174
|
+
</DropdownMenuContent>
|
|
175
|
+
</DropdownMenu>
|
|
176
|
+
|
|
177
|
+
<Dialog open={exportDialogOpen} onOpenChange={setExportDialogOpen}>
|
|
178
|
+
<DialogContent className="max-w-sm">
|
|
179
|
+
<DialogHeader>
|
|
180
|
+
<DialogTitle>{t('exportDialogTitle')}</DialogTitle>
|
|
181
|
+
<DialogDescription>
|
|
182
|
+
{t('exportDialogDescription')}
|
|
183
|
+
</DialogDescription>
|
|
184
|
+
</DialogHeader>
|
|
185
|
+
<div className="flex flex-col gap-4 py-4">
|
|
186
|
+
<label className="flex items-center gap-2">
|
|
187
|
+
<Checkbox
|
|
188
|
+
checked={includeSecrets}
|
|
189
|
+
onCheckedChange={(checked) =>
|
|
190
|
+
setIncludeSecrets(checked === true)
|
|
191
|
+
}
|
|
192
|
+
/>
|
|
193
|
+
{t('exportIncludeSecrets')}
|
|
194
|
+
</label>
|
|
195
|
+
</div>
|
|
196
|
+
<DialogFooter>
|
|
197
|
+
<DialogClose asChild>
|
|
198
|
+
<Button variant="outline">{t('exportCancel')}</Button>
|
|
199
|
+
</DialogClose>
|
|
200
|
+
<Button
|
|
201
|
+
onClick={() => {
|
|
202
|
+
setExportDialogOpen(false);
|
|
203
|
+
handleExport(includeSecrets);
|
|
204
|
+
}}
|
|
205
|
+
>
|
|
206
|
+
{t('exportButton')}
|
|
207
|
+
</Button>
|
|
208
|
+
</DialogFooter>
|
|
209
|
+
</DialogContent>
|
|
210
|
+
</Dialog>
|
|
211
|
+
|
|
212
|
+
<Dialog open={importDialogOpen} onOpenChange={setImportDialogOpen}>
|
|
213
|
+
<DialogContent className="max-w-md">
|
|
214
|
+
<DialogHeader>
|
|
215
|
+
<DialogTitle>{t('importDialogTitle')}</DialogTitle>
|
|
216
|
+
<DialogDescription>
|
|
217
|
+
{t('importDialogDescription')}
|
|
218
|
+
</DialogDescription>
|
|
219
|
+
</DialogHeader>
|
|
220
|
+
{importData && (
|
|
221
|
+
<div className="flex flex-col gap-4 py-4">
|
|
222
|
+
<div className="rounded-lg border p-4 space-y-2">
|
|
223
|
+
<div className="flex justify-between">
|
|
224
|
+
<span className="text-sm font-medium">
|
|
225
|
+
{t('importTotalSettings')}
|
|
226
|
+
</span>
|
|
227
|
+
<span className="text-sm">
|
|
228
|
+
{importData.totalSettings}
|
|
229
|
+
</span>
|
|
230
|
+
</div>
|
|
231
|
+
<div className="flex justify-between">
|
|
232
|
+
<span className="text-sm font-medium text-green-600">
|
|
233
|
+
{t('importValidSettings')}
|
|
234
|
+
</span>
|
|
235
|
+
<span className="text-sm text-green-600">
|
|
236
|
+
{importData.validSettings}
|
|
237
|
+
</span>
|
|
238
|
+
</div>
|
|
239
|
+
{importData.invalidSlugs.length > 0 && (
|
|
240
|
+
<div className="flex justify-between">
|
|
241
|
+
<span className="text-sm font-medium text-orange-600">
|
|
242
|
+
{t('importInvalidSettings')}
|
|
243
|
+
</span>
|
|
244
|
+
<span className="text-sm text-orange-600">
|
|
245
|
+
{importData.invalidSlugs.length}
|
|
246
|
+
</span>
|
|
247
|
+
</div>
|
|
248
|
+
)}
|
|
249
|
+
</div>
|
|
250
|
+
|
|
251
|
+
{importData.invalidSlugs.length > 0 && (
|
|
252
|
+
<div className="rounded-lg border border-orange-200 bg-orange-50 dark:border-orange-700 dark:bg-orange-900 p-4 space-y-2">
|
|
253
|
+
<p className="text-sm font-medium text-orange-900 dark:text-orange-200">
|
|
254
|
+
{t('importInvalidSettingsLabel')}
|
|
255
|
+
</p>
|
|
256
|
+
<div className="max-h-32 overflow-y-auto">
|
|
257
|
+
<ul className="text-xs text-orange-800 dark:text-orange-100 space-y-1">
|
|
258
|
+
{importData.invalidSlugs.map((slug) => (
|
|
259
|
+
<li key={slug} className="font-mono">
|
|
260
|
+
• {slug}
|
|
261
|
+
</li>
|
|
262
|
+
))}
|
|
263
|
+
</ul>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
)}
|
|
267
|
+
</div>
|
|
268
|
+
)}
|
|
269
|
+
<DialogFooter>
|
|
270
|
+
<DialogClose asChild>
|
|
271
|
+
<Button variant="outline">{t('importCancel')}</Button>
|
|
272
|
+
</DialogClose>
|
|
273
|
+
<Button onClick={handleConfirmImport}>
|
|
274
|
+
{t('importButton')}
|
|
275
|
+
</Button>
|
|
276
|
+
</DialogFooter>
|
|
277
|
+
</DialogContent>
|
|
278
|
+
</Dialog>
|
|
279
|
+
</>
|
|
280
|
+
}
|
|
281
|
+
/>
|
|
282
|
+
|
|
283
|
+
<div className="border-b border-border mb-6">
|
|
284
|
+
<nav className="flex gap-6" aria-label="Configuration tabs">
|
|
285
|
+
{(settingGroups?.data || []).map((item: SettingGroup) => {
|
|
286
|
+
const isActive = pathname === `/configurations/${item.slug}`;
|
|
287
|
+
return (
|
|
288
|
+
<Link
|
|
289
|
+
key={item.slug}
|
|
290
|
+
href={item.slug}
|
|
291
|
+
className={cn(
|
|
292
|
+
'pb-3 px-1 text-sm font-medium border-b-2 transition-colors',
|
|
293
|
+
isActive
|
|
294
|
+
? 'border-primary text-primary'
|
|
295
|
+
: 'border-transparent text-muted-foreground hover:text-foreground hover:border-muted-foreground'
|
|
296
|
+
)}
|
|
297
|
+
>
|
|
298
|
+
{item.name}
|
|
299
|
+
</Link>
|
|
300
|
+
);
|
|
301
|
+
})}
|
|
302
|
+
</nav>
|
|
303
|
+
</div>
|
|
304
|
+
|
|
305
|
+
<div className="pb-[100px]">{children}</div>
|
|
306
|
+
|
|
307
|
+
<input
|
|
308
|
+
ref={fileInputRef}
|
|
309
|
+
type="file"
|
|
310
|
+
accept=".hedhog"
|
|
311
|
+
className="hidden"
|
|
312
|
+
onChange={handleImportValidate}
|
|
313
|
+
/>
|
|
314
|
+
</div>
|
|
315
|
+
);
|
|
316
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { Loading } from '@/components/loading';
|
|
4
|
+
import { PaginatedResult } from '@hed-hog/api-pagination';
|
|
5
|
+
import { SettingGroup } from '@hed-hog/api-types';
|
|
6
|
+
import { useApp, useQuery } from '@hed-hog/next-app-provider';
|
|
7
|
+
import { useRouter } from 'next/navigation';
|
|
8
|
+
import { useEffect } from 'react';
|
|
9
|
+
|
|
10
|
+
export default function Page() {
|
|
11
|
+
const router = useRouter();
|
|
12
|
+
const { request, currentLocaleCode } = useApp();
|
|
13
|
+
|
|
14
|
+
const { data: settingGroups } = useQuery<PaginatedResult<SettingGroup>>({
|
|
15
|
+
queryKey: ['setting-groups', currentLocaleCode],
|
|
16
|
+
queryFn: async () => {
|
|
17
|
+
const response = await request<PaginatedResult<SettingGroup>>({
|
|
18
|
+
url: '/setting/group',
|
|
19
|
+
});
|
|
20
|
+
return response.data;
|
|
21
|
+
},
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if ((settingGroups?.data || []).length > 0) {
|
|
26
|
+
router.push(`/configurations/${settingGroups.data[0].slug}`);
|
|
27
|
+
}
|
|
28
|
+
}, [settingGroups]);
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div className="flex h-full w-full items-center justify-center">
|
|
32
|
+
<Loading />
|
|
33
|
+
</div>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
AddWidgetSelectorDialog,
|
|
5
|
+
DraggableGrid,
|
|
6
|
+
LayoutItem,
|
|
7
|
+
} from '@/components/dashboard';
|
|
8
|
+
import {
|
|
9
|
+
Breadcrumb,
|
|
10
|
+
BreadcrumbItem,
|
|
11
|
+
BreadcrumbLink,
|
|
12
|
+
BreadcrumbList,
|
|
13
|
+
BreadcrumbPage,
|
|
14
|
+
BreadcrumbSeparator,
|
|
15
|
+
} from '@/components/ui/breadcrumb';
|
|
16
|
+
import { Button } from '@/components/ui/button';
|
|
17
|
+
import { Separator } from '@/components/ui/separator';
|
|
18
|
+
import { SidebarTrigger } from '@/components/ui/sidebar';
|
|
19
|
+
import { Skeleton } from '@/components/ui/skeleton';
|
|
20
|
+
import { useApp, useQuery } from '@hed-hog/next-app-provider';
|
|
21
|
+
import { IconDeviceFloppy } from '@tabler/icons-react';
|
|
22
|
+
import { useTranslations } from 'next-intl';
|
|
23
|
+
import { useRouter } from 'next/navigation';
|
|
24
|
+
import { useCallback, useEffect, useState } from 'react';
|
|
25
|
+
import '../dashboard.css';
|
|
26
|
+
import {
|
|
27
|
+
DashboardAccessResponse,
|
|
28
|
+
DashboardComponent,
|
|
29
|
+
WidgetLayout,
|
|
30
|
+
} from './types';
|
|
31
|
+
import { WidgetRenderer } from './widget-renderer';
|
|
32
|
+
|
|
33
|
+
interface DashboardContentProps {
|
|
34
|
+
dashboardSlug: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const DashboardContent = ({ dashboardSlug }: DashboardContentProps) => {
|
|
38
|
+
const t = useTranslations('core.DashboardPage');
|
|
39
|
+
const { request } = useApp();
|
|
40
|
+
const router = useRouter();
|
|
41
|
+
|
|
42
|
+
const [layout, setLayout] = useState<LayoutItem[]>([]);
|
|
43
|
+
const [widgets, setWidgets] = useState<WidgetLayout[]>([]);
|
|
44
|
+
const [hasChanges, setHasChanges] = useState(false);
|
|
45
|
+
const [isSaving, setIsSaving] = useState(false);
|
|
46
|
+
|
|
47
|
+
const { data: dashboardAccess, isLoading: isCheckingAccess } =
|
|
48
|
+
useQuery<DashboardAccessResponse>({
|
|
49
|
+
queryKey: ['dashboard-access', dashboardSlug],
|
|
50
|
+
queryFn: async () => {
|
|
51
|
+
const { data } = await request<DashboardAccessResponse>({
|
|
52
|
+
url: `/dashboard-core/access/${dashboardSlug}`,
|
|
53
|
+
method: 'GET',
|
|
54
|
+
});
|
|
55
|
+
return data;
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
useEffect(() => {
|
|
60
|
+
if (dashboardAccess && !dashboardAccess.hasAccess) {
|
|
61
|
+
router.replace('/dashboard/default');
|
|
62
|
+
}
|
|
63
|
+
}, [dashboardAccess, router]);
|
|
64
|
+
|
|
65
|
+
const {
|
|
66
|
+
data: availableComponents,
|
|
67
|
+
isLoading: isLoadingComponents,
|
|
68
|
+
refetch: refetchComponents,
|
|
69
|
+
} = useQuery<any>({
|
|
70
|
+
queryKey: ['dashboard-components'],
|
|
71
|
+
queryFn: async () => {
|
|
72
|
+
const { data } = await request<any>({
|
|
73
|
+
url: '/dashboard-component/user',
|
|
74
|
+
method: 'GET',
|
|
75
|
+
});
|
|
76
|
+
return data.data;
|
|
77
|
+
},
|
|
78
|
+
enabled: dashboardAccess?.hasAccess ?? false,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const {
|
|
82
|
+
data: userLayout,
|
|
83
|
+
isLoading: isLoadingLayout,
|
|
84
|
+
refetch: refetchLayout,
|
|
85
|
+
} = useQuery<WidgetLayout[]>({
|
|
86
|
+
queryKey: ['dashboard-layout', dashboardSlug],
|
|
87
|
+
queryFn: async () => {
|
|
88
|
+
const { data } = await request<WidgetLayout[]>({
|
|
89
|
+
url: `/dashboard-core/layout/${dashboardSlug}`,
|
|
90
|
+
method: 'GET',
|
|
91
|
+
});
|
|
92
|
+
return data;
|
|
93
|
+
},
|
|
94
|
+
enabled: dashboardAccess?.hasAccess ?? false,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
useEffect(() => {
|
|
98
|
+
if (userLayout) {
|
|
99
|
+
if (userLayout.length > 0) {
|
|
100
|
+
const gridLayout = userLayout.map((item) => ({
|
|
101
|
+
i: item.i,
|
|
102
|
+
x: item.x,
|
|
103
|
+
y: item.y,
|
|
104
|
+
w: item.w,
|
|
105
|
+
h: item.h,
|
|
106
|
+
minW: item.minW || 1,
|
|
107
|
+
maxW: item.maxW || 12,
|
|
108
|
+
minH: item.minH || 1,
|
|
109
|
+
maxH: item.maxH || 10,
|
|
110
|
+
static: false,
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
setLayout(gridLayout);
|
|
114
|
+
setWidgets(userLayout);
|
|
115
|
+
} else {
|
|
116
|
+
setLayout([]);
|
|
117
|
+
setWidgets([]);
|
|
118
|
+
}
|
|
119
|
+
setHasChanges(false);
|
|
120
|
+
}
|
|
121
|
+
}, [userLayout]);
|
|
122
|
+
|
|
123
|
+
const componentsToFilter = availableComponents?.data?.length
|
|
124
|
+
? availableComponents.data
|
|
125
|
+
: availableComponents || [];
|
|
126
|
+
|
|
127
|
+
const filteredComponents =
|
|
128
|
+
componentsToFilter.filter(
|
|
129
|
+
(component: DashboardComponent) =>
|
|
130
|
+
!widgets.some((widget) => widget.slug === component.slug)
|
|
131
|
+
) || [];
|
|
132
|
+
|
|
133
|
+
const handleLayoutChange = useCallback((newLayout: LayoutItem[]) => {
|
|
134
|
+
setLayout((prevLayout) => {
|
|
135
|
+
const hasRealChange =
|
|
136
|
+
JSON.stringify(prevLayout) !== JSON.stringify(newLayout);
|
|
137
|
+
if (hasRealChange) {
|
|
138
|
+
setHasChanges(true);
|
|
139
|
+
return newLayout;
|
|
140
|
+
}
|
|
141
|
+
return prevLayout;
|
|
142
|
+
});
|
|
143
|
+
}, []);
|
|
144
|
+
|
|
145
|
+
const handleSaveLayout = async () => {
|
|
146
|
+
setIsSaving(true);
|
|
147
|
+
try {
|
|
148
|
+
await request({
|
|
149
|
+
url: `/dashboard-core/layout/${dashboardSlug}`,
|
|
150
|
+
method: 'POST',
|
|
151
|
+
data: { layout },
|
|
152
|
+
});
|
|
153
|
+
setHasChanges(false);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
console.error('❌ Erro ao salvar layout:', error);
|
|
156
|
+
} finally {
|
|
157
|
+
setIsSaving(false);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const handleAddWidget = async (slug: string) => {
|
|
162
|
+
try {
|
|
163
|
+
await request({
|
|
164
|
+
url: `/dashboard-core/widget/${dashboardSlug}`,
|
|
165
|
+
method: 'POST',
|
|
166
|
+
data: { componentSlug: slug },
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
170
|
+
await Promise.all([refetchLayout(), refetchComponents()]);
|
|
171
|
+
setHasChanges(false);
|
|
172
|
+
} catch (error) {
|
|
173
|
+
console.error('Erro ao adicionar widget:', error);
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const handleRemoveWidget = async (widgetId: string) => {
|
|
178
|
+
try {
|
|
179
|
+
await request({
|
|
180
|
+
url: `/dashboard-core/widget/${dashboardSlug}/${widgetId}`,
|
|
181
|
+
method: 'DELETE',
|
|
182
|
+
});
|
|
183
|
+
await Promise.all([refetchLayout(), refetchComponents()]);
|
|
184
|
+
setHasChanges(false);
|
|
185
|
+
} catch (error) {
|
|
186
|
+
console.error('Erro ao remover widget:', error);
|
|
187
|
+
}
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const renderWidget = (widget: WidgetLayout) => {
|
|
191
|
+
return (
|
|
192
|
+
<WidgetRenderer
|
|
193
|
+
widget={widget}
|
|
194
|
+
onRemove={() => handleRemoveWidget(widget.i)}
|
|
195
|
+
/>
|
|
196
|
+
);
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
if (isCheckingAccess || isLoadingLayout) {
|
|
200
|
+
return (
|
|
201
|
+
<>
|
|
202
|
+
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
|
203
|
+
<div className="flex w-full items-center justify-between gap-2 px-4">
|
|
204
|
+
<div className="flex items-center gap-2">
|
|
205
|
+
<SidebarTrigger className="-ml-1" />
|
|
206
|
+
<Separator
|
|
207
|
+
orientation="vertical"
|
|
208
|
+
className="mr-2 data-[orientation=vertical]:h-4"
|
|
209
|
+
/>
|
|
210
|
+
<Breadcrumb>
|
|
211
|
+
<BreadcrumbList>
|
|
212
|
+
<BreadcrumbItem className="hidden md:block">
|
|
213
|
+
<BreadcrumbLink href="#">{t('dashboard')}</BreadcrumbLink>
|
|
214
|
+
</BreadcrumbItem>
|
|
215
|
+
<BreadcrumbSeparator className="hidden md:block" />
|
|
216
|
+
<BreadcrumbItem>
|
|
217
|
+
<BreadcrumbPage>{t('overview')}</BreadcrumbPage>
|
|
218
|
+
</BreadcrumbItem>
|
|
219
|
+
</BreadcrumbList>
|
|
220
|
+
</Breadcrumb>
|
|
221
|
+
</div>
|
|
222
|
+
</div>
|
|
223
|
+
</header>
|
|
224
|
+
<div className="flex flex-1 flex-col gap-4 p-4 pt-0">
|
|
225
|
+
<Skeleton className="h-32 w-full" />
|
|
226
|
+
<Skeleton className="h-32 w-full" />
|
|
227
|
+
<Skeleton className="h-64 w-full" />
|
|
228
|
+
</div>
|
|
229
|
+
</>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
if (!dashboardAccess?.hasAccess) {
|
|
234
|
+
return (
|
|
235
|
+
<>
|
|
236
|
+
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
|
237
|
+
<div className="flex w-full items-center justify-between gap-2 px-4">
|
|
238
|
+
<div className="flex items-center gap-2">
|
|
239
|
+
<SidebarTrigger className="-ml-1" />
|
|
240
|
+
<Separator
|
|
241
|
+
orientation="vertical"
|
|
242
|
+
className="mr-2 data-[orientation=vertical]:h-4"
|
|
243
|
+
/>
|
|
244
|
+
<Breadcrumb>
|
|
245
|
+
<BreadcrumbList>
|
|
246
|
+
<BreadcrumbItem className="hidden md:block">
|
|
247
|
+
<BreadcrumbLink href="#">{t('dashboard')}</BreadcrumbLink>
|
|
248
|
+
</BreadcrumbItem>
|
|
249
|
+
<BreadcrumbSeparator className="hidden md:block" />
|
|
250
|
+
<BreadcrumbItem>
|
|
251
|
+
<BreadcrumbPage>{t('accessDenied')}</BreadcrumbPage>
|
|
252
|
+
</BreadcrumbItem>
|
|
253
|
+
</BreadcrumbList>
|
|
254
|
+
</Breadcrumb>
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
</header>
|
|
258
|
+
<div className="flex flex-1 flex-col items-center justify-center gap-4 p-4 pt-0">
|
|
259
|
+
<div className="text-center">
|
|
260
|
+
<h2 className="text-2xl font-bold">{t('accessDenied')}</h2>
|
|
261
|
+
<p className="text-muted-foreground mt-2">
|
|
262
|
+
{t('noAccessToDashboard')}
|
|
263
|
+
</p>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
</>
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const dashboardName = dashboardAccess?.dashboard?.name || dashboardSlug;
|
|
271
|
+
|
|
272
|
+
return (
|
|
273
|
+
<>
|
|
274
|
+
<header className="flex h-16 shrink-0 items-center gap-2 transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
|
275
|
+
<div className="flex w-full items-center justify-between gap-2 px-4">
|
|
276
|
+
<div className="flex items-center gap-2">
|
|
277
|
+
<SidebarTrigger className="-ml-1" />
|
|
278
|
+
<Separator
|
|
279
|
+
orientation="vertical"
|
|
280
|
+
className="mr-2 data-[orientation=vertical]:h-4"
|
|
281
|
+
/>
|
|
282
|
+
<Breadcrumb>
|
|
283
|
+
<BreadcrumbList>
|
|
284
|
+
<BreadcrumbItem className="hidden md:block">
|
|
285
|
+
<BreadcrumbLink href="#">{t('dashboard')}</BreadcrumbLink>
|
|
286
|
+
</BreadcrumbItem>
|
|
287
|
+
<BreadcrumbSeparator className="hidden md:block" />
|
|
288
|
+
<BreadcrumbItem>
|
|
289
|
+
<BreadcrumbPage>{dashboardName}</BreadcrumbPage>
|
|
290
|
+
</BreadcrumbItem>
|
|
291
|
+
</BreadcrumbList>
|
|
292
|
+
</Breadcrumb>
|
|
293
|
+
</div>
|
|
294
|
+
<div className="flex items-center gap-2">
|
|
295
|
+
{hasChanges && (
|
|
296
|
+
<Button
|
|
297
|
+
size="sm"
|
|
298
|
+
variant="default"
|
|
299
|
+
className="gap-2"
|
|
300
|
+
onClick={handleSaveLayout}
|
|
301
|
+
disabled={isSaving}
|
|
302
|
+
>
|
|
303
|
+
<IconDeviceFloppy className="size-4" />
|
|
304
|
+
{isSaving ? t('saving') : t('saveLayout')}
|
|
305
|
+
</Button>
|
|
306
|
+
)}
|
|
307
|
+
<AddWidgetSelectorDialog
|
|
308
|
+
availableComponents={filteredComponents}
|
|
309
|
+
isLoading={isLoadingComponents}
|
|
310
|
+
onAdd={handleAddWidget}
|
|
311
|
+
currentSlug={dashboardSlug}
|
|
312
|
+
/>
|
|
313
|
+
</div>
|
|
314
|
+
</div>
|
|
315
|
+
</header>
|
|
316
|
+
<div className="flex flex-1 flex-col gap-4 overflow-auto p-4 pt-0">
|
|
317
|
+
{widgets.length > 0 ? (
|
|
318
|
+
<div className="min-h-[600px]">
|
|
319
|
+
<DraggableGrid
|
|
320
|
+
layout={layout}
|
|
321
|
+
onLayoutChange={handleLayoutChange}
|
|
322
|
+
cols={12}
|
|
323
|
+
rowHeight={80}
|
|
324
|
+
isDraggable={true}
|
|
325
|
+
isResizable={true}
|
|
326
|
+
>
|
|
327
|
+
{widgets.map((widget) => (
|
|
328
|
+
<div key={widget.i}>{renderWidget(widget)}</div>
|
|
329
|
+
))}
|
|
330
|
+
</DraggableGrid>
|
|
331
|
+
</div>
|
|
332
|
+
) : (
|
|
333
|
+
<div className="flex min-h-[400px] flex-col items-center justify-center rounded-lg border border-dashed p-8 text-center">
|
|
334
|
+
<h3 className="text-lg font-semibold">{t('noWidgetAdded')}</h3>
|
|
335
|
+
<p className="text-muted-foreground mt-2 text-sm">
|
|
336
|
+
{t('startAddingWidgets')}
|
|
337
|
+
</p>
|
|
338
|
+
<div className="mt-4">
|
|
339
|
+
<AddWidgetSelectorDialog
|
|
340
|
+
availableComponents={filteredComponents}
|
|
341
|
+
isLoading={isLoadingComponents}
|
|
342
|
+
onAdd={handleAddWidget}
|
|
343
|
+
currentSlug={dashboardSlug}
|
|
344
|
+
/>
|
|
345
|
+
</div>
|
|
346
|
+
</div>
|
|
347
|
+
)}
|
|
348
|
+
</div>
|
|
349
|
+
</>
|
|
350
|
+
);
|
|
351
|
+
};
|