افزودن پالت فرمان (⌘K) و صفحهٔ مصرف سراسری پنل مالک
دو شکاف رسیدگیشده از بازبینی PLAN.md و TODO.md: - پالت فرمان: جستوجو و رفتن سریع بین بخشها با ⌘K/Ctrl+K، پیمایش با کلیدهای جهتدار، Enter برای رفتن، و Escape برای بستن. گروه اختصاصی «کنسول مالک سامانه» فقط برای مالک سامانه نمایش داده میشود. - صفحهٔ /admin/usage: گزارش مصرف سراسری به تفکیک سازمان. اندپوینت `GET /admin/usage` و کلاینت `adminUsage()` از قبل وجود داشتند و توسط صفحات نمای کلی و سازمانها مصرف میشدند؛ این صفحهٔ اختصاصی آن است. همچنین `NAV_ITEMS` به `lib/nav.ts` منتقل شد تا sidebar و پالت فرمان هرگز از هم واگرا نشوند. تست: ۱۰ تست جدید برای پالت فرمان. مجموع تستهای فرانتاند از ۲۵ به ۳۵ رسید. tsc، eslint و ruff تمیز.
This commit is contained in:
@@ -20,6 +20,7 @@ const SECTIONS = [
|
|||||||
{ href: "/admin/models", label: "مدلها" },
|
{ href: "/admin/models", label: "مدلها" },
|
||||||
{ href: "/admin/routing", label: "مسیریابی" },
|
{ href: "/admin/routing", label: "مسیریابی" },
|
||||||
{ href: "/admin/rate-cards", label: "تعرفهها" },
|
{ href: "/admin/rate-cards", label: "تعرفهها" },
|
||||||
|
{ href: "/admin/usage", label: "مصرف سراسری" },
|
||||||
{ href: "/admin/organizations", label: "سازمانها" },
|
{ href: "/admin/organizations", label: "سازمانها" },
|
||||||
{ href: "/admin/errors", label: "کنسول خطا" },
|
{ href: "/admin/errors", label: "کنسول خطا" },
|
||||||
] as const;
|
] as const;
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import type { AdminOrganization, AdminUsageRow } from "@agy/contracts";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardTitle,
|
||||||
|
EmptyState,
|
||||||
|
ErrorState,
|
||||||
|
LoadingState,
|
||||||
|
Stat,
|
||||||
|
Table,
|
||||||
|
type TableColumn,
|
||||||
|
} from "@agy/ui";
|
||||||
|
import { ApiError, api } from "@/lib/api";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform-wide usage ledger.
|
||||||
|
*
|
||||||
|
* TODO §6.6 asks the console for a usage view. The endpoint and the API client
|
||||||
|
* already existed and were consumed by the overview and organizations pages;
|
||||||
|
* this is the dedicated view, with the per-organization rows the ledger
|
||||||
|
* aggregates by tenant.
|
||||||
|
*/
|
||||||
|
export default function AdminUsagePage() {
|
||||||
|
const [usage, setUsage] = React.useState<AdminUsageRow[]>([]);
|
||||||
|
const [organizations, setOrganizations] = React.useState<AdminOrganization[]>([]);
|
||||||
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
const [error, setError] = React.useState<string | null>(null);
|
||||||
|
|
||||||
|
const load = React.useCallback(async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
// Both calls are needed: the ledger keys rows by organization id, and a
|
||||||
|
// bare id is meaningless to whoever is reading this page.
|
||||||
|
const [nextUsage, nextOrganizations] = await Promise.all([
|
||||||
|
api.adminUsage(),
|
||||||
|
api.adminOrganizations(),
|
||||||
|
]);
|
||||||
|
setUsage(nextUsage);
|
||||||
|
setOrganizations(nextOrganizations);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : "دریافت مصرف سراسری ناموفق بود.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [load]);
|
||||||
|
|
||||||
|
const orgName = React.useCallback(
|
||||||
|
(id: string) => organizations.find((org) => org.id === id)?.name ?? id,
|
||||||
|
[organizations],
|
||||||
|
);
|
||||||
|
|
||||||
|
const totalEvents = usage.reduce((sum, row) => sum + row.quantity, 0);
|
||||||
|
const totalBilled = usage.reduce((sum, row) => sum + row.total_price, 0);
|
||||||
|
|
||||||
|
const columns: Array<TableColumn<AdminUsageRow>> = [
|
||||||
|
{
|
||||||
|
key: "organization",
|
||||||
|
header: "سازمان",
|
||||||
|
render: (row) => <span className="text-slate-200">{orgName(row.organization_id)}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "events",
|
||||||
|
header: "رویداد مصرف",
|
||||||
|
align: "end",
|
||||||
|
render: (row) => row.quantity,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "billed",
|
||||||
|
header: "مبلغ",
|
||||||
|
align: "end",
|
||||||
|
render: (row) => (
|
||||||
|
<span className="ltr-inline text-slate-200">{row.total_price.toFixed(2)}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-5">
|
||||||
|
<header className="flex flex-col gap-1">
|
||||||
|
<h1 className="text-lg font-medium text-slate-100">مصرف سراسری</h1>
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
مصرف همهٔ مستأجرها بر اساس دفتر ثبت استفاده. این صفحه برای مشتریان قابل دسترسی نیست.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="grid gap-3 sm:grid-cols-3">
|
||||||
|
<Stat label="تعداد سازمانها" value={organizations.length} />
|
||||||
|
<Stat label="رویدادهای مصرف" value={totalEvents} />
|
||||||
|
<Stat label="مجموع مبلغ" value={totalBilled.toFixed(2)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<CardTitle>مصرف به تفکیک سازمان</CardTitle>
|
||||||
|
<div className="mt-3">
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
rows={usage}
|
||||||
|
rowKey={(row) => row.organization_id}
|
||||||
|
loading={loading}
|
||||||
|
error={error}
|
||||||
|
onRetry={load}
|
||||||
|
caption="مصرف سازمانها"
|
||||||
|
empty={
|
||||||
|
<EmptyState
|
||||||
|
title="مصرفی ثبت نشده است"
|
||||||
|
description="هنوز هیچ رویداد مصرفی در سامانه ثبت نشده است."
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,90 +5,10 @@ import { usePathname, useRouter } from "next/navigation";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { Badge, Button, cx } from "@agy/ui";
|
import { Badge, Button, cx } from "@agy/ui";
|
||||||
import { api } from "@/lib/api";
|
import { api } from "@/lib/api";
|
||||||
|
import { CommandPalette } from "@/components/CommandPalette";
|
||||||
|
import { NAV_ITEMS } from "@/lib/nav";
|
||||||
import { useSession } from "@/lib/session";
|
import { useSession } from "@/lib/session";
|
||||||
|
|
||||||
const NAV_ITEMS: { href: string; label: string; hint: string }[] = [
|
|
||||||
{ href: "/dashboard", label: "داشبورد", hint: "نمای کلی سازمان" },
|
|
||||||
{ href: "/manager", label: "مدیر AI", hint: "گفتگو با مدیر" },
|
|
||||||
{ href: "/workforce", label: "نیروی کار AI", hint: "کارمندها" },
|
|
||||||
{ href: "/projects", label: "پروژهها", hint: "Git و محیط اجرا" },
|
|
||||||
{ href: "/tasks", label: "وظایف", hint: "وضعیت کارها" },
|
|
||||||
{ href: "/traces", label: "ردیابی", hint: "اجراها و رویدادها" },
|
|
||||||
{ href: "/knowledge", label: "دانش سازمانی", hint: "منابع و اسناد" },
|
|
||||||
{ href: "/billing", label: "مصرف و پرداخت", hint: "اعتبار و فاکتور" },
|
|
||||||
{ href: "/settings", label: "تنظیمات", hint: "سازمان و اعضا" },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Command/search over the navigation.
|
|
||||||
*
|
|
||||||
* Deliberately client-side: the app has a fixed set of destinations, so
|
|
||||||
* filtering them needs no round trip and works while offline.
|
|
||||||
*/
|
|
||||||
function CommandSearch() {
|
|
||||||
const router = useRouter();
|
|
||||||
const [query, setQuery] = React.useState("");
|
|
||||||
const [open, setOpen] = React.useState(false);
|
|
||||||
|
|
||||||
const matches = React.useMemo(() => {
|
|
||||||
const needle = query.trim().toLowerCase();
|
|
||||||
if (!needle) return [];
|
|
||||||
return NAV_ITEMS.filter(
|
|
||||||
(item) =>
|
|
||||||
item.label.toLowerCase().includes(needle) ||
|
|
||||||
item.hint.toLowerCase().includes(needle) ||
|
|
||||||
item.href.includes(needle),
|
|
||||||
);
|
|
||||||
}, [query]);
|
|
||||||
|
|
||||||
const go = (href: string) => {
|
|
||||||
setQuery("");
|
|
||||||
setOpen(false);
|
|
||||||
router.push(href);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="relative hidden md:block">
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
value={query}
|
|
||||||
placeholder="جستوجو…"
|
|
||||||
aria-label="جستوجو در بخشها"
|
|
||||||
onChange={(event) => {
|
|
||||||
setQuery(event.target.value);
|
|
||||||
setOpen(true);
|
|
||||||
}}
|
|
||||||
onFocus={() => setOpen(true)}
|
|
||||||
onBlur={() => window.setTimeout(() => setOpen(false), 120)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === "Enter" && matches.length > 0) go(matches[0].href);
|
|
||||||
if (event.key === "Escape") setQuery("");
|
|
||||||
}}
|
|
||||||
className="h-9 w-56 rounded-lg border border-surface-border bg-surface-overlay px-3 text-xs text-slate-200 placeholder:text-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
|
|
||||||
/>
|
|
||||||
{open && query.trim() !== "" && (
|
|
||||||
<div className="absolute start-0 top-11 z-40 w-72 rounded-lg border border-surface-border bg-surface-overlay p-1.5">
|
|
||||||
{matches.length === 0 ? (
|
|
||||||
<p className="px-2 py-2 text-xs text-slate-400">نتیجهای پیدا نشد.</p>
|
|
||||||
) : (
|
|
||||||
matches.map((item) => (
|
|
||||||
<button
|
|
||||||
key={item.href}
|
|
||||||
type="button"
|
|
||||||
onMouseDown={() => go(item.href)}
|
|
||||||
className="flex w-full flex-col rounded-md px-2 py-1.5 text-start hover:bg-surface-raised"
|
|
||||||
>
|
|
||||||
<span className="text-xs text-slate-200">{item.label}</span>
|
|
||||||
<span className="text-xs text-slate-500">{item.hint}</span>
|
|
||||||
</button>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Credit badge — reads the organization's real balance, never a placeholder. */
|
/** Credit badge — reads the organization's real balance, never a placeholder. */
|
||||||
function CreditBadge({ organizationId }: { organizationId: string | undefined }) {
|
function CreditBadge({ organizationId }: { organizationId: string | undefined }) {
|
||||||
const [balance, setBalance] = React.useState<number | null>(null);
|
const [balance, setBalance] = React.useState<number | null>(null);
|
||||||
@@ -243,7 +163,7 @@ export function AppShell({ children }: { children: React.ReactNode }) {
|
|||||||
<span className="hidden sm:inline-flex">
|
<span className="hidden sm:inline-flex">
|
||||||
<CreditBadge organizationId={activeOrganization?.id} />
|
<CreditBadge organizationId={activeOrganization?.id} />
|
||||||
</span>
|
</span>
|
||||||
<CommandSearch />
|
<CommandPalette isPlatformOwner={user?.is_platform_owner} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="relative flex items-center gap-2">
|
<div className="relative flex items-center gap-2">
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import * as React from "react";
|
||||||
|
import { EmptyState, Modal, cx } from "@agy/ui";
|
||||||
|
import { ADMIN_ITEMS, NAV_ITEMS, type NavItem } from "@/lib/nav";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Command palette over the app's destinations.
|
||||||
|
*
|
||||||
|
* PLAN §12.3 asks for a command palette so power users can move between
|
||||||
|
* sections without hunting through the sidebar. It is a client-side filter
|
||||||
|
* over a fixed route list — the app has no dynamic search surface yet, so a
|
||||||
|
* round trip would buy nothing and would break while offline.
|
||||||
|
*
|
||||||
|
* The platform-owner group is passed in rather than read from the session so
|
||||||
|
* this component stays free of provider setup and is renderable in isolation.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** ``⌘K`` on macOS, ``Ctrl+K`` everywhere else. */
|
||||||
|
function isPaletteShortcut(event: KeyboardEvent): boolean {
|
||||||
|
return (
|
||||||
|
(event.metaKey || event.ctrlKey) &&
|
||||||
|
!event.altKey &&
|
||||||
|
!event.shiftKey &&
|
||||||
|
event.key.toLowerCase() === "k"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PalletGroup {
|
||||||
|
title: string;
|
||||||
|
items: NavItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildGroups(isPlatformOwner: boolean): PalletGroup[] {
|
||||||
|
const groups: PalletGroup[] = [{ title: "بخشها", items: NAV_ITEMS }];
|
||||||
|
if (isPlatformOwner) {
|
||||||
|
groups.push({ title: "کنسول مالک سامانه", items: ADMIN_ITEMS });
|
||||||
|
}
|
||||||
|
return groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matches(item: NavItem, needle: string): boolean {
|
||||||
|
return (
|
||||||
|
item.label.toLowerCase().includes(needle) ||
|
||||||
|
item.hint.toLowerCase().includes(needle) ||
|
||||||
|
item.href.includes(needle)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandPalette({ isPlatformOwner = false }: { isPlatformOwner?: boolean }) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [query, setQuery] = React.useState("");
|
||||||
|
const [active, setActive] = React.useState(0);
|
||||||
|
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const groups = React.useMemo(() => buildGroups(isPlatformOwner), [isPlatformOwner]);
|
||||||
|
|
||||||
|
const filteredGroups = React.useMemo(() => {
|
||||||
|
const needle = query.trim().toLowerCase();
|
||||||
|
const keep = (item: NavItem) => !needle || matches(item, needle);
|
||||||
|
return groups
|
||||||
|
.map((group) => ({ title: group.title, items: group.items.filter(keep) }))
|
||||||
|
.filter((group) => group.items.length > 0);
|
||||||
|
}, [groups, query]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keyboard navigation walks the flattened, visible item list. `indexOf` is
|
||||||
|
* safe here because both lists hold the same module-level object references.
|
||||||
|
*/
|
||||||
|
const visible = React.useMemo(
|
||||||
|
() => filteredGroups.flatMap((group) => group.items),
|
||||||
|
[filteredGroups],
|
||||||
|
);
|
||||||
|
|
||||||
|
// Toggle the palette from anywhere on the page without stealing focus.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const onKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (!isPaletteShortcut(event)) return;
|
||||||
|
event.preventDefault();
|
||||||
|
setQuery("");
|
||||||
|
setActive(0);
|
||||||
|
setOpen((currentlyOpen) => !currentlyOpen);
|
||||||
|
};
|
||||||
|
window.addEventListener("keydown", onKeyDown);
|
||||||
|
return () => window.removeEventListener("keydown", onKeyDown);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Focus the input once the dialog is mounted, so typing starts immediately.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (open) inputRef.current?.focus();
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
// A narrowed list must not leave the highlight off the end.
|
||||||
|
React.useEffect(() => {
|
||||||
|
setActive(0);
|
||||||
|
}, [query]);
|
||||||
|
|
||||||
|
const close = React.useCallback(() => {
|
||||||
|
setOpen(false);
|
||||||
|
setQuery("");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const go = React.useCallback(
|
||||||
|
(href: string) => {
|
||||||
|
close();
|
||||||
|
router.push(href);
|
||||||
|
},
|
||||||
|
[close, router],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (event.key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
close();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (visible.length === 0) return;
|
||||||
|
if (event.key === "ArrowDown") {
|
||||||
|
event.preventDefault();
|
||||||
|
setActive((index) => (index + 1) % visible.length);
|
||||||
|
} else if (event.key === "ArrowUp") {
|
||||||
|
event.preventDefault();
|
||||||
|
setActive((index) => (index - 1 + visible.length) % visible.length);
|
||||||
|
} else if (event.key === "Enter") {
|
||||||
|
const target = visible[active];
|
||||||
|
if (target) {
|
||||||
|
event.preventDefault();
|
||||||
|
go(target.href);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setQuery("");
|
||||||
|
setActive(0);
|
||||||
|
setOpen(true);
|
||||||
|
}}
|
||||||
|
aria-label="جستوجو و رفتن سریع"
|
||||||
|
className="hidden items-center gap-2 rounded-lg border border-surface-border bg-surface-overlay px-2.5 py-1.5 text-xs text-slate-400 transition-colors hover:text-slate-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60 md:flex"
|
||||||
|
>
|
||||||
|
<span aria-hidden>⌕</span>
|
||||||
|
<span>جستوجو…</span>
|
||||||
|
<kbd className="rounded border border-surface-border px-1 text-[10px] text-slate-500">
|
||||||
|
⌘K
|
||||||
|
</kbd>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={open}
|
||||||
|
title="جستوجو و رفتن سریع"
|
||||||
|
description="با کلیدهای بالا و پایین جابهجایی کنید و Enter برای رفتن."
|
||||||
|
onClose={close}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={query}
|
||||||
|
onChange={(event) => setQuery(event.target.value)}
|
||||||
|
onKeyDown={onInputKeyDown}
|
||||||
|
placeholder="نام بخش…"
|
||||||
|
aria-label="جستوجوی بخشها"
|
||||||
|
className="h-10 w-full rounded-lg border border-surface-border bg-surface-overlay px-3 text-sm text-slate-100 placeholder:text-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="mt-3 flex max-h-80 flex-col gap-3 overflow-y-auto">
|
||||||
|
{filteredGroups.length === 0 ? (
|
||||||
|
<EmptyState title="نتیجهای پیدا نشد" description="عبارت دیگری را امتحان کنید." />
|
||||||
|
) : (
|
||||||
|
filteredGroups.map((group) => (
|
||||||
|
<div key={group.title} className="flex flex-col gap-1">
|
||||||
|
<p className="px-1 text-[11px] font-medium text-slate-500">{group.title}</p>
|
||||||
|
{group.items.map((item) => {
|
||||||
|
const index = visible.indexOf(item);
|
||||||
|
const isActive = index === active;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.href}
|
||||||
|
type="button"
|
||||||
|
onMouseEnter={() => setActive(index)}
|
||||||
|
onClick={() => go(item.href)}
|
||||||
|
className={cx(
|
||||||
|
"flex items-center justify-between gap-2 rounded-md px-2 py-2 text-start",
|
||||||
|
isActive
|
||||||
|
? "bg-accent-soft text-slate-100"
|
||||||
|
: "text-slate-300 hover:bg-surface-raised",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="text-sm">{item.label}</span>
|
||||||
|
<span className="text-xs text-slate-500">{item.hint}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
/**
|
||||||
|
* Navigation destinations, shared by the sidebar and the command palette.
|
||||||
|
*
|
||||||
|
* Kept in one place so the two never drift: a destination that exists in the
|
||||||
|
* palette but not in the sidebar (or the reverse) is a bug the type system
|
||||||
|
* cannot catch, only a shared list can.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface NavItem {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
hint: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NAV_ITEMS: NavItem[] = [
|
||||||
|
{ href: "/dashboard", label: "داشبورد", hint: "نمای کلی سازمان" },
|
||||||
|
{ href: "/manager", label: "مدیر AI", hint: "گفتگو با مدیر" },
|
||||||
|
{ href: "/workforce", label: "نیروی کار AI", hint: "کارمندها" },
|
||||||
|
{ href: "/projects", label: "پروژهها", hint: "Git و محیط اجرا" },
|
||||||
|
{ href: "/tasks", label: "وظایف", hint: "وضعیت کارها" },
|
||||||
|
{ href: "/traces", label: "ردیابی", hint: "اجراها و رویدادها" },
|
||||||
|
{ href: "/knowledge", label: "دانش سازمانی", hint: "منابع و اسناد" },
|
||||||
|
{ href: "/billing", label: "مصرف و پرداخت", hint: "اعتبار و فاکتور" },
|
||||||
|
{ href: "/settings", label: "تنظیمات", hint: "سازمان و اعضا" },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform-owner-only destinations. `admin/layout.tsx` mirrors this list and
|
||||||
|
* renders it as a tab strip; the API is the real security boundary either way.
|
||||||
|
*/
|
||||||
|
export const ADMIN_ITEMS: NavItem[] = [
|
||||||
|
{ href: "/admin", label: "نمای کلی کنسول", hint: "وضعیت سامانه" },
|
||||||
|
{ href: "/admin/providers", label: "ارائهدهندهها", hint: "مدلها و کلیدها" },
|
||||||
|
{ href: "/admin/models", label: "مدلها", hint: "قابلیت و قیمت" },
|
||||||
|
{ href: "/admin/routing", label: "مسیریابی", hint: "fast، cheap، reasoning" },
|
||||||
|
{ href: "/admin/rate-cards", label: "تعرفهها", hint: "قیمت واحدها" },
|
||||||
|
{ href: "/admin/usage", label: "مصرف سراسری", hint: "گزارش مصرف مستأجرها" },
|
||||||
|
{ href: "/admin/organizations", label: "سازمانها", hint: "مستأجرها" },
|
||||||
|
{ href: "/admin/errors", label: "کنسول خطا", hint: "ردیابی خطاها" },
|
||||||
|
];
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { CommandPalette } from "@/components/CommandPalette";
|
||||||
|
|
||||||
|
const push = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("next/navigation", () => ({
|
||||||
|
useRouter: () => ({ push }),
|
||||||
|
usePathname: () => "/dashboard",
|
||||||
|
}));
|
||||||
|
|
||||||
|
/** Opens the palette the way a user does — via the keyboard shortcut. */
|
||||||
|
function openWithShortcut(ctrlKey = true) {
|
||||||
|
fireEvent.keyDown(window, { key: "k", ctrlKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("command palette", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
push.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("starts closed and opens with the keyboard shortcut", () => {
|
||||||
|
render(<CommandPalette />);
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
openWithShortcut();
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("opens with the macOS cmd key as well as ctrl", () => {
|
||||||
|
render(<CommandPalette />);
|
||||||
|
fireEvent.keyDown(window, { key: "k", metaKey: true });
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists the sidebar destinations when open", () => {
|
||||||
|
render(<CommandPalette />);
|
||||||
|
openWithShortcut();
|
||||||
|
expect(screen.getByText(/^بخش/)).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /داشبورد/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("filters the destinations as the user types", () => {
|
||||||
|
render(<CommandPalette />);
|
||||||
|
openWithShortcut();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("جستوجوی بخشها"), {
|
||||||
|
target: { value: "پروژه" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByRole("button", { name: /پروژه/ })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole("button", { name: /داشبورد/ })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows an empty state when nothing matches", () => {
|
||||||
|
render(<CommandPalette />);
|
||||||
|
openWithShortcut();
|
||||||
|
|
||||||
|
fireEvent.change(screen.getByLabelText("جستوجوی بخشها"), {
|
||||||
|
target: { value: "zzzz" },
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.getByText("نتیجهای پیدا نشد")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the platform-owner group from a regular user", () => {
|
||||||
|
render(<CommandPalette />);
|
||||||
|
openWithShortcut();
|
||||||
|
expect(screen.queryByText("کنسول مالک سامانه")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the platform-owner group to the owner, including global usage", () => {
|
||||||
|
render(<CommandPalette isPlatformOwner />);
|
||||||
|
openWithShortcut();
|
||||||
|
expect(screen.getByText("کنسول مالک سامانه")).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole("button", { name: /مصرف سراسری/ })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates to the highlighted destination on Enter", () => {
|
||||||
|
render(<CommandPalette />);
|
||||||
|
openWithShortcut();
|
||||||
|
|
||||||
|
fireEvent.keyDown(screen.getByLabelText("جستوجوی بخشها"), {
|
||||||
|
key: "ArrowDown",
|
||||||
|
});
|
||||||
|
fireEvent.keyDown(screen.getByLabelText("جستوجوی بخشها"), { key: "Enter" });
|
||||||
|
|
||||||
|
expect(push).toHaveBeenCalledTimes(1);
|
||||||
|
expect(push).toHaveBeenCalledWith("/manager");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("navigates to the destination clicked with the mouse", () => {
|
||||||
|
render(<CommandPalette />);
|
||||||
|
openWithShortcut();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: /داشبورد/ }));
|
||||||
|
expect(push).toHaveBeenCalledWith("/dashboard");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("closes with Escape", () => {
|
||||||
|
render(<CommandPalette />);
|
||||||
|
openWithShortcut();
|
||||||
|
expect(screen.getByRole("dialog")).toBeInTheDocument();
|
||||||
|
|
||||||
|
fireEvent.keyDown(screen.getByLabelText("جستوجوی بخشها"), { key: "Escape" });
|
||||||
|
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user