افزودن پالت فرمان (⌘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:
mjalmoudy94
2026-10-13 02:28:30 +03:30
parent 0b28a9f365
commit 55b9055b50
6 changed files with 479 additions and 83 deletions
+1
View File
@@ -20,6 +20,7 @@ const SECTIONS = [
{ href: "/admin/models", label: "مدل‌ها" },
{ href: "/admin/routing", label: "مسیریابی" },
{ href: "/admin/rate-cards", label: "تعرفه‌ها" },
{ href: "/admin/usage", label: "مصرف سراسری" },
{ href: "/admin/organizations", label: "سازمان‌ها" },
{ href: "/admin/errors", label: "کنسول خطا" },
] as const;
+121
View File
@@ -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>
);
}
+3 -83
View File
@@ -5,90 +5,10 @@ import { usePathname, useRouter } from "next/navigation";
import * as React from "react";
import { Badge, Button, cx } from "@agy/ui";
import { api } from "@/lib/api";
import { CommandPalette } from "@/components/CommandPalette";
import { NAV_ITEMS } from "@/lib/nav";
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. */
function CreditBadge({ organizationId }: { organizationId: string | undefined }) {
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">
<CreditBadge organizationId={activeOrganization?.id} />
</span>
<CommandSearch />
<CommandPalette isPlatformOwner={user?.is_platform_owner} />
</div>
<div className="relative flex items-center gap-2">
+206
View File
@@ -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>
</>
);
}
+40
View File
@@ -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: "ردیابی خطاها" },
];
+108
View File
@@ -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();
});
});