@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.
Files changed (55) hide show
  1. package/hedhog/frontend/app/account/2fa/page.tsx.ejs +5 -0
  2. package/hedhog/frontend/app/account/accounts/page.tsx.ejs +5 -0
  3. package/hedhog/frontend/app/account/components/active-sessions.tsx.ejs +356 -0
  4. package/hedhog/frontend/app/account/components/change-email-form.tsx.ejs +379 -0
  5. package/hedhog/frontend/app/account/components/change-password-form.tsx.ejs +184 -0
  6. package/hedhog/frontend/app/account/components/connected-accounts.tsx.ejs +144 -0
  7. package/hedhog/frontend/app/account/components/email-request-dialog.tsx.ejs +96 -0
  8. package/hedhog/frontend/app/account/components/mfa-add-buttons.tsx.ejs +43 -0
  9. package/hedhog/frontend/app/account/components/mfa-method-card.tsx.ejs +115 -0
  10. package/hedhog/frontend/app/account/components/mfa-setup-dialog.tsx.ejs +236 -0
  11. package/hedhog/frontend/app/account/components/profile-form.tsx.ejs +209 -0
  12. package/hedhog/frontend/app/account/components/recovery-codes-dialog.tsx.ejs +192 -0
  13. package/hedhog/frontend/app/account/components/regenerate-codes-dialog.tsx.ejs +372 -0
  14. package/hedhog/frontend/app/account/components/remove-mfa-dialog.tsx.ejs +337 -0
  15. package/hedhog/frontend/app/account/components/two-factor-auth.tsx.ejs +393 -0
  16. package/hedhog/frontend/app/account/components/verify-before-add-dialog.tsx.ejs +332 -0
  17. package/hedhog/frontend/app/account/email/page.tsx.ejs +5 -0
  18. package/hedhog/frontend/app/account/hooks/use-mfa-methods.ts.ejs +27 -0
  19. package/hedhog/frontend/app/account/hooks/use-mfa-setup.ts.ejs +461 -0
  20. package/hedhog/frontend/app/account/layout.tsx.ejs +105 -0
  21. package/hedhog/frontend/app/account/lib/mfa-utils.tsx.ejs +37 -0
  22. package/hedhog/frontend/app/account/page.tsx.ejs +5 -0
  23. package/hedhog/frontend/app/account/password/page.tsx.ejs +5 -0
  24. package/hedhog/frontend/app/account/profile/page.tsx.ejs +5 -0
  25. package/hedhog/frontend/app/account/sessions/page.tsx.ejs +5 -0
  26. package/hedhog/frontend/app/configurations/[slug]/components/setting-field.tsx.ejs +490 -0
  27. package/hedhog/frontend/app/configurations/[slug]/page.tsx.ejs +62 -0
  28. package/hedhog/frontend/app/configurations/layout.tsx.ejs +316 -0
  29. package/hedhog/frontend/app/configurations/page.tsx.ejs +35 -0
  30. package/hedhog/frontend/app/dashboard/[slug]/dashboard-content.tsx.ejs +351 -0
  31. package/hedhog/frontend/app/dashboard/[slug]/page.tsx.ejs +11 -0
  32. package/hedhog/frontend/app/dashboard/[slug]/types.ts.ejs +62 -0
  33. package/hedhog/frontend/app/dashboard/[slug]/widget-renderer.tsx.ejs +45 -0
  34. package/hedhog/frontend/app/dashboard/dashboard.css.ejs +196 -0
  35. package/hedhog/frontend/app/dashboard/management/page.tsx.ejs +63 -0
  36. package/hedhog/frontend/app/dashboard/management/tabs/component-roles-tab.tsx.ejs +516 -0
  37. package/hedhog/frontend/app/dashboard/management/tabs/components-tab.tsx.ejs +753 -0
  38. package/hedhog/frontend/app/dashboard/management/tabs/dashboard-roles-tab.tsx.ejs +516 -0
  39. package/hedhog/frontend/app/dashboard/management/tabs/dashboards-tab.tsx.ejs +489 -0
  40. package/hedhog/frontend/app/dashboard/management/tabs/items-tab.tsx.ejs +621 -0
  41. package/hedhog/frontend/app/dashboard/page.tsx.ejs +14 -0
  42. package/hedhog/frontend/app/mail/log/page.tsx.ejs +312 -0
  43. package/hedhog/frontend/app/mail/template/page.tsx.ejs +1177 -0
  44. package/hedhog/frontend/app/preferences/page.tsx.ejs +448 -0
  45. package/hedhog/frontend/app/roles/menus.tsx.ejs +504 -0
  46. package/hedhog/frontend/app/roles/page.tsx.ejs +814 -0
  47. package/hedhog/frontend/app/roles/routes.tsx.ejs +397 -0
  48. package/hedhog/frontend/app/roles/users.tsx.ejs +306 -0
  49. package/hedhog/frontend/app/users/active-session.tsx.ejs +159 -0
  50. package/hedhog/frontend/app/users/identifiers.tsx.ejs +279 -0
  51. package/hedhog/frontend/app/users/page.tsx.ejs +1257 -0
  52. package/hedhog/frontend/app/users/permissions.tsx.ejs +155 -0
  53. package/hedhog/frontend/messages/en.json +1080 -0
  54. package/hedhog/frontend/messages/pt.json +1135 -0
  55. package/package.json +4 -4
@@ -0,0 +1,397 @@
1
+ 'use client';
2
+
3
+ import { Button } from '@/components/ui/button';
4
+ import { Card, CardContent } from '@/components/ui/card';
5
+ import { Input } from '@/components/ui/input';
6
+ import { Label } from '@/components/ui/label';
7
+ import {
8
+ Select,
9
+ SelectContent,
10
+ SelectItem,
11
+ SelectTrigger,
12
+ SelectValue,
13
+ } from '@/components/ui/select';
14
+ import { Switch } from '@/components/ui/switch';
15
+ import { useDebounce } from '@/hooks/use-debounce';
16
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
17
+ import {
18
+ ChevronLeft,
19
+ ChevronRight,
20
+ ChevronsLeft,
21
+ ChevronsRight,
22
+ Loader2,
23
+ Route as RouteIcon,
24
+ Search,
25
+ } from 'lucide-react';
26
+ import { useTranslations } from 'next-intl';
27
+ import { useState } from 'react';
28
+ import { toast } from 'sonner';
29
+
30
+ type RouteType = {
31
+ id: number;
32
+ url: string;
33
+ method: string;
34
+ description?: string;
35
+ role_route?: Array<{
36
+ route_id: number;
37
+ role_id: number;
38
+ }>;
39
+ };
40
+
41
+ type RoleRoutesSectionProps = {
42
+ roleId: number;
43
+ onRouteChange?: () => void;
44
+ };
45
+
46
+ export function RoleRoutesSection({
47
+ roleId,
48
+ onRouteChange,
49
+ }: RoleRoutesSectionProps) {
50
+ const t = useTranslations('core.RolePage');
51
+ const { request } = useApp();
52
+ const [togglingRouteId, setTogglingRouteId] = useState<number | null>(null);
53
+ const [page, setPage] = useState(1);
54
+ const [pageSize, setPageSize] = useState(10);
55
+ const [searchTerm, setSearchTerm] = useState('');
56
+ const [searchType, setSearchType] = useState<
57
+ 'contains' | 'startsWith' | 'endsWith'
58
+ >('contains');
59
+ const [methodFilter, setMethodFilter] = useState<string>('all');
60
+ const debouncedSearch = useDebounce(searchTerm);
61
+
62
+ const {
63
+ data: assignedRoutesData,
64
+ isLoading: isLoadingAssigned,
65
+ refetch: refetchAssignedRoutes,
66
+ } = useQuery<{ data: RouteType[] }>({
67
+ queryKey: ['role-routes-assigned', roleId],
68
+ queryFn: async () => {
69
+ const response = await request<{ data: RouteType[] }>({
70
+ url: `/role/${roleId}/route?pageSize=10000`,
71
+ method: 'GET',
72
+ });
73
+ return response.data;
74
+ },
75
+ enabled: !!roleId,
76
+ });
77
+
78
+ const {
79
+ data: routesData,
80
+ isLoading: isLoadingRoutes,
81
+ refetch: refetchRoutes,
82
+ } = useQuery<{ data: RouteType[]; total: number; lastPage: number }>({
83
+ queryKey: [
84
+ 'role-routes-paginated',
85
+ roleId,
86
+ page,
87
+ pageSize,
88
+ debouncedSearch,
89
+ searchType,
90
+ methodFilter,
91
+ ],
92
+ queryFn: async () => {
93
+ const params = new URLSearchParams();
94
+ params.set('page', String(page));
95
+ params.set('pageSize', String(pageSize));
96
+ if (debouncedSearch) {
97
+ params.set('search', debouncedSearch);
98
+ params.set('searchType', searchType);
99
+ }
100
+ if (methodFilter && methodFilter !== 'all') {
101
+ params.set('method', methodFilter);
102
+ }
103
+
104
+ const response = await request<{
105
+ data: RouteType[];
106
+ total: number;
107
+ lastPage: number;
108
+ }>({
109
+ url: `/role/${roleId}/route?${params.toString()}`,
110
+ method: 'GET',
111
+ });
112
+ return response.data;
113
+ },
114
+ enabled: !!roleId,
115
+ });
116
+
117
+ const routes = routesData?.data || [];
118
+ const totalPages = routesData?.lastPage || 1;
119
+ const totalRoutes = routesData?.total || 0;
120
+
121
+ const handleToggleRoute = async (routeId: number, isAssigned: boolean) => {
122
+ setTogglingRouteId(routeId);
123
+ try {
124
+ const currentRouteIds =
125
+ assignedRoutesData?.data
126
+ ?.filter((r: RouteType) => r.role_route && r.role_route.length > 0)
127
+ .map((r: RouteType) => r.id) || [];
128
+
129
+ let newRouteIds: number[];
130
+ if (isAssigned) {
131
+ newRouteIds = currentRouteIds.filter((id: number) => id !== routeId);
132
+ } else {
133
+ newRouteIds = [...currentRouteIds, routeId];
134
+ }
135
+
136
+ await request({
137
+ url: `/role/${roleId}/route`,
138
+ method: 'PATCH',
139
+ data: { ids: newRouteIds },
140
+ });
141
+
142
+ toast.success(isAssigned ? t('routeRemoved') : t('routeAssigned'));
143
+ await refetchAssignedRoutes();
144
+ await refetchRoutes();
145
+ onRouteChange?.();
146
+ } catch (error) {
147
+ toast.error(
148
+ isAssigned ? t('errorRemovingRoute') : t('errorAssigningRoute')
149
+ );
150
+ } finally {
151
+ setTogglingRouteId(null);
152
+ }
153
+ };
154
+
155
+ const isRouteAssigned = (route: RouteType) => {
156
+ const assignedRoute = assignedRoutesData?.data?.find(
157
+ (r: RouteType) => r.id === route.id
158
+ );
159
+ return !!(assignedRoute?.role_route && assignedRoute.role_route.length > 0);
160
+ };
161
+
162
+ const getMethodColor = (method: string) => {
163
+ const colors: Record<string, string> = {
164
+ GET: 'bg-blue-100 text-blue-700',
165
+ POST: 'bg-green-100 text-green-700',
166
+ PUT: 'bg-amber-100 text-amber-700',
167
+ PATCH: 'bg-purple-100 text-purple-700',
168
+ DELETE: 'bg-red-100 text-red-700',
169
+ };
170
+ return colors[method.toUpperCase()] || 'bg-gray-100 text-gray-700';
171
+ };
172
+
173
+ if (isLoadingRoutes || isLoadingAssigned) {
174
+ return (
175
+ <div className="flex items-center justify-center py-8">
176
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
177
+ <span className="ml-2 text-sm text-muted-foreground">
178
+ {t('loadingRoutes')}
179
+ </span>
180
+ </div>
181
+ );
182
+ }
183
+ return (
184
+ <div className="space-y-3">
185
+ <div>
186
+ <h4 className="text-sm font-semibold flex items-center gap-2">
187
+ <RouteIcon className="h-4 w-4" />
188
+ {t('routesTitle')}
189
+ </h4>
190
+ <p className="text-xs text-muted-foreground mt-1">
191
+ {t('routesDescription')}
192
+ </p>
193
+ </div>
194
+
195
+ <div className="space-y-2">
196
+ <div className="relative">
197
+ <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
198
+ <Input
199
+ placeholder={t('searchRoutes')}
200
+ value={searchTerm}
201
+ onChange={(e) => setSearchTerm(e.target.value)}
202
+ className="pl-10"
203
+ />
204
+ </div>
205
+ <div className="w-full flex gap-2">
206
+ <Select
207
+ value={searchType}
208
+ onValueChange={(value: any) => setSearchType(value)}
209
+ >
210
+ <SelectTrigger className="w-full">
211
+ <SelectValue placeholder={t('selectSearchType')} />
212
+ </SelectTrigger>
213
+ <SelectContent>
214
+ <SelectItem value="contains">{t('searchContains')}</SelectItem>
215
+ <SelectItem value="startsWith">
216
+ {t('searchStartsWith')}
217
+ </SelectItem>
218
+ <SelectItem value="endsWith">{t('searchEndsWith')}</SelectItem>
219
+ </SelectContent>
220
+ </Select>
221
+ <Select value={methodFilter} onValueChange={setMethodFilter}>
222
+ <SelectTrigger className="w-full">
223
+ <SelectValue placeholder={t('selectMethod')} />
224
+ </SelectTrigger>
225
+ <SelectContent>
226
+ <SelectItem value="all">{t('allMethods')}</SelectItem>
227
+ <SelectItem value="GET">GET</SelectItem>
228
+ <SelectItem value="POST">POST</SelectItem>
229
+ <SelectItem value="PATCH">PATCH</SelectItem>
230
+ <SelectItem value="PUT">PUT</SelectItem>
231
+ <SelectItem value="DELETE">DELETE</SelectItem>
232
+ </SelectContent>
233
+ </Select>
234
+ </div>
235
+ </div>
236
+
237
+ <div className="space-y-2">
238
+ {Boolean(routes.length) ? (
239
+ routes.map((route) => {
240
+ const isAssigned = isRouteAssigned(route);
241
+ const isToggling = togglingRouteId === route.id;
242
+
243
+ return (
244
+ <Card
245
+ key={route.id}
246
+ className={`transition-all ${
247
+ isAssigned
248
+ ? 'border-primary bg-primary/5'
249
+ : 'border-border hover:border-primary/50'
250
+ }`}
251
+ >
252
+ <CardContent className="flex items-center justify-between">
253
+ <div className="flex items-center gap-3 flex-1">
254
+ <div
255
+ className={`rounded-md ${
256
+ isAssigned ? 'bg-primary/10' : 'bg-muted'
257
+ }`}
258
+ >
259
+ <RouteIcon
260
+ className={`h-5 w-5 ${
261
+ isAssigned ? 'text-primary' : 'text-muted-foreground'
262
+ }`}
263
+ />
264
+ </div>
265
+ <div className="flex-1">
266
+ <div className="flex items-center gap-2">
267
+ <span
268
+ className={`text-[10px] font-bold px-2 py-0.5 rounded ${getMethodColor(
269
+ route.method
270
+ )}`}
271
+ >
272
+ {route.method}
273
+ </span>
274
+ <Label
275
+ htmlFor={`route-${route.id}`}
276
+ className="text-sm font-medium cursor-pointer"
277
+ >
278
+ {route.url}
279
+ </Label>
280
+ </div>
281
+ {route.description && (
282
+ <p className="text-xs text-muted-foreground mt-0.5">
283
+ {route.description}
284
+ </p>
285
+ )}
286
+ </div>
287
+ </div>
288
+ <Switch
289
+ id={`route-${route.id}`}
290
+ checked={isAssigned}
291
+ disabled={isToggling}
292
+ onCheckedChange={() =>
293
+ handleToggleRoute(route.id, isAssigned)
294
+ }
295
+ className="ml-4"
296
+ />
297
+ </CardContent>
298
+ </Card>
299
+ );
300
+ })
301
+ ) : (
302
+ <Card className="border-dashed">
303
+ <CardContent className="flex flex-col items-center justify-center py-8">
304
+ <RouteIcon className="h-12 w-12 text-muted-foreground mb-3" />
305
+ <p className="text-sm font-medium text-center">
306
+ {t('noRoutesAvailable')}
307
+ </p>
308
+ </CardContent>
309
+ </Card>
310
+ )}
311
+ </div>
312
+
313
+ <div className="flex items-center justify-between px-2 pt-4">
314
+ <div className="hidden flex-1 text-sm text-muted-foreground lg:flex">
315
+ {t('showing')} {routes.length} {t('of')} {totalRoutes} {t('routes')}
316
+ </div>
317
+ <div className="flex w-full items-center gap-8 lg:w-fit">
318
+ <div className="hidden items-center gap-2 lg:flex">
319
+ <Label
320
+ htmlFor="rows-per-page-routes"
321
+ className="text-sm font-medium"
322
+ >
323
+ {t('rowsPerPage')}
324
+ </Label>
325
+ <Select
326
+ value={`${pageSize}`}
327
+ onValueChange={(value) => {
328
+ setPageSize(Number(value));
329
+ setPage(1);
330
+ }}
331
+ >
332
+ <SelectTrigger
333
+ size="sm"
334
+ className="w-20"
335
+ id="rows-per-page-routes"
336
+ >
337
+ <SelectValue placeholder={pageSize} />
338
+ </SelectTrigger>
339
+ <SelectContent side="top">
340
+ {[10, 20, 30, 40, 50].map((size) => (
341
+ <SelectItem key={size} value={`${size}`}>
342
+ {size}
343
+ </SelectItem>
344
+ ))}
345
+ </SelectContent>
346
+ </Select>
347
+ </div>
348
+ <div className="flex w-fit items-center justify-center text-sm font-medium">
349
+ {t('page')} {page} {t('of')} {totalPages}
350
+ </div>
351
+ <div className="ml-auto flex items-center gap-2 lg:ml-0">
352
+ <Button
353
+ variant="outline"
354
+ className="hidden size-8 lg:flex"
355
+ size="icon"
356
+ onClick={() => setPage(1)}
357
+ disabled={page === 1}
358
+ >
359
+ <span className="sr-only">{t('goToFirstPage')}</span>
360
+ <ChevronsLeft className="size-4" />
361
+ </Button>
362
+ <Button
363
+ variant="outline"
364
+ className="size-8"
365
+ size="icon"
366
+ onClick={() => setPage(page - 1)}
367
+ disabled={page === 1}
368
+ >
369
+ <span className="sr-only">{t('goToPreviousPage')}</span>
370
+ <ChevronLeft className="size-4" />
371
+ </Button>
372
+ <Button
373
+ variant="outline"
374
+ className="size-8"
375
+ size="icon"
376
+ onClick={() => setPage(page + 1)}
377
+ disabled={page >= totalPages}
378
+ >
379
+ <span className="sr-only">{t('goToNextPage')}</span>
380
+ <ChevronRight className="size-4" />
381
+ </Button>
382
+ <Button
383
+ variant="outline"
384
+ className="hidden size-8 lg:flex"
385
+ size="icon"
386
+ onClick={() => setPage(totalPages)}
387
+ disabled={page >= totalPages}
388
+ >
389
+ <span className="sr-only">{t('goToLastPage')}</span>
390
+ <ChevronsRight className="size-4" />
391
+ </Button>
392
+ </div>
393
+ </div>
394
+ </div>
395
+ </div>
396
+ );
397
+ }
@@ -0,0 +1,306 @@
1
+ 'use client';
2
+
3
+ import { Button } from '@/components/ui/button';
4
+ import { Card, CardContent } from '@/components/ui/card';
5
+ import { Input } from '@/components/ui/input';
6
+ import { Label } from '@/components/ui/label';
7
+ import {
8
+ Select,
9
+ SelectContent,
10
+ SelectItem,
11
+ SelectTrigger,
12
+ SelectValue,
13
+ } from '@/components/ui/select';
14
+ import { Switch } from '@/components/ui/switch';
15
+ import { useDebounce } from '@/hooks/use-debounce';
16
+ import { getPhotoUrl } from '@/lib/get-photo-url';
17
+ import { getUserEmail } from '@/lib/get-user-email';
18
+ import { User } from '@hed-hog/api-types';
19
+ import { useApp, useQuery } from '@hed-hog/next-app-provider';
20
+ import {
21
+ ChevronLeft,
22
+ ChevronRight,
23
+ ChevronsLeft,
24
+ ChevronsRight,
25
+ Loader2,
26
+ Search,
27
+ UserCircle,
28
+ } from 'lucide-react';
29
+ import { useTranslations } from 'next-intl';
30
+ import { useState } from 'react';
31
+ import { toast } from 'sonner';
32
+
33
+ type RoleUsersSectionProps = {
34
+ roleId: number;
35
+ onUserChange?: () => void;
36
+ };
37
+
38
+ export function RoleUsersSection({
39
+ roleId,
40
+ onUserChange,
41
+ }: RoleUsersSectionProps) {
42
+ const t = useTranslations('core.RolePage');
43
+ const { request } = useApp();
44
+ const [togglingUserId, setTogglingUserId] = useState<number | null>(null);
45
+ const [page, setPage] = useState(1);
46
+ const [pageSize, setPageSize] = useState(10);
47
+ const [searchTerm, setSearchTerm] = useState('');
48
+ const debouncedSearch = useDebounce(searchTerm);
49
+
50
+ const {
51
+ data: assignedUsersData,
52
+ isLoading: isLoadingAssigned,
53
+ refetch: refetchAssignedUsers,
54
+ } = useQuery<{ data: User[] }>({
55
+ queryKey: ['role-users-assigned', roleId],
56
+ queryFn: async () => {
57
+ const response = await request<{ data: User[] }>({
58
+ url: `/role/${roleId}/user?pageSize=10000`,
59
+ method: 'GET',
60
+ });
61
+ return response.data;
62
+ },
63
+ enabled: !!roleId,
64
+ });
65
+
66
+ const {
67
+ data: allUsersData,
68
+ isLoading: isLoadingAllUsers,
69
+ refetch: refetchAllUsers,
70
+ } = useQuery<{ paginate: { data: User[]; total: number; lastPage: number } }>(
71
+ {
72
+ queryKey: ['all-users-paginated', page, pageSize, debouncedSearch],
73
+ queryFn: async () => {
74
+ const params = new URLSearchParams();
75
+ params.set('page', String(page));
76
+ params.set('pageSize', String(pageSize));
77
+ if (debouncedSearch) params.set('search', debouncedSearch);
78
+
79
+ const response = await request<{
80
+ paginate: { data: User[]; total: number; lastPage: number };
81
+ }>({
82
+ url: `/user?${params.toString()}`,
83
+ method: 'GET',
84
+ });
85
+ return response.data;
86
+ },
87
+ }
88
+ );
89
+
90
+ const allUsers = allUsersData?.paginate?.data || [];
91
+ const totalPages = allUsersData?.paginate?.lastPage || 1;
92
+ const totalUsers = allUsersData?.paginate?.total || 0;
93
+
94
+ const handleToggleUser = async (userId: number, isAssigned: boolean) => {
95
+ setTogglingUserId(userId);
96
+ try {
97
+ const currentUserIds =
98
+ assignedUsersData?.data
99
+ ?.filter((u: User) => u.role_user && u.role_user.length > 0)
100
+ .map((u: User) => u.id) || [];
101
+
102
+ let newUserIds: number[];
103
+ if (isAssigned) {
104
+ newUserIds = currentUserIds.filter(
105
+ (id): id is number => typeof id === 'number' && id !== userId
106
+ );
107
+ } else {
108
+ newUserIds = [...currentUserIds, userId].filter(
109
+ (id): id is number => typeof id === 'number'
110
+ );
111
+ }
112
+
113
+ await request({
114
+ url: `/role/${roleId}/user`,
115
+ method: 'PATCH',
116
+ data: { ids: newUserIds.filter(Boolean) },
117
+ });
118
+
119
+ toast.success(isAssigned ? t('userRemoved') : t('userAssigned'));
120
+ await refetchAssignedUsers();
121
+ await refetchAllUsers();
122
+ onUserChange?.();
123
+ } catch (error) {
124
+ toast.error(
125
+ isAssigned ? t('errorRemovingUser') : t('errorAssigningUser')
126
+ );
127
+ } finally {
128
+ setTogglingUserId(null);
129
+ }
130
+ };
131
+
132
+ const isUserAssigned = (userId: number) => {
133
+ const user = assignedUsersData?.data?.find((u: User) => u.id === userId);
134
+ return !!(user?.role_user && user.role_user.length > 0);
135
+ };
136
+
137
+ if (isLoadingAllUsers || isLoadingAssigned) {
138
+ return (
139
+ <div className="flex items-center justify-center py-8">
140
+ <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
141
+ <span className="ml-2 text-sm text-muted-foreground">
142
+ {t('loadingUsers')}
143
+ </span>
144
+ </div>
145
+ );
146
+ }
147
+
148
+ return (
149
+ <div className="space-y-3">
150
+ <div>
151
+ <h4 className="text-sm font-semibold flex items-center gap-2">
152
+ <UserCircle className="h-4 w-4" />
153
+ {t('usersTitle')}
154
+ </h4>
155
+ <p className="text-xs text-muted-foreground mt-1">
156
+ {t('usersDescription')}
157
+ </p>
158
+ </div>
159
+
160
+ <div className="relative">
161
+ <Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
162
+ <Input
163
+ placeholder={t('searchUsers')}
164
+ value={searchTerm}
165
+ onChange={(e) => setSearchTerm(e.target.value)}
166
+ className="pl-10"
167
+ />
168
+ </div>
169
+
170
+ <div className="space-y-2">
171
+ {Boolean(allUsers.length) ? (
172
+ allUsers.map((user) => {
173
+ const isAssigned = isUserAssigned(user.id!);
174
+ const isToggling = togglingUserId === user.id;
175
+
176
+ return (
177
+ <Card
178
+ key={user.id}
179
+ className={`transition-all ${
180
+ isAssigned
181
+ ? 'border-primary bg-primary/5'
182
+ : 'border-border hover:border-primary/50'
183
+ }`}
184
+ >
185
+ <CardContent className="flex items-center justify-between">
186
+ <div className="flex items-center gap-3 flex-1">
187
+ <img
188
+ src={getPhotoUrl(user.photo_id)}
189
+ alt={user.name}
190
+ className="h-10 w-10 rounded-full object-cover"
191
+ />
192
+ <div className="flex-1">
193
+ <Label
194
+ htmlFor={`user-${user.id}`}
195
+ className="text-sm font-medium cursor-pointer"
196
+ >
197
+ {user.name}
198
+ </Label>
199
+ <p className="text-xs text-muted-foreground mt-0.5">
200
+ {getUserEmail(user)}
201
+ </p>
202
+ </div>
203
+ </div>
204
+ <Switch
205
+ id={`user-${user.id}`}
206
+ checked={isAssigned}
207
+ disabled={isToggling}
208
+ onCheckedChange={() =>
209
+ handleToggleUser(user.id!, isAssigned)
210
+ }
211
+ className="ml-4"
212
+ />
213
+ </CardContent>
214
+ </Card>
215
+ );
216
+ })
217
+ ) : (
218
+ <Card className="border-dashed">
219
+ <CardContent className="flex flex-col items-center justify-center py-8">
220
+ <UserCircle className="h-12 w-12 text-muted-foreground mb-3" />
221
+ <p className="text-sm font-medium text-center">
222
+ {t('noUsersAvailable')}
223
+ </p>
224
+ </CardContent>
225
+ </Card>
226
+ )}
227
+ </div>
228
+
229
+ <div className="flex items-center justify-between px-2 pt-4">
230
+ <div className="hidden flex-1 text-sm text-muted-foreground lg:flex">
231
+ {t('showing')} {allUsers.length} {t('of')} {totalUsers} {t('users')}
232
+ </div>
233
+ <div className="flex w-full items-center gap-8 lg:w-fit">
234
+ <div className="hidden items-center gap-2 lg:flex">
235
+ <Label htmlFor="rows-per-page" className="text-sm font-medium">
236
+ {t('rowsPerPage')}
237
+ </Label>
238
+ <Select
239
+ value={`${pageSize}`}
240
+ onValueChange={(value) => {
241
+ setPageSize(Number(value));
242
+ setPage(1);
243
+ }}
244
+ >
245
+ <SelectTrigger size="sm" className="w-20" id="rows-per-page">
246
+ <SelectValue placeholder={pageSize} />
247
+ </SelectTrigger>
248
+ <SelectContent side="top">
249
+ {[10, 20, 30, 40, 50].map((size) => (
250
+ <SelectItem key={size} value={`${size}`}>
251
+ {size}
252
+ </SelectItem>
253
+ ))}
254
+ </SelectContent>
255
+ </Select>
256
+ </div>
257
+ <div className="flex w-fit items-center justify-center text-sm font-medium">
258
+ {t('page')} {page} {t('of')} {totalPages}
259
+ </div>
260
+ <div className="ml-auto flex items-center gap-2 lg:ml-0">
261
+ <Button
262
+ variant="outline"
263
+ className="hidden size-8 lg:flex"
264
+ size="icon"
265
+ onClick={() => setPage(1)}
266
+ disabled={page === 1}
267
+ >
268
+ <span className="sr-only">{t('goToFirstPage')}</span>
269
+ <ChevronsLeft className="size-4" />
270
+ </Button>
271
+ <Button
272
+ variant="outline"
273
+ className="size-8"
274
+ size="icon"
275
+ onClick={() => setPage(page - 1)}
276
+ disabled={page === 1}
277
+ >
278
+ <span className="sr-only">{t('goToPreviousPage')}</span>
279
+ <ChevronLeft className="size-4" />
280
+ </Button>
281
+ <Button
282
+ variant="outline"
283
+ className="size-8"
284
+ size="icon"
285
+ onClick={() => setPage(page + 1)}
286
+ disabled={page >= totalPages}
287
+ >
288
+ <span className="sr-only">{t('goToNextPage')}</span>
289
+ <ChevronRight className="size-4" />
290
+ </Button>
291
+ <Button
292
+ variant="outline"
293
+ className="hidden size-8 lg:flex"
294
+ size="icon"
295
+ onClick={() => setPage(totalPages)}
296
+ disabled={page >= totalPages}
297
+ >
298
+ <span className="sr-only">{t('goToLastPage')}</span>
299
+ <ChevronsRight className="size-4" />
300
+ </Button>
301
+ </div>
302
+ </div>
303
+ </div>
304
+ </div>
305
+ );
306
+ }