refactor(admin): refactor admin roles and users management
This commit is contained in:
@@ -5,14 +5,14 @@ import { Table } from '@tanstack/react-table';
|
||||
|
||||
const TableHeader = <TData, _>({ table }: { table: Table<TData> }) => {
|
||||
return (
|
||||
<ShadTableHeader>
|
||||
<ShadTableHeader className="sticky top-0 z-10 bg-card">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
className="h-10 px-2 text-foreground"
|
||||
className="h-10 px-2 text-foreground bg-card"
|
||||
>
|
||||
{header.isPlaceholder ? null : (
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
@@ -135,7 +135,7 @@ export function DataTable<TData, TValue>({
|
||||
return (
|
||||
<section className="space-y-4 rounded-lg border bg-card p-4">
|
||||
<TopHeader
|
||||
title={title}
|
||||
title={onAddNew ? title : undefined}
|
||||
itemCount={pagination?.totalItems ?? data.length}
|
||||
onAddNew={onAddNew}
|
||||
addButtonText={addButtonText}
|
||||
|
||||
56
src/components/permission-tree.tsx
Normal file
56
src/components/permission-tree.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CheckboxTree } from '@/components/ui/tree';
|
||||
import type { PermissionTreeItem, PermissionTreeNode } from '@/types';
|
||||
|
||||
export function mapPermissionTree(nodes: PermissionTreeNode[]): PermissionTreeItem[] {
|
||||
return nodes.map((node) => ({
|
||||
id: node.id,
|
||||
key: node.slug || node.name,
|
||||
label: node.display_name || node.name,
|
||||
description: node.description,
|
||||
type: node.type,
|
||||
isGrantedByDefault: Boolean(node.is_granted_by_default),
|
||||
children: mapPermissionTree(node.children || []),
|
||||
}));
|
||||
}
|
||||
|
||||
export function collectDefaultPermissionIds(nodes: PermissionTreeItem[]): number[] {
|
||||
return nodes.flatMap((node) => [
|
||||
...(node.isGrantedByDefault ? [node.id] : []),
|
||||
...collectDefaultPermissionIds(node.children),
|
||||
]);
|
||||
}
|
||||
|
||||
export function collectPermissionIdsByKeys(nodes: PermissionTreeItem[], keys: string[]): number[] {
|
||||
const selectedKeys = new Set(keys);
|
||||
return nodes.flatMap((node) => [
|
||||
...(selectedKeys.has(node.key) ? [node.id] : []),
|
||||
...collectPermissionIdsByKeys(node.children, keys),
|
||||
]);
|
||||
}
|
||||
|
||||
export function PermissionTree({
|
||||
items,
|
||||
selectedIds,
|
||||
onChange,
|
||||
}: {
|
||||
items: PermissionTreeItem[];
|
||||
selectedIds: number[];
|
||||
onChange: (ids: number[]) => void;
|
||||
}) {
|
||||
return (
|
||||
<CheckboxTree
|
||||
items={items}
|
||||
selectedIds={selectedIds}
|
||||
onSelectedIdsChange={onChange}
|
||||
emptyMessage="No permissions available."
|
||||
renderMeta={(item) => (
|
||||
<Badge variant="outline" className="text-[10px] capitalize">
|
||||
{item.type}
|
||||
</Badge>
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
161
src/components/ui/tree.tsx
Normal file
161
src/components/ui/tree.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
'use client';
|
||||
|
||||
import { ChevronDown } from 'lucide-react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
export interface TreeItem {
|
||||
id: number;
|
||||
label: string;
|
||||
description?: string;
|
||||
children: TreeItem[];
|
||||
}
|
||||
|
||||
export function collectTreeItemIds<TItem extends TreeItem>(item: TItem): number[] {
|
||||
return [item.id, ...item.children.flatMap((child) => collectTreeItemIds(child as TItem))];
|
||||
}
|
||||
|
||||
function TreeCheckbox({
|
||||
checked,
|
||||
indeterminate,
|
||||
onChange,
|
||||
}: {
|
||||
checked: boolean;
|
||||
indeterminate: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
const ref = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
ref.current.indeterminate = indeterminate;
|
||||
}
|
||||
}, [indeterminate]);
|
||||
|
||||
return (
|
||||
<input
|
||||
ref={ref}
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
onChange={(event) => onChange(event.target.checked)}
|
||||
className="size-4 rounded border-border accent-primary"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function CheckboxTreeNode<TItem extends TreeItem>({
|
||||
item,
|
||||
selectedIds,
|
||||
onToggle,
|
||||
renderMeta,
|
||||
}: {
|
||||
item: TItem;
|
||||
selectedIds: Set<number>;
|
||||
onToggle: (item: TItem, checked: boolean) => void;
|
||||
renderMeta?: (item: TItem) => ReactNode;
|
||||
}) {
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const itemIds = collectTreeItemIds(item);
|
||||
const selectedCount = itemIds.filter((id) => selectedIds.has(id)).length;
|
||||
const checked = selectedCount === itemIds.length;
|
||||
const indeterminate = selectedCount > 0 && !checked;
|
||||
const hasChildren = item.children.length > 0;
|
||||
|
||||
return (
|
||||
<li className="space-y-2">
|
||||
<div className="flex items-start gap-2 rounded-md border border-border/60 bg-background px-3 py-2">
|
||||
{hasChildren ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsExpanded((current) => !current)}
|
||||
aria-label={isExpanded ? `Collapse ${item.label}` : `Expand ${item.label}`}
|
||||
className="mt-0.5 inline-flex size-5 shrink-0 items-center justify-center rounded text-muted-foreground transition hover:bg-muted hover:text-foreground"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`size-4 transition-transform ${isExpanded ? '' : '-rotate-90'}`}
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<span className="size-5 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
<TreeCheckbox
|
||||
checked={checked}
|
||||
indeterminate={indeterminate}
|
||||
onChange={(isChecked) => onToggle(item, isChecked)}
|
||||
/>
|
||||
<div className="min-w-0 flex-1 leading-none">
|
||||
<div className="flex min-h-5 flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium leading-5">{item.label}</span>
|
||||
{renderMeta?.(item)}
|
||||
</div>
|
||||
{item.description ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{item.description}</p>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{hasChildren && isExpanded ? (
|
||||
<ul className="ml-5 space-y-2 border-l border-border/70 pl-3">
|
||||
{item.children.map((child) => (
|
||||
<CheckboxTreeNode
|
||||
key={child.id}
|
||||
item={child as TItem}
|
||||
selectedIds={selectedIds}
|
||||
onToggle={onToggle}
|
||||
renderMeta={renderMeta}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function CheckboxTree<TItem extends TreeItem>({
|
||||
items,
|
||||
selectedIds,
|
||||
onSelectedIdsChange,
|
||||
renderMeta,
|
||||
emptyMessage = 'No items available.',
|
||||
}: {
|
||||
items: TItem[];
|
||||
selectedIds: number[];
|
||||
onSelectedIdsChange: (ids: number[]) => void;
|
||||
renderMeta?: (item: TItem) => ReactNode;
|
||||
emptyMessage?: string;
|
||||
}) {
|
||||
const selectedSet = useMemo(() => new Set(selectedIds), [selectedIds]);
|
||||
|
||||
const handleToggle = (item: TItem, checked: boolean) => {
|
||||
const next = new Set(selectedSet);
|
||||
collectTreeItemIds(item).forEach((id) => {
|
||||
if (checked) {
|
||||
next.add(id);
|
||||
} else {
|
||||
next.delete(id);
|
||||
}
|
||||
});
|
||||
onSelectedIdsChange(Array.from(next));
|
||||
};
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className="rounded-md border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
{emptyMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ul className="space-y-2">
|
||||
{items.map((item) => (
|
||||
<CheckboxTreeNode
|
||||
key={item.id}
|
||||
item={item}
|
||||
selectedIds={selectedSet}
|
||||
onToggle={handleToggle}
|
||||
renderMeta={renderMeta}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user