نسخهٔ وب مستقل — پلتفرم نیروی کار هوش مصنوعی
مخزن اختصاصی برنامهٔ وب؛ محتوای پوشهٔ web در ریشهٔ مخزن قرار گرفت. تاریخچهٔ پروژهٔ ربات (agy-bot) در این مخزن وجود ندارد و هیچ وابستگی runtime به آن نیست.
This commit is contained in:
@@ -0,0 +1,557 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type {
|
||||
ErrorEvent,
|
||||
ErrorGroup,
|
||||
ErrorSeverity,
|
||||
ErrorSource,
|
||||
ErrorStatusFilter,
|
||||
ErrorSummary,
|
||||
} from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Drawer,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
LoadingState,
|
||||
Select,
|
||||
Stat,
|
||||
Table,
|
||||
Tabs,
|
||||
} from "@agy/ui";
|
||||
import type { TableColumn } from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
|
||||
/**
|
||||
* Platform-owner error console.
|
||||
*
|
||||
* Two views over the same table. **Groups** answers "what is broken, and how
|
||||
* often?" — one row per distinct bug, however many times it fired. **Occurrences**
|
||||
* answers "what exactly happened on this request?" — the full record, including
|
||||
* the traceback and the request id.
|
||||
*
|
||||
* The split matters: a bug that fired four thousand times must not push every
|
||||
* other bug off the screen, and a bug that fired once must still be diagnosable
|
||||
* without reproducing it.
|
||||
*/
|
||||
|
||||
const SEVERITY_TONE: Record<ErrorSeverity, "neutral" | "warning" | "danger" | "info"> = {
|
||||
warning: "warning",
|
||||
error: "danger",
|
||||
critical: "danger",
|
||||
};
|
||||
|
||||
const SEVERITY_LABEL: Record<string, string> = {
|
||||
warning: "هشدار",
|
||||
error: "خطا",
|
||||
critical: "بحرانی",
|
||||
};
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
http: "درخواست",
|
||||
worker: "کارگر پسزمینه",
|
||||
startup: "راهاندازی",
|
||||
};
|
||||
|
||||
const STATUS_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "open", label: "باز" },
|
||||
{ value: "resolved", label: "بستهشده" },
|
||||
{ value: "", label: "همه" },
|
||||
];
|
||||
|
||||
const SEVERITY_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "", label: "همه" },
|
||||
{ value: "critical", label: "بحرانی" },
|
||||
{ value: "error", label: "خطا" },
|
||||
{ value: "warning", label: "هشدار" },
|
||||
];
|
||||
|
||||
const SOURCE_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: "", label: "همه" },
|
||||
{ value: "http", label: "درخواست" },
|
||||
{ value: "worker", label: "کارگر پسزمینه" },
|
||||
{ value: "startup", label: "راهاندازی" },
|
||||
];
|
||||
|
||||
function formatTime(value: string | null): string {
|
||||
if (!value) return "—";
|
||||
return new Date(value).toLocaleString("fa-IR");
|
||||
}
|
||||
|
||||
export default function AdminErrorsPage() {
|
||||
const [tab, setTab] = React.useState<"groups" | "occurrences">("groups");
|
||||
const [status, setStatus] = React.useState<ErrorStatusFilter | "">("open");
|
||||
const [severity, setSeverity] = React.useState<ErrorSeverity | "">("");
|
||||
const [source, setSource] = React.useState<ErrorSource | "">("");
|
||||
const [search, setSearch] = React.useState("");
|
||||
const [fingerprint, setFingerprint] = React.useState<string | null>(null);
|
||||
|
||||
const [groups, setGroups] = React.useState<ErrorGroup[]>([]);
|
||||
const [events, setEvents] = React.useState<ErrorEvent[]>([]);
|
||||
const [total, setTotal] = React.useState(0);
|
||||
const [summary, setSummary] = React.useState<ErrorSummary | null>(null);
|
||||
const [selected, setSelected] = React.useState<ErrorEvent | null>(null);
|
||||
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [busy, setBusy] = React.useState<string | null>(null);
|
||||
const [notice, setNotice] = React.useState<string | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const query = {
|
||||
status: status || undefined,
|
||||
severity: severity || undefined,
|
||||
source: source || undefined,
|
||||
search: search || undefined,
|
||||
fingerprint: fingerprint || undefined,
|
||||
};
|
||||
const [nextGroups, nextEvents, nextSummary] = await Promise.all([
|
||||
api.adminErrorGroups(query),
|
||||
api.adminErrors({ ...query, limit: 100 }),
|
||||
api.adminErrorSummary(),
|
||||
]);
|
||||
setGroups(nextGroups);
|
||||
setEvents(nextEvents.items);
|
||||
setTotal(nextEvents.total);
|
||||
setSummary(nextSummary);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت خطاها ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [status, severity, source, search, fingerprint]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const openGroup = React.useCallback((group: ErrorGroup) => {
|
||||
setFingerprint(group.fingerprint);
|
||||
setTab("occurrences");
|
||||
}, []);
|
||||
|
||||
const clearGroup = React.useCallback(() => {
|
||||
setFingerprint(null);
|
||||
setTab("groups");
|
||||
}, []);
|
||||
|
||||
const resolveGroup = React.useCallback(
|
||||
async (group: ErrorGroup) => {
|
||||
setBusy(group.fingerprint);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await api.adminResolveErrorGroup(
|
||||
group.fingerprint,
|
||||
"بستهشده از کنسول خطا",
|
||||
);
|
||||
setNotice(`${result.resolved} رخداد بسته شد.`);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setNotice(err instanceof ApiError ? err.message : "بستن خطا ناموفق بود.");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[load],
|
||||
);
|
||||
|
||||
const reopenGroup = React.useCallback(
|
||||
async (group: ErrorGroup) => {
|
||||
setBusy(group.fingerprint);
|
||||
setNotice(null);
|
||||
try {
|
||||
const result = await api.adminReopenErrorGroup(group.fingerprint);
|
||||
setNotice(`${result.resolved} رخداد بازگشایی شد.`);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setNotice(err instanceof ApiError ? err.message : "بازگشایی خطا ناموفق بود.");
|
||||
} finally {
|
||||
setBusy(null);
|
||||
}
|
||||
},
|
||||
[load],
|
||||
);
|
||||
|
||||
const groupColumns: Array<TableColumn<ErrorGroup>> = [
|
||||
{
|
||||
key: "severity",
|
||||
header: "شدت",
|
||||
render: (group) => (
|
||||
<Badge tone={SEVERITY_TONE[group.severity] ?? "neutral"}>
|
||||
{SEVERITY_LABEL[group.severity] ?? group.severity}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "what",
|
||||
header: "خطا",
|
||||
render: (group) => (
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-slate-200">{group.sample_message || group.kind}</span>
|
||||
<span className="text-xs text-slate-500">
|
||||
<span className="ltr-inline">{group.kind}</span>
|
||||
{group.error_type ? ` · ${group.error_type}` : ""}
|
||||
{group.path ? (
|
||||
<>
|
||||
{" · "}
|
||||
<span className="ltr-inline">{group.path}</span>
|
||||
</>
|
||||
) : null}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "source",
|
||||
header: "منبع",
|
||||
render: (group) => (
|
||||
<Badge tone="neutral">{SOURCE_LABEL[group.source] ?? group.source}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "count",
|
||||
header: "رخداد",
|
||||
align: "center",
|
||||
render: (group) => (
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Badge tone="info">{group.occurrences}</Badge>
|
||||
{group.open_count > 0 ? (
|
||||
<Badge tone="danger">{group.open_count} باز</Badge>
|
||||
) : (
|
||||
<Badge tone="success">بسته</Badge>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "last_seen",
|
||||
header: "آخرین بار",
|
||||
render: (group) => (
|
||||
<span className="text-xs text-slate-400">{formatTime(group.last_seen)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "عملیات",
|
||||
align: "end",
|
||||
render: (group) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" variant="ghost" onClick={() => openGroup(group)}>
|
||||
رخدادها
|
||||
</Button>
|
||||
{group.open_count > 0 ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
loading={busy === group.fingerprint}
|
||||
onClick={() => void resolveGroup(group)}
|
||||
>
|
||||
بستن
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
loading={busy === group.fingerprint}
|
||||
onClick={() => void reopenGroup(group)}
|
||||
>
|
||||
بازگشایی
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const eventColumns: Array<TableColumn<ErrorEvent>> = [
|
||||
{
|
||||
key: "severity",
|
||||
header: "شدت",
|
||||
render: (event) => (
|
||||
<Badge tone={SEVERITY_TONE[event.severity] ?? "neutral"}>
|
||||
{SEVERITY_LABEL[event.severity] ?? event.severity}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "when",
|
||||
header: "زمان",
|
||||
render: (event) => (
|
||||
<span className="text-xs text-slate-400">{formatTime(event.created_at)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "request",
|
||||
header: "درخواست",
|
||||
render: (event) => (
|
||||
<div className="flex flex-col gap-0.5 text-xs">
|
||||
{event.method || event.path ? (
|
||||
<span className="ltr-inline text-slate-300">
|
||||
{event.method} {event.path}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-slate-500">
|
||||
{SOURCE_LABEL[event.source] ?? event.source}
|
||||
</span>
|
||||
)}
|
||||
<span className="ltr-inline text-slate-500">
|
||||
{event.error_type || event.kind}
|
||||
{event.status_code ? ` · ${event.status_code}` : ""}
|
||||
</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "message",
|
||||
header: "پیام",
|
||||
render: (event) => (
|
||||
<span className="line-clamp-2 text-slate-200">{event.message}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "resolved",
|
||||
header: "وضعیت",
|
||||
align: "center",
|
||||
render: (event) =>
|
||||
event.resolved_at ? (
|
||||
<Badge tone="success">بسته</Badge>
|
||||
) : (
|
||||
<Badge tone="danger">باز</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "",
|
||||
align: "end",
|
||||
render: (event) => (
|
||||
<Button size="sm" variant="ghost" onClick={() => setSelected(event)}>
|
||||
جزئیات
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const tiles = summary?.open_by_severity ?? {};
|
||||
|
||||
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-4">
|
||||
<Stat
|
||||
label="خطاهای باز"
|
||||
value={summary?.total_open ?? 0}
|
||||
tone={summary && summary.total_open > 0 ? "danger" : "neutral"}
|
||||
/>
|
||||
<Stat label="بحرانی" value={tiles.critical ?? 0} tone={(tiles.critical ?? 0) > 0 ? "danger" : "neutral"} />
|
||||
<Stat label="خطا" value={tiles.error ?? 0} tone={(tiles.error ?? 0) > 0 ? "danger" : "neutral"} />
|
||||
<Stat label="هشدار" value={tiles.warning ?? 0} />
|
||||
</div>
|
||||
|
||||
{notice && (
|
||||
<Card className="border-accent/40 bg-accent-soft">
|
||||
<p className="text-sm text-slate-200">{notice}</p>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{fingerprint && (
|
||||
<Card className="border-accent/40 bg-accent-soft">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-sm text-slate-200">
|
||||
فقط رخدادهای این خطا نمایش داده میشود:{" "}
|
||||
<span className="ltr-inline text-xs text-slate-400">{fingerprint}</span>
|
||||
</p>
|
||||
<Button size="sm" variant="ghost" onClick={clearGroup}>
|
||||
حذف فیلتر
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<Select
|
||||
label="وضعیت"
|
||||
options={STATUS_OPTIONS}
|
||||
value={status}
|
||||
onChange={(event) => setStatus(event.target.value as ErrorStatusFilter | "")}
|
||||
className="w-32"
|
||||
/>
|
||||
<Select
|
||||
label="شدت"
|
||||
options={SEVERITY_OPTIONS}
|
||||
value={severity}
|
||||
onChange={(event) => setSeverity(event.target.value as ErrorSeverity | "")}
|
||||
className="w-32"
|
||||
/>
|
||||
<Select
|
||||
label="منبع"
|
||||
options={SOURCE_OPTIONS}
|
||||
value={source}
|
||||
onChange={(event) => setSource(event.target.value as ErrorSource | "")}
|
||||
className="w-40"
|
||||
/>
|
||||
<Field
|
||||
label="جستوجو در پیام"
|
||||
placeholder="مثلاً timeout"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="w-56"
|
||||
/>
|
||||
<Button variant="secondary" onClick={() => void load()} loading={loading}>
|
||||
بهروزرسانی
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Tabs
|
||||
items={[
|
||||
{ key: "groups", label: "گروهها", badge: <Badge tone="neutral">{groups.length}</Badge> },
|
||||
{ key: "occurrences", label: "رخدادها", badge: <Badge tone="neutral">{total}</Badge> },
|
||||
]}
|
||||
active={tab}
|
||||
onChange={(key) => setTab(key as "groups" | "occurrences")}
|
||||
/>
|
||||
|
||||
{loading ? (
|
||||
<LoadingState label="در حال دریافت خطاها…" />
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={load} />
|
||||
) : tab === "groups" ? (
|
||||
<Table
|
||||
columns={groupColumns}
|
||||
rows={groups}
|
||||
rowKey={(group) => group.fingerprint}
|
||||
empty={
|
||||
<EmptyState
|
||||
title="خطایی ثبت نشده است"
|
||||
description="با این فیلترها هیچ خطایی پیدا نشد. این خبر خوبی است."
|
||||
/>
|
||||
}
|
||||
caption="گروههای خطا"
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
columns={eventColumns}
|
||||
rows={events}
|
||||
rowKey={(event) => event.id}
|
||||
empty={
|
||||
<EmptyState
|
||||
title="رخدادی ثبت نشده است"
|
||||
description="با این فیلترها هیچ رخدادی پیدا نشد."
|
||||
/>
|
||||
}
|
||||
caption="رخدادهای خطا"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Drawer
|
||||
open={selected !== null}
|
||||
title="جزئیات خطا"
|
||||
onClose={() => setSelected(null)}
|
||||
>
|
||||
{selected && <ErrorDetail event={selected} />}
|
||||
</Drawer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 border-b border-surface-border/60 py-2">
|
||||
<span className="text-xs text-slate-500">{label}</span>
|
||||
<span className="break-words text-sm text-slate-200">{value || "—"}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorDetail({ event }: { event: ErrorEvent }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
<Badge tone={SEVERITY_TONE[event.severity] ?? "neutral"}>
|
||||
{SEVERITY_LABEL[event.severity] ?? event.severity}
|
||||
</Badge>
|
||||
<Badge tone="neutral">{SOURCE_LABEL[event.source] ?? event.source}</Badge>
|
||||
{event.resolved_at ? (
|
||||
<Badge tone="success">بستهشده</Badge>
|
||||
) : (
|
||||
<Badge tone="danger">باز</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DetailRow label="نوع" value={event.kind} />
|
||||
<DetailRow label="کلاس خطا" value={event.error_type} />
|
||||
<DetailRow label="پیام" value={event.message} />
|
||||
<DetailRow
|
||||
label="درخواست"
|
||||
value={
|
||||
<span className="ltr-inline">
|
||||
{event.method} {event.path}
|
||||
{event.status_code ? ` → ${event.status_code}` : ""}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
<DetailRow
|
||||
label="شناسهٔ درخواست"
|
||||
value={<span className="ltr-inline text-xs">{event.request_id}</span>}
|
||||
/>
|
||||
<DetailRow
|
||||
label="اثر انگشت"
|
||||
value={<span className="ltr-inline text-xs">{event.fingerprint}</span>}
|
||||
/>
|
||||
<DetailRow label="زمان" value={formatTime(event.created_at)} />
|
||||
<DetailRow label="سازمان" value={<span className="ltr-inline text-xs">{event.organization_id}</span>} />
|
||||
<DetailRow label="کاربر" value={<span className="ltr-inline text-xs">{event.user_id}</span>} />
|
||||
<DetailRow label="اجرا" value={<span className="ltr-inline text-xs">{event.run_id}</span>} />
|
||||
<DetailRow label="نشانی" value={<span className="ltr-inline text-xs">{event.ip}</span>} />
|
||||
<DetailRow
|
||||
label="مرورگر"
|
||||
value={<span className="ltr-inline text-xs">{event.user_agent}</span>}
|
||||
/>
|
||||
|
||||
{event.resolved_at && (
|
||||
<DetailRow
|
||||
label="بستهشده در"
|
||||
value={`${formatTime(event.resolved_at)} — ${event.resolved_note || "بدون توضیح"}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{Object.keys(event.metadata_json ?? {}).length > 0 && (
|
||||
<div className="mt-3 flex flex-col gap-1">
|
||||
<span className="text-xs text-slate-500">فراداده</span>
|
||||
<pre className="ltr-inline max-h-56 overflow-auto rounded-lg border border-surface-border bg-surface-overlay p-3 text-xs text-slate-300">
|
||||
{JSON.stringify(event.metadata_json, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex flex-col gap-1">
|
||||
<span className="text-xs text-slate-500">ردِّ خطا</span>
|
||||
{event.stack ? (
|
||||
<pre className="ltr-inline max-h-96 overflow-auto rounded-lg border border-surface-border bg-surface-overlay p-3 text-xs text-slate-300">
|
||||
{event.stack}
|
||||
</pre>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">
|
||||
برای این خطا ردِّ خطا ثبت نشده است.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { EmptyState, LoadingState } from "@agy/ui";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
/**
|
||||
* Guard + chrome for the platform-owner console.
|
||||
*
|
||||
* The API enforces ownership on every endpoint; this layout only avoids
|
||||
* rendering a console that would 401 on every request. It is a convenience,
|
||||
* never the security boundary.
|
||||
*/
|
||||
|
||||
const SECTIONS = [
|
||||
{ href: "/admin", label: "نمای کلی" },
|
||||
{ href: "/admin/providers", label: "ارائهدهندهها" },
|
||||
{ href: "/admin/models", label: "مدلها" },
|
||||
{ href: "/admin/routing", label: "مسیریابی" },
|
||||
{ href: "/admin/rate-cards", label: "تعرفهها" },
|
||||
{ href: "/admin/organizations", label: "سازمانها" },
|
||||
{ href: "/admin/errors", label: "کنسول خطا" },
|
||||
] as const;
|
||||
|
||||
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||
const { user, ready } = useSession();
|
||||
const pathname = usePathname();
|
||||
|
||||
if (!ready) return <LoadingState label="در حال بررسی دسترسی…" />;
|
||||
|
||||
if (!user?.is_platform_owner) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="دسترسی محدود"
|
||||
description="این بخش فقط برای مالک سامانه قابل دسترسی است."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<nav className="flex flex-wrap gap-1 border-b border-surface-border">
|
||||
{SECTIONS.map((section) => {
|
||||
const active =
|
||||
section.href === "/admin"
|
||||
? pathname === "/admin"
|
||||
: pathname.startsWith(section.href);
|
||||
return (
|
||||
<Link
|
||||
key={section.href}
|
||||
href={section.href}
|
||||
className={
|
||||
"border-b-2 px-3 py-2 text-sm transition-colors " +
|
||||
(active
|
||||
? "border-accent text-slate-100"
|
||||
: "border-transparent text-slate-400 hover:text-slate-200")
|
||||
}
|
||||
>
|
||||
{section.label}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type { AdminModel, AdminProvider, Capability } from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
Checkbox,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
LoadingState,
|
||||
Select,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
|
||||
const CAPABILITIES: Capability[] = ["text", "vision", "tools", "reasoning", "long_context", "code"];
|
||||
|
||||
export default function AdminModelsPage() {
|
||||
const [models, setModels] = React.useState<AdminModel[]>([]);
|
||||
const [providers, setProviders] = React.useState<AdminProvider[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [providerId, setProviderId] = React.useState("");
|
||||
const [internalName, setInternalName] = React.useState("");
|
||||
const [displayName, setDisplayName] = React.useState("");
|
||||
const [caps, setCaps] = React.useState<Capability[]>(["text"]);
|
||||
const [inputPrice, setInputPrice] = React.useState("");
|
||||
const [outputPrice, setOutputPrice] = React.useState("");
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
const [formError, setFormError] = React.useState<string | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [nextModels, nextProviders] = await Promise.all([
|
||||
api.adminModels(),
|
||||
api.adminProviders(),
|
||||
]);
|
||||
setModels(nextModels);
|
||||
setProviders(nextProviders);
|
||||
setProviderId((current) => current || nextProviders[0]?.id || "");
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت مدلها ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const toggleCap = (cap: Capability) => {
|
||||
setCaps((current) =>
|
||||
current.includes(cap) ? current.filter((c) => c !== cap) : [...current, cap],
|
||||
);
|
||||
};
|
||||
|
||||
const onCreate = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setFormError(null);
|
||||
if (!providerId) {
|
||||
setFormError("ابتدا یک ارائهدهنده بسازید.");
|
||||
return;
|
||||
}
|
||||
if (caps.length === 0) {
|
||||
setFormError("حداقل یک توانایی انتخاب کنید.");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const created = await api.adminCreateModel({
|
||||
provider_id: providerId,
|
||||
internal_name: internalName.trim(),
|
||||
display_name_for_admin: displayName.trim() || internalName.trim(),
|
||||
capabilities: caps,
|
||||
input_price_per_1m: Number(inputPrice) || 0,
|
||||
output_price_per_1m: Number(outputPrice) || 0,
|
||||
});
|
||||
setModels((current) => [created, ...current]);
|
||||
setInternalName("");
|
||||
setDisplayName("");
|
||||
setInputPrice("");
|
||||
setOutputPrice("");
|
||||
setCaps(["text"]);
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : "ساخت مدل ناموفق بود.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const providerName = (id: string) => providers.find((p) => p.id === id)?.name ?? id;
|
||||
|
||||
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>
|
||||
|
||||
<Card>
|
||||
<CardTitle>افزودن مدل</CardTitle>
|
||||
<CardHint>
|
||||
قیمتها به «واحد پول به ازای هر یکمیلیون توکن» وارد میشوند و مبنای محاسبهٔ هزینهٔ
|
||||
داخلی هستند.
|
||||
</CardHint>
|
||||
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Select
|
||||
label="ارائهدهنده"
|
||||
required
|
||||
value={providerId}
|
||||
onChange={(e) => setProviderId(e.target.value)}
|
||||
options={
|
||||
providers.length === 0
|
||||
? [{ value: "", label: "ارائهدهندهای وجود ندارد" }]
|
||||
: providers.map((p) => ({ value: p.id, label: p.name }))
|
||||
}
|
||||
/>
|
||||
<Field
|
||||
label="نام داخلی"
|
||||
required
|
||||
value={internalName}
|
||||
onChange={(e) => setInternalName(e.target.value)}
|
||||
placeholder="gpt-4o-mini"
|
||||
className="ltr-inline"
|
||||
/>
|
||||
<Field
|
||||
label="نام نمایشی برای مالک"
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="مدل سریع و ارزان"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field
|
||||
label="قیمت ورودی (به ازای ۱M توکن)"
|
||||
inputMode="decimal"
|
||||
value={inputPrice}
|
||||
onChange={(e) => setInputPrice(e.target.value)}
|
||||
placeholder="0.15"
|
||||
className="ltr-inline"
|
||||
/>
|
||||
<Field
|
||||
label="قیمت خروجی (به ازای ۱M توکن)"
|
||||
inputMode="decimal"
|
||||
value={outputPrice}
|
||||
onChange={(e) => setOutputPrice(e.target.value)}
|
||||
placeholder="0.60"
|
||||
className="ltr-inline"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
<p className="text-xs font-medium text-slate-300">تواناییها</p>
|
||||
<div className="flex flex-wrap gap-4">
|
||||
{CAPABILITIES.map((cap) => (
|
||||
<Checkbox
|
||||
key={cap}
|
||||
label={cap}
|
||||
checked={caps.includes(cap)}
|
||||
onChange={() => toggleCap(cap)}
|
||||
className="ltr-inline"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" loading={creating}>
|
||||
افزودن مدل
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{formError && (
|
||||
<div className="mt-3">
|
||||
<ErrorState title="ساخت مدل ناموفق" message={formError} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{loading ? (
|
||||
<LoadingState label="در حال دریافت مدلها…" />
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={load} />
|
||||
) : models.length === 0 ? (
|
||||
<EmptyState
|
||||
title="مدلی ثبت نشده است"
|
||||
description="بدون مدل فعال، هیچ حالت مسیریابی قابل اجرا نیست."
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardTitle>فهرست مدلها</CardTitle>
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-border text-xs text-slate-400">
|
||||
<th className="px-2 py-2 text-right font-medium">نام داخلی</th>
|
||||
<th className="px-2 py-2 text-right font-medium">ارائهدهنده</th>
|
||||
<th className="px-2 py-2 text-right font-medium">تواناییها</th>
|
||||
<th className="px-2 py-2 text-right font-medium">ورودی</th>
|
||||
<th className="px-2 py-2 text-right font-medium">خروجی</th>
|
||||
<th className="px-2 py-2 text-right font-medium">وضعیت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{models.map((model) => (
|
||||
<tr key={model.id} className="border-b border-surface-border/60">
|
||||
<td className="px-2 py-2">
|
||||
<span className="ltr-inline text-slate-200">{model.internal_name}</span>
|
||||
<p className="text-xs text-slate-500">{model.display_name_for_admin}</p>
|
||||
</td>
|
||||
<td className="ltr-inline px-2 py-2 text-xs text-slate-400">
|
||||
{providerName(model.provider_id)}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{model.capabilities.map((cap) => (
|
||||
<Badge key={cap} tone="info">
|
||||
<span className="ltr-inline">{cap}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="ltr-inline px-2 py-2 text-xs text-slate-300">
|
||||
{model.input_price_per_1m}
|
||||
</td>
|
||||
<td className="ltr-inline px-2 py-2 text-xs text-slate-300">
|
||||
{model.output_price_per_1m}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<Badge tone={model.is_active ? "success" : "neutral"}>
|
||||
{model.is_active ? "فعال" : "غیرفعال"}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type { AdminOrganization, AdminUsageRow } from "@agy/contracts";
|
||||
import { Badge, Card, CardTitle, EmptyState, ErrorState, LoadingState, Stat } from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
|
||||
export default function AdminOrganizationsPage() {
|
||||
const [organizations, setOrganizations] = React.useState<AdminOrganization[]>([]);
|
||||
const [usage, setUsage] = React.useState<AdminUsageRow[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [nextOrganizations, nextUsage] = await Promise.all([
|
||||
api.adminOrganizations(),
|
||||
api.adminUsage(),
|
||||
]);
|
||||
setOrganizations(nextOrganizations);
|
||||
setUsage(nextUsage);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت سازمانها ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
if (loading) return <LoadingState label="در حال دریافت سازمانها…" />;
|
||||
if (error) return <ErrorState message={error} onRetry={load} />;
|
||||
|
||||
const usageFor = (orgId: string) => usage.find((row) => row.organization_id === orgId);
|
||||
const totalBilled = usage.reduce((sum, row) => sum + row.total_price, 0);
|
||||
|
||||
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={organizations.reduce((sum, org) => sum + org.member_count, 0)}
|
||||
/>
|
||||
<Stat label="مجموع مصرف" value={totalBilled.toFixed(2)} />
|
||||
</div>
|
||||
|
||||
{organizations.length === 0 ? (
|
||||
<EmptyState
|
||||
title="سازمانی وجود ندارد"
|
||||
description="هنوز هیچ مستأجری ثبت نشده است."
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardTitle>فهرست سازمانها</CardTitle>
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-border text-xs text-slate-400">
|
||||
<th className="px-2 py-2 text-right font-medium">نام</th>
|
||||
<th className="px-2 py-2 text-right font-medium">شناسهٔ یکتا</th>
|
||||
<th className="px-2 py-2 text-right font-medium">اعضا</th>
|
||||
<th className="px-2 py-2 text-right font-medium">مصرف</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{organizations.map((org) => {
|
||||
const row = usageFor(org.id);
|
||||
return (
|
||||
<tr key={org.id} className="border-b border-surface-border/60">
|
||||
<td className="px-2 py-2 text-slate-200">{org.name}</td>
|
||||
<td className="ltr-inline px-2 py-2 text-xs text-slate-500">{org.slug}</td>
|
||||
<td className="px-2 py-2">
|
||||
<Badge tone="neutral">{org.member_count}</Badge>
|
||||
</td>
|
||||
<td className="ltr-inline px-2 py-2 text-xs text-slate-300">
|
||||
{row ? row.total_price.toFixed(2) : "0.00"}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
import type { AdminOrganization, AdminUsageRow, ReadinessResponse } from "@agy/contracts";
|
||||
import { Badge, Card, CardHint, CardTitle, ErrorState, LoadingState, Stat } from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
|
||||
/**
|
||||
* Platform owner overview.
|
||||
*
|
||||
* Everything on this page is invisible to normal customers by design — the whole
|
||||
* point of the AI Control Plane is that model identity never reaches a tenant.
|
||||
*/
|
||||
export default function AdminOverviewPage() {
|
||||
const [readiness, setReadiness] = React.useState<ReadinessResponse | null>(null);
|
||||
const [organizations, setOrganizations] = React.useState<AdminOrganization[]>([]);
|
||||
const [usage, setUsage] = React.useState<AdminUsageRow[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [nextReadiness, nextOrganizations, nextUsage] = await Promise.all([
|
||||
api.readiness(),
|
||||
api.adminOrganizations(),
|
||||
api.adminUsage(),
|
||||
]);
|
||||
setReadiness(nextReadiness);
|
||||
setOrganizations(nextOrganizations);
|
||||
setUsage(nextUsage);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت وضعیت ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
if (loading) return <LoadingState label="در حال دریافت وضعیت سامانه…" />;
|
||||
if (error) return <ErrorState message={error} onRetry={load} />;
|
||||
|
||||
const totalBilled = usage.reduce((sum, row) => sum + row.total_price, 0);
|
||||
|
||||
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={organizations.reduce((s, o) => s + o.member_count, 0)} />
|
||||
<Stat
|
||||
label="مجموع مصرف ثبتشده"
|
||||
value={totalBilled.toFixed(2)}
|
||||
hint="بر پایهٔ دفتر مصرف، به واحد پول داخلی سامانه."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardTitle>آداپتورهای فعال</CardTitle>
|
||||
<CardHint>
|
||||
این مقادیر از محیط خوانده میشوند. هر مقدار «mock» یعنی آن سرویس بیرونی هنوز
|
||||
متصل نشده است.
|
||||
</CardHint>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{Object.entries(readiness?.checks.adapters ?? {}).map(([key, value]) => (
|
||||
<Badge key={key} tone={value === "mock" ? "warning" : "success"}>
|
||||
{key}: {value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
{[
|
||||
{
|
||||
title: "ارائهدهندهها و کلیدها",
|
||||
hint: "ساخت ارائهدهنده و ذخیرهٔ کلید رمزنگاریشده. کلید خام هرگز نمایش داده نمیشود.",
|
||||
path: "/admin/providers",
|
||||
},
|
||||
{
|
||||
title: "مدلها",
|
||||
hint: "نام داخلی، تواناییها و قیمت ورودی/خروجی — فقط برای مالک سامانه.",
|
||||
path: "/admin/models",
|
||||
},
|
||||
{
|
||||
title: "سیاست مسیریابی",
|
||||
hint: "حالتهای fast / cheap / balanced / reasoning / vision / coding و اجرای آزمایشی.",
|
||||
path: "/admin/routing",
|
||||
},
|
||||
{
|
||||
title: "تعرفهها",
|
||||
hint: "قیمت مشتری و برآورد هزینهٔ داخلی به تفکیک واحد مصرف.",
|
||||
path: "/admin/rate-cards",
|
||||
},
|
||||
{
|
||||
title: "سازمانها",
|
||||
hint: "فهرست همهٔ سازمانها، تعداد اعضا و مصرف تجمیعی هرکدام.",
|
||||
path: "/admin/organizations",
|
||||
},
|
||||
].map((item) => (
|
||||
<Link key={item.path} href={item.path}>
|
||||
<Card className="h-full transition-colors hover:border-accent/60">
|
||||
<CardTitle>{item.title}</CardTitle>
|
||||
<CardHint>{item.hint}</CardHint>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,268 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type { AdminCredential, AdminProvider, ProviderType } from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
LoadingState,
|
||||
Select,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
|
||||
const PROVIDER_TYPES: { value: ProviderType; label: string }[] = [
|
||||
{ value: "mock", label: "آزمایشی (mock)" },
|
||||
{ value: "openai_compatible", label: "سازگار با OpenAI" },
|
||||
{ value: "anthropic", label: "Anthropic" },
|
||||
{ value: "gemini", label: "Gemini" },
|
||||
{ value: "openrouter", label: "OpenRouter" },
|
||||
];
|
||||
|
||||
export default function AdminProvidersPage() {
|
||||
const [providers, setProviders] = React.useState<AdminProvider[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [name, setName] = React.useState("");
|
||||
const [type, setType] = React.useState<ProviderType>("openai_compatible");
|
||||
const [baseUrl, setBaseUrl] = React.useState("");
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
const [formError, setFormError] = React.useState<string | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setProviders(await api.adminProviders());
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت ارائهدهندهها ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const onCreate = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setFormError(null);
|
||||
setCreating(true);
|
||||
try {
|
||||
const created = await api.adminCreateProvider({
|
||||
name: name.trim(),
|
||||
type,
|
||||
base_url: baseUrl.trim(),
|
||||
});
|
||||
setProviders((current) => [created, ...current]);
|
||||
setName("");
|
||||
setBaseUrl("");
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : "ساخت ارائهدهنده ناموفق بود.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<Card>
|
||||
<CardTitle>افزودن ارائهدهنده</CardTitle>
|
||||
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Field
|
||||
label="نام"
|
||||
required
|
||||
minLength={2}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="مثلاً openai-main"
|
||||
className="ltr-inline"
|
||||
/>
|
||||
<Select
|
||||
label="نوع"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as ProviderType)}
|
||||
options={PROVIDER_TYPES}
|
||||
/>
|
||||
<Field
|
||||
label="آدرس پایه"
|
||||
value={baseUrl}
|
||||
onChange={(e) => setBaseUrl(e.target.value)}
|
||||
placeholder="https://api.example.com/v1"
|
||||
className="ltr-inline"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" loading={creating}>
|
||||
افزودن
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{formError && (
|
||||
<div className="mt-3">
|
||||
<ErrorState title="ساخت ارائهدهنده ناموفق" message={formError} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{loading ? (
|
||||
<LoadingState label="در حال دریافت ارائهدهندهها…" />
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={load} />
|
||||
) : providers.length === 0 ? (
|
||||
<EmptyState
|
||||
title="ارائهدهندهای ثبت نشده است"
|
||||
description="با فرم بالا اولین ارائهدهنده را اضافه کنید."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{providers.map((provider) => (
|
||||
<ProviderCard key={provider.id} provider={provider} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderCard({ provider }: { provider: AdminProvider }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [credentials, setCredentials] = React.useState<AdminCredential[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [credName, setCredName] = React.useState("");
|
||||
const [apiKey, setApiKey] = React.useState("");
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [formError, setFormError] = React.useState<string | null>(null);
|
||||
|
||||
const loadCredentials = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setCredentials(await api.adminCredentials(provider.id));
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت کلیدها ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [provider.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) void loadCredentials();
|
||||
}, [open, loadCredentials]);
|
||||
|
||||
const onAddCredential = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setFormError(null);
|
||||
setSaving(true);
|
||||
try {
|
||||
const created = await api.adminCreateCredential(provider.id, {
|
||||
name: credName.trim(),
|
||||
api_key: apiKey.trim(),
|
||||
});
|
||||
// The response carries the masked form only; the plaintext is gone.
|
||||
setCredentials((current) => [...current, created]);
|
||||
setCredName("");
|
||||
setApiKey("");
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : "ذخیرهٔ کلید ناموفق بود.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="ltr-inline text-sm font-medium text-slate-100">{provider.name}</p>
|
||||
<Badge tone={provider.is_active ? "success" : "neutral"}>
|
||||
{provider.is_active ? "فعال" : "غیرفعال"}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="ltr-inline text-xs text-slate-500">
|
||||
{provider.type}
|
||||
{provider.base_url ? ` · ${provider.base_url}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" size="sm" onClick={() => setOpen((v) => !v)}>
|
||||
{open ? "بستن کلیدها" : "مدیریت کلیدها"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
<div className="mt-4 flex flex-col gap-4 border-t border-surface-border pt-4">
|
||||
{loading ? (
|
||||
<LoadingState label="در حال دریافت کلیدها…" />
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={loadCredentials} />
|
||||
) : credentials.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">کلیدی ثبت نشده است.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{credentials.map((credential) => (
|
||||
<li
|
||||
key={credential.id}
|
||||
className="flex items-center justify-between gap-3 rounded-lg border border-surface-border bg-surface-overlay px-3 py-2"
|
||||
>
|
||||
<span className="text-xs text-slate-300">{credential.name}</span>
|
||||
<span className="ltr-inline font-mono text-xs text-slate-500">
|
||||
{credential.masked_key}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
<form onSubmit={onAddCredential} className="flex flex-col gap-3 sm:flex-row sm:items-end">
|
||||
<div className="flex-1">
|
||||
<Field
|
||||
label="نام کلید"
|
||||
required
|
||||
value={credName}
|
||||
onChange={(e) => setCredName(e.target.value)}
|
||||
placeholder="production"
|
||||
className="ltr-inline"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Field
|
||||
label="کلید API"
|
||||
required
|
||||
type="password"
|
||||
minLength={4}
|
||||
autoComplete="off"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
hint="پس از ذخیره دیگر قابل بازیابی نیست."
|
||||
className="ltr-inline"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" loading={saving}>
|
||||
ذخیره
|
||||
</Button>
|
||||
</form>
|
||||
{formError && <ErrorState title="ذخیرهٔ کلید ناموفق" message={formError} />}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type { AdminRateCard, RateUnit } from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
Checkbox,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
LoadingState,
|
||||
Select,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
|
||||
const UNITS: { value: RateUnit; label: string }[] = [
|
||||
{ value: "model_input_tokens", label: "توکن ورودی مدل" },
|
||||
{ value: "model_output_tokens", label: "توکن خروجی مدل" },
|
||||
{ value: "sandbox_cpu_second", label: "ثانیهٔ CPU سندباکس" },
|
||||
{ value: "sandbox_memory_gb_second", label: "گیگابایتثانیهٔ حافظه" },
|
||||
{ value: "storage_gb_day", label: "گیگابایتروز ذخیرهسازی" },
|
||||
{ value: "tool_call", label: "فراخوانی ابزار" },
|
||||
{ value: "premium_employee_run", label: "اجرای کارمند حرفهای" },
|
||||
];
|
||||
|
||||
const unitLabel = (unit: RateUnit) => UNITS.find((u) => u.value === unit)?.label ?? unit;
|
||||
|
||||
export default function AdminRateCardsPage() {
|
||||
const [cards, setCards] = React.useState<AdminRateCard[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [unit, setUnit] = React.useState<RateUnit>("model_input_tokens");
|
||||
const [name, setName] = React.useState("");
|
||||
const [customerPrice, setCustomerPrice] = React.useState("");
|
||||
const [internalCost, setInternalCost] = React.useState("");
|
||||
const [isActive, setIsActive] = React.useState(true);
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [formError, setFormError] = React.useState<string | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setCards(await api.adminRateCards());
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت تعرفهها ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
// Prefill the form when the operator picks a unit that already has a card —
|
||||
// the endpoint is an upsert, so showing current values avoids blind overwrite.
|
||||
React.useEffect(() => {
|
||||
const existing = cards.find((c) => c.unit === unit);
|
||||
if (existing) {
|
||||
setName(existing.name);
|
||||
setCustomerPrice(String(existing.customer_price));
|
||||
setInternalCost(String(existing.internal_cost_estimate));
|
||||
setIsActive(existing.is_active);
|
||||
} else {
|
||||
setName("");
|
||||
setCustomerPrice("");
|
||||
setInternalCost("");
|
||||
setIsActive(true);
|
||||
}
|
||||
}, [unit, cards]);
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setFormError(null);
|
||||
const price = Number(customerPrice);
|
||||
if (!Number.isFinite(price) || price < 0) {
|
||||
setFormError("قیمت مشتری باید عددی نامنفی باشد.");
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const saved = await api.adminUpsertRateCard({
|
||||
unit,
|
||||
name: name.trim(),
|
||||
customer_price: price,
|
||||
internal_cost_estimate: Number(internalCost) || 0,
|
||||
is_active: isActive,
|
||||
});
|
||||
setCards((current) => {
|
||||
const others = current.filter((c) => c.unit !== saved.unit);
|
||||
return [...others, saved].sort((a, b) => a.unit.localeCompare(b.unit));
|
||||
});
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : "ذخیرهٔ تعرفه ناموفق بود.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<Card>
|
||||
<CardTitle>تعیین تعرفه</CardTitle>
|
||||
<CardHint>
|
||||
این عملیات «بهروزرسانی یا ایجاد» است: برای هر واحد مصرف فقط یک تعرفه وجود دارد و
|
||||
ذخیرهٔ دوباره آن را جایگزین میکند.
|
||||
</CardHint>
|
||||
<form onSubmit={onSubmit} className="mt-4 flex flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Select
|
||||
label="واحد مصرف"
|
||||
value={unit}
|
||||
onChange={(e) => setUnit(e.target.value as RateUnit)}
|
||||
options={UNITS}
|
||||
/>
|
||||
<Field
|
||||
label="نام (اختیاری)"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Field
|
||||
label="قیمت مشتری"
|
||||
required
|
||||
inputMode="decimal"
|
||||
value={customerPrice}
|
||||
onChange={(e) => setCustomerPrice(e.target.value)}
|
||||
placeholder="0.0004"
|
||||
className="ltr-inline"
|
||||
/>
|
||||
<Field
|
||||
label="برآورد هزینهٔ داخلی"
|
||||
inputMode="decimal"
|
||||
value={internalCost}
|
||||
onChange={(e) => setInternalCost(e.target.value)}
|
||||
placeholder="0.0002"
|
||||
className="ltr-inline"
|
||||
/>
|
||||
</div>
|
||||
<Checkbox
|
||||
label="این تعرفه فعال باشد"
|
||||
checked={isActive}
|
||||
onChange={(e) => setIsActive(e.target.checked)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" loading={saving}>
|
||||
ذخیرهٔ تعرفه
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{formError && (
|
||||
<div className="mt-3">
|
||||
<ErrorState title="ذخیرهٔ تعرفه ناموفق" message={formError} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{loading ? (
|
||||
<LoadingState label="در حال دریافت تعرفهها…" />
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={load} />
|
||||
) : cards.length === 0 ? (
|
||||
<EmptyState
|
||||
title="تعرفهای ثبت نشده است"
|
||||
description="بدون تعرفه، مصرف سازمانها قابل محاسبه نیست."
|
||||
/>
|
||||
) : (
|
||||
<Card>
|
||||
<CardTitle>تعرفههای جاری</CardTitle>
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-border text-xs text-slate-400">
|
||||
<th className="px-2 py-2 text-right font-medium">واحد مصرف</th>
|
||||
<th className="px-2 py-2 text-right font-medium">قیمت مشتری</th>
|
||||
<th className="px-2 py-2 text-right font-medium">هزینهٔ داخلی</th>
|
||||
<th className="px-2 py-2 text-right font-medium">حاشیه</th>
|
||||
<th className="px-2 py-2 text-right font-medium">وضعیت</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cards.map((card) => {
|
||||
const margin = card.customer_price - card.internal_cost_estimate;
|
||||
const marginPct =
|
||||
card.customer_price > 0 ? (margin / card.customer_price) * 100 : 0;
|
||||
return (
|
||||
<tr key={card.id} className="border-b border-surface-border/60">
|
||||
<td className="px-2 py-2 text-slate-200">{unitLabel(card.unit)}</td>
|
||||
<td className="ltr-inline px-2 py-2 text-xs text-slate-300">
|
||||
{card.customer_price}
|
||||
</td>
|
||||
<td className="ltr-inline px-2 py-2 text-xs text-slate-500">
|
||||
{card.internal_cost_estimate}
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<Badge tone={margin >= 0 ? "success" : "danger"}>
|
||||
{marginPct.toFixed(1)}%
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-2 py-2">
|
||||
<Badge tone={card.is_active ? "success" : "neutral"}>
|
||||
{card.is_active ? "فعال" : "غیرفعال"}
|
||||
</Badge>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type { AdminRoutingPolicy, ModelChoice, RoutingMode } from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
Checkbox,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
LoadingState,
|
||||
Select,
|
||||
Stat,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
|
||||
const MODES: { value: RoutingMode; label: string }[] = [
|
||||
{ value: "fast", label: "سریع (fast)" },
|
||||
{ value: "cheap", label: "ارزان (cheap)" },
|
||||
{ value: "balanced", label: "متعادل (balanced)" },
|
||||
{ value: "reasoning", label: "استدلالی (reasoning)" },
|
||||
{ value: "vision", label: "تصویری (vision)" },
|
||||
{ value: "coding", label: "کدنویسی (coding)" },
|
||||
];
|
||||
|
||||
export default function AdminRoutingPage() {
|
||||
const [policies, setPolicies] = React.useState<AdminRoutingPolicy[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [name, setName] = React.useState("");
|
||||
const [mode, setMode] = React.useState<RoutingMode>("balanced");
|
||||
const [description, setDescription] = React.useState("");
|
||||
const [isDefault, setIsDefault] = React.useState(false);
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
const [formError, setFormError] = React.useState<string | null>(null);
|
||||
|
||||
const [probeMode, setProbeMode] = React.useState<RoutingMode>("coding");
|
||||
const [choice, setChoice] = React.useState<ModelChoice | null>(null);
|
||||
const [probing, setProbing] = React.useState(false);
|
||||
const [probeError, setProbeError] = React.useState<string | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setPolicies(await api.adminRoutingPolicies());
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت سیاستها ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const onCreate = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setFormError(null);
|
||||
setCreating(true);
|
||||
try {
|
||||
const created = await api.adminCreateRoutingPolicy({
|
||||
name: name.trim(),
|
||||
mode,
|
||||
description: description.trim(),
|
||||
is_default: isDefault,
|
||||
});
|
||||
// A new default demotes the previous one server-side; mirror that locally.
|
||||
setPolicies((current) => [
|
||||
created,
|
||||
...current.map((p) => (isDefault ? { ...p, is_default: false } : p)),
|
||||
]);
|
||||
setName("");
|
||||
setDescription("");
|
||||
setIsDefault(false);
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : "ساخت سیاست ناموفق بود.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onProbe = async () => {
|
||||
setProbing(true);
|
||||
setProbeError(null);
|
||||
try {
|
||||
setChoice(await api.adminValidateModelChoice(probeMode));
|
||||
} catch (err) {
|
||||
setChoice(null);
|
||||
setProbeError(err instanceof ApiError ? err.message : "اجرای آزمایشی ناموفق بود.");
|
||||
} finally {
|
||||
setProbing(false);
|
||||
}
|
||||
};
|
||||
|
||||
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>
|
||||
|
||||
<Card>
|
||||
<CardTitle>اجرای آزمایشی مسیریابی</CardTitle>
|
||||
<CardHint>
|
||||
بررسی کنید در هر حالت کدام مدل انتخاب میشود و هزینهٔ برآوردی آن چقدر است. هیچ فراخوانی
|
||||
واقعی به مدل انجام نمیشود.
|
||||
</CardHint>
|
||||
<div className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
label="حالت"
|
||||
value={probeMode}
|
||||
onChange={(e) => setProbeMode(e.target.value as RoutingMode)}
|
||||
options={MODES}
|
||||
/>
|
||||
</div>
|
||||
<Button onClick={onProbe} loading={probing} variant="secondary">
|
||||
اجرای آزمایشی
|
||||
</Button>
|
||||
</div>
|
||||
{probeError && (
|
||||
<div className="mt-3">
|
||||
<ErrorState title="اجرای آزمایشی ناموفق" message={probeError} />
|
||||
</div>
|
||||
)}
|
||||
{choice && (
|
||||
<div className="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Stat label="حالت" value={<span className="ltr-inline">{choice.mode}</span>} />
|
||||
<Stat label="مدل انتخابشده" value={<span className="ltr-inline">{choice.internal_name}</span>} />
|
||||
<Stat label="قیمت ورودی" value={choice.estimated_input_price_per_1m} hint="به ازای ۱M توکن" />
|
||||
<Stat label="قیمت خروجی" value={choice.estimated_output_price_per_1m} hint="به ازای ۱M توکن" />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle>افزودن سیاست</CardTitle>
|
||||
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Field
|
||||
label="نام"
|
||||
required
|
||||
minLength={2}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="مثلاً coding-preferred"
|
||||
className="ltr-inline"
|
||||
/>
|
||||
<Select
|
||||
label="حالت"
|
||||
value={mode}
|
||||
onChange={(e) => setMode(e.target.value as RoutingMode)}
|
||||
options={MODES}
|
||||
/>
|
||||
<Field
|
||||
label="توضیح"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Checkbox
|
||||
label="این سیاست پیشفرض سامانه باشد"
|
||||
checked={isDefault}
|
||||
onChange={(e) => setIsDefault(e.target.checked)}
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" loading={creating}>
|
||||
افزودن سیاست
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{formError && (
|
||||
<div className="mt-3">
|
||||
<ErrorState title="ساخت سیاست ناموفق" message={formError} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{loading ? (
|
||||
<LoadingState label="در حال دریافت سیاستها…" />
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={load} />
|
||||
) : policies.length === 0 ? (
|
||||
<EmptyState
|
||||
title="سیاستی ثبت نشده است"
|
||||
description="بدون سیاست پیشفرض، سامانه به حالت متعادل برمیگردد."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{policies.map((policy) => (
|
||||
<Card key={policy.id} className="flex flex-col gap-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="ltr-inline text-sm font-medium text-slate-100">{policy.name}</p>
|
||||
<div className="flex shrink-0 gap-1.5">
|
||||
{policy.is_default && <Badge tone="info">پیشفرض</Badge>}
|
||||
<Badge tone={policy.is_active ? "success" : "neutral"}>
|
||||
{policy.is_active ? "فعال" : "غیرفعال"}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Badge tone="neutral" className="w-fit">
|
||||
<span className="ltr-inline">{policy.mode}</span>
|
||||
</Badge>
|
||||
{policy.description && (
|
||||
<p className="text-xs text-slate-400">{policy.description}</p>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,391 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type {
|
||||
BillingSummary,
|
||||
Employee,
|
||||
Invoice,
|
||||
Payment,
|
||||
UsageEvent,
|
||||
} from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
LoadingState,
|
||||
Stat,
|
||||
UsageBar,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { CAN_MANAGE_BILLING, roleHasCapability } from "@/lib/permissions";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
const UNIT_LABELS: Record<string, string> = {
|
||||
model_input_tokens: "توکن ورودی",
|
||||
model_output_tokens: "توکن خروجی",
|
||||
sandbox_cpu_second: "ثانیهٔ CPU",
|
||||
sandbox_memory_gb_second: "گیگابایتثانیهٔ حافظه",
|
||||
storage_gb_day: "گیگابایتروز",
|
||||
tool_call: "فراخوانی ابزار",
|
||||
premium_employee_run: "اجرای کارمند حرفهای",
|
||||
};
|
||||
|
||||
const INVOICE_TONES: Record<string, "neutral" | "info" | "success" | "warning" | "danger"> = {
|
||||
draft: "neutral",
|
||||
open: "info",
|
||||
paid: "success",
|
||||
void: "neutral",
|
||||
uncollectible: "danger",
|
||||
};
|
||||
|
||||
/** Format a money amount. The platform currency is a single global unit. */
|
||||
function money(value: number, currency = ""): string {
|
||||
const formatted = value.toLocaleString("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 6,
|
||||
});
|
||||
return currency ? `${formatted} ${currency}` : formatted;
|
||||
}
|
||||
|
||||
export default function BillingPage() {
|
||||
const { activeOrganization } = useSession();
|
||||
const orgId = activeOrganization?.id ?? null;
|
||||
|
||||
const [summary, setSummary] = React.useState<BillingSummary | null>(null);
|
||||
const [events, setEvents] = React.useState<UsageEvent[]>([]);
|
||||
const [invoices, setInvoices] = React.useState<Invoice[]>([]);
|
||||
const [payments, setPayments] = React.useState<Payment[]>([]);
|
||||
const [employees, setEmployees] = React.useState<Employee[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [topUp, setTopUp] = React.useState("");
|
||||
const [adding, setAdding] = React.useState(false);
|
||||
const [actionError, setActionError] = React.useState<string | null>(null);
|
||||
const [checkoutUrl, setCheckoutUrl] = React.useState<string | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!orgId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [nextSummary, nextEvents, nextInvoices, nextPayments, nextEmployees] =
|
||||
await Promise.all([
|
||||
api.billingSummary(orgId),
|
||||
api.billingUsageEvents(orgId, 50),
|
||||
api.billingInvoices(orgId),
|
||||
api.billingPayments(orgId),
|
||||
api.listEmployees(orgId),
|
||||
]);
|
||||
setSummary(nextSummary);
|
||||
setEvents(nextEvents);
|
||||
setInvoices(nextInvoices);
|
||||
setPayments(nextPayments);
|
||||
setEmployees(nextEmployees);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت اطلاعات مالی ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orgId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const onAddCredit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!orgId) return;
|
||||
setActionError(null);
|
||||
const amount = Number(topUp);
|
||||
if (!Number.isFinite(amount) || amount <= 0) {
|
||||
setActionError("مبلغ باید عددی بزرگتر از صفر باشد.");
|
||||
return;
|
||||
}
|
||||
setAdding(true);
|
||||
try {
|
||||
const wallet = await api.billingAddCredit(orgId, amount);
|
||||
setSummary((current) =>
|
||||
current ? { ...current, balance: wallet.balance, low_balance: wallet.low_balance } : current,
|
||||
);
|
||||
setTopUp("");
|
||||
} catch (err) {
|
||||
setActionError(err instanceof ApiError ? err.message : "افزایش اعتبار ناموفق بود.");
|
||||
} finally {
|
||||
setAdding(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onCheckout = async () => {
|
||||
if (!orgId) return;
|
||||
setActionError(null);
|
||||
const amount = Number(topUp) || 10;
|
||||
try {
|
||||
const result = await api.billingCheckout(orgId, amount);
|
||||
setCheckoutUrl(result.checkout_url);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof ApiError ? err.message : "ایجاد پرداخت ناموفق بود.");
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeOrganization) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="سازمانی انتخاب نشده است"
|
||||
description="برای دیدن اطلاعات مالی ابتدا یک سازمان بسازید."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) return <LoadingState label="در حال دریافت اطلاعات مالی…" />;
|
||||
if (error) return <ErrorState message={error} onRetry={load} />;
|
||||
|
||||
const currency = summary?.currency ?? "";
|
||||
const canManage = roleHasCapability(activeOrganization.role, CAN_MANAGE_BILLING);
|
||||
|
||||
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>
|
||||
|
||||
{summary?.low_balance && (
|
||||
<Card className="border-warning/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge tone="warning">اعتبار کم</Badge>
|
||||
<p className="text-xs text-slate-300">
|
||||
موجودی اعتبار به آستانهٔ هشدار رسیده است. اجراهای جدید ممکن است متوقف شوند.
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Stat
|
||||
label="اعتبار باقیمانده"
|
||||
value={money(summary?.balance ?? 0, currency)}
|
||||
tone={summary?.low_balance ? "warning" : "neutral"}
|
||||
hint={`آستانهٔ هشدار: ${money(summary?.low_balance_threshold ?? 0)}`}
|
||||
/>
|
||||
<Stat label="مجموع مصرف" value={money(summary?.total_spend ?? 0, currency)} />
|
||||
<Stat label="تعداد ردیف مصرف" value={events.length} hint="۵۰ ردیف آخر" />
|
||||
</div>
|
||||
|
||||
{canManage && (
|
||||
<Card>
|
||||
<CardTitle>افزایش اعتبار</CardTitle>
|
||||
<CardHint>
|
||||
افزایش اعتبار دستی توسط مدیر سازمان. اگر درگاه پرداخت متصل باشد، میتوانید از مسیر
|
||||
پرداخت آنلاین هم استفاده کنید.
|
||||
</CardHint>
|
||||
<form onSubmit={onAddCredit} className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<div className="flex-1">
|
||||
<Field
|
||||
label="مبلغ"
|
||||
required
|
||||
inputMode="decimal"
|
||||
value={topUp}
|
||||
onChange={(e) => setTopUp(e.target.value)}
|
||||
placeholder="مثلاً 25"
|
||||
className="ltr-inline"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" loading={adding}>
|
||||
افزودن اعتبار
|
||||
</Button>
|
||||
<Button type="button" variant="secondary" onClick={onCheckout}>
|
||||
پرداخت آنلاین
|
||||
</Button>
|
||||
</form>
|
||||
{actionError && (
|
||||
<div className="mt-3">
|
||||
<ErrorState title="عملیات مالی ناموفق" message={actionError} />
|
||||
</div>
|
||||
)}
|
||||
{checkoutUrl && (
|
||||
<p className="mt-3 text-xs text-slate-400">
|
||||
لینک پرداخت:{" "}
|
||||
<a
|
||||
href={checkoutUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="ltr-inline text-accent hover:underline"
|
||||
>
|
||||
{checkoutUrl}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardTitle>تفکیک بر اساس واحد مصرف</CardTitle>
|
||||
{!summary || summary.by_unit.length === 0 ? (
|
||||
<p className="mt-2 text-xs text-slate-500">هنوز مصرفی ثبت نشده است.</p>
|
||||
) : (
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-border text-xs text-slate-400">
|
||||
<th className="px-2 py-2 text-right font-medium">واحد</th>
|
||||
<th className="px-2 py-2 text-right font-medium">مقدار</th>
|
||||
<th className="px-2 py-2 text-right font-medium">هزینه</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{summary.by_unit.map((row) => (
|
||||
<tr key={row.unit} className="border-b border-surface-border/60">
|
||||
<td className="px-2 py-2 text-slate-200">
|
||||
{UNIT_LABELS[row.unit] ?? row.unit}
|
||||
</td>
|
||||
<td className="ltr-inline px-2 py-2 text-xs text-slate-400">
|
||||
{row.quantity.toLocaleString("en-US", { maximumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className="ltr-inline px-2 py-2 text-xs text-slate-300">
|
||||
{money(row.total_price, currency)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardTitle>مصرف به تفکیک کارمند</CardTitle>
|
||||
{!summary || Object.keys(summary.by_employee).length === 0 ? (
|
||||
<p className="mt-2 text-xs text-slate-500">مصرفی به کارمندی نسبت داده نشده است.</p>
|
||||
) : (
|
||||
<ul className="mt-3 flex flex-col gap-2">
|
||||
{Object.entries(summary.by_employee).map(([employeeId, total]) => {
|
||||
const employee = employees.find((e) => e.id === employeeId);
|
||||
const share =
|
||||
(summary.total_spend > 0 ? total / summary.total_spend : 0) * 100;
|
||||
return (
|
||||
<li key={employeeId} className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-slate-300">{employee?.name ?? employeeId.slice(0, 8)}</span>
|
||||
<span className="ltr-inline text-slate-400">{money(total, currency)}</span>
|
||||
</div>
|
||||
<UsageBar used={share} total={100} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle>صورتحسابها</CardTitle>
|
||||
{invoices.length === 0 ? (
|
||||
<p className="mt-2 text-xs text-slate-500">صورتحسابی صادر نشده است.</p>
|
||||
) : (
|
||||
<ul className="mt-3 flex flex-col gap-2">
|
||||
{invoices.map((invoice) => (
|
||||
<li
|
||||
key={invoice.id}
|
||||
className="flex items-center justify-between gap-2 rounded-lg border border-surface-border bg-surface-overlay px-3 py-2"
|
||||
>
|
||||
<span className="ltr-inline text-xs text-slate-300">
|
||||
{money(invoice.amount, invoice.currency)}
|
||||
</span>
|
||||
<Badge tone={INVOICE_TONES[invoice.status] ?? "neutral"}>
|
||||
{invoice.status}
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardTitle>آخرین ردیفهای مصرف</CardTitle>
|
||||
<CardHint>هر ردیف یک واحد قابل صورتحساب است و حذف نمیشود.</CardHint>
|
||||
{events.length === 0 ? (
|
||||
<p className="mt-2 text-xs text-slate-500">مصرفی ثبت نشده است.</p>
|
||||
) : (
|
||||
<div className="mt-3 overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-surface-border text-xs text-slate-400">
|
||||
<th className="px-2 py-2 text-right font-medium">واحد</th>
|
||||
<th className="px-2 py-2 text-right font-medium">مقدار</th>
|
||||
<th className="px-2 py-2 text-right font-medium">هزینه</th>
|
||||
<th className="px-2 py-2 text-right font-medium">زمان</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{events.map((event) => (
|
||||
<tr key={event.id} className="border-b border-surface-border/60">
|
||||
<td className="px-2 py-2 text-slate-200">
|
||||
{UNIT_LABELS[event.unit] ?? event.unit}
|
||||
</td>
|
||||
<td className="ltr-inline px-2 py-2 text-xs text-slate-400">
|
||||
{event.quantity.toLocaleString("en-US", { maximumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className="ltr-inline px-2 py-2 text-xs text-slate-300">
|
||||
{money(event.total_price, currency)}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-xs text-slate-500">
|
||||
{new Date(event.created_at).toLocaleString("fa-IR")}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{payments.length > 0 && (
|
||||
<Card>
|
||||
<CardTitle>پرداختها</CardTitle>
|
||||
<ul className="mt-3 flex flex-col gap-2">
|
||||
{payments.map((payment) => (
|
||||
<li
|
||||
key={payment.id}
|
||||
className="flex items-center justify-between gap-2 rounded-lg border border-surface-border bg-surface-overlay px-3 py-2"
|
||||
>
|
||||
<span className="ltr-inline text-xs text-slate-300">
|
||||
{money(payment.amount, payment.currency)}
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Badge tone="neutral">
|
||||
<span className="ltr-inline">{payment.provider}</span>
|
||||
</Badge>
|
||||
<Badge
|
||||
tone={
|
||||
payment.status === "succeeded"
|
||||
? "success"
|
||||
: payment.status === "failed"
|
||||
? "danger"
|
||||
: "warning"
|
||||
}
|
||||
>
|
||||
{payment.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type { AuditEvent, ReadinessResponse } from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
Stat,
|
||||
Timeline,
|
||||
UsageBar,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { activeOrganization, user } = useSession();
|
||||
const [readiness, setReadiness] = React.useState<ReadinessResponse | null>(null);
|
||||
const [audit, setAudit] = React.useState<AuditEvent[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const health = await api.readiness();
|
||||
setReadiness(health);
|
||||
if (activeOrganization) {
|
||||
setAudit(await api.listAudit(activeOrganization.id).catch(() => []));
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت وضعیت ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeOrganization]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
if (loading) return <LoadingState label="در حال دریافت وضعیت سازمان…" />;
|
||||
if (error) return <ErrorState message={error} onRetry={load} />;
|
||||
|
||||
if (!activeOrganization) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="هنوز سازمانی انتخاب نشده است"
|
||||
description="برای دیدن داشبورد، ابتدا یک سازمان بسازید یا از منوی بالا یکی را انتخاب کنید."
|
||||
action={
|
||||
<a
|
||||
href="/onboarding"
|
||||
className="rounded-lg bg-accent px-4 py-2 text-sm text-white hover:bg-accent-strong"
|
||||
>
|
||||
ساخت سازمان
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const adapters = readiness?.checks.adapters ?? {};
|
||||
const databaseOk = readiness?.checks.database === "ok";
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<header className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-medium text-slate-100">
|
||||
{activeOrganization.name}
|
||||
</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
خوش آمدید{user?.name ? `، ${user.name}` : ""}. وضعیت کلی سازمان در یک نگاه.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Stat label="اعتبار" value="—" hint="پس از فاز billing" />
|
||||
<Stat label="وظایف فعال" value="0" hint="پس از فاز Task engine" />
|
||||
<Stat label="کارمندهای فعال" value="0" hint="پس از فاز Workforce" />
|
||||
<Stat
|
||||
label="وضعیت سرویس"
|
||||
value={databaseOk ? "سالم" : "ناپایدار"}
|
||||
tone={databaseOk ? "neutral" : "danger"}
|
||||
hint={`محیط: ${readiness?.checks.adapters ? "dev" : "—"}`}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardTitle>مصرف این ماه</CardTitle>
|
||||
<CardHint>سقف مصرف بر اساس پلن سازمان محاسبه میشود.</CardHint>
|
||||
<div className="mt-4">
|
||||
<UsageBar used={0} total={100} label="مصرف از سقف" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle>وضعیت اتصالها</CardTitle>
|
||||
<CardHint>
|
||||
مدل و provider از دید شما پنهان است؛ اینها فقط وضعیت فنی سرویساند.
|
||||
</CardHint>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Badge tone={databaseOk ? "success" : "danger"}>
|
||||
پایگاه داده: {databaseOk ? "متصل" : "خطا"}
|
||||
</Badge>
|
||||
{Object.entries(adapters).map(([key, value]) => (
|
||||
<Badge key={key} tone={value === "mock" ? "warning" : "info"}>
|
||||
{key}: {value}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardTitle>رویدادهای اخیر سازمان</CardTitle>
|
||||
<CardHint>هر عملیات حساس در audit log ثبت میشود.</CardHint>
|
||||
<div className="mt-4">
|
||||
{audit.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">رویدادی ثبت نشده است.</p>
|
||||
) : (
|
||||
<Timeline
|
||||
items={audit.slice(0, 8).map((event) => ({
|
||||
id: event.id,
|
||||
title: event.action,
|
||||
meta: new Date(event.created_at).toLocaleString("fa-IR"),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle>دامنههای پیشنمایش</CardTitle>
|
||||
<CardHint>پس از فاز Domain Routing، دامنههای تست پروژهها اینجا دیده میشوند.</CardHint>
|
||||
<p className="mt-4 text-xs text-slate-500">دامنهای ثبت نشده است.</p>
|
||||
</Card>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { PlaceholderPage } from "@/components/PlaceholderPage";
|
||||
|
||||
export default function KnowledgePage() {
|
||||
return (
|
||||
<PlaceholderPage
|
||||
title="دانش سازمانی"
|
||||
description="منابع دانش سازمان که کارمندهای هوش مصنوعی از آنها استفاده میکنند."
|
||||
planned={[
|
||||
"افزودن فایل، متن، URL و مخزن بهعنوان منبع دانش",
|
||||
"استخراج متن، تکهبندی (chunk) و ایندکس برداری",
|
||||
"جستوجوی سادهٔ کلیدواژهای در MVP",
|
||||
"اتصال هر منبع دانش به کارمندهای مشخص",
|
||||
"جداسازی کامل دانش بین سازمانها",
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { LoadingState } from "@agy/ui";
|
||||
import { AppShell } from "@/components/AppShell";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const { ready, user } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (ready && !user) router.replace("/login");
|
||||
}, [ready, user, router]);
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-8">
|
||||
<LoadingState label="در حال آمادهسازی نشست…" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
return <AppShell>{children}</AppShell>;
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
import type { Employee, Message, Task, Thread } from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
LoadingState,
|
||||
Select,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { CAN_RUN_TASKS, roleHasCapability } from "@/lib/permissions";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
/**
|
||||
* Manager Chat — three columns, matching the plan: conversations, the
|
||||
* conversation itself, and the work it produced.
|
||||
*/
|
||||
export default function ManagerPage() {
|
||||
const { activeOrganization } = useSession();
|
||||
const orgId = activeOrganization?.id ?? null;
|
||||
|
||||
const [threads, setThreads] = React.useState<Thread[]>([]);
|
||||
const [employees, setEmployees] = React.useState<Employee[]>([]);
|
||||
const [activeThreadId, setActiveThreadId] = React.useState<string | null>(null);
|
||||
const [messages, setMessages] = React.useState<Message[]>([]);
|
||||
const [tasks, setTasks] = React.useState<Task[]>([]);
|
||||
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [loadingThread, setLoadingThread] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [draft, setDraft] = React.useState("");
|
||||
const [sending, setSending] = React.useState(false);
|
||||
const [sendError, setSendError] = React.useState<string | null>(null);
|
||||
|
||||
const [creatingTask, setCreatingTask] = React.useState(false);
|
||||
const [taskDraft, setTaskDraft] = React.useState("");
|
||||
const [taskEmployee, setTaskEmployee] = React.useState("");
|
||||
const [taskError, setTaskError] = React.useState<string | null>(null);
|
||||
|
||||
const bottomRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!orgId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [nextThreads, nextEmployees, nextTasks] = await Promise.all([
|
||||
api.listThreads(orgId),
|
||||
api.listEmployees(orgId),
|
||||
api.listTasks(orgId),
|
||||
]);
|
||||
setThreads(nextThreads);
|
||||
setEmployees(nextEmployees);
|
||||
setTasks(nextTasks);
|
||||
setActiveThreadId((current) => current ?? nextThreads[0]?.id ?? null);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت گفتگوها ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orgId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const loadThread = React.useCallback(
|
||||
async (threadId: string) => {
|
||||
if (!orgId) return;
|
||||
setLoadingThread(true);
|
||||
setSendError(null);
|
||||
try {
|
||||
const detail = await api.getThread(orgId, threadId);
|
||||
setMessages(detail.messages);
|
||||
} catch (err) {
|
||||
setSendError(err instanceof ApiError ? err.message : "دریافت پیامها ناموفق بود.");
|
||||
} finally {
|
||||
setLoadingThread(false);
|
||||
}
|
||||
},
|
||||
[orgId],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (activeThreadId) void loadThread(activeThreadId);
|
||||
else setMessages([]);
|
||||
}, [activeThreadId, loadThread]);
|
||||
|
||||
React.useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages]);
|
||||
|
||||
const onCreateThread = async () => {
|
||||
if (!orgId) return;
|
||||
try {
|
||||
const thread = await api.createThread(orgId, { title: "گفتگوی جدید" });
|
||||
setThreads((current) => [thread, ...current]);
|
||||
setActiveThreadId(thread.id);
|
||||
setMessages([]);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "ساخت گفتگو ناموفق بود.");
|
||||
}
|
||||
};
|
||||
|
||||
const onSend = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!orgId || !activeThreadId || !draft.trim()) return;
|
||||
setSendError(null);
|
||||
setSending(true);
|
||||
const content = draft.trim();
|
||||
setDraft("");
|
||||
try {
|
||||
const result = await api.postMessage(orgId, activeThreadId, { content });
|
||||
setMessages((current) => [...current, result.user_message, result.assistant_message]);
|
||||
} catch (err) {
|
||||
setSendError(err instanceof ApiError ? err.message : "ارسال پیام ناموفق بود.");
|
||||
setDraft(content);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onCreateTask = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!orgId || !activeThreadId) return;
|
||||
setTaskError(null);
|
||||
setCreatingTask(true);
|
||||
try {
|
||||
const task = await api.createTask(orgId, {
|
||||
title: taskDraft.trim(),
|
||||
description: `از گفتگو: ${messages.filter((m) => m.role === "user").at(-1)?.content.slice(0, 400) ?? ""}`,
|
||||
thread_id: activeThreadId,
|
||||
assigned_employee_id: taskEmployee || null,
|
||||
});
|
||||
setTasks((current) => [task, ...current]);
|
||||
setTaskDraft("");
|
||||
} catch (err) {
|
||||
setTaskError(err instanceof ApiError ? err.message : "ساخت کار ناموفق بود.");
|
||||
} finally {
|
||||
setCreatingTask(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeOrganization) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="سازمانی انتخاب نشده است"
|
||||
description="برای گفتگو با مدیر هوش مصنوعی ابتدا یک سازمان بسازید."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const canRun = roleHasCapability(activeOrganization.role, CAN_RUN_TASKS);
|
||||
const threadTasks = tasks.filter((t) => t.thread_id === activeThreadId);
|
||||
|
||||
if (loading) return <LoadingState label="در حال دریافت گفتگوها…" />;
|
||||
if (error) return <ErrorState message={error} onRetry={load} />;
|
||||
|
||||
return (
|
||||
<div className="flex h-[calc(100vh-8rem)] flex-col gap-4">
|
||||
<header className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-medium text-slate-100">گفتگو با مدیر</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
درخواست خود را بنویسید؛ مدیر آن را میفهمد و در صورت نیاز به یک کار تبدیل میکند.
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={onCreateThread}>
|
||||
گفتگوی جدید
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<div className="grid min-h-0 flex-1 gap-4 lg:grid-cols-[16rem_1fr_20rem]">
|
||||
{/* Column 1 — conversations */}
|
||||
<Card className="flex min-h-0 flex-col gap-2 overflow-y-auto">
|
||||
<CardTitle>گفتگوها</CardTitle>
|
||||
{threads.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">گفتگویی وجود ندارد.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{threads.map((thread) => (
|
||||
<li key={thread.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setActiveThreadId(thread.id)}
|
||||
className={
|
||||
"w-full rounded-lg px-2 py-1.5 text-right text-xs transition-colors " +
|
||||
(thread.id === activeThreadId
|
||||
? "bg-accent-soft text-slate-100"
|
||||
: "text-slate-400 hover:bg-surface-overlay")
|
||||
}
|
||||
>
|
||||
{thread.title || "بدون عنوان"}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* Column 2 — the conversation */}
|
||||
<Card className="flex min-h-0 flex-col">
|
||||
<CardTitle>پیامها</CardTitle>
|
||||
<div className="mt-3 flex min-h-0 flex-1 flex-col gap-3 overflow-y-auto pl-1">
|
||||
{loadingThread ? (
|
||||
<LoadingState label="در حال دریافت پیامها…" />
|
||||
) : messages.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">
|
||||
هنوز پیامی رد و بدل نشده است. اولین پیام را بنویسید.
|
||||
</p>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={
|
||||
"max-w-[85%] rounded-xl border px-3 py-2 text-sm " +
|
||||
(message.role === "user"
|
||||
? "self-end border-accent/40 bg-accent-soft text-slate-100"
|
||||
: "self-start border-surface-border bg-surface-overlay text-slate-300")
|
||||
}
|
||||
>
|
||||
{message.content}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{sendError && (
|
||||
<div className="mt-3">
|
||||
<ErrorState title="ارسال ناموفق" message={sendError} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSend} className="mt-3 flex items-end gap-2 border-t border-surface-border pt-3">
|
||||
<div className="flex-1">
|
||||
<Field
|
||||
label="پیام شما"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
placeholder="مثلاً یک صفحهٔ گزارش فروش طراحی کن…"
|
||||
disabled={!activeThreadId || sending}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" loading={sending} disabled={!activeThreadId || !draft.trim()}>
|
||||
ارسال
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* Column 3 — the work this conversation produced */}
|
||||
<Card className="flex min-h-0 flex-col gap-3 overflow-y-auto">
|
||||
<CardTitle>کارهای این گفتگو</CardTitle>
|
||||
<CardHint>از گفتگو یک کار بسازید تا کارمند هوش مصنوعی آن را اجرا کند.</CardHint>
|
||||
|
||||
{threadTasks.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">کاری ساخته نشده است.</p>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{threadTasks.map((task) => (
|
||||
<li key={task.id}>
|
||||
<Link
|
||||
href={`/tasks/${task.id}`}
|
||||
className="flex items-center justify-between gap-2 rounded-lg border border-surface-border bg-surface-overlay px-3 py-2 text-xs text-slate-300 hover:border-accent/50"
|
||||
>
|
||||
<span className="truncate">{task.title}</span>
|
||||
<Badge tone={task.status === "done" ? "success" : "neutral"}>
|
||||
{task.status}
|
||||
</Badge>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{canRun && (
|
||||
<form onSubmit={onCreateTask} className="flex flex-col gap-3 border-t border-surface-border pt-3">
|
||||
<Field
|
||||
label="عنوان کار جدید"
|
||||
required
|
||||
minLength={2}
|
||||
value={taskDraft}
|
||||
onChange={(e) => setTaskDraft(e.target.value)}
|
||||
placeholder="عنوان کار…"
|
||||
/>
|
||||
<Select
|
||||
label="کارمند مسئول"
|
||||
value={taskEmployee}
|
||||
onChange={(e) => setTaskEmployee(e.target.value)}
|
||||
options={[
|
||||
{ value: "", label: "سپردن خودکار به مدیر" },
|
||||
...employees.map((e) => ({ value: e.id, label: e.name })),
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
type="submit"
|
||||
loading={creatingTask}
|
||||
disabled={!activeThreadId || !taskDraft.trim()}
|
||||
>
|
||||
ساخت کار
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
{taskError && <ErrorState title="ساخت کار ناموفق" message={taskError} />}
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,464 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
import type { PreviewDomain, Project, RepoStatus, Repository } from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
ConfirmDialog,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
LoadingState,
|
||||
Stat,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { useSession } from "@/lib/session";
|
||||
import { SandboxPanel } from "@/components/sandbox-panel";
|
||||
|
||||
type Tab = "repo" | "preview" | "sandbox";
|
||||
|
||||
const DOMAIN_TONES = {
|
||||
active: "success",
|
||||
disabled: "neutral",
|
||||
failed: "danger",
|
||||
} as const;
|
||||
|
||||
export default function ProjectDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id: projectId } = React.use(params);
|
||||
const { activeOrganization } = useSession();
|
||||
|
||||
const [tab, setTab] = React.useState<Tab>("repo");
|
||||
const [project, setProject] = React.useState<Project | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const orgId = activeOrganization?.id ?? null;
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!orgId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setProject(await api.getProject(orgId, projectId));
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت پروژه ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orgId, projectId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
if (!activeOrganization) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="سازمانی انتخاب نشده است"
|
||||
description="برای دیدن جزئیات پروژه ابتدا یک سازمان بسازید."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) return <LoadingState label="در حال دریافت پروژه…" />;
|
||||
if (error) return <ErrorState message={error} onRetry={load} />;
|
||||
if (!project) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="پروژه پیدا نشد"
|
||||
description="این پروژه حذف شده است یا به سازمان دیگری تعلق دارد."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<nav className="text-xs text-slate-500">
|
||||
<Link href="/projects" className="hover:text-slate-300">
|
||||
پروژهها
|
||||
</Link>
|
||||
<span className="mx-1.5">/</span>
|
||||
<span className="text-slate-400">{project.name}</span>
|
||||
</nav>
|
||||
|
||||
<header className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-medium text-slate-100">{project.name}</h1>
|
||||
<Badge tone="neutral">
|
||||
<span className="ltr-inline">{project.slug}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
{project.description ? (
|
||||
<p className="text-sm text-slate-400">{project.description}</p>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">توضیحی ثبت نشده است.</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="flex gap-1 border-b border-surface-border">
|
||||
{(
|
||||
[
|
||||
["repo", "مخزن کد"],
|
||||
["preview", "دامنهٔ تست"],
|
||||
["sandbox", "محیط ایزوله"],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setTab(value)}
|
||||
className={
|
||||
"border-b-2 px-3 py-2 text-sm transition-colors " +
|
||||
(tab === value
|
||||
? "border-accent text-slate-100"
|
||||
: "border-transparent text-slate-400 hover:text-slate-200")
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === "repo" ? (
|
||||
<RepositoryPanel orgId={activeOrganization.id} projectId={project.id} />
|
||||
) : tab === "preview" ? (
|
||||
<PreviewDomainPanel orgId={activeOrganization.id} projectId={project.id} />
|
||||
) : (
|
||||
<SandboxPanel orgId={activeOrganization.id} projectId={project.id} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Repository */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function RepositoryPanel({ orgId, projectId }: { orgId: string; projectId: string }) {
|
||||
const [status, setStatus] = React.useState<RepoStatus | null>(null);
|
||||
const [repo, setRepo] = React.useState<Repository | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const nextStatus = await api.getRepoStatus(orgId, projectId);
|
||||
setStatus(nextStatus);
|
||||
if (nextStatus.exists) {
|
||||
// The status probe only reports shape; the detail call carries the URLs.
|
||||
setRepo(await api.getRepo(orgId, projectId));
|
||||
} else {
|
||||
setRepo(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت وضعیت مخزن ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orgId, projectId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const onEnsure = async () => {
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
try {
|
||||
setRepo(await api.ensureRepo(orgId, projectId));
|
||||
setStatus(await api.getRepoStatus(orgId, projectId));
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "ساخت مخزن ناموفق بود.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onCopy = async () => {
|
||||
if (!repo?.clone_url) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(repo.clone_url);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1600);
|
||||
} catch {
|
||||
/* clipboard blocked — the URL is visible and selectable anyway */
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) return <LoadingState label="در حال بررسی مخزن…" />;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{error && <ErrorState message={error} onRetry={load} />}
|
||||
|
||||
{!status?.exists ? (
|
||||
<Card>
|
||||
<CardTitle>مخزنی وجود ندارد</CardTitle>
|
||||
<CardHint>
|
||||
یک مخزن خصوصی Git برای این پروژه ساخته میشود و اعتبارنامهٔ آن بهصورت رمزنگاریشده
|
||||
ذخیره میگردد. کلید خام هرگز از طریق API برگردانده نمیشود.
|
||||
</CardHint>
|
||||
<div className="mt-4">
|
||||
<Button onClick={onEnsure} loading={creating}>
|
||||
ساخت مخزن خصوصی
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<Stat
|
||||
label="وضعیت"
|
||||
value={<Badge tone="success">فعال</Badge>}
|
||||
hint={status.private ? "مخزن خصوصی" : "مخزن عمومی"}
|
||||
/>
|
||||
<Stat label="شاخهٔ پیشفرض" value={<span className="ltr-inline">{status.default_branch}</span>} />
|
||||
<Stat label="تعداد شاخهها" value={status.branches.length} />
|
||||
<Stat label="درخواستهای ادغام باز" value={status.open_pull_requests} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardTitle>دسترسی</CardTitle>
|
||||
<CardHint>از این آدرس برای clone استفاده کنید؛ اعتبارنامه بهصورت خودکار تزریق میشود.</CardHint>
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-2 rounded-lg border border-surface-border bg-surface-overlay px-3 py-2">
|
||||
<code className="ltr-inline flex-1 truncate text-xs text-slate-300">
|
||||
{repo?.clone_url ?? "—"}
|
||||
</code>
|
||||
<Button variant="ghost" size="sm" onClick={onCopy} disabled={!repo?.clone_url}>
|
||||
{copied ? "کپی شد" : "کپی"}
|
||||
</Button>
|
||||
</div>
|
||||
{repo?.web_url && (
|
||||
<a
|
||||
href={repo.web_url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="ltr-inline w-fit text-xs text-accent hover:underline"
|
||||
>
|
||||
{repo.web_url}
|
||||
</a>
|
||||
)}
|
||||
<p className="text-xs text-slate-500">
|
||||
نام مخزن: <span className="ltr-inline">{repo?.repo_name ?? "—"}</span> · مالک:{" "}
|
||||
<span className="ltr-inline">{repo?.owner ?? "—"}</span> · ارائهدهنده:{" "}
|
||||
<span className="ltr-inline">{repo?.provider ?? "—"}</span>
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle>شاخهها</CardTitle>
|
||||
{status.branches.length === 0 ? (
|
||||
<p className="mt-2 text-xs text-slate-500">شاخهای ثبت نشده است.</p>
|
||||
) : (
|
||||
<ul className="mt-3 flex flex-wrap gap-2">
|
||||
{status.branches.map((branch) => (
|
||||
<li key={branch}>
|
||||
<Badge tone={branch === status.default_branch ? "info" : "neutral"}>
|
||||
<span className="ltr-inline">{branch}</span>
|
||||
</Badge>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Preview domains */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
function PreviewDomainPanel({ orgId, projectId }: { orgId: string; projectId: string }) {
|
||||
const [domains, setDomains] = React.useState<PreviewDomain[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [subdomain, setSubdomain] = React.useState("");
|
||||
const [port, setPort] = React.useState("");
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
const [formError, setFormError] = React.useState<string | null>(null);
|
||||
const [pendingDelete, setPendingDelete] = React.useState<PreviewDomain | null>(null);
|
||||
const [deleting, setDeleting] = React.useState(false);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setDomains(await api.listPreviewDomains(orgId, projectId));
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت دامنهها ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orgId, projectId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const onCreate = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setFormError(null);
|
||||
const parsedPort = port.trim() === "" ? undefined : Number(port);
|
||||
if (parsedPort !== undefined && (!Number.isInteger(parsedPort) || parsedPort < 1 || parsedPort > 65535)) {
|
||||
setFormError("شمارهٔ پورت باید عددی بین ۱ تا ۶۵۵۳۵ باشد.");
|
||||
return;
|
||||
}
|
||||
setCreating(true);
|
||||
try {
|
||||
const created = await api.createPreviewDomain(orgId, projectId, {
|
||||
subdomain: subdomain.trim(),
|
||||
port: parsedPort,
|
||||
});
|
||||
setDomains((current) => [...current, created]);
|
||||
setSubdomain("");
|
||||
setPort("");
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : "ساخت دامنه ناموفق بود.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onConfirmDelete = async () => {
|
||||
if (!pendingDelete) return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
await api.deletePreviewDomain(orgId, projectId, pendingDelete.id);
|
||||
setDomains((current) => current.filter((d) => d.id !== pendingDelete.id));
|
||||
setPendingDelete(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "حذف دامنه ناموفق بود.");
|
||||
setPendingDelete(null);
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardTitle>ساخت دامنهٔ تست</CardTitle>
|
||||
<CardHint>
|
||||
برچسب زیردامنه باید بین ۳ تا ۴۰ نویسه و فقط شامل حروف کوچک انگلیسی، رقم و خط تیره باشد.
|
||||
اگر پورت را خالی بگذارید، سامانه خودش یک پورت آزاد تخصیص میدهد.
|
||||
</CardHint>
|
||||
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<div className="flex-1">
|
||||
<Field
|
||||
label="زیردامنه"
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={40}
|
||||
pattern="[a-z0-9-]+"
|
||||
value={subdomain}
|
||||
onChange={(e) => setSubdomain(e.target.value)}
|
||||
placeholder="مثلاً demo-shop"
|
||||
className="ltr-inline"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full sm:w-40">
|
||||
<Field
|
||||
label="پورت (اختیاری)"
|
||||
inputMode="numeric"
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
placeholder="۸۰۸۰"
|
||||
className="ltr-inline"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" loading={creating}>
|
||||
ساخت دامنه
|
||||
</Button>
|
||||
</form>
|
||||
{formError && (
|
||||
<div className="mt-3">
|
||||
<ErrorState title="ساخت دامنه ناموفق" message={formError} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{error && <ErrorState message={error} onRetry={load} />}
|
||||
|
||||
{loading ? (
|
||||
<LoadingState label="در حال دریافت دامنهها…" />
|
||||
) : domains.length === 0 ? (
|
||||
<EmptyState
|
||||
title="دامنهای ثبت نشده است"
|
||||
description="برای انتشار موقت پروژه، یک زیردامنه بسازید."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
{domains.map((domain) => (
|
||||
<Card key={domain.id} className="flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<p className="ltr-inline truncate text-sm font-medium text-slate-100">
|
||||
{domain.host}
|
||||
</p>
|
||||
<p className="ltr-inline text-xs text-slate-500">پورت {domain.port}</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Badge tone={DOMAIN_TONES[domain.status] ?? "neutral"}>{domain.status}</Badge>
|
||||
{domain.protected && <Badge tone="warning">محافظتشده</Badge>}
|
||||
</div>
|
||||
</div>
|
||||
<a
|
||||
href={domain.url}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
className="ltr-inline w-fit truncate text-xs text-accent hover:underline"
|
||||
>
|
||||
{domain.url}
|
||||
</a>
|
||||
<div className="mt-auto flex justify-end">
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => setPendingDelete(domain)}
|
||||
disabled={domain.protected}
|
||||
title={domain.protected ? "دامنههای محافظتشده حذف نمیشوند." : undefined}
|
||||
>
|
||||
حذف
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingDelete !== null}
|
||||
title="حذف دامنهٔ تست"
|
||||
description={
|
||||
pendingDelete
|
||||
? `دامنهٔ «${pendingDelete.host}» حذف میشود و از این پس در دسترس نخواهد بود.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel={deleting ? "در حال حذف…" : "حذف کن"}
|
||||
onConfirm={onConfirmDelete}
|
||||
onCancel={() => setPendingDelete(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
import type { Project } from "@agy/contracts";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
LoadingState,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
export default function ProjectsPage() {
|
||||
const { activeOrganization } = useSession();
|
||||
const [projects, setProjects] = React.useState<Project[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [name, setName] = React.useState("");
|
||||
const [description, setDescription] = React.useState("");
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
const [formError, setFormError] = React.useState<string | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!activeOrganization) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setProjects(await api.listProjects(activeOrganization.id));
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت پروژهها ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [activeOrganization]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const onCreate = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!activeOrganization) return;
|
||||
setFormError(null);
|
||||
setCreating(true);
|
||||
try {
|
||||
const created = await api.createProject(activeOrganization.id, {
|
||||
name: name.trim(),
|
||||
description: description.trim(),
|
||||
});
|
||||
setProjects((current) => [created, ...current]);
|
||||
setName("");
|
||||
setDescription("");
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : "ساخت پروژه ناموفق بود.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeOrganization) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="سازمانی انتخاب نشده است"
|
||||
description="برای دیدن پروژهها ابتدا یک سازمان بسازید."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
|
||||
<Card>
|
||||
<CardTitle>ساخت پروژه</CardTitle>
|
||||
<CardHint>نام پروژه به یک slug یکتا در سازمان تبدیل میشود.</CardHint>
|
||||
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<div className="flex-1">
|
||||
<Field
|
||||
label="نام پروژه"
|
||||
required
|
||||
minLength={2}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="مثلاً فروشگاه آنلاین"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<Field
|
||||
label="توضیح (اختیاری)"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" loading={creating}>
|
||||
ساخت پروژه
|
||||
</Button>
|
||||
</form>
|
||||
{formError && (
|
||||
<div className="mt-3">
|
||||
<ErrorState title="ساخت پروژه ناموفق" message={formError} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{loading ? (
|
||||
<LoadingState label="در حال دریافت پروژهها…" />
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={load} />
|
||||
) : projects.length === 0 ? (
|
||||
<EmptyState
|
||||
title="هنوز پروژهای نساختهاید"
|
||||
description="اولین پروژه را با فرم بالا بسازید؛ سپس میتوانید مخزن Git و دامنهٔ تست بسازید."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<Card key={project.id} className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-slate-100">{project.name}</p>
|
||||
<p className="text-xs text-slate-500 ltr-inline">{project.slug}</p>
|
||||
</div>
|
||||
{project.description && (
|
||||
<p className="text-xs text-slate-400">{project.description}</p>
|
||||
)}
|
||||
<Link href={`/projects/${project.id}`} className="mt-auto">
|
||||
<Button variant="secondary" size="sm" className="w-full">
|
||||
جزئیات پروژه
|
||||
</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,379 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import type { AuditEvent, Invite, Member, Role } from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
ConfirmDialog,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
LoadingState,
|
||||
Select,
|
||||
Timeline,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import {
|
||||
CAN_MANAGE_MEMBERS,
|
||||
CAN_VIEW_AUDIT,
|
||||
roleHasCapability,
|
||||
} from "@/lib/permissions";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
const ROLE_LABELS: Record<Role, string> = {
|
||||
owner: "مالک",
|
||||
admin: "مدیر",
|
||||
manager: "سرپرست",
|
||||
member: "عضو",
|
||||
viewer: "بیننده",
|
||||
};
|
||||
|
||||
const AUDIT_LABELS: Record<string, string> = {
|
||||
"auth.login": "ورود به حساب",
|
||||
"auth.logout": "خروج از حساب",
|
||||
"auth.signup": "ثبتنام",
|
||||
"organization.create": "ساخت سازمان",
|
||||
"organization.delete": "حذف سازمان",
|
||||
"member.invite": "دعوت عضو",
|
||||
"member.role_change": "تغییر نقش",
|
||||
"member.remove": "حذف عضو",
|
||||
"employee.create": "ساخت کارمند",
|
||||
"employee.update": "ویرایش کارمند",
|
||||
"repo.create": "ساخت مخزن",
|
||||
"repo.delete": "حذف مخزن",
|
||||
"domain.create": "ساخت دامنه",
|
||||
"domain.delete": "حذف دامنه",
|
||||
"sandbox.command": "اجرای دستور سندباکس",
|
||||
"sandbox.destroy": "نابودی سندباکس",
|
||||
"approval.decision": "تصمیم تأیید",
|
||||
"billing.credit": "تغییر اعتبار",
|
||||
"billing.payment": "پرداخت",
|
||||
"provider.change": "تغییر ارائهدهنده",
|
||||
"model.change": "تغییر مدل",
|
||||
"routing.change": "تغییر مسیریابی",
|
||||
"project.create": "ساخت پروژه",
|
||||
"project.delete": "حذف پروژه",
|
||||
"task.create": "ساخت کار",
|
||||
"run.cancel": "لغو اجرا",
|
||||
};
|
||||
|
||||
type Tab = "members" | "audit";
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { activeOrganization } = useSession();
|
||||
const orgId = activeOrganization?.id ?? null;
|
||||
|
||||
const [tab, setTab] = React.useState<Tab>("members");
|
||||
const [members, setMembers] = React.useState<Member[]>([]);
|
||||
const [audit, setAudit] = React.useState<AuditEvent[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [auditLoading, setAuditLoading] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [email, setEmail] = React.useState("");
|
||||
const [inviteRole, setInviteRole] = React.useState<Role>("member");
|
||||
const [inviting, setInviting] = React.useState(false);
|
||||
const [inviteError, setInviteError] = React.useState<string | null>(null);
|
||||
const [lastInvite, setLastInvite] = React.useState<Invite | null>(null);
|
||||
|
||||
const [busyMemberId, setBusyMemberId] = React.useState<string | null>(null);
|
||||
const [pendingRemove, setPendingRemove] = React.useState<Member | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!orgId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
setMembers(await api.listMembers(orgId));
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت اعضا ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orgId]);
|
||||
|
||||
const loadAudit = React.useCallback(async () => {
|
||||
if (!orgId) return;
|
||||
setAuditLoading(true);
|
||||
try {
|
||||
setAudit(await api.listAudit(orgId));
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت رویدادهای حسابرسی ناموفق بود.");
|
||||
} finally {
|
||||
setAuditLoading(false);
|
||||
}
|
||||
}, [orgId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (tab === "audit") void loadAudit();
|
||||
}, [tab, loadAudit]);
|
||||
|
||||
const onInvite = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!orgId) return;
|
||||
setInviteError(null);
|
||||
setLastInvite(null);
|
||||
setInviting(true);
|
||||
try {
|
||||
const invite = await api.createInvite(orgId, { email: email.trim(), role: inviteRole });
|
||||
setLastInvite(invite);
|
||||
setEmail("");
|
||||
} catch (err) {
|
||||
setInviteError(err instanceof ApiError ? err.message : "ارسال دعوت ناموفق بود.");
|
||||
} finally {
|
||||
setInviting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onChangeRole = async (member: Member, role: Role) => {
|
||||
if (!orgId) return;
|
||||
setBusyMemberId(member.id);
|
||||
setError(null);
|
||||
try {
|
||||
const updated = await api.updateMember(orgId, member.id, { role });
|
||||
setMembers((current) => current.map((m) => (m.id === updated.id ? updated : m)));
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "تغییر نقش ناموفق بود.");
|
||||
} finally {
|
||||
setBusyMemberId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onConfirmRemove = async () => {
|
||||
if (!orgId || !pendingRemove) return;
|
||||
setBusyMemberId(pendingRemove.id);
|
||||
try {
|
||||
await api.removeMember(orgId, pendingRemove.id);
|
||||
setMembers((current) => current.filter((m) => m.id !== pendingRemove.id));
|
||||
setPendingRemove(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "حذف عضو ناموفق بود.");
|
||||
setPendingRemove(null);
|
||||
} finally {
|
||||
setBusyMemberId(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeOrganization) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="سازمانی انتخاب نشده است"
|
||||
description="برای مشاهدهٔ تنظیمات، ابتدا یک سازمان بسازید."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const canManageMembers = roleHasCapability(activeOrganization.role, CAN_MANAGE_MEMBERS);
|
||||
const canViewAudit = roleHasCapability(activeOrganization.role, CAN_VIEW_AUDIT);
|
||||
|
||||
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">{activeOrganization.name}</p>
|
||||
</header>
|
||||
|
||||
<div className="flex gap-1 border-b border-surface-border">
|
||||
{(
|
||||
[
|
||||
["members", "اعضا و دعوت"],
|
||||
["audit", "رویدادهای حسابرسی"],
|
||||
] as const
|
||||
).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
type="button"
|
||||
onClick={() => setTab(value)}
|
||||
className={
|
||||
"border-b-2 px-3 py-2 text-sm transition-colors " +
|
||||
(tab === value
|
||||
? "border-accent text-slate-100"
|
||||
: "border-transparent text-slate-400 hover:text-slate-200")
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{error && <ErrorState message={error} onRetry={load} />}
|
||||
|
||||
{tab === "members" ? (
|
||||
<>
|
||||
<Card>
|
||||
<CardTitle>اعضای سازمان</CardTitle>
|
||||
<CardHint>
|
||||
نقشها به ترتیب قدرت: مالک، مدیر، سرپرست، عضو، بیننده. آخرین مالک قابل حذف نیست.
|
||||
</CardHint>
|
||||
|
||||
<div className="mt-4">
|
||||
{loading ? (
|
||||
<LoadingState label="در حال دریافت اعضا…" />
|
||||
) : members.length === 0 ? (
|
||||
<EmptyState title="عضوی یافت نشد" />
|
||||
) : (
|
||||
<ul className="flex flex-col divide-y divide-surface-border">
|
||||
{members.map((member) => (
|
||||
<li key={member.id} className="flex flex-wrap items-center justify-between gap-3 py-3">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm text-slate-200">
|
||||
{member.name || member.email}
|
||||
</span>
|
||||
<span className="ltr-inline text-xs text-slate-500">{member.email}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge tone={member.status === "active" ? "success" : "warning"}>
|
||||
{member.status}
|
||||
</Badge>
|
||||
{canManageMembers ? (
|
||||
<>
|
||||
<div className="w-36">
|
||||
<Select
|
||||
label=""
|
||||
value={member.role}
|
||||
disabled={busyMemberId === member.id}
|
||||
onChange={(e) => void onChangeRole(member, e.target.value as Role)}
|
||||
options={Object.entries(ROLE_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
disabled={member.role === "owner"}
|
||||
title={
|
||||
member.role === "owner"
|
||||
? "مالک سازمان حذف نمیشود."
|
||||
: undefined
|
||||
}
|
||||
onClick={() => setPendingRemove(member)}
|
||||
>
|
||||
حذف
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Badge tone="neutral">{ROLE_LABELS[member.role] ?? member.role}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{canManageMembers && (
|
||||
<Card>
|
||||
<CardTitle>دعوت عضو جدید</CardTitle>
|
||||
<CardHint>
|
||||
یک لینک دعوت یکبارمصرف ساخته میشود. لینک را خودتان برای فرد بفرستید — سامانه
|
||||
ایمیل ارسال نمیکند.
|
||||
</CardHint>
|
||||
<form onSubmit={onInvite} className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<div className="flex-1">
|
||||
<Field
|
||||
label="ایمیل"
|
||||
type="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="name@example.com"
|
||||
className="ltr-inline"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full sm:w-40">
|
||||
<Select
|
||||
label="نقش"
|
||||
value={inviteRole}
|
||||
onChange={(e) => setInviteRole(e.target.value as Role)}
|
||||
options={Object.entries(ROLE_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" loading={inviting}>
|
||||
ساخت دعوت
|
||||
</Button>
|
||||
</form>
|
||||
{inviteError && (
|
||||
<div className="mt-3">
|
||||
<ErrorState title="دعوت ناموفق" message={inviteError} />
|
||||
</div>
|
||||
)}
|
||||
{lastInvite && (
|
||||
<div className="mt-3 rounded-lg border border-surface-border bg-surface-overlay p-3">
|
||||
<p className="text-xs text-slate-400">
|
||||
دعوت برای <span className="ltr-inline">{lastInvite.email}</span> ساخته شد.
|
||||
</p>
|
||||
<p className="ltr-inline mt-1 break-all text-xs text-accent">
|
||||
{lastInvite.invite_url}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Card>
|
||||
<CardTitle>رویدادهای حسابرسی</CardTitle>
|
||||
<CardHint>
|
||||
این فهرست فقط-افزودنی است. هر عملیات حساس — ورود، تغییر نقش، ساخت کارمند، تغییر
|
||||
اعتبار و تصمیم تأیید — اینجا ثبت میشود.
|
||||
</CardHint>
|
||||
<div className="mt-4">
|
||||
{!canViewAudit ? (
|
||||
<p className="text-xs text-slate-500">
|
||||
برای دیدن رویدادهای حسابرسی به سطح دسترسی مدیر یا بالاتر نیاز دارید.
|
||||
</p>
|
||||
) : auditLoading ? (
|
||||
<LoadingState label="در حال دریافت رویدادها…" />
|
||||
) : audit.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">رویدادی ثبت نشده است.</p>
|
||||
) : (
|
||||
<Timeline
|
||||
items={audit.slice(0, 60).map((event) => ({
|
||||
id: event.id,
|
||||
title: AUDIT_LABELS[event.action] ?? event.action,
|
||||
meta: `${event.target_type || "—"} · ${new Date(event.created_at).toLocaleString("fa-IR")}`,
|
||||
tone:
|
||||
event.action.includes("delete") || event.action.includes("remove")
|
||||
? ("danger" as const)
|
||||
: event.action.includes("create") || event.action.includes("payment")
|
||||
? ("success" as const)
|
||||
: ("info" as const),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={pendingRemove !== null}
|
||||
title="حذف عضو"
|
||||
description={
|
||||
pendingRemove
|
||||
? `دسترسی «${pendingRemove.name || pendingRemove.email}» به این سازمان قطع میشود.`
|
||||
: undefined
|
||||
}
|
||||
confirmLabel={busyMemberId ? "در حال حذف…" : "حذف کن"}
|
||||
onConfirm={onConfirmRemove}
|
||||
onCancel={() => setPendingRemove(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
import type { Employee, RunDetail, TaskDetail, TaskStatus, ToolCall } from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
Stat,
|
||||
Timeline,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { CAN_RUN_TASKS, roleHasCapability } from "@/lib/permissions";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
const STATUS_LABELS: Record<TaskStatus, string> = {
|
||||
new: "جدید",
|
||||
triaged: "دستهبندیشده",
|
||||
waiting_approval: "در انتظار تأیید",
|
||||
running: "در حال اجرا",
|
||||
done: "انجامشده",
|
||||
failed: "ناموفق",
|
||||
cancelled: "لغوشده",
|
||||
};
|
||||
|
||||
const RUN_TONES: Record<string, "neutral" | "info" | "success" | "warning" | "danger"> = {
|
||||
queued: "neutral",
|
||||
planning: "info",
|
||||
running: "info",
|
||||
tool_calling: "info",
|
||||
sandbox_running: "info",
|
||||
waiting_approval: "warning",
|
||||
completed: "success",
|
||||
failed: "danger",
|
||||
cancelled: "neutral",
|
||||
timed_out: "danger",
|
||||
};
|
||||
|
||||
const TERMINAL = new Set(["completed", "failed", "cancelled", "timed_out"]);
|
||||
|
||||
export default function TaskDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id: taskId } = React.use(params);
|
||||
const { activeOrganization } = useSession();
|
||||
const orgId = activeOrganization?.id ?? null;
|
||||
|
||||
const [task, setTask] = React.useState<TaskDetail | null>(null);
|
||||
const [employees, setEmployees] = React.useState<Employee[]>([]);
|
||||
const [selectedRun, setSelectedRun] = React.useState<RunDetail | null>(null);
|
||||
const [approvals, setApprovals] = React.useState<ToolCall[]>([]);
|
||||
const [decidingId, setDecidingId] = React.useState<string | null>(null);
|
||||
const [approvalNote, setApprovalNote] = React.useState<Record<string, string>>({});
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [running, setRunning] = React.useState(false);
|
||||
const [actionError, setActionError] = React.useState<string | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!orgId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [nextTask, nextEmployees, nextApprovals] = await Promise.all([
|
||||
api.getTask(orgId, taskId),
|
||||
api.listEmployees(orgId),
|
||||
// Deny-by-default: a dangerous tool stays blocked until a human decides,
|
||||
// so this list is the only thing standing between a run and the action.
|
||||
api.listPendingApprovals(orgId),
|
||||
]);
|
||||
setTask(nextTask);
|
||||
setEmployees(nextEmployees);
|
||||
setApprovals(nextApprovals);
|
||||
// Hydrate the newest run so the timeline is visible without a click.
|
||||
const newest = nextTask.runs[0];
|
||||
setSelectedRun(newest ? await api.getRun(orgId, newest.id) : null);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت کار ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orgId, taskId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const onDecideApproval = async (toolCallId: string, approve: boolean) => {
|
||||
if (!orgId) return;
|
||||
setActionError(null);
|
||||
setDecidingId(toolCallId);
|
||||
try {
|
||||
await api.decideApproval(orgId, toolCallId, approve, approvalNote[toolCallId] ?? "");
|
||||
// Re-read rather than filter locally: the server owns the final status and
|
||||
// may resolve more than one call.
|
||||
setApprovals(await api.listPendingApprovals(orgId));
|
||||
setApprovalNote((current) => {
|
||||
const next = { ...current };
|
||||
delete next[toolCallId];
|
||||
return next;
|
||||
});
|
||||
await load();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof ApiError ? err.message : "ثبت تصمیم ناموفق بود.");
|
||||
} finally {
|
||||
setDecidingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const onStartRun = async () => {
|
||||
if (!orgId) return;
|
||||
setActionError(null);
|
||||
setRunning(true);
|
||||
try {
|
||||
const detail = await api.startRun(orgId, taskId, task?.assigned_employee_id ?? null);
|
||||
setSelectedRun(detail);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof ApiError ? err.message : "اجرای کار ناموفق بود.");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onCancelRun = async () => {
|
||||
if (!orgId || !selectedRun) return;
|
||||
setActionError(null);
|
||||
setRunning(true);
|
||||
try {
|
||||
setSelectedRun(await api.cancelRun(orgId, selectedRun.id));
|
||||
await load();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof ApiError ? err.message : "لغو اجرا ناموفق بود.");
|
||||
} finally {
|
||||
setRunning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onSelectRun = async (runId: string) => {
|
||||
if (!orgId) return;
|
||||
try {
|
||||
setSelectedRun(await api.getRun(orgId, runId));
|
||||
} catch (err) {
|
||||
setActionError(err instanceof ApiError ? err.message : "دریافت اجرا ناموفق بود.");
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeOrganization) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="سازمانی انتخاب نشده است"
|
||||
description="برای دیدن جزئیات کار ابتدا یک سازمان بسازید."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) return <LoadingState label="در حال دریافت کار…" />;
|
||||
if (error) return <ErrorState message={error} onRetry={load} />;
|
||||
if (!task) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="کار پیدا نشد"
|
||||
description="این کار حذف شده است یا به سازمان دیگری تعلق دارد."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const canRun = roleHasCapability(activeOrganization.role, CAN_RUN_TASKS);
|
||||
const assignee = employees.find((e) => e.id === task.assigned_employee_id);
|
||||
const runIsLive = selectedRun ? !TERMINAL.has(selectedRun.status) : false;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<nav className="text-xs text-slate-500">
|
||||
<Link href="/tasks" className="hover:text-slate-300">
|
||||
کارها
|
||||
</Link>
|
||||
<span className="mx-1.5">/</span>
|
||||
<span className="text-slate-400">{task.title}</span>
|
||||
</nav>
|
||||
|
||||
<header className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-medium text-slate-100">{task.title}</h1>
|
||||
<Badge tone={task.status === "done" ? "success" : "info"}>
|
||||
{STATUS_LABELS[task.status] ?? task.status}
|
||||
</Badge>
|
||||
</div>
|
||||
{task.description && <p className="text-sm text-slate-400">{task.description}</p>}
|
||||
<p className="text-xs text-slate-500">
|
||||
{assignee ? `کارمند مسئول: ${assignee.name}` : "بدون کارمند مسئول — مدیر سازمان اجرا میکند."}
|
||||
</p>
|
||||
</div>
|
||||
{canRun && (
|
||||
<Button onClick={onStartRun} loading={running}>
|
||||
اجرای کار
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{actionError && <ErrorState title="عملیات ناموفق" message={actionError} />}
|
||||
|
||||
{approvals.length > 0 && (
|
||||
<Card className="border-warning/40">
|
||||
<CardTitle>در انتظار تأیید شما ({approvals.length})</CardTitle>
|
||||
<CardHint>
|
||||
ابزارهای خطرناک بهصورت پیشفرض مجاز نیستند. تا زمانی که تصمیم نگیرید، اجرا متوقف
|
||||
میماند و هیچ اقدامی انجام نمیشود.
|
||||
</CardHint>
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
{approvals.map((call) => (
|
||||
<div
|
||||
key={call.id}
|
||||
className="flex flex-col gap-3 rounded-lg border border-surface-border bg-surface-overlay p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="ltr-inline text-sm text-slate-200">{call.tool_key}</span>
|
||||
<Badge tone="warning">نیازمند تأیید</Badge>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-xs text-slate-500">آرگومانها</p>
|
||||
<pre className="ltr-inline max-h-40 overflow-auto whitespace-pre-wrap break-all rounded border border-surface-border bg-surface-base p-2 text-xs text-slate-300">
|
||||
{JSON.stringify(call.arguments_json ?? {}, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
{canRun && (
|
||||
<>
|
||||
<input
|
||||
value={approvalNote[call.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setApprovalNote((current) => ({ ...current, [call.id]: e.target.value }))
|
||||
}
|
||||
placeholder="یادداشت (اختیاری)"
|
||||
className="h-9 w-full rounded-lg border border-surface-border bg-surface-base px-3 text-xs text-slate-200 outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
|
||||
/>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
onClick={() => void onDecideApproval(call.id, false)}
|
||||
loading={decidingId === call.id}
|
||||
>
|
||||
رد کن
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => void onDecideApproval(call.id, true)}
|
||||
loading={decidingId === call.id}
|
||||
>
|
||||
تأیید کن
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Stat label="تعداد اجراها" value={task.runs.length} />
|
||||
<Stat
|
||||
label="آخرین وضعیت اجرا"
|
||||
value={
|
||||
selectedRun ? (
|
||||
<Badge tone={RUN_TONES[selectedRun.status] ?? "neutral"}>{selectedRun.status}</Badge>
|
||||
) : (
|
||||
"—"
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Stat
|
||||
label="خروجیها"
|
||||
value={selectedRun ? selectedRun.artifacts.length : 0}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{task.runs.length > 0 && (
|
||||
<Card>
|
||||
<CardTitle>تاریخچهٔ اجراها</CardTitle>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{task.runs.map((run) => (
|
||||
<Button
|
||||
key={run.id}
|
||||
size="sm"
|
||||
variant={selectedRun?.id === run.id ? "primary" : "secondary"}
|
||||
onClick={() => void onSelectRun(run.id)}
|
||||
>
|
||||
<Badge tone={RUN_TONES[run.status] ?? "neutral"}>{run.status}</Badge>
|
||||
<span className="ltr-inline text-xs">{run.id.slice(0, 6)}</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!selectedRun ? (
|
||||
<EmptyState
|
||||
title="هنوز اجرایی ثبت نشده است"
|
||||
description="برای دیدن مراحل اجرا، دکمهٔ «اجرای کار» را بزنید."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Card>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>خط زمانی اجرا</CardTitle>
|
||||
<CardHint>
|
||||
هر گذار وضعیت بهصورت یک گام ثبت میشود؛ این خط زمانی منبع حقیقت ردیابی است.
|
||||
</CardHint>
|
||||
</div>
|
||||
{canRun && runIsLive && (
|
||||
<Button variant="danger" size="sm" onClick={onCancelRun} loading={running}>
|
||||
لغو اجرا
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
{selectedRun.steps.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">گامی ثبت نشده است.</p>
|
||||
) : (
|
||||
<Timeline
|
||||
items={selectedRun.steps.map((step) => ({
|
||||
id: step.id,
|
||||
title: step.title || step.kind,
|
||||
meta: new Date(step.created_at).toLocaleString("fa-IR"),
|
||||
tone:
|
||||
step.kind === "error"
|
||||
? ("danger" as const)
|
||||
: step.kind === "artifact"
|
||||
? ("success" as const)
|
||||
: ("info" as const),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{selectedRun.error && (
|
||||
<div className="mt-3">
|
||||
<ErrorState title="خطای اجرا" message={selectedRun.error} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{selectedRun.tool_calls.length > 0 && (
|
||||
<Card>
|
||||
<CardTitle>فراخوانی ابزارها</CardTitle>
|
||||
<CardHint>
|
||||
ابزارهای خطرناک تا زمانی که تأیید نشوند اجرا نمیشوند.
|
||||
</CardHint>
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{selectedRun.tool_calls.map((call) => (
|
||||
<div
|
||||
key={call.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-surface-border bg-surface-overlay px-3 py-2"
|
||||
>
|
||||
<span className="ltr-inline text-xs text-slate-300">{call.tool_key}</span>
|
||||
<div className="flex gap-2">
|
||||
{call.requires_approval && <Badge tone="warning">نیازمند تأیید</Badge>}
|
||||
<Badge
|
||||
tone={
|
||||
call.status === "succeeded"
|
||||
? "success"
|
||||
: call.status === "failed" || call.status === "rejected"
|
||||
? "danger"
|
||||
: "neutral"
|
||||
}
|
||||
>
|
||||
{call.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardTitle>خروجیها</CardTitle>
|
||||
{selectedRun.artifacts.length === 0 ? (
|
||||
<p className="mt-2 text-xs text-slate-500">خروجیای ثبت نشده است.</p>
|
||||
) : (
|
||||
<div className="mt-3 flex flex-col gap-3">
|
||||
{selectedRun.artifacts.map((artifact) => (
|
||||
<div
|
||||
key={artifact.id}
|
||||
className="flex flex-col gap-2 rounded-lg border border-surface-border bg-surface-overlay p-3"
|
||||
>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="text-sm text-slate-200">{artifact.name}</span>
|
||||
<div className="flex gap-2">
|
||||
<Badge tone="neutral">
|
||||
<span className="ltr-inline">{artifact.kind}</span>
|
||||
</Badge>
|
||||
<Badge tone="neutral">{artifact.size_bytes} بایت</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-words text-xs text-slate-400">
|
||||
{artifact.content_ref}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
import type { Employee, Task, TaskStatus } from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
LoadingState,
|
||||
Select,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { CAN_RUN_TASKS, roleHasCapability } from "@/lib/permissions";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
const STATUS_LABELS: Record<TaskStatus, string> = {
|
||||
new: "جدید",
|
||||
triaged: "دستهبندیشده",
|
||||
waiting_approval: "در انتظار تأیید",
|
||||
running: "در حال اجرا",
|
||||
done: "انجامشده",
|
||||
failed: "ناموفق",
|
||||
cancelled: "لغوشده",
|
||||
};
|
||||
|
||||
const STATUS_TONES: Record<TaskStatus, "neutral" | "info" | "success" | "warning" | "danger"> = {
|
||||
new: "neutral",
|
||||
triaged: "info",
|
||||
waiting_approval: "warning",
|
||||
running: "info",
|
||||
done: "success",
|
||||
failed: "danger",
|
||||
cancelled: "neutral",
|
||||
};
|
||||
|
||||
export default function TasksPage() {
|
||||
const { activeOrganization } = useSession();
|
||||
const orgId = activeOrganization?.id ?? null;
|
||||
|
||||
const [tasks, setTasks] = React.useState<Task[]>([]);
|
||||
const [employees, setEmployees] = React.useState<Employee[]>([]);
|
||||
const [filter, setFilter] = React.useState<TaskStatus | "">("");
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [showForm, setShowForm] = React.useState(false);
|
||||
const [title, setTitle] = React.useState("");
|
||||
const [description, setDescription] = React.useState("");
|
||||
const [assignedTo, setAssignedTo] = React.useState("");
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
const [formError, setFormError] = React.useState<string | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!orgId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [nextTasks, nextEmployees] = await Promise.all([
|
||||
api.listTasks(orgId, filter || undefined),
|
||||
api.listEmployees(orgId),
|
||||
]);
|
||||
setTasks(nextTasks);
|
||||
setEmployees(nextEmployees);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت کارها ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orgId, filter]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const onCreate = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!orgId) return;
|
||||
setFormError(null);
|
||||
setCreating(true);
|
||||
try {
|
||||
const created = await api.createTask(orgId, {
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
assigned_employee_id: assignedTo || null,
|
||||
});
|
||||
setTasks((current) => [created, ...current]);
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setAssignedTo("");
|
||||
setShowForm(false);
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : "ساخت کار ناموفق بود.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeOrganization) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="سازمانی انتخاب نشده است"
|
||||
description="برای دیدن کارها ابتدا یک سازمان بسازید."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const canRun = roleHasCapability(activeOrganization.role, CAN_RUN_TASKS);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<header className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-medium text-slate-100">کارها</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
هر کار میتواند به یک کارمند سپرده شود و در چند اجرا پیش برود.
|
||||
</p>
|
||||
</div>
|
||||
{canRun && (
|
||||
<Button onClick={() => setShowForm((v) => !v)} variant={showForm ? "ghost" : "primary"}>
|
||||
{showForm ? "بستن" : "کار جدید"}
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{showForm && (
|
||||
<Card>
|
||||
<CardTitle>ساخت کار</CardTitle>
|
||||
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field
|
||||
label="عنوان"
|
||||
required
|
||||
minLength={2}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder="مثلاً افزودن صفحهٔ تسویهحساب"
|
||||
/>
|
||||
<Select
|
||||
label="کارمند مسئول (اختیاری)"
|
||||
value={assignedTo}
|
||||
onChange={(e) => setAssignedTo(e.target.value)}
|
||||
options={[
|
||||
{ value: "", label: "سپردن خودکار به مدیر" },
|
||||
...employees.map((e) => ({ value: e.id, label: e.name })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<Field
|
||||
label="توضیحات"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
placeholder="جزئیات و معیار پذیرش…"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" loading={creating}>
|
||||
ساخت کار
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{formError && (
|
||||
<div className="mt-3">
|
||||
<ErrorState title="ساخت کار ناموفق" message={formError} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-56">
|
||||
<Select
|
||||
label="فیلتر وضعیت"
|
||||
value={filter}
|
||||
onChange={(e) => setFilter(e.target.value as TaskStatus | "")}
|
||||
options={[
|
||||
{ value: "", label: "همه" },
|
||||
...Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<LoadingState label="در حال دریافت کارها…" />
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={load} />
|
||||
) : tasks.length === 0 ? (
|
||||
<EmptyState
|
||||
title="کاری یافت نشد"
|
||||
description="یک کار جدید بسازید تا کارمند هوش مصنوعی آن را اجرا کند."
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{tasks.map((task) => {
|
||||
const employee = employees.find((e) => e.id === task.assigned_employee_id);
|
||||
return (
|
||||
<Card key={task.id} className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<p className="truncate text-sm font-medium text-slate-100">{task.title}</p>
|
||||
{task.description && (
|
||||
<p className="line-clamp-1 text-xs text-slate-500">{task.description}</p>
|
||||
)}
|
||||
<p className="text-xs text-slate-500">
|
||||
{employee ? `مسئول: ${employee.name}` : "بدون مسئول"}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Badge tone={STATUS_TONES[task.status] ?? "neutral"}>
|
||||
{STATUS_LABELS[task.status] ?? task.status}
|
||||
</Badge>
|
||||
<Link href={`/tasks/${task.id}`}>
|
||||
<Button variant="secondary" size="sm">
|
||||
جزئیات
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHint>
|
||||
کارهای در انتظار تأیید ابزارهای خطرناک در صفحهٔ جزئیات همان کار نمایش داده میشوند.
|
||||
</CardHint>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,371 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Cross-cutting trace view.
|
||||
*
|
||||
* One place to reconstruct what the platform did: every run across the
|
||||
* organization, its step timeline, the tools it called, and the audit stream
|
||||
* beside it.
|
||||
*
|
||||
* Two deliberate constraints:
|
||||
*
|
||||
* - The task list is capped before fanning out. There is no "list all runs for
|
||||
* an organization" endpoint, so we read runs through their owning task; an
|
||||
* uncapped fan-out would turn one page load into hundreds of requests.
|
||||
* - No model or provider identity is rendered. Those never leave the platform
|
||||
* owner's console.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
import type { AuditEvent, Run, RunDetail } from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
LoadingState,
|
||||
Stat,
|
||||
Timeline,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
/** How many tasks to fan out into. Bounds the request count. */
|
||||
const TASK_SCAN_LIMIT = 12;
|
||||
|
||||
const RUN_TONES: Record<string, "neutral" | "info" | "success" | "warning" | "danger"> = {
|
||||
queued: "neutral",
|
||||
planning: "info",
|
||||
running: "info",
|
||||
tool_calling: "info",
|
||||
sandbox_running: "info",
|
||||
waiting_approval: "warning",
|
||||
completed: "success",
|
||||
failed: "danger",
|
||||
cancelled: "neutral",
|
||||
timed_out: "danger",
|
||||
};
|
||||
|
||||
const AUDIT_LABELS: Record<string, string> = {
|
||||
"auth.login": "ورود کاربر",
|
||||
"auth.signup": "ثبتنام کاربر",
|
||||
"org.create": "ساخت سازمان",
|
||||
"org.member_invite": "دعوت عضو",
|
||||
"org.member_update": "تغییر نقش عضو",
|
||||
"org.member_remove": "حذف عضو",
|
||||
"project.create": "ساخت پروژه",
|
||||
"project.repo_ensure": "ساخت مخزن",
|
||||
"project.preview_domain_create": "ساخت دامنهٔ تست",
|
||||
"project.preview_domain_delete": "حذف دامنهٔ تست",
|
||||
"employee.create": "ساخت کارمند",
|
||||
"employee.update": "ویرایش کارمند",
|
||||
"employee.prompt_version": "نسخهٔ پرامپت جدید",
|
||||
"employee.tool_permission": "تغییر دسترسی ابزار",
|
||||
"task.create": "ساخت کار",
|
||||
"run.start": "شروع اجرا",
|
||||
"run.cancel": "لغو اجرا",
|
||||
"approval.decide": "تصمیم تأیید",
|
||||
"billing.add_credit": "افزایش اعتبار",
|
||||
"billing.checkout": "شروع پرداخت",
|
||||
"billing.settle": "تسویهٔ پرداخت",
|
||||
"sandbox.workspace_create": "ساخت محیط ایزوله",
|
||||
"sandbox.destroy": "حذف محیط ایزوله",
|
||||
"sandbox.command": "اجرای دستور در sandbox",
|
||||
};
|
||||
|
||||
function auditLabel(action: string): string {
|
||||
return AUDIT_LABELS[action] ?? action;
|
||||
}
|
||||
|
||||
function formatDuration(run: Run): string {
|
||||
if (!run.started_at) return "—";
|
||||
const end = run.finished_at ? new Date(run.finished_at).getTime() : Date.now();
|
||||
const ms = end - new Date(run.started_at).getTime();
|
||||
if (ms < 1000) return `${ms}ms`;
|
||||
if (ms < 60_000) return `${(ms / 1000).toFixed(1)}s`;
|
||||
return `${Math.round(ms / 60_000)}m`;
|
||||
}
|
||||
|
||||
interface RunRow {
|
||||
run: Run;
|
||||
taskId: string;
|
||||
taskTitle: string;
|
||||
}
|
||||
|
||||
export default function TracesPage() {
|
||||
const { activeOrganization } = useSession();
|
||||
const orgId = activeOrganization?.id ?? null;
|
||||
|
||||
const [rows, setRows] = React.useState<RunRow[]>([]);
|
||||
const [audit, setAudit] = React.useState<AuditEvent[]>([]);
|
||||
const [selected, setSelected] = React.useState<RunDetail | null>(null);
|
||||
const [selectedId, setSelectedId] = React.useState<string | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [detailLoading, setDetailLoading] = React.useState(false);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!orgId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [tasks, auditEvents] = await Promise.all([
|
||||
api.listTasks(orgId),
|
||||
api.listAudit(orgId),
|
||||
]);
|
||||
setAudit(auditEvents);
|
||||
|
||||
const recent = tasks.slice(0, TASK_SCAN_LIMIT);
|
||||
const details = await Promise.all(
|
||||
recent.map(async (task) => {
|
||||
try {
|
||||
return { task, detail: await api.getTask(orgId, task.id) };
|
||||
} catch {
|
||||
// One unreadable task must not blank the whole trace view.
|
||||
return { task, detail: null };
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const collected: RunRow[] = [];
|
||||
for (const { task, detail } of details) {
|
||||
for (const run of detail?.runs ?? []) {
|
||||
collected.push({ run, taskId: task.id, taskTitle: task.title });
|
||||
}
|
||||
}
|
||||
collected.sort(
|
||||
(a, b) => new Date(b.run.created_at).getTime() - new Date(a.run.created_at).getTime(),
|
||||
);
|
||||
setRows(collected);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت ردیابی ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orgId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const onSelect = async (runId: string) => {
|
||||
if (!orgId) return;
|
||||
setSelectedId(runId);
|
||||
setDetailLoading(true);
|
||||
try {
|
||||
setSelected(await api.getRun(orgId, runId));
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت جزئیات اجرا ناموفق بود.");
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeOrganization) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="سازمانی انتخاب نشده است"
|
||||
description="برای دیدن ردیابی ابتدا یک سازمان بسازید."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) return <LoadingState label="در حال جمعآوری ردیابی…" />;
|
||||
if (error) return <ErrorState message={error} onRetry={load} />;
|
||||
|
||||
const failed = rows.filter((r) => r.run.status === "failed" || r.run.status === "timed_out").length;
|
||||
|
||||
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">
|
||||
هر اجرا با خط زمانی گامها، ابزارهای فراخوانیشده و رویدادهای audit. هویت مدل و
|
||||
ارائهدهنده در این نما نمایش داده نمیشود.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Stat label="اجراها" value={rows.length} hint={`از ${TASK_SCAN_LIMIT} کار آخر`} />
|
||||
<Stat label="اجراهای ناموفق" value={failed} tone={failed > 0 ? "danger" : "neutral"} />
|
||||
<Stat label="رویدادهای audit" value={audit.length} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1fr_22rem]">
|
||||
<div className="flex flex-col gap-4">
|
||||
<Card>
|
||||
<CardTitle>اجراها</CardTitle>
|
||||
<CardHint>جدیدترین اجراها در همهٔ کارها.</CardHint>
|
||||
{rows.length === 0 ? (
|
||||
<p className="mt-3 text-xs text-slate-500">هنوز اجرایی ثبت نشده است.</p>
|
||||
) : (
|
||||
<ul className="mt-3 flex flex-col gap-2">
|
||||
{rows.map(({ run, taskId, taskTitle }) => (
|
||||
<li key={run.id}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onSelect(run.id)}
|
||||
className={
|
||||
"flex w-full flex-wrap items-center justify-between gap-2 rounded-lg border px-3 py-2 text-right transition-colors " +
|
||||
(selectedId === run.id
|
||||
? "border-accent/60 bg-surface-overlay"
|
||||
: "border-surface-border hover:bg-surface-overlay")
|
||||
}
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<Link
|
||||
href={`/tasks/${taskId}`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="truncate text-xs text-slate-200 hover:text-accent"
|
||||
>
|
||||
{taskTitle}
|
||||
</Link>
|
||||
<span className="ltr-inline text-xs text-slate-500">
|
||||
{run.id.slice(0, 8)} · {new Date(run.created_at).toLocaleString("fa-IR")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className="text-xs text-slate-500">{formatDuration(run)}</span>
|
||||
<Badge tone={RUN_TONES[run.status] ?? "neutral"}>{run.status}</Badge>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{detailLoading && <LoadingState label="در حال دریافت جزئیات اجرا…" />}
|
||||
|
||||
{selected && !detailLoading && (
|
||||
<Card>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>خط زمانی اجرا</CardTitle>
|
||||
<CardHint>
|
||||
<span className="ltr-inline">{selected.id}</span>
|
||||
</CardHint>
|
||||
</div>
|
||||
<Badge tone={RUN_TONES[selected.status] ?? "neutral"}>{selected.status}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
{selected.steps.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">گامی ثبت نشده است.</p>
|
||||
) : (
|
||||
<Timeline
|
||||
items={selected.steps.map((step) => ({
|
||||
id: step.id,
|
||||
title: step.title || step.kind,
|
||||
meta: new Date(step.created_at).toLocaleString("fa-IR"),
|
||||
tone:
|
||||
step.kind === "error"
|
||||
? ("danger" as const)
|
||||
: step.kind === "artifact"
|
||||
? ("success" as const)
|
||||
: ("info" as const),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selected.error && (
|
||||
<div className="mt-3">
|
||||
<ErrorState title="خطای اجرا" message={selected.error} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.tool_calls.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2 text-xs font-medium text-slate-300">ابزارها</p>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{selected.tool_calls.map((call) => (
|
||||
<li
|
||||
key={call.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-surface-border bg-surface-overlay px-3 py-2"
|
||||
>
|
||||
<span className="ltr-inline text-xs text-slate-300">{call.tool_key}</span>
|
||||
<div className="flex gap-2">
|
||||
{call.requires_approval && <Badge tone="warning">نیازمند تأیید</Badge>}
|
||||
<Badge
|
||||
tone={
|
||||
call.status === "succeeded"
|
||||
? "success"
|
||||
: call.status === "failed" || call.status === "rejected"
|
||||
? "danger"
|
||||
: "neutral"
|
||||
}
|
||||
>
|
||||
{call.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selected.artifacts.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2 text-xs font-medium text-slate-300">خروجیها</p>
|
||||
<ul className="flex flex-col gap-1.5">
|
||||
{selected.artifacts.map((artifact) => (
|
||||
<li key={artifact.id} className="text-xs text-slate-400">
|
||||
<span className="text-slate-200">{artifact.name}</span> ·{" "}
|
||||
<span className="ltr-inline">{artifact.kind}</span> ·{" "}
|
||||
{artifact.size_bytes} بایت
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!selected && !detailLoading && rows.length > 0 && (
|
||||
<EmptyState
|
||||
title="یک اجرا را انتخاب کنید"
|
||||
description="برای دیدن گامها، ابزارها و خروجیها یکی از اجراهای بالا را بزنید."
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Card className="h-fit">
|
||||
<CardTitle>رویدادهای audit</CardTitle>
|
||||
<CardHint>هر اقدام حساس با کنشگر و هدف ثبت میشود.</CardHint>
|
||||
<div className="mt-4 max-h-[32rem] overflow-auto">
|
||||
{audit.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">رویدادی ثبت نشده است.</p>
|
||||
) : (
|
||||
<Timeline
|
||||
items={audit.slice(0, 60).map((event) => ({
|
||||
id: event.id,
|
||||
title: auditLabel(event.action),
|
||||
meta: `${new Date(event.created_at).toLocaleString("fa-IR")} · ${
|
||||
event.target_type
|
||||
}`,
|
||||
tone: event.action.includes("remove") || event.action.includes("cancel")
|
||||
? ("danger" as const)
|
||||
: ("info" as const),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="secondary" size="sm" onClick={load}>
|
||||
بازخوانی
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
import type {
|
||||
EmployeeDetail,
|
||||
EmployeeStatus,
|
||||
PromptVersion,
|
||||
RoleKey,
|
||||
RoutingChoice,
|
||||
ToolSpec,
|
||||
} from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
LoadingState,
|
||||
Select,
|
||||
Stat,
|
||||
Timeline,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { CAN_MANAGE_EMPLOYEES, roleHasCapability } from "@/lib/permissions";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
const ROLE_LABELS: Record<RoleKey, string> = {
|
||||
manager: "مدیر",
|
||||
planner: "تحلیلگر",
|
||||
developer: "برنامهنویس",
|
||||
reviewer: "بازبین کد",
|
||||
tester: "تستر",
|
||||
researcher: "محقق",
|
||||
content_creator: "تولیدکننده محتوا",
|
||||
support: "پشتیبان",
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<EmployeeStatus, string> = {
|
||||
active: "فعال",
|
||||
paused: "متوقف",
|
||||
archived: "بایگانی",
|
||||
};
|
||||
|
||||
export default function EmployeeDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id: employeeId } = React.use(params);
|
||||
const { activeOrganization } = useSession();
|
||||
const orgId = activeOrganization?.id ?? null;
|
||||
|
||||
const [employee, setEmployee] = React.useState<EmployeeDetail | null>(null);
|
||||
const [choices, setChoices] = React.useState<RoutingChoice[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [name, setName] = React.useState("");
|
||||
const [status, setStatus] = React.useState<EmployeeStatus>("active");
|
||||
const [routingPolicyId, setRoutingPolicyId] = React.useState("");
|
||||
const [saving, setSaving] = React.useState(false);
|
||||
const [saveError, setSaveError] = React.useState<string | null>(null);
|
||||
const [saved, setSaved] = React.useState(false);
|
||||
|
||||
const [promptDraft, setPromptDraft] = React.useState("");
|
||||
const [addingPrompt, setAddingPrompt] = React.useState(false);
|
||||
const [promptError, setPromptError] = React.useState<string | null>(null);
|
||||
|
||||
const [tools, setTools] = React.useState<ToolSpec[]>([]);
|
||||
const [toolBusy, setToolBusy] = React.useState<string | null>(null);
|
||||
const [toolError, setToolError] = React.useState<string | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!orgId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [nextEmployee, nextChoices, nextTools] = await Promise.all([
|
||||
api.getEmployee(orgId, employeeId),
|
||||
api.listRoutingChoices(orgId),
|
||||
api.listTools(orgId),
|
||||
]);
|
||||
setEmployee(nextEmployee);
|
||||
setChoices(nextChoices);
|
||||
setTools(nextTools);
|
||||
setName(nextEmployee.name);
|
||||
setStatus(nextEmployee.status);
|
||||
setRoutingPolicyId(nextEmployee.routing_policy_id ?? "");
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت کارمند ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orgId, employeeId]);
|
||||
|
||||
const onToggleTool = async (toolKey: string, allowed: boolean) => {
|
||||
if (!orgId) return;
|
||||
setToolError(null);
|
||||
setToolBusy(toolKey);
|
||||
try {
|
||||
const updated = await api.setToolPermission(orgId, employeeId, toolKey, allowed);
|
||||
setEmployee((current) => {
|
||||
if (!current) return current;
|
||||
const others = current.tool_permissions.filter((p) => p.tool_key !== toolKey);
|
||||
return { ...current, tool_permissions: [...others, updated] };
|
||||
});
|
||||
} catch (err) {
|
||||
setToolError(err instanceof ApiError ? err.message : "تغییر دسترسی ناموفق بود.");
|
||||
} finally {
|
||||
setToolBusy(null);
|
||||
}
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const onSave = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!orgId) return;
|
||||
setSaveError(null);
|
||||
setSaved(false);
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.updateEmployee(orgId, employeeId, {
|
||||
name: name.trim(),
|
||||
status,
|
||||
routing_policy_id: routingPolicyId || null,
|
||||
});
|
||||
setSaved(true);
|
||||
window.setTimeout(() => setSaved(false), 1800);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setSaveError(err instanceof ApiError ? err.message : "ذخیرهٔ تغییرات ناموفق بود.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onAddPrompt = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!orgId) return;
|
||||
setPromptError(null);
|
||||
setAddingPrompt(true);
|
||||
try {
|
||||
const version: PromptVersion = await api.addPromptVersion(
|
||||
orgId,
|
||||
employeeId,
|
||||
promptDraft.trim(),
|
||||
);
|
||||
setEmployee((current) =>
|
||||
current
|
||||
? { ...current, prompt_versions: [version, ...current.prompt_versions] }
|
||||
: current,
|
||||
);
|
||||
setPromptDraft("");
|
||||
} catch (err) {
|
||||
setPromptError(err instanceof ApiError ? err.message : "ثبت نسخهٔ پرامپت ناموفق بود.");
|
||||
} finally {
|
||||
setAddingPrompt(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeOrganization) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="سازمانی انتخاب نشده است"
|
||||
description="برای دیدن جزئیات کارمند ابتدا یک سازمان بسازید."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) return <LoadingState label="در حال دریافت کارمند…" />;
|
||||
if (error) return <ErrorState message={error} onRetry={load} />;
|
||||
if (!employee) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="کارمند پیدا نشد"
|
||||
description="این کارمند حذف شده است یا به سازمان دیگری تعلق دارد."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const canManage = roleHasCapability(activeOrganization.role, CAN_MANAGE_EMPLOYEES);
|
||||
const allowedTools = employee.tool_permissions.filter((p) => p.allowed);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<nav className="text-xs text-slate-500">
|
||||
<Link href="/workforce" className="hover:text-slate-300">
|
||||
کارمندهای هوش مصنوعی
|
||||
</Link>
|
||||
<span className="mx-1.5">/</span>
|
||||
<span className="text-slate-400">{employee.name}</span>
|
||||
</nav>
|
||||
|
||||
<header className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-lg font-medium text-slate-100">{employee.name}</h1>
|
||||
<Badge tone="info">{ROLE_LABELS[employee.role_key]}</Badge>
|
||||
<Badge tone={employee.status === "active" ? "success" : "neutral"}>
|
||||
{STATUS_LABELS[employee.status]}
|
||||
</Badge>
|
||||
</header>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Stat label="کارهای باز" value={employee.open_task_count} />
|
||||
<Stat label="نسخههای پرامپت" value={employee.prompt_versions.length} />
|
||||
<Stat label="ابزارهای مجاز" value={allowedTools.length} />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardTitle>تنظیمات کارمند</CardTitle>
|
||||
<CardHint>
|
||||
«سیاست کاری» تعیین میکند کارمند با چه سبکی کار کند. انتخاب مدل و ارائهدهنده در اختیار
|
||||
سامانه است و نمایش داده نمیشود.
|
||||
</CardHint>
|
||||
<form onSubmit={onSave} className="mt-4 flex flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Field
|
||||
label="نام"
|
||||
required
|
||||
minLength={2}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={!canManage}
|
||||
/>
|
||||
<Select
|
||||
label="وضعیت"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value as EmployeeStatus)}
|
||||
disabled={!canManage}
|
||||
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
<Select
|
||||
label="سیاست کاری"
|
||||
value={routingPolicyId}
|
||||
onChange={(e) => setRoutingPolicyId(e.target.value)}
|
||||
disabled={!canManage}
|
||||
options={[
|
||||
{ value: "", label: "پیشفرض سازمان" },
|
||||
...choices.map((c) => ({ value: c.id, label: c.name })),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{canManage && (
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
{saved && <span className="text-xs text-positive">ذخیره شد.</span>}
|
||||
<Button type="submit" loading={saving}>
|
||||
ذخیرهٔ تغییرات
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
{saveError && (
|
||||
<div className="mt-3">
|
||||
<ErrorState title="ذخیره ناموفق" message={saveError} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle>تاریخچهٔ پرامپت</CardTitle>
|
||||
<CardHint>
|
||||
هر ویرایش یک نسخهٔ جدید میسازد. نسخهها حذف نمیشوند تا بتوان رفتار کارمند را ردیابی کرد.
|
||||
</CardHint>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-3">
|
||||
{employee.prompt_versions.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">هنوز پرامپتی ثبت نشده است.</p>
|
||||
) : (
|
||||
<Timeline
|
||||
items={employee.prompt_versions.slice(0, 5).map((version) => ({
|
||||
id: version.id,
|
||||
title: `نسخهٔ ${version.version}`,
|
||||
meta: version.content.slice(0, 140),
|
||||
tone: "info" as const,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canManage && (
|
||||
<form onSubmit={onAddPrompt} className="mt-4 flex flex-col gap-3">
|
||||
<Field
|
||||
label="نسخهٔ جدید پرامپت"
|
||||
required
|
||||
value={promptDraft}
|
||||
onChange={(e) => setPromptDraft(e.target.value)}
|
||||
placeholder="دستورهای این کارمند را بنویسید…"
|
||||
/>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" loading={addingPrompt}>
|
||||
ثبت نسخهٔ جدید
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
{promptError && (
|
||||
<div className="mt-3">
|
||||
<ErrorState title="ثبت نسخه ناموفق" message={promptError} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle>دسترسی ابزارها</CardTitle>
|
||||
<CardHint>
|
||||
بهصورت پیشفرض هیچ ابزاری مجاز نیست؛ هر دسترسی باید صریحاً داده شود. ابزارهای
|
||||
بازگشتناپذیر همیشه به تأیید انسانی نیاز دارند.
|
||||
</CardHint>
|
||||
<div className="mt-3">
|
||||
{employee.tool_permissions.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">هیچ ابزاری برای این کارمند تعریف نشده است.</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{employee.tool_permissions.map((permission) => (
|
||||
<Badge
|
||||
key={permission.tool_key}
|
||||
tone={permission.allowed ? "success" : "neutral"}
|
||||
>
|
||||
<span className="ltr-inline">{permission.tool_key}</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{canManage && (
|
||||
<div className="mt-4 border-t border-surface-border pt-4">
|
||||
<p className="text-xs font-medium text-slate-300">ویرایش دسترسیها</p>
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{tools.length === 0 ? (
|
||||
<LoadingState label="در حال دریافت فهرست ابزارها…" />
|
||||
) : (
|
||||
tools.map((tool) => {
|
||||
const granted =
|
||||
employee.tool_permissions.find((p) => p.tool_key === tool.key)?.allowed ??
|
||||
false;
|
||||
return (
|
||||
<div
|
||||
key={tool.key}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-surface-border bg-surface-overlay px-3 py-2"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-slate-200">{tool.label}</span>
|
||||
<code className="ltr-inline text-[10px] text-slate-500">{tool.key}</code>
|
||||
{tool.requires_approval && <Badge tone="warning">تأیید لازم</Badge>}
|
||||
{tool.destructive && <Badge tone="danger">بازگشتناپذیر</Badge>}
|
||||
</div>
|
||||
<span className="text-[11px] text-slate-500">{tool.description}</span>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={granted ? "danger" : "secondary"}
|
||||
loading={toolBusy === tool.key}
|
||||
onClick={() => void onToggleTool(tool.key, !granted)}
|
||||
>
|
||||
{granted ? "لغو دسترسی" : "اعطای دسترسی"}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
{toolError && (
|
||||
<div className="mt-3">
|
||||
<ErrorState title="تغییر دسترسی ناموفق" message={toolError} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
import type {
|
||||
AgentTemplate,
|
||||
Employee,
|
||||
EmployeeDetail,
|
||||
RoleKey,
|
||||
} from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
LoadingState,
|
||||
Select,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { CAN_MANAGE_EMPLOYEES, roleHasCapability } from "@/lib/permissions";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
const ROLE_LABELS: Record<RoleKey, string> = {
|
||||
manager: "مدیر",
|
||||
planner: "تحلیلگر",
|
||||
developer: "برنامهنویس",
|
||||
reviewer: "بازبین کد",
|
||||
tester: "تستر",
|
||||
researcher: "محقق",
|
||||
content_creator: "تولیدکننده محتوا",
|
||||
support: "پشتیبان",
|
||||
};
|
||||
|
||||
const STATUS_TONES = {
|
||||
active: "success",
|
||||
paused: "warning",
|
||||
archived: "neutral",
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Above this size we stop enriching cards with per-employee detail, because the
|
||||
* enrichment costs one request per employee. The page stays usable either way.
|
||||
*/
|
||||
const ENRICH_LIMIT = 50;
|
||||
|
||||
export default function WorkforcePage() {
|
||||
const { activeOrganization } = useSession();
|
||||
const [employees, setEmployees] = React.useState<Employee[]>([]);
|
||||
const [details, setDetails] = React.useState<Record<string, EmployeeDetail>>({});
|
||||
const [templates, setTemplates] = React.useState<AgentTemplate[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const [showForm, setShowForm] = React.useState(false);
|
||||
const [name, setName] = React.useState("");
|
||||
const [templateId, setTemplateId] = React.useState("");
|
||||
const [roleKey, setRoleKey] = React.useState<RoleKey>("developer");
|
||||
const [creating, setCreating] = React.useState(false);
|
||||
const [formError, setFormError] = React.useState<string | null>(null);
|
||||
|
||||
const orgId = activeOrganization?.id ?? null;
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
if (!orgId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [nextEmployees, nextTemplates] = await Promise.all([
|
||||
api.listEmployees(orgId),
|
||||
api.listAgentTemplates(orgId),
|
||||
]);
|
||||
setEmployees(nextEmployees);
|
||||
setTemplates(nextTemplates);
|
||||
|
||||
if (nextEmployees.length > 0 && nextEmployees.length <= ENRICH_LIMIT) {
|
||||
const enriched = await Promise.all(
|
||||
nextEmployees.map(async (employee) => {
|
||||
try {
|
||||
return [employee.id, await api.getEmployee(orgId, employee.id)] as const;
|
||||
} catch {
|
||||
// A single failed enrichment must not blank the whole page.
|
||||
return null;
|
||||
}
|
||||
}),
|
||||
);
|
||||
setDetails(
|
||||
Object.fromEntries(
|
||||
enriched.filter((entry): entry is readonly [string, EmployeeDetail] => entry !== null),
|
||||
),
|
||||
);
|
||||
} else {
|
||||
setDetails({});
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت کارمندها ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orgId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const onTemplateChange = (value: string) => {
|
||||
setTemplateId(value);
|
||||
const template = templates.find((t) => t.id === value);
|
||||
if (template) {
|
||||
setRoleKey(template.role_key);
|
||||
if (!name.trim()) setName(template.name);
|
||||
}
|
||||
};
|
||||
|
||||
const onCreate = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!orgId) return;
|
||||
setFormError(null);
|
||||
setCreating(true);
|
||||
try {
|
||||
await api.createEmployee(orgId, {
|
||||
name: name.trim(),
|
||||
role_key: roleKey,
|
||||
template_id: templateId || null,
|
||||
});
|
||||
setShowForm(false);
|
||||
setName("");
|
||||
setTemplateId("");
|
||||
await load();
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : "ساخت کارمند ناموفق بود.");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeOrganization) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="سازمانی انتخاب نشده است"
|
||||
description="برای دیدن کارمندهای هوش مصنوعی ابتدا یک سازمان بسازید."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const canManage = roleHasCapability(activeOrganization.role, CAN_MANAGE_EMPLOYEES);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<header className="flex items-start justify-between gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h1 className="text-lg font-medium text-slate-100">کارمندهای هوش مصنوعی</h1>
|
||||
<p className="text-sm text-slate-400">
|
||||
هر کارمند یک نقش، یک پرامپت نسخهبندیشده و یک سیاست مسیریابی دارد. جزئیات مدل هرگز
|
||||
نمایش داده نمیشود.
|
||||
</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<Button onClick={() => setShowForm((v) => !v)} variant={showForm ? "ghost" : "primary"}>
|
||||
{showForm ? "بستن" : "کارمند جدید"}
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{showForm && (
|
||||
<Card>
|
||||
<CardTitle>ساخت کارمند</CardTitle>
|
||||
<CardHint>
|
||||
با انتخاب یک قالب، پرامپت نسخهٔ ۱ و دسترسی ابزارها بهصورت خودکار ساخته میشود.
|
||||
</CardHint>
|
||||
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<Select
|
||||
label="قالب"
|
||||
value={templateId}
|
||||
onChange={(e) => onTemplateChange(e.target.value)}
|
||||
options={[
|
||||
{ value: "", label: "بدون قالب (سفارشی)" },
|
||||
...templates.map((t) => ({ value: t.id, label: t.name })),
|
||||
]}
|
||||
/>
|
||||
<Field
|
||||
label="نام کارمند"
|
||||
required
|
||||
minLength={2}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="مثلاً برنامهنویس ارشد"
|
||||
/>
|
||||
<Select
|
||||
label="نقش"
|
||||
value={roleKey}
|
||||
onChange={(e) => setRoleKey(e.target.value as RoleKey)}
|
||||
options={Object.entries(ROLE_LABELS).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" loading={creating}>
|
||||
ساخت کارمند
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
{formError && (
|
||||
<div className="mt-3">
|
||||
<ErrorState title="ساخت کارمند ناموفق" message={formError} />
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<LoadingState label="در حال دریافت کارمندها…" />
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={load} />
|
||||
) : employees.length === 0 ? (
|
||||
<EmptyState
|
||||
title="هنوز کارمندی ساخته نشده است"
|
||||
description="یک قالب انتخاب کنید تا اولین کارمند هوش مصنوعی سازمان ساخته شود."
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||||
{employees.map((employee) => {
|
||||
const detail = details[employee.id];
|
||||
return (
|
||||
<Card key={employee.id} className="flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-sm font-medium text-slate-100">{employee.name}</p>
|
||||
<p className="text-xs text-slate-500">{ROLE_LABELS[employee.role_key]}</p>
|
||||
</div>
|
||||
<Badge tone={STATUS_TONES[employee.status] ?? "neutral"}>
|
||||
{employee.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<Badge tone="neutral">
|
||||
کارهای باز: {detail ? detail.open_task_count : "—"}
|
||||
</Badge>
|
||||
<Badge tone="neutral">
|
||||
نسخهٔ پرامپت: {detail ? detail.prompt_versions.length : "—"}
|
||||
</Badge>
|
||||
<Badge tone="neutral">
|
||||
ابزارها: {detail ? detail.tool_permissions.filter((p) => p.allowed).length : "—"}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<Link href={`/workforce/${employee.id}`} className="mt-auto">
|
||||
<Button variant="secondary" size="sm" className="w-full">
|
||||
تنظیمات و جزئیات
|
||||
</Button>
|
||||
</Link>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/*
|
||||
* Dark-first, RTL. The font stack prefers a Persian face when the user has one
|
||||
* installed and falls back to the system UI font — no network fetch at build
|
||||
* time, so `pnpm build` works offline.
|
||||
*/
|
||||
:root {
|
||||
--font-sans: Vazirmatn, "IRANSans", "Segoe UI", Tahoma, system-ui, -apple-system,
|
||||
"Noto Sans Arabic", sans-serif;
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-sans);
|
||||
background-color: #0b0d12;
|
||||
color: #e2e8f0;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* Numerals and latin fragments inside Persian text read better LTR. */
|
||||
.ltr-inline {
|
||||
direction: ltr;
|
||||
unicode-bidi: embed;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background-color: #2b3b63;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { SessionProvider } from "@/lib/session";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "AGY Web — پلتفرم نیروی کار هوش مصنوعی",
|
||||
description: "سازمان بسازید، کارمند هوش مصنوعی استخدام کنید و کارها را بسپارید.",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="fa" dir="rtl" className="dark">
|
||||
<body>
|
||||
<SessionProvider>{children}</SessionProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { Button, Card, CardHint, CardTitle, ErrorState, Field } from "@agy/ui";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { login, user, ready } = useSession();
|
||||
const [email, setEmail] = React.useState("");
|
||||
const [password, setPassword] = React.useState("");
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (ready && user) router.replace("/dashboard");
|
||||
}, [ready, user, router]);
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
router.replace("/dashboard");
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "ورود ناموفق بود.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-5">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardTitle>ورود به AGY Web</CardTitle>
|
||||
<CardHint>با ایمیل و رمز عبور خود وارد شوید.</CardHint>
|
||||
|
||||
<form onSubmit={onSubmit} className="mt-5 flex flex-col gap-4">
|
||||
<Field
|
||||
label="ایمیل"
|
||||
type="email"
|
||||
dir="ltr"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Field
|
||||
label="رمز عبور"
|
||||
type="password"
|
||||
dir="ltr"
|
||||
autoComplete="current-password"
|
||||
required
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
|
||||
{error && <ErrorState title="ورود ناموفق" message={error} />}
|
||||
|
||||
<Button type="submit" loading={submitting}>
|
||||
ورود
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-xs text-slate-400">
|
||||
حساب ندارید؟{" "}
|
||||
<Link href="/signup" className="text-accent hover:underline">
|
||||
ثبتنام کنید
|
||||
</Link>
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { Button, Card, CardHint, CardTitle, ErrorState, Field, LoadingState } from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
/** First-run flow: create the user's first organization. */
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter();
|
||||
const { ready, user, organizations, refresh } = useSession();
|
||||
const [name, setName] = React.useState("");
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (ready && !user) router.replace("/login");
|
||||
}, [ready, user, router]);
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await api.createOrganization(name.trim());
|
||||
await refresh();
|
||||
router.replace("/dashboard");
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "ساخت سازمان ناموفق بود.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!ready) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-8">
|
||||
<LoadingState />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-5">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardTitle>ساخت اولین سازمان</CardTitle>
|
||||
<CardHint>
|
||||
سازمان ظرف اصلی محصول است: اعضا، پروژهها، کارمندهای هوش مصنوعی و مصرف
|
||||
همه به سازمان تعلق دارند.
|
||||
</CardHint>
|
||||
|
||||
{organizations.length > 0 && (
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
<p className="text-xs text-slate-400">سازمانهای فعلی شما:</p>
|
||||
{organizations.map((org) => (
|
||||
<button
|
||||
key={org.id}
|
||||
type="button"
|
||||
onClick={() => router.replace("/dashboard")}
|
||||
className="rounded-lg border border-surface-border px-3 py-2 text-right text-xs text-slate-200 hover:bg-surface-overlay"
|
||||
>
|
||||
{org.name}
|
||||
</button>
|
||||
))}
|
||||
<div className="my-2 border-t border-surface-border" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={onSubmit} className="mt-4 flex flex-col gap-4">
|
||||
<Field
|
||||
label="نام سازمان"
|
||||
required
|
||||
minLength={2}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="مثلاً شرکت نمونه"
|
||||
/>
|
||||
{error && <ErrorState title="ساخت سازمان ناموفق" message={error} />}
|
||||
<Button type="submit" loading={submitting}>
|
||||
ساخت سازمان
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { LoadingState } from "@agy/ui";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
/** Entry point: route the visitor to the dashboard or the login screen. */
|
||||
export default function RootPage() {
|
||||
const { ready, user } = useSession();
|
||||
const router = useRouter();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!ready) return;
|
||||
router.replace(user ? "/dashboard" : "/login");
|
||||
}, [ready, user, router]);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-8">
|
||||
<LoadingState label="در حال بارگذاری…" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import * as React from "react";
|
||||
import { Button, Card, CardHint, CardTitle, ErrorState, Field } from "@agy/ui";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
export default function SignupPage() {
|
||||
const router = useRouter();
|
||||
const { signup } = useSession();
|
||||
const [name, setName] = React.useState("");
|
||||
const [email, setEmail] = React.useState("");
|
||||
const [password, setPassword] = React.useState("");
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await signup(email, password, name);
|
||||
router.replace("/onboarding");
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "ثبتنام ناموفق بود.");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center p-5">
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardTitle>ساخت حساب</CardTitle>
|
||||
<CardHint>پس از ثبتنام، اولین سازمان خود را میسازید.</CardHint>
|
||||
|
||||
<form onSubmit={onSubmit} className="mt-5 flex flex-col gap-4">
|
||||
<Field
|
||||
label="نام"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="نام شما"
|
||||
/>
|
||||
<Field
|
||||
label="ایمیل"
|
||||
type="email"
|
||||
dir="ltr"
|
||||
autoComplete="email"
|
||||
required
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<Field
|
||||
label="رمز عبور"
|
||||
type="password"
|
||||
dir="ltr"
|
||||
autoComplete="new-password"
|
||||
required
|
||||
minLength={8}
|
||||
hint="حداقل ۸ کاراکتر."
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
/>
|
||||
|
||||
{error && <ErrorState title="ثبتنام ناموفق" message={error} />}
|
||||
|
||||
<Button type="submit" loading={submitting}>
|
||||
ثبتنام
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-xs text-slate-400">
|
||||
قبلاً ثبتنام کردهاید؟{" "}
|
||||
<Link href="/login" className="text-accent hover:underline">
|
||||
ورود
|
||||
</Link>
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user