diff --git a/apps/web/src/app/(app)/admin/layout.tsx b/apps/web/src/app/(app)/admin/layout.tsx index 5ea2e52..b9e09a2 100644 --- a/apps/web/src/app/(app)/admin/layout.tsx +++ b/apps/web/src/app/(app)/admin/layout.tsx @@ -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; diff --git a/apps/web/src/app/(app)/admin/usage/page.tsx b/apps/web/src/app/(app)/admin/usage/page.tsx new file mode 100644 index 0000000..b56e37d --- /dev/null +++ b/apps/web/src/app/(app)/admin/usage/page.tsx @@ -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([]); + const [organizations, setOrganizations] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(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> = [ + { + key: "organization", + header: "سازمان", + render: (row) => {orgName(row.organization_id)}, + }, + { + key: "events", + header: "رویداد مصرف", + align: "end", + render: (row) => row.quantity, + }, + { + key: "billed", + header: "مبلغ", + align: "end", + render: (row) => ( + {row.total_price.toFixed(2)} + ), + }, + ]; + + return ( +
+
+

مصرف سراسری

+

+ مصرف همهٔ مستأجرها بر اساس دفتر ثبت استفاده. این صفحه برای مشتریان قابل دسترسی نیست. +

+
+ +
+ + + +
+ + + مصرف به تفکیک سازمان +
+ row.organization_id} + loading={loading} + error={error} + onRetry={load} + caption="مصرف سازمان‌ها" + empty={ + + } + /> + + + + ); +} diff --git a/apps/web/src/components/AppShell.tsx b/apps/web/src/components/AppShell.tsx index ccb692c..492d261 100644 --- a/apps/web/src/components/AppShell.tsx +++ b/apps/web/src/components/AppShell.tsx @@ -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 ( -
- { - 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() !== "" && ( -
- {matches.length === 0 ? ( -

نتیجه‌ای پیدا نشد.

- ) : ( - matches.map((item) => ( - - )) - )} -
- )} -
- ); -} - /** Credit badge — reads the organization's real balance, never a placeholder. */ function CreditBadge({ organizationId }: { organizationId: string | undefined }) { const [balance, setBalance] = React.useState(null); @@ -243,7 +163,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { - +
diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx new file mode 100644 index 0000000..09cfe66 --- /dev/null +++ b/apps/web/src/components/CommandPalette.tsx @@ -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(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) => { + 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 ( + <> + + + + 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" + /> + +
+ {filteredGroups.length === 0 ? ( + + ) : ( + filteredGroups.map((group) => ( +
+

{group.title}

+ {group.items.map((item) => { + const index = visible.indexOf(item); + const isActive = index === active; + return ( + + ); + })} +
+ )) + )} +
+
+ + ); +} diff --git a/apps/web/src/lib/nav.ts b/apps/web/src/lib/nav.ts new file mode 100644 index 0000000..2a95c7e --- /dev/null +++ b/apps/web/src/lib/nav.ts @@ -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: "ردیابی خطاها" }, +]; diff --git a/apps/web/src/test/command-palette.test.tsx b/apps/web/src/test/command-palette.test.tsx new file mode 100644 index 0000000..e7a22aa --- /dev/null +++ b/apps/web/src/test/command-palette.test.tsx @@ -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(); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + + openWithShortcut(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + it("opens with the macOS cmd key as well as ctrl", () => { + render(); + fireEvent.keyDown(window, { key: "k", metaKey: true }); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + }); + + it("lists the sidebar destinations when open", () => { + render(); + openWithShortcut(); + expect(screen.getByText(/^بخش/)).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /داشبورد/ })).toBeInTheDocument(); + }); + + it("filters the destinations as the user types", () => { + render(); + 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(); + openWithShortcut(); + + fireEvent.change(screen.getByLabelText("جست‌وجوی بخش‌ها"), { + target: { value: "zzzz" }, + }); + + expect(screen.getByText("نتیجه‌ای پیدا نشد")).toBeInTheDocument(); + }); + + it("hides the platform-owner group from a regular user", () => { + render(); + openWithShortcut(); + expect(screen.queryByText("کنسول مالک سامانه")).not.toBeInTheDocument(); + }); + + it("shows the platform-owner group to the owner, including global usage", () => { + render(); + openWithShortcut(); + expect(screen.getByText("کنسول مالک سامانه")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /مصرف سراسری/ })).toBeInTheDocument(); + }); + + it("navigates to the highlighted destination on Enter", () => { + render(); + 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(); + openWithShortcut(); + + fireEvent.click(screen.getByRole("button", { name: /داشبورد/ })); + expect(push).toHaveBeenCalledWith("/dashboard"); + }); + + it("closes with Escape", () => { + render(); + openWithShortcut(); + expect(screen.getByRole("dialog")).toBeInTheDocument(); + + fireEvent.keyDown(screen.getByLabelText("جست‌وجوی بخش‌ها"), { key: "Escape" }); + expect(screen.queryByRole("dialog")).not.toBeInTheDocument(); + }); +});