افزودن پالت فرمان (⌘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>
);
}