Added the ability to filter admin dashboard data by site code and agent node ID, improving data retrieval capabilities. Introduced new functions for fetching dashboard data based on these parameters. Updated the admin users and roles management components to reflect these changes. Enhanced multi-language support by adding new translations for agent management and permission levels in English, Nepali, and Chinese, ensuring a consistent user experience across the admin interface.
201 lines
7.3 KiB
TypeScript
201 lines
7.3 KiB
TypeScript
"use client";
|
|
|
|
import { ChevronDown } from "lucide-react";
|
|
import { useMemo, useState } from "react";
|
|
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
|
import { cn } from "@/lib/utils";
|
|
import type { AdminPermissionCatalogData } from "@/types/api/admin-user";
|
|
|
|
type PermissionSelectorProps = {
|
|
catalog: AdminPermissionCatalogData | null;
|
|
selectedSlugs: string[];
|
|
onChange: (next: string[]) => void;
|
|
resolveGroupLabel: (key: string, fallback: string) => string;
|
|
resolvePermissionLabel: (slug: string, fallback: string) => string;
|
|
selectableSlugs?: string[] | null;
|
|
helperText?: string;
|
|
summaryText?: string;
|
|
emptyText: string;
|
|
heightClassName?: string;
|
|
};
|
|
|
|
export function AdminPermissionSelector({
|
|
catalog,
|
|
selectedSlugs,
|
|
onChange,
|
|
resolveGroupLabel,
|
|
resolvePermissionLabel,
|
|
selectableSlugs = null,
|
|
helperText,
|
|
summaryText,
|
|
emptyText,
|
|
heightClassName = "h-[52vh]",
|
|
}: PermissionSelectorProps): React.ReactElement {
|
|
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
|
|
const selectedSet = useMemo(() => new Set(selectedSlugs), [selectedSlugs]);
|
|
const allowedSet = useMemo(
|
|
() => (selectableSlugs ? new Set(selectableSlugs) : null),
|
|
[selectableSlugs],
|
|
);
|
|
|
|
const groups = useMemo(() => {
|
|
const rawGroups = catalog?.permission_menu_groups ?? [];
|
|
const mapped = rawGroups
|
|
.map((group) => ({
|
|
key: group.key,
|
|
label: resolveGroupLabel(group.key, group.label),
|
|
permissions: group.permissions.filter((permission) =>
|
|
allowedSet ? allowedSet.has(permission.slug) : true,
|
|
),
|
|
}))
|
|
.filter((group) => group.permissions.length > 0);
|
|
|
|
if (mapped.length > 0) {
|
|
return mapped;
|
|
}
|
|
|
|
const fallbackPermissions = (catalog?.permissions ?? []).filter((permission) =>
|
|
allowedSet ? allowedSet.has(permission.slug) : true,
|
|
);
|
|
|
|
if (fallbackPermissions.length === 0) {
|
|
return [];
|
|
}
|
|
|
|
return [
|
|
{
|
|
key: "all",
|
|
label: resolveGroupLabel("all", "全部权限"),
|
|
permissions: fallbackPermissions,
|
|
},
|
|
];
|
|
}, [allowedSet, catalog, resolveGroupLabel]);
|
|
|
|
const totalCount = useMemo(
|
|
() => groups.reduce((sum, group) => sum + group.permissions.length, 0),
|
|
[groups],
|
|
);
|
|
|
|
const toggleOne = (slug: string, checked: boolean) => {
|
|
const next = new Set(selectedSet);
|
|
if (checked) {
|
|
next.add(slug);
|
|
} else {
|
|
next.delete(slug);
|
|
}
|
|
onChange(Array.from(next).sort());
|
|
};
|
|
|
|
const toggleGroup = (slugs: string[], checked: boolean) => {
|
|
const next = new Set(selectedSet);
|
|
for (const slug of slugs) {
|
|
if (checked) {
|
|
next.add(slug);
|
|
} else {
|
|
next.delete(slug);
|
|
}
|
|
}
|
|
onChange(Array.from(next).sort());
|
|
};
|
|
|
|
const toggleExpanded = (key: string) => {
|
|
setExpandedGroups((prev) => ({ ...prev, [key]: !(prev[key] ?? true) }));
|
|
};
|
|
|
|
const isExpanded = (key: string): boolean => expandedGroups[key] ?? true;
|
|
|
|
if (groups.length === 0 || totalCount === 0) {
|
|
return (
|
|
<div className="rounded-xl border border-dashed p-4 text-sm text-muted-foreground">
|
|
{emptyText}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-3">
|
|
{helperText || summaryText ? (
|
|
<div className="flex flex-wrap items-center justify-between gap-2 rounded-xl border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
|
<span>{helperText}</span>
|
|
<span>{summaryText}</span>
|
|
</div>
|
|
) : null}
|
|
|
|
<ScrollArea className={cn("pr-3", heightClassName)}>
|
|
<div className="overflow-hidden rounded-xl border border-border/70 bg-background">
|
|
{groups.map((group) => {
|
|
const expanded = isExpanded(group.key);
|
|
const groupSlugs = group.permissions.map((permission) => permission.slug);
|
|
const selectedCount = groupSlugs.filter((slug) => selectedSet.has(slug)).length;
|
|
const checkedState =
|
|
selectedCount === 0 ? false : selectedCount === groupSlugs.length ? true : "indeterminate";
|
|
|
|
return (
|
|
<div key={group.key} className={cn("border-b border-border/60 last:border-b-0", expanded && "bg-muted/10")}>
|
|
<div className="flex items-center gap-3 px-4 py-3 text-sm transition-colors hover:bg-muted/20">
|
|
<button
|
|
type="button"
|
|
className="flex size-7 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-muted"
|
|
onClick={() => toggleExpanded(group.key)}
|
|
aria-label={expanded ? "collapse" : "expand"}
|
|
>
|
|
<ChevronDown aria-hidden className={cn("size-4 transition-transform", expanded && "rotate-180")} />
|
|
</button>
|
|
<Checkbox
|
|
checked={checkedState === true}
|
|
indeterminate={checkedState === "indeterminate"}
|
|
onCheckedChange={(value) => toggleGroup(groupSlugs, value === true)}
|
|
/>
|
|
<button type="button" className="min-w-0 flex-1 text-left" onClick={() => toggleExpanded(group.key)}>
|
|
<div className="flex min-w-0 items-center gap-2">
|
|
<span className="min-w-0 truncate text-[15px] font-medium leading-6 text-foreground">
|
|
{group.label}
|
|
</span>
|
|
<span className="shrink-0 rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
|
|
{group.permissions.length}
|
|
</span>
|
|
</div>
|
|
</button>
|
|
<span className="shrink-0 tabular-nums text-xs text-muted-foreground">
|
|
{selectedCount}/{group.permissions.length}
|
|
</span>
|
|
</div>
|
|
{expanded ? (
|
|
<div className="pb-2">
|
|
{group.permissions.map((permission, index) => (
|
|
<label
|
|
key={permission.slug}
|
|
className={cn(
|
|
"flex cursor-pointer items-start gap-3 px-4 py-2.5 text-sm transition-colors hover:bg-muted/20",
|
|
index === 0 && "border-t border-border/50",
|
|
selectedSet.has(permission.slug) && "bg-muted/20",
|
|
)}
|
|
>
|
|
<span className="mt-1 flex h-4 w-8 shrink-0 items-center">
|
|
<span className="h-px w-full bg-border/70" />
|
|
</span>
|
|
<Checkbox
|
|
className="mt-0.5"
|
|
checked={selectedSet.has(permission.slug)}
|
|
onCheckedChange={(value) => toggleOne(permission.slug, value === true)}
|
|
/>
|
|
<span className="min-w-0">
|
|
<div className="whitespace-normal break-words leading-6 text-foreground">
|
|
{resolvePermissionLabel(permission.slug, permission.name)}
|
|
</div>
|
|
</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
);
|
|
}
|