feat(api, i18n): add agent_node_id to various admin queries and enhance multi-language support

Introduced the agent_node_id field in AdminDrawListQuery, AdminPlayerListQuery, AdminSettlementBatchListQuery, TicketItemsListQuery, and TransferOrderListQuery to improve filtering capabilities. Updated the admin-breadcrumb and admin-sidebar components to include new translations for agent-related terms in English, Nepali, and Chinese, enhancing the overall user experience and multi-language support across the admin interface.
This commit is contained in:
2026-06-02 14:37:08 +08:00
parent a4e7a2d228
commit b15e377187
105 changed files with 5305 additions and 1596 deletions

View File

@@ -0,0 +1,256 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { ChevronRight } from "lucide-react";
import type { TFunction } from "i18next";
import { useEffect, useMemo, useState, type ReactElement } from "react";
import { useTranslation } from "react-i18next";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarMenuSub,
SidebarMenuSubButton,
SidebarMenuSubItem,
} from "@/components/ui/sidebar";
import {
ADMIN_NAV_GROUP_ICON,
ADMIN_NAV_GROUP_ORDER,
groupAdminNavItems,
} from "@/lib/admin-nav-groups";
import { adminNavLabel } from "@/lib/admin-nav-label";
import { cn } from "@/lib/utils";
import { resolveAdminNavIcon } from "@/modules/_config/admin-nav-icons";
import { ADMIN_BASE, type AdminNavGroup, type AdminNavItem } from "@/modules/_config/admin-nav";
const NAV_BTN =
"h-8 gap-2 px-2.5 py-0 text-[13px] leading-snug font-normal text-sidebar-foreground/90 hover:text-sidebar-accent-foreground [&_svg]:size-4";
const NAV_ACTIVE = "data-active:bg-red-600 data-active:text-white data-active:font-medium data-active:shadow-sm";
const SUB_NAV =
"h-8 min-h-8 rounded-sm px-2.5 py-0 text-sm leading-snug font-normal text-sidebar-foreground/90 hover:text-sidebar-accent-foreground data-[size=md]:text-sm data-[size=sm]:text-sm [&>span]:text-sm";
function isActive(
pathname: string,
item: { href: string; activeMatchPrefix?: string; segment?: string },
): boolean {
const { href, activeMatchPrefix, segment } = item;
const prefix = activeMatchPrefix ?? href;
if (prefix === ADMIN_BASE || prefix === `${ADMIN_BASE}/`) {
return pathname === ADMIN_BASE || pathname === `${ADMIN_BASE}/`;
}
if (segment === "settings") {
return pathname === href;
}
return pathname === prefix || pathname.startsWith(`${prefix}/`);
}
function defaultOpenGroups(
groups: { group: AdminNavGroup; items: AdminNavItem[] }[],
pathname: string,
): Record<AdminNavGroup, boolean> {
const open = Object.fromEntries(
ADMIN_NAV_GROUP_ORDER.map((g) => [g, true]),
) as Record<AdminNavGroup, boolean>;
for (const { group, items } of groups) {
if (items.some((item) => isActive(pathname, item))) {
open[group] = true;
}
}
return open;
}
function NavLeaf({
item,
pathname,
t,
}: {
item: AdminNavItem;
pathname: string;
t: TFunction;
}): ReactElement {
const Icon = resolveAdminNavIcon(item.segment);
const active = isActive(pathname, item);
const label = adminNavLabel(item.segment, t, item.label);
return (
<SidebarMenuItem>
<SidebarMenuButton
size="sm"
tooltip={label}
isActive={active}
render={<Link href={item.href} />}
className={cn(NAV_BTN, NAV_ACTIVE)}
>
<Icon data-icon="inline-start" aria-hidden />
<span>{label}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
function NavSubLeaf({
item,
pathname,
t,
}: {
item: AdminNavItem;
pathname: string;
t: TFunction;
}): ReactElement {
const active = isActive(pathname, item);
const label = adminNavLabel(item.segment, t, item.label);
return (
<SidebarMenuSubItem>
<SidebarMenuSubButton
size="md"
isActive={active}
render={<Link href={item.href} />}
className={cn(SUB_NAV, NAV_ACTIVE)}
>
<span>{label}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
}
function NavCollapsibleGroup({
group,
items,
pathname,
open,
onToggle,
t,
}: {
group: AdminNavGroup;
items: AdminNavItem[];
pathname: string;
open: boolean;
onToggle: () => void;
t: TFunction;
}): ReactElement {
const GroupIcon = ADMIN_NAV_GROUP_ICON[group];
const groupLabel = t(`sidebar.group.${group}`, { ns: "common", defaultValue: group });
const hasActiveChild = items.some((item) => isActive(pathname, item));
return (
<SidebarMenuItem>
<SidebarMenuButton
size="sm"
type="button"
data-open={open ? "" : undefined}
isActive={hasActiveChild && !open}
className={cn(NAV_BTN, hasActiveChild && !open && NAV_ACTIVE)}
onClick={onToggle}
>
<GroupIcon aria-hidden />
<span className="flex-1 truncate">{groupLabel}</span>
<ChevronRight
aria-hidden
className={cn(
"ml-auto size-4 shrink-0 text-sidebar-foreground/50 transition-transform duration-200",
open && "rotate-90",
)}
/>
</SidebarMenuButton>
{open ? (
<SidebarMenuSub className="mx-2 gap-0.5 border-sidebar-border/50 px-1.5 py-0.5">
{items.map((item) => (
<NavSubLeaf key={item.segment} item={item} pathname={pathname} t={t} />
))}
</SidebarMenuSub>
) : null}
</SidebarMenuItem>
);
}
export function AdminSidebarNav({
items,
}: {
items: readonly AdminNavItem[];
}): ReactElement {
const { t } = useTranslation("common");
const pathname = usePathname();
const navGroups = useMemo(() => groupAdminNavItems(items), [items]);
const [openGroups, setOpenGroups] = useState<Record<AdminNavGroup, boolean>>(() =>
defaultOpenGroups(navGroups, pathname),
);
useEffect(() => {
setOpenGroups((prev) => {
const next = { ...prev };
let changed = false;
for (const { group, items: groupItems } of navGroups) {
if (groupItems.some((item) => isActive(pathname, item)) && !next[group]) {
next[group] = true;
changed = true;
}
}
return changed ? next : prev;
});
}, [pathname, navGroups]);
const overview = navGroups.find((g) => g.group === "overview");
const collapsible = navGroups.filter((g) => g.group !== "overview");
return (
<SidebarMenu className="gap-0.5 px-1.5 py-1.5">
{overview?.items.map((item) => (
<NavLeaf key={item.segment} item={item} pathname={pathname} t={t} />
))}
{collapsible.map(({ group, items: groupItems }) => (
<NavCollapsibleGroup
key={group}
group={group}
items={groupItems}
pathname={pathname}
open={openGroups[group] ?? true}
onToggle={() =>
setOpenGroups((prev) => ({
...prev,
[group]: !(prev[group] ?? true),
}))
}
t={t}
/>
))}
</SidebarMenu>
);
}
export function AdminSidebarNavSkeleton(): ReactElement {
const { t } = useTranslation("common");
const widths = ["68%", "74%", "58%", "70%", "62%"] as const;
return (
<SidebarMenu className="gap-0.5 px-1.5 py-1.5 motion-safe:opacity-90">
<SidebarMenuItem>
<div aria-hidden className="flex h-8 items-center gap-2 rounded-md px-2.5">
<span className="size-4 rounded-sm bg-white/12 motion-safe:animate-pulse" />
<span className="h-2.5 w-14 rounded-full bg-white/12 motion-safe:animate-pulse" />
</div>
</SidebarMenuItem>
{widths.map((width, i) => (
<SidebarMenuItem key={i}>
<div aria-hidden className="flex h-8 items-center gap-2 rounded-md px-2.5">
<span
className="size-4 rounded-sm bg-white/12 motion-safe:animate-pulse"
style={{ animationDelay: `${i * 55}ms` }}
/>
<span
className="h-2 rounded-full bg-white/12 motion-safe:animate-pulse"
style={{ width, animationDelay: `${i * 55 + 40}ms` }}
/>
<span className="ml-auto size-3 rounded-sm bg-white/10 motion-safe:animate-pulse" />
</div>
</SidebarMenuItem>
))}
<span className="sr-only">{t("auth.checking")}</span>
</SidebarMenu>
);
}