rl-core-front 0.6.0 → 0.8.0
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/README.md +20 -0
- package/package.json +1 -1
- package/src/_services/api/schema.d.ts +381 -2
- package/src/components/app-shell.tsx +229 -51
- package/src/components/ui/data-table.tsx +18 -1
- package/src/components/ui/dialog.tsx +157 -62
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/job-progress.tsx +100 -0
- package/src/components/ui/sheet.tsx +1 -1
- package/src/features/errors/error-state.tsx +50 -0
- package/src/features/errors/forbidden-screen.tsx +37 -0
- package/src/features/errors/not-found-screen.tsx +35 -0
- package/src/features/queues/hooks/use-queue-jobs.ts +103 -0
- package/src/features/queues/queues-screen.tsx +264 -0
- package/src/features/queues/services/queues.service.ts +57 -0
- package/src/features/rbac/components/permission-matrix.tsx +210 -0
- package/src/features/rbac/panels/definitions-panel.tsx +75 -0
- package/src/features/rbac/panels/index.ts +1 -0
- package/src/features/rbac/panels/roles-panel.tsx +113 -57
- package/src/features/rbac/rbac-screen.tsx +35 -37
- package/src/hooks/use-job-progress.ts +192 -0
- package/src/i18n/messages/en.ts +52 -0
- package/src/i18n/messages/pt.ts +53 -0
- package/src/index.ts +24 -0
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
ChevronRight,
|
|
6
6
|
History,
|
|
7
7
|
LayoutDashboard,
|
|
8
|
+
ListChecks,
|
|
8
9
|
LogOut,
|
|
9
10
|
Menu,
|
|
10
11
|
ScrollText,
|
|
@@ -36,6 +37,7 @@ import {
|
|
|
36
37
|
} from "#core/components/ui";
|
|
37
38
|
import { UserAvatar } from "#core/components/user-avatar";
|
|
38
39
|
import { useAuth, useBrand, useI18n } from "#core/contexts";
|
|
40
|
+
import { ForbiddenScreen } from "#core/features/errors/forbidden-screen";
|
|
39
41
|
import { NotificationsCenter } from "#core/features/notifications/notifications-center";
|
|
40
42
|
import { ForcePasswordChange } from "#core/features/profile/force-password-change";
|
|
41
43
|
import { cn } from "#core/lib/utils";
|
|
@@ -50,6 +52,23 @@ interface NavItem {
|
|
|
50
52
|
perm: string | string[] | null;
|
|
51
53
|
}
|
|
52
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Item de menu que abre e fecha em vez de navegar.
|
|
57
|
+
*
|
|
58
|
+
* O "Administração" era isto escrito à mão dentro do componente; agora é só o
|
|
59
|
+
* primeiro cliente do grupo genérico, e o projeto declara os dele por
|
|
60
|
+
* `AppNavItem.children`.
|
|
61
|
+
*/
|
|
62
|
+
interface NavGroup {
|
|
63
|
+
key: string;
|
|
64
|
+
icon: React.ComponentType<{ className?: string }>;
|
|
65
|
+
children: NavItem[];
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
type NavEntry = NavItem | NavGroup;
|
|
69
|
+
|
|
70
|
+
const isGroup = (entry: NavEntry): entry is NavGroup => "children" in entry;
|
|
71
|
+
|
|
53
72
|
/**
|
|
54
73
|
* Item de menu declarado pelo projeto.
|
|
55
74
|
*
|
|
@@ -68,7 +87,14 @@ export interface AppNavItem {
|
|
|
68
87
|
* funcionam — o projeto só precisa do dicionário quando for bilíngue.
|
|
69
88
|
*/
|
|
70
89
|
label: string;
|
|
71
|
-
|
|
90
|
+
/**
|
|
91
|
+
* Rota da tela. Opcional **só** em item com `children`: o pai de um grupo
|
|
92
|
+
* abre e fecha, não navega.
|
|
93
|
+
*
|
|
94
|
+
* Pai que navega e expande fica ambíguo no celular, onde o toque faz as duas
|
|
95
|
+
* coisas — por isso o grupo não herda rota de filho nenhum.
|
|
96
|
+
*/
|
|
97
|
+
href?: string;
|
|
72
98
|
icon: React.ComponentType<{ className?: string }>;
|
|
73
99
|
/**
|
|
74
100
|
* Permissão exigida para o item aparecer. Ausente, aparece para todos.
|
|
@@ -77,8 +103,34 @@ export interface AppNavItem {
|
|
|
77
103
|
* atender dois escopos, como `users:read:any` e `users:read:team`.
|
|
78
104
|
*/
|
|
79
105
|
permission?: string | string[] | null;
|
|
106
|
+
/**
|
|
107
|
+
* Telas filhas, recolhidas sob o pai — o mesmo comportamento que o grupo de
|
|
108
|
+
* administração já tinha.
|
|
109
|
+
*
|
|
110
|
+
* ```tsx
|
|
111
|
+
* { label: "nav.funkos", icon: Package, children: [
|
|
112
|
+
* { label: "nav.funkosCollection", href: "/funkos", icon: Boxes },
|
|
113
|
+
* { label: "nav.funkosCatalogs", href: "/funkos/catalogos", icon: Tags },
|
|
114
|
+
* ] }
|
|
115
|
+
* ```
|
|
116
|
+
*
|
|
117
|
+
* A permissão continua valendo por item: cada filho é filtrado pelo que
|
|
118
|
+
* declarou, e pai que ficou sem nenhum filho visível não é desenhado — grupo
|
|
119
|
+
* vazio é uma seta que não abre nada.
|
|
120
|
+
*
|
|
121
|
+
* Um nível só. Filho de filho não é menu lateral, é sitemap.
|
|
122
|
+
*/
|
|
123
|
+
children?: AppNavItem[];
|
|
80
124
|
}
|
|
81
125
|
|
|
126
|
+
/** Item declarado pelo projeto no formato interno do menu. */
|
|
127
|
+
const toNavItem = (item: AppNavItem & { href: string }): NavItem => ({
|
|
128
|
+
key: item.label,
|
|
129
|
+
href: item.href,
|
|
130
|
+
icon: item.icon,
|
|
131
|
+
perm: item.permission ?? null,
|
|
132
|
+
});
|
|
133
|
+
|
|
82
134
|
const DASHBOARD: NavItem = {
|
|
83
135
|
key: "nav.dashboard",
|
|
84
136
|
href: "/dashboard",
|
|
@@ -102,6 +154,14 @@ const ADMIN_CHILDREN: NavItem[] = [
|
|
|
102
154
|
},
|
|
103
155
|
{ key: "nav.rbac", href: "/rbac", icon: ShieldCheck, perm: "roles:read:any" },
|
|
104
156
|
{ key: "nav.logs", href: "/logs", icon: ScrollText, perm: "audit:read:any" },
|
|
157
|
+
// Só aparece para quem opera a fila — em ambiente sem Redis ninguém tem a
|
|
158
|
+
// permissão, e o item some sozinho.
|
|
159
|
+
{
|
|
160
|
+
key: "nav.queues",
|
|
161
|
+
href: "/filas",
|
|
162
|
+
icon: ListChecks,
|
|
163
|
+
perm: "queues:read:any",
|
|
164
|
+
},
|
|
105
165
|
// Restrito a administradores — só quem tem audit:read-trail:any (só Super Admin) vê este item.
|
|
106
166
|
{
|
|
107
167
|
key: "nav.audit",
|
|
@@ -115,9 +175,21 @@ export interface AppShellProps {
|
|
|
115
175
|
children: React.ReactNode;
|
|
116
176
|
/** Telas do projeto no menu, entre o dashboard e o grupo de administração. */
|
|
117
177
|
navItems?: AppNavItem[];
|
|
178
|
+
/**
|
|
179
|
+
* O que mostrar quando a rota atual exige permissão que o usuário não tem.
|
|
180
|
+
*
|
|
181
|
+
* Padrão: a `ForbiddenScreen` do core. Passe outra para trocar a tela sem
|
|
182
|
+
* abrir mão do bloqueio — que é o ponto: esconder o item do menu não impede
|
|
183
|
+
* ninguém de digitar a URL.
|
|
184
|
+
*/
|
|
185
|
+
forbidden?: React.ReactNode;
|
|
118
186
|
}
|
|
119
187
|
|
|
120
|
-
export function AppShell({
|
|
188
|
+
export function AppShell({
|
|
189
|
+
children,
|
|
190
|
+
navItems,
|
|
191
|
+
forbidden,
|
|
192
|
+
}: AppShellProps): JSX.Element {
|
|
121
193
|
const { user, loading, logout, hasPermission } = useAuth();
|
|
122
194
|
const { t } = useI18n();
|
|
123
195
|
const brand = useBrand();
|
|
@@ -144,23 +216,93 @@ export function AppShell({ children, navItems }: AppShellProps): JSX.Element {
|
|
|
144
216
|
[user],
|
|
145
217
|
);
|
|
146
218
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
219
|
+
const adminGroup: NavGroup = {
|
|
220
|
+
key: "nav.admin",
|
|
221
|
+
icon: Settings,
|
|
222
|
+
children: adminChildren,
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Os itens do projeto passam pelo mesmo filtro de permissão dos do core: um
|
|
227
|
+
* menu que mostra o que o usuário não pode abrir só entrega 403.
|
|
228
|
+
*
|
|
229
|
+
* O filtro é por item, inclusive dentro do grupo: um pai cujo último filho
|
|
230
|
+
* visível sumiu não é desenhado, e um pai sem `href` também não vira link
|
|
231
|
+
* solto — grupo vazio é uma seta que não abre nada.
|
|
232
|
+
*/
|
|
233
|
+
const projectEntries = useMemo<NavEntry[]>(
|
|
150
234
|
() =>
|
|
151
235
|
(navItems ?? [])
|
|
152
236
|
.filter((item) => allows(item.permission ?? null))
|
|
153
|
-
.map((item) =>
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
237
|
+
.map((item): NavEntry | null => {
|
|
238
|
+
if (item.children?.length) {
|
|
239
|
+
const children = item.children
|
|
240
|
+
.filter((child) => allows(child.permission ?? null))
|
|
241
|
+
.filter((child): child is AppNavItem & { href: string } =>
|
|
242
|
+
Boolean(child.href),
|
|
243
|
+
)
|
|
244
|
+
.map(toNavItem);
|
|
245
|
+
return children.length > 0
|
|
246
|
+
? { key: item.label, icon: item.icon, children }
|
|
247
|
+
: null;
|
|
248
|
+
}
|
|
249
|
+
return item.href ? toNavItem({ ...item, href: item.href }) : null;
|
|
250
|
+
})
|
|
251
|
+
.filter((entry): entry is NavEntry => entry !== null),
|
|
159
252
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
160
253
|
[user, navItems],
|
|
161
254
|
);
|
|
162
|
-
|
|
163
|
-
|
|
255
|
+
/**
|
|
256
|
+
* A rota atual exige permissão que o usuário não tem?
|
|
257
|
+
*
|
|
258
|
+
* Percorre **todas** as rotas conhecidas, não só as que sobreviveram ao
|
|
259
|
+
* filtro do menu: é justamente a que foi filtrada que precisa ser barrada
|
|
260
|
+
* aqui. Esconder o item nunca impediu ninguém de digitar a URL — sem este
|
|
261
|
+
* bloqueio a tela montava, chamava a API, tomava 403 e virava uma página
|
|
262
|
+
* vazia com um toast, em vez de dizer que falta acesso.
|
|
263
|
+
*
|
|
264
|
+
* Rota que não está em lista nenhuma passa: o shell não sabe o que ela exige,
|
|
265
|
+
* e chutar bloqueio esconderia tela legítima do projeto que não declarou
|
|
266
|
+
* `navItems`.
|
|
267
|
+
*/
|
|
268
|
+
const routeBlocked = useMemo(() => {
|
|
269
|
+
const known: NavItem[] = [
|
|
270
|
+
DASHBOARD,
|
|
271
|
+
PROFILE,
|
|
272
|
+
...ADMIN_CHILDREN,
|
|
273
|
+
// Achata pai e filhos: a rota de um filho precisa ser barrada como
|
|
274
|
+
// qualquer outra, e o pai de grupo não tem rota para checar.
|
|
275
|
+
...(navItems ?? [])
|
|
276
|
+
.flatMap((item) => [item, ...(item.children ?? [])])
|
|
277
|
+
.filter((item): item is AppNavItem & { href: string } =>
|
|
278
|
+
Boolean(item.href),
|
|
279
|
+
)
|
|
280
|
+
.map(toNavItem),
|
|
281
|
+
];
|
|
282
|
+
const current = known.find((item) => item.href === pathname);
|
|
283
|
+
return current ? !allows(current.perm) : false;
|
|
284
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
285
|
+
}, [user, navItems, pathname]);
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Aberto/fechado por grupo, e não um booleano só como era com o
|
|
289
|
+
* "Administração" sozinho.
|
|
290
|
+
*
|
|
291
|
+
* Sem entrada no mapa, o grupo nasce aberto quando a rota atual é de um
|
|
292
|
+
* filho — o que o `adminActive` já fazia. A entrada aparece no primeiro
|
|
293
|
+
* clique: a partir daí manda a escolha do usuário, não a rota.
|
|
294
|
+
*/
|
|
295
|
+
const [openGroups, setOpenGroups] = useState<Record<string, boolean>>({});
|
|
296
|
+
|
|
297
|
+
const isGroupOpen = (group: NavGroup): boolean =>
|
|
298
|
+
openGroups[group.key] ??
|
|
299
|
+
group.children.some((child) => pathname === child.href);
|
|
300
|
+
|
|
301
|
+
const toggleGroup = (group: NavGroup): void =>
|
|
302
|
+
setOpenGroups((current) => ({
|
|
303
|
+
...current,
|
|
304
|
+
[group.key]: !isGroupOpen(group),
|
|
305
|
+
}));
|
|
164
306
|
|
|
165
307
|
useEffect(() => {
|
|
166
308
|
if (!loading && !user) {router.replace("/login");}
|
|
@@ -198,17 +340,29 @@ export function AppShell({ children, navItems }: AppShellProps): JSX.Element {
|
|
|
198
340
|
item,
|
|
199
341
|
mini,
|
|
200
342
|
nested,
|
|
343
|
+
parentKey,
|
|
201
344
|
}: {
|
|
202
345
|
item: NavItem;
|
|
203
346
|
mini: boolean;
|
|
204
347
|
nested?: boolean;
|
|
348
|
+
/**
|
|
349
|
+
* Grupo a que o item pertence, só para o tooltip do modo recolhido.
|
|
350
|
+
*
|
|
351
|
+
* Ali os filhos aparecem soltos, sem o pai: "Catálogos" sozinho, ao lado de
|
|
352
|
+
* "Tarefas", não diria de quê. O caminho no tooltip resolve isso sem
|
|
353
|
+
* obrigar o rótulo a repetir o nome da feature no menu aberto.
|
|
354
|
+
*/
|
|
355
|
+
parentKey?: string;
|
|
205
356
|
}): JSX.Element => {
|
|
206
357
|
const active = pathname === item.href;
|
|
207
358
|
const Icon = item.icon;
|
|
359
|
+
const tooltip = parentKey
|
|
360
|
+
? `${t(parentKey)} › ${t(item.key)}`
|
|
361
|
+
: t(item.key);
|
|
208
362
|
return (
|
|
209
363
|
<button
|
|
210
364
|
onClick={() => go(item.href)}
|
|
211
|
-
title={mini ?
|
|
365
|
+
title={mini ? tooltip : undefined}
|
|
212
366
|
className={cn(
|
|
213
367
|
"flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium transition-colors",
|
|
214
368
|
mini && "justify-center px-2",
|
|
@@ -224,46 +378,70 @@ export function AppShell({ children, navItems }: AppShellProps): JSX.Element {
|
|
|
224
378
|
);
|
|
225
379
|
};
|
|
226
380
|
|
|
381
|
+
/**
|
|
382
|
+
* Um grupo do menu — o pai que abre e fecha, e os filhos indentados.
|
|
383
|
+
*
|
|
384
|
+
* Com a barra recolhida não há onde desenhar seta nem rótulo, então os
|
|
385
|
+
* filhos aparecem soltos, separados do resto; é o que o "Administração" já
|
|
386
|
+
* fazia, e agora vale para qualquer grupo.
|
|
387
|
+
*/
|
|
388
|
+
const NavGroupBlock = ({
|
|
389
|
+
group,
|
|
390
|
+
mini,
|
|
391
|
+
}: {
|
|
392
|
+
group: NavGroup;
|
|
393
|
+
mini: boolean;
|
|
394
|
+
}): JSX.Element => {
|
|
395
|
+
const Icon = group.icon;
|
|
396
|
+
const open = isGroupOpen(group);
|
|
397
|
+
if (mini) {
|
|
398
|
+
return (
|
|
399
|
+
<>
|
|
400
|
+
<Separator className="my-2" />
|
|
401
|
+
{group.children.map((child) => (
|
|
402
|
+
<NavLink key={child.href} item={child} mini parentKey={group.key} />
|
|
403
|
+
))}
|
|
404
|
+
</>
|
|
405
|
+
);
|
|
406
|
+
}
|
|
407
|
+
return (
|
|
408
|
+
<div className="mt-2">
|
|
409
|
+
<button
|
|
410
|
+
onClick={() => toggleGroup(group)}
|
|
411
|
+
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
|
412
|
+
>
|
|
413
|
+
<Icon className="h-5 w-5 shrink-0" />
|
|
414
|
+
<span className="flex-1 text-left">{t(group.key)}</span>
|
|
415
|
+
<ChevronRight
|
|
416
|
+
className={cn("h-4 w-4 transition-transform", open && "rotate-90")}
|
|
417
|
+
/>
|
|
418
|
+
</button>
|
|
419
|
+
{open && (
|
|
420
|
+
<div className="mt-1 flex flex-col gap-1">
|
|
421
|
+
{group.children.map((child) => (
|
|
422
|
+
<NavLink key={child.href} item={child} mini={false} nested />
|
|
423
|
+
))}
|
|
424
|
+
</div>
|
|
425
|
+
)}
|
|
426
|
+
</div>
|
|
427
|
+
);
|
|
428
|
+
};
|
|
429
|
+
|
|
227
430
|
const NavContent = ({ mini }: { mini: boolean }): JSX.Element => (
|
|
228
431
|
<div className="flex flex-1 flex-col gap-1 overflow-y-auto overflow-x-hidden p-3">
|
|
229
432
|
<NavLink item={DASHBOARD} mini={mini} />
|
|
230
433
|
|
|
231
|
-
{
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
{adminChildren.length > 0 &&
|
|
236
|
-
(mini ? (
|
|
237
|
-
<>
|
|
238
|
-
<Separator className="my-2" />
|
|
239
|
-
{adminChildren.map((c) => (
|
|
240
|
-
<NavLink key={c.href} item={c} mini />
|
|
241
|
-
))}
|
|
242
|
-
</>
|
|
434
|
+
{projectEntries.map((entry) =>
|
|
435
|
+
isGroup(entry) ? (
|
|
436
|
+
<NavGroupBlock key={entry.key} group={entry} mini={mini} />
|
|
243
437
|
) : (
|
|
244
|
-
<
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
<ChevronRight
|
|
252
|
-
className={cn(
|
|
253
|
-
"h-4 w-4 transition-transform",
|
|
254
|
-
adminOpen && "rotate-90",
|
|
255
|
-
)}
|
|
256
|
-
/>
|
|
257
|
-
</button>
|
|
258
|
-
{adminOpen && (
|
|
259
|
-
<div className="mt-1 flex flex-col gap-1">
|
|
260
|
-
{adminChildren.map((c) => (
|
|
261
|
-
<NavLink key={c.href} item={c} mini={false} nested />
|
|
262
|
-
))}
|
|
263
|
-
</div>
|
|
264
|
-
)}
|
|
265
|
-
</div>
|
|
266
|
-
))}
|
|
438
|
+
<NavLink key={entry.href} item={entry} mini={mini} />
|
|
439
|
+
),
|
|
440
|
+
)}
|
|
441
|
+
|
|
442
|
+
{adminChildren.length > 0 && (
|
|
443
|
+
<NavGroupBlock group={adminGroup} mini={mini} />
|
|
444
|
+
)}
|
|
267
445
|
|
|
268
446
|
<div className="mt-1">
|
|
269
447
|
<NavLink item={PROFILE} mini={mini} />
|
|
@@ -379,8 +557,8 @@ export function AppShell({ children, navItems }: AppShellProps): JSX.Element {
|
|
|
379
557
|
)}
|
|
380
558
|
>
|
|
381
559
|
<div className="p-4 md:p-8">
|
|
382
|
-
<Breadcrumbs />
|
|
383
|
-
{children}
|
|
560
|
+
{!routeBlocked && <Breadcrumbs />}
|
|
561
|
+
{routeBlocked ? (forbidden ?? <ForbiddenScreen />) : children}
|
|
384
562
|
</div>
|
|
385
563
|
</main>
|
|
386
564
|
</div>
|
|
@@ -103,6 +103,15 @@ interface DataTableProps<T extends RowData> {
|
|
|
103
103
|
* não os desmarca ao aplicar nem ao limpar: não são dele.
|
|
104
104
|
*/
|
|
105
105
|
hiddenFilterFields?: string[];
|
|
106
|
+
/**
|
|
107
|
+
* Texto da tabela vazia, quando a listagem sabe algo que a tabela não sabe.
|
|
108
|
+
*
|
|
109
|
+
* Sem isto, "Nenhum registro" é a resposta para tudo — inclusive para
|
|
110
|
+
* listagem podada por permissão, onde os registros existem e é o alcance de
|
|
111
|
+
* quem olha que não chega neles. Uma tela vazia que não diz o motivo é
|
|
112
|
+
* indistinguível de bug, e manda o usuário procurar defeito onde não há.
|
|
113
|
+
*/
|
|
114
|
+
emptyMessage?: string;
|
|
106
115
|
}
|
|
107
116
|
|
|
108
117
|
/** Extras que uma coluna pode declarar em `meta`. */
|
|
@@ -154,6 +163,7 @@ export function DataTable<T extends RowData>({
|
|
|
154
163
|
enableSorting = true,
|
|
155
164
|
filters,
|
|
156
165
|
hiddenFilterFields,
|
|
166
|
+
emptyMessage,
|
|
157
167
|
}: DataTableProps<T>): JSX.Element {
|
|
158
168
|
const { t } = useI18n();
|
|
159
169
|
const [colsOpen, setColsOpen] = useState(false);
|
|
@@ -232,6 +242,13 @@ export function DataTable<T extends RowData>({
|
|
|
232
242
|
: 0
|
|
233
243
|
: activeFilterCount(filters.values, hiddenFilterFields);
|
|
234
244
|
|
|
245
|
+
// Vazio com filtro aplicado não é "não há registros", é "não há *estes*
|
|
246
|
+
// registros" — e a diferença decide se o usuário vai limpar o filtro ou
|
|
247
|
+
// procurar um bug.
|
|
248
|
+
const emptyText =
|
|
249
|
+
emptyMessage ??
|
|
250
|
+
(activeFilters > 0 ? t("table.emptyFiltered") : t("table.empty"));
|
|
251
|
+
|
|
235
252
|
return (
|
|
236
253
|
<div>
|
|
237
254
|
<div className="mb-3 flex items-center justify-between gap-2">
|
|
@@ -323,7 +340,7 @@ export function DataTable<T extends RowData>({
|
|
|
323
340
|
colSpan={colSpan}
|
|
324
341
|
className="h-24 text-center text-muted-foreground"
|
|
325
342
|
>
|
|
326
|
-
{
|
|
343
|
+
{emptyText}
|
|
327
344
|
</TableCell>
|
|
328
345
|
</TableRow>
|
|
329
346
|
)}
|