feat(integration): prepaid billing, engine UI, credits console, direct tasks, v2 final
- Prepaid credit system: org wallet, debit on usage, chat gate, admin credit console - Engine UI: /manager, /tasks, /tasks/[id], /workforce, /dashboard, /traces pages - Direct task creation (HTTP POST /tasks, MCP tasks_create) at pending_manager_review - Pause/resume/cancel/answer actions in engine API and UI - Employee cap (7 per org default), review pipeline guard (active reviewer required) - Scheduler: wide window prevents org starvation; archived employees excluded from cap - Tenant isolation: project-id cross-org 404, engine never uses another tenant's sandbox - Order route fix: manager built with pipeline states + employee profiles - Contract extension: ModelGrantPort.get_org_policy with DefaultOrgPolicy - Engine event types in @agy/contracts (task.step, task.gate, run.log, run.tool, team.changed) - api-engine.ts typed client; api-credit.ts client - Alembic migration c4d5e6f7a8b9: prepaid credit + engine UI tables - 859 tests passed, 1 skipped; ruff/mypy/tsc/eslint/vitest all green Refs: PLAN-v2.md §9 (T1–T43), todo-v2-final.md
This commit is contained in:
@@ -0,0 +1,417 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Platform owner console: the prepaid credit of every organization (D-B).
|
||||
*
|
||||
* The owner adds credit by hand. Each addition writes one history row and one audit row on
|
||||
* the server, and an organization's engine work runs again once its balance is above zero.
|
||||
* A balance at or below zero pauses that work, so its row says so. The page reloads after
|
||||
* each action: the wallet event goes to the organization's channel, not to the admin channel.
|
||||
*
|
||||
* Nothing here names a model or a provider. The admin layout guards the route; the API is
|
||||
* the real boundary.
|
||||
*/
|
||||
|
||||
import * as React from "react";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Drawer,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
Field,
|
||||
Modal,
|
||||
Stat,
|
||||
Table,
|
||||
type TableColumn,
|
||||
ToastStack,
|
||||
type ToastMessage,
|
||||
} from "@agy/ui";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { type CreditTransaction, type OrgBalance, creditApi } from "@/lib/api-credit";
|
||||
|
||||
/** Mirrors ``MAX_OWNER_CREDIT`` in ``app/application/services/credit_service.py``. */
|
||||
const MAX_OWNER_CREDIT = 100_000;
|
||||
/** Mirrors the service: a reason of 3 to 500 characters once trimmed. */
|
||||
const REASON_MIN = 3;
|
||||
const REASON_MAX = 500;
|
||||
|
||||
/** The open credit dialog: the organization, what was typed, and the step it is on. */
|
||||
interface CreditDraft {
|
||||
org: OrgBalance;
|
||||
amount: string;
|
||||
reason: string;
|
||||
step: "edit" | "confirm";
|
||||
}
|
||||
|
||||
/** The open history drawer for one organization. */
|
||||
interface HistoryView {
|
||||
org: OrgBalance;
|
||||
rows: CreditTransaction[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
function errorMessage(err: unknown, fallback: string): string {
|
||||
return err instanceof ApiError ? err.message : fallback;
|
||||
}
|
||||
|
||||
/** The typed amount as a number, or null when it is not a positive amount within the ceiling. */
|
||||
function parseAmount(text: string): number | null {
|
||||
if (text.trim() === "") return null;
|
||||
const value = Number(text);
|
||||
return Number.isFinite(value) && value > 0 && value <= MAX_OWNER_CREDIT ? value : null;
|
||||
}
|
||||
|
||||
function isReasonValid(text: string): boolean {
|
||||
const length = text.trim().length;
|
||||
return length >= REASON_MIN && length <= REASON_MAX;
|
||||
}
|
||||
|
||||
function money(value: number, currency: string): string {
|
||||
const formatted = value.toLocaleString("en-US", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 6,
|
||||
});
|
||||
return currency ? `${formatted} ${currency}` : formatted;
|
||||
}
|
||||
|
||||
function dateTime(iso: string | null): string {
|
||||
return iso ? new Date(iso).toLocaleString("fa-IR") : "—";
|
||||
}
|
||||
|
||||
function historyColumns(currency: string): Array<TableColumn<CreditTransaction>> {
|
||||
return [
|
||||
{
|
||||
key: "when",
|
||||
header: "زمان",
|
||||
render: (row) => <span className="text-xs text-slate-400">{dateTime(row.created_at)}</span>,
|
||||
},
|
||||
{
|
||||
key: "amount",
|
||||
header: "مبلغ",
|
||||
render: (row) => (
|
||||
<span className="ltr-inline text-xs text-slate-300">{money(row.amount, currency)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "reason",
|
||||
header: "دلیل",
|
||||
render: (row) => <span className="text-xs text-slate-200">{row.reason}</span>,
|
||||
},
|
||||
{
|
||||
key: "after",
|
||||
header: "موجودی پس از ثبت",
|
||||
render: (row) => (
|
||||
<span className="ltr-inline text-xs text-slate-300">
|
||||
{money(row.balance_after, currency)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default function AdminCreditsPage() {
|
||||
const [rows, setRows] = React.useState<OrgBalance[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [draft, setDraft] = React.useState<CreditDraft | null>(null);
|
||||
const [submitting, setSubmitting] = React.useState(false);
|
||||
const [draftError, setDraftError] = React.useState<string | null>(null);
|
||||
const [history, setHistory] = React.useState<HistoryView | null>(null);
|
||||
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
|
||||
|
||||
const toast = React.useCallback(
|
||||
(title: string, tone: ToastMessage["tone"] = "success", description?: string) => {
|
||||
setToasts((current) => [
|
||||
...current,
|
||||
{ id: `${Date.now()}-${Math.random().toString(16).slice(2)}`, title, tone, description },
|
||||
]);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// A reload after an action keeps the rows on screen; only the first load shows the loading state.
|
||||
const load = React.useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
setRows(await creditApi.balances());
|
||||
} catch (err) {
|
||||
setError(errorMessage(err, "دریافت اعتبار سازمانها ناموفق بود."));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const openCredit = (org: OrgBalance) => {
|
||||
setDraft({ org, amount: "", reason: "", step: "edit" });
|
||||
setDraftError(null);
|
||||
};
|
||||
|
||||
const closeCredit = () => {
|
||||
if (submitting) return;
|
||||
setDraft(null);
|
||||
setDraftError(null);
|
||||
};
|
||||
|
||||
const continueCredit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!draft || parseAmount(draft.amount) === null || !isReasonValid(draft.reason)) return;
|
||||
setDraft({ ...draft, step: "confirm" });
|
||||
};
|
||||
|
||||
const submitCredit = async () => {
|
||||
if (!draft) return;
|
||||
const amount = parseAmount(draft.amount);
|
||||
if (amount === null || !isReasonValid(draft.reason)) return;
|
||||
const { org } = draft;
|
||||
setSubmitting(true);
|
||||
setDraftError(null);
|
||||
try {
|
||||
const transaction = await creditApi.credit(org.organization_id, {
|
||||
amount,
|
||||
reason: draft.reason.trim(),
|
||||
});
|
||||
setDraft(null);
|
||||
toast(
|
||||
"اعتبار افزوده شد",
|
||||
"success",
|
||||
`${money(transaction.amount, org.currency)} به «${org.name}» افزوده شد. موجودی: ${money(transaction.balance_after, org.currency)}`,
|
||||
);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setDraftError(errorMessage(err, "افزودن اعتبار ناموفق بود."));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openHistory = async (org: OrgBalance) => {
|
||||
setHistory({ org, rows: [], loading: true, error: null });
|
||||
try {
|
||||
const next = await creditApi.history(org.organization_id);
|
||||
setHistory((current) =>
|
||||
current?.org.organization_id === org.organization_id
|
||||
? { ...current, rows: next, loading: false, error: null }
|
||||
: current,
|
||||
);
|
||||
} catch (err) {
|
||||
const message = errorMessage(err, "دریافت تاریخچه ناموفق بود.");
|
||||
setHistory((current) =>
|
||||
current?.org.organization_id === org.organization_id
|
||||
? { ...current, loading: false, error: message }
|
||||
: current,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: Array<TableColumn<OrgBalance>> = [
|
||||
{
|
||||
key: "name",
|
||||
header: "نام",
|
||||
render: (row) => <span className="text-slate-100">{row.name}</span>,
|
||||
},
|
||||
{
|
||||
key: "slug",
|
||||
header: "شناسه",
|
||||
render: (row) => <span className="ltr-inline text-xs text-slate-500">{row.slug}</span>,
|
||||
},
|
||||
{
|
||||
key: "balance",
|
||||
header: "اعتبار",
|
||||
render: (row) => (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="ltr-inline text-xs text-slate-300">
|
||||
{money(row.balance, row.currency)}
|
||||
</span>
|
||||
{row.balance <= 0 && <Badge tone="danger">بدون اعتبار</Badge>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "paused",
|
||||
header: "کارهای متوقف",
|
||||
align: "center",
|
||||
render: (row) => row.paused_tasks,
|
||||
},
|
||||
{
|
||||
key: "last",
|
||||
header: "آخرین افزایش",
|
||||
render: (row) => (
|
||||
<span className="text-xs text-slate-400">{dateTime(row.last_credit_at)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "actions",
|
||||
header: "عملیات",
|
||||
align: "end",
|
||||
render: (row) => (
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button size="sm" onClick={() => openCredit(row)}>
|
||||
افزودن اعتبار
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => void openHistory(row)}>
|
||||
تاریخچه
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const withoutCredit = rows.filter((row) => row.balance <= 0).length;
|
||||
const pausedTotal = rows.reduce((sum, row) => sum + row.paused_tasks, 0);
|
||||
|
||||
const amountValue = draft ? parseAmount(draft.amount) : null;
|
||||
const reasonOk = draft ? isReasonValid(draft.reason) : false;
|
||||
const canContinue = amountValue !== null && reasonOk;
|
||||
const amountInvalid = draft !== null && draft.amount.trim() !== "" && amountValue === null;
|
||||
const reasonInvalid = draft !== null && draft.reason.trim() !== "" && !reasonOk;
|
||||
|
||||
return (
|
||||
<div className="anim-stagger 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>
|
||||
|
||||
{!loading && !error && (
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<Stat label="تعداد سازمانها" value={rows.length} />
|
||||
<Stat
|
||||
label="بدون اعتبار"
|
||||
value={withoutCredit}
|
||||
tone={withoutCredit > 0 ? "danger" : "neutral"}
|
||||
/>
|
||||
<Stat label="کارهای متوقف" value={pausedTotal} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Table
|
||||
caption="فهرست سازمانها و اعتبار آنها"
|
||||
columns={columns}
|
||||
rows={rows}
|
||||
rowKey={(row) => row.organization_id}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRetry={() => void load()}
|
||||
empty={
|
||||
<EmptyState
|
||||
title="سازمانی وجود ندارد"
|
||||
description="هنوز هیچ سازمانی ساخته نشده است."
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
{draft && (
|
||||
<Modal
|
||||
open
|
||||
title={`افزودن اعتبار به «${draft.org.name}»`}
|
||||
description={
|
||||
draft.step === "edit"
|
||||
? "مبلغ و دلیل را وارد کنید. در مرحلهٔ بعد تأیید میخواهیم."
|
||||
: "مبلغ و دلیل را بررسی کنید."
|
||||
}
|
||||
onClose={closeCredit}
|
||||
footer={
|
||||
draft.step === "edit" ? (
|
||||
<>
|
||||
<Button variant="secondary" onClick={closeCredit}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button type="submit" form="credit-form" disabled={!canContinue}>
|
||||
ادامه
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
disabled={submitting}
|
||||
onClick={() => setDraft({ ...draft, step: "edit" })}
|
||||
>
|
||||
بازگشت
|
||||
</Button>
|
||||
<Button loading={submitting} onClick={() => void submitCredit()}>
|
||||
تأیید و افزودن
|
||||
</Button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{draft.step === "edit" ? (
|
||||
<form id="credit-form" onSubmit={continueCredit} className="flex flex-col gap-4">
|
||||
<Field
|
||||
label="مبلغ"
|
||||
inputMode="decimal"
|
||||
className="ltr-inline"
|
||||
placeholder="مثلاً 50"
|
||||
value={draft.amount}
|
||||
onChange={(event) => setDraft({ ...draft, amount: event.target.value })}
|
||||
error={
|
||||
amountInvalid
|
||||
? `مبلغ باید عددی بیشتر از صفر و حداکثر ${MAX_OWNER_CREDIT.toLocaleString("en-US")} باشد.`
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<Field
|
||||
label="دلیل"
|
||||
maxLength={REASON_MAX}
|
||||
placeholder="مثلاً واریز بانکی"
|
||||
value={draft.reason}
|
||||
onChange={(event) => setDraft({ ...draft, reason: event.target.value })}
|
||||
hint="این دلیل در تاریخچه و گزارش ممیزی ثبت میشود."
|
||||
error={reasonInvalid ? `دلیل باید حداقل ${REASON_MIN} نویسه باشد.` : undefined}
|
||||
/>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-slate-200">
|
||||
مبلغ{" "}
|
||||
<span className="ltr-inline">
|
||||
{money(amountValue ?? 0, draft.org.currency)}
|
||||
</span>{" "}
|
||||
به اعتبار «{draft.org.name}» افزوده شود؟
|
||||
</p>
|
||||
<p className="text-xs text-slate-400">دلیل: {draft.reason.trim()}</p>
|
||||
<p className="text-xs text-slate-500">
|
||||
این عملیات در تاریخچهٔ اعتبار و گزارش ممیزی ثبت میشود.
|
||||
</p>
|
||||
{draftError && <ErrorState title="افزودن اعتبار ناموفق" message={draftError} />}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
<Drawer open={history !== null} title="تاریخچهٔ اعتبار" onClose={() => setHistory(null)}>
|
||||
{history && (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm font-medium text-slate-200">{history.org.name}</p>
|
||||
<Table
|
||||
caption={`تاریخچهٔ اعتبار «${history.org.name}»`}
|
||||
columns={historyColumns(history.org.currency)}
|
||||
rows={history.rows}
|
||||
rowKey={(row) => row.id}
|
||||
loading={history.loading}
|
||||
error={history.error}
|
||||
onRetry={() => void openHistory(history.org)}
|
||||
empty={<EmptyState title="هنوز اعتبار دستی ثبت نشده است." />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
<ToastStack
|
||||
toasts={toasts}
|
||||
onDismiss={(id) => setToasts((current) => current.filter((item) => item.id !== id))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -67,7 +67,7 @@ export default function AdminModelGrantsPage() {
|
||||
const [toasts, setToasts] = React.useState<ToastMessage[]>([]);
|
||||
|
||||
const [budget, setBudget] = React.useState("");
|
||||
const [maxEmployees, setMaxEmployees] = React.useState("5");
|
||||
const [maxEmployees, setMaxEmployees] = React.useState("7");
|
||||
|
||||
const toast = React.useCallback(
|
||||
(title: string, tone: ToastMessage["tone"] = "success", description?: string) => {
|
||||
|
||||
@@ -21,8 +21,8 @@ import {
|
||||
Stat,
|
||||
UsageBar,
|
||||
} from "@agy/ui";
|
||||
import { CreditNotice, useEngineStatus } from "@/components/engine/credit-notice";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { CAN_MANAGE_BILLING, roleHasCapability } from "@/lib/permissions";
|
||||
import { useLiveChannel } from "@/lib/realtime";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
@@ -53,8 +53,14 @@ function money(value: number, currency = ""): string {
|
||||
return currency ? `${formatted} ${currency}` : formatted;
|
||||
}
|
||||
|
||||
/** The engine's credit notice for the active organization; empty while credit is there. */
|
||||
function BillingCreditNotice({ orgId }: { orgId: string }) {
|
||||
const { status } = useEngineStatus(orgId);
|
||||
return <CreditNotice status={status} />;
|
||||
}
|
||||
|
||||
export default function BillingPage() {
|
||||
const { activeOrganization } = useSession();
|
||||
const { activeOrganization, user } = useSession();
|
||||
const orgId = activeOrganization?.id ?? null;
|
||||
|
||||
const [summary, setSummary] = React.useState<BillingSummary | null>(null);
|
||||
@@ -189,7 +195,6 @@ export default function BillingPage() {
|
||||
if (error) return <ErrorState message={error} onRetry={load} />;
|
||||
|
||||
const currency = summary?.currency ?? "";
|
||||
const canManage = roleHasCapability(activeOrganization.role, CAN_MANAGE_BILLING);
|
||||
|
||||
return (
|
||||
<div className="anim-stagger flex flex-col gap-5">
|
||||
@@ -201,6 +206,8 @@ export default function BillingPage() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<BillingCreditNotice orgId={activeOrganization.id} />
|
||||
|
||||
{summary?.low_balance && (
|
||||
<Card className="border-warning/50">
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -223,11 +230,11 @@ export default function BillingPage() {
|
||||
<Stat label="تعداد ردیف مصرف" value={events.length} hint="۵۰ ردیف آخر" />
|
||||
</div>
|
||||
|
||||
{canManage && (
|
||||
{user?.is_platform_owner && (
|
||||
<Card>
|
||||
<CardTitle>افزایش اعتبار</CardTitle>
|
||||
<CardHint>
|
||||
افزایش اعتبار دستی توسط مدیر سازمان. اگر درگاه پرداخت متصل باشد، میتوانید از مسیر
|
||||
افزایش اعتبار دستی توسط مالک سامانه. اگر درگاه پرداخت متصل باشد، میتوانید از مسیر
|
||||
پرداخت آنلاین هم استفاده کنید.
|
||||
</CardHint>
|
||||
<form onSubmit={onAddCredit} className="mt-4 flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
@@ -270,6 +277,15 @@ export default function BillingPage() {
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{!user?.is_platform_owner && (
|
||||
<Card>
|
||||
<CardHint>
|
||||
اعتبار سازمان به صورت پیشپرداخت توسط مالک سامانه افزوده میشود. هر مصرف از همین اعتبار
|
||||
کم میشود.
|
||||
</CardHint>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardTitle>تفکیک بر اساس واحد مصرف</CardTitle>
|
||||
{!summary || summary.by_unit.length === 0 ? (
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The engine's summary on the dashboard: the credit balance (with the notice when work has
|
||||
* stopped), the active tasks, how many tasks sit in each board column, and the latest completed
|
||||
* tasks.
|
||||
*
|
||||
* The credit and the stage counts come from ``useEngineStatus``. The two task lists come from
|
||||
* ``engineApi.listTasks``, loaded again on every ``task.step`` of the organization. A legacy task
|
||||
* (no engine stage) is never listed here: the engine does not own it.
|
||||
*/
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
import { Badge, Card, CardHint, CardTitle, ErrorState, Stat } from "@agy/ui";
|
||||
import { CreditNotice, useEngineStatus } from "@/components/engine/credit-notice";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { type EngineTask, engineApi } from "@/lib/api-engine";
|
||||
import { BOARD_COLUMNS, STAGE_LABELS, stageTone } from "@/lib/engine-labels";
|
||||
import { useLiveChannel } from "@/lib/realtime";
|
||||
|
||||
/** How many tasks each list shows. */
|
||||
const LIST_LIMIT = 5;
|
||||
|
||||
/** A task at one of these stages has finished, so it is not active. */
|
||||
const FINISHED: ReadonlySet<string> = new Set(["completed", "cancelled", "failed"]);
|
||||
|
||||
interface EngineTasksState {
|
||||
tasks: EngineTask[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** The organization's engine tasks, loaded again on every ``task.step``. */
|
||||
function useEngineTasks(orgId: string): EngineTasksState {
|
||||
const [tasks, setTasks] = React.useState<EngineTask[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
try {
|
||||
setTasks(await engineApi.listTasks(orgId));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "فهرست کارها دریافت نشد.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orgId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useLiveChannel([`org:${orgId}`], (event) => {
|
||||
if (event.type === "task.step") void load();
|
||||
});
|
||||
|
||||
return { tasks, loading, error };
|
||||
}
|
||||
|
||||
/** An engine task (one with a stage) that has not finished yet. */
|
||||
function isActive(task: EngineTask): boolean {
|
||||
return task.stage !== null && !FINISHED.has(task.stage);
|
||||
}
|
||||
|
||||
function TaskList({
|
||||
tasks,
|
||||
empty,
|
||||
meta,
|
||||
}: {
|
||||
tasks: EngineTask[];
|
||||
empty: string;
|
||||
meta: (task: EngineTask) => React.ReactNode;
|
||||
}) {
|
||||
if (tasks.length === 0) return <p className="text-xs text-slate-500">{empty}</p>;
|
||||
return (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{tasks.map((task) => (
|
||||
<li
|
||||
key={task.id}
|
||||
className="flex flex-wrap items-center justify-between gap-2 rounded-lg border border-surface-border px-3 py-2"
|
||||
>
|
||||
<Link
|
||||
href={`/tasks/${task.id}`}
|
||||
className="min-w-0 truncate text-xs text-slate-200 transition-colors hover:text-accent"
|
||||
>
|
||||
{task.title}
|
||||
</Link>
|
||||
{meta(task)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function EngineWidgets({ orgId }: { orgId: string }) {
|
||||
const engine = useEngineStatus(orgId);
|
||||
const board = useEngineTasks(orgId);
|
||||
const { status } = engine;
|
||||
|
||||
if (status === null) {
|
||||
return (
|
||||
<section aria-label="کارمندان هوش مصنوعی">
|
||||
{engine.error ? (
|
||||
<ErrorState
|
||||
title="وضعیت کارمندان دریافت نشد."
|
||||
message={engine.error}
|
||||
onRetry={engine.reload}
|
||||
/>
|
||||
) : (
|
||||
<p className="text-xs text-slate-500">در حال دریافت وضعیت کارمندان…</p>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const activeAll = board.tasks.filter(isActive);
|
||||
const active = activeAll.slice(0, LIST_LIMIT);
|
||||
const completed = board.tasks
|
||||
.filter((task) => task.stage === "completed")
|
||||
.sort((a, b) => Date.parse(b.updated_at) - Date.parse(a.updated_at))
|
||||
.slice(0, LIST_LIMIT);
|
||||
|
||||
const columns = BOARD_COLUMNS.map((column) => ({
|
||||
key: column.key,
|
||||
label: column.label,
|
||||
count: column.stages.reduce((sum, stage) => sum + (status.stage_counts[stage] ?? 0), 0),
|
||||
}));
|
||||
const largest = Math.max(1, ...columns.map((column) => column.count));
|
||||
|
||||
return (
|
||||
<section aria-label="کارمندان هوش مصنوعی" className="flex flex-col gap-4">
|
||||
<CreditNotice status={status} />
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||
<Stat
|
||||
label="موجودی اعتبار"
|
||||
value={
|
||||
<>
|
||||
{status.balance.toLocaleString("fa-IR")}{" "}
|
||||
<span className="ltr-inline text-sm text-slate-400">{status.currency}</span>
|
||||
</>
|
||||
}
|
||||
tone={status.credit_ok ? "neutral" : "danger"}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{board.error && (
|
||||
<p role="alert" className="text-xs text-danger">
|
||||
{board.error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardTitle>کارهای فعال</CardTitle>
|
||||
<CardHint>کارهایی که هنوز کامل نشدهاند. برای جزئیات، عنوان کار را بزنید.</CardHint>
|
||||
<div className="mt-4">
|
||||
{board.loading && <p className="text-xs text-slate-500">در حال دریافت…</p>}
|
||||
{!board.loading && !board.error && (
|
||||
<TaskList
|
||||
tasks={active}
|
||||
empty="کار فعالی وجود ندارد."
|
||||
meta={(task) =>
|
||||
task.stage && (
|
||||
<Badge tone={stageTone(task.stage)}>{STAGE_LABELS[task.stage]}</Badge>
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!board.loading && !board.error && activeAll.length > LIST_LIMIT && (
|
||||
<p className="mt-2 text-xs text-slate-500">
|
||||
و {(activeAll.length - LIST_LIMIT).toLocaleString("fa-IR")} کار دیگر
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle>توزیع مراحل</CardTitle>
|
||||
<CardHint>تعداد کارها در هر ستون تابلوی کارها.</CardHint>
|
||||
<ul aria-label="توزیع مراحل" className="mt-4 flex flex-col gap-3">
|
||||
{columns.map((column) => (
|
||||
<li key={column.key} className="flex flex-col gap-1.5">
|
||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||
<span>{column.label}</span>
|
||||
<span className="text-slate-200">{column.count.toLocaleString("fa-IR")}</span>
|
||||
</div>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="h-1.5 w-full overflow-hidden rounded-full bg-surface-overlay"
|
||||
>
|
||||
<div
|
||||
className="h-full rounded-full bg-accent transition-[width] duration-500 ease-out"
|
||||
style={{ width: `${Math.round((column.count / largest) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardTitle>آخرین کارهای انجامشده</CardTitle>
|
||||
<CardHint>پنج کار آخری که به مرحلهٔ «انجام شد» رسیدهاند.</CardHint>
|
||||
<div className="mt-4">
|
||||
{board.loading && <p className="text-xs text-slate-500">در حال دریافت…</p>}
|
||||
{!board.loading && !board.error && (
|
||||
<TaskList
|
||||
tasks={completed}
|
||||
empty="هنوز کاری انجام نشده است."
|
||||
meta={(task) => (
|
||||
<span className="text-xs text-slate-500">
|
||||
{new Date(task.updated_at).toLocaleString("fa-IR")}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { useLiveChannel } from "@/lib/realtime";
|
||||
import { useSession } from "@/lib/session";
|
||||
import { EngineWidgets } from "./_components/engine-widgets";
|
||||
|
||||
export default function DashboardPage() {
|
||||
const { activeOrganization, user } = useSession();
|
||||
@@ -105,6 +106,8 @@ export default function DashboardPage() {
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<EngineWidgets orgId={activeOrganization.id} />
|
||||
|
||||
<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" />
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The tasks that wait for a person's answer, shown at the top of the manager page.
|
||||
*
|
||||
* A task at ``pending_user_decision`` has asked a question. Each one gets its own answer box and
|
||||
* «ارسال پاسخ», which calls ``engineApi.answerTask``. A refusal from the server is shown in Persian
|
||||
* under its box. The banner is hidden while no task waits for an answer.
|
||||
*/
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
import { Button, Card, CardTitle } from "@agy/ui";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { type EngineTask, engineApi } from "@/lib/api-engine";
|
||||
|
||||
/** The server accepts an answer of 1 to 4,000 characters. */
|
||||
const MAX_ANSWER_LENGTH = 4000;
|
||||
|
||||
export interface DecisionBannerProps {
|
||||
orgId: string;
|
||||
/** Any tasks of the organization; only those at ``pending_user_decision`` are shown. */
|
||||
tasks: EngineTask[];
|
||||
/** Called once an answer is accepted, so the page reloads its task list. */
|
||||
onAnswered: () => void;
|
||||
}
|
||||
|
||||
export function DecisionBanner({ orgId, tasks, onAnswered }: DecisionBannerProps) {
|
||||
const waiting = tasks.filter((task) => task.stage === "pending_user_decision");
|
||||
if (waiting.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col gap-3 border-warning/50">
|
||||
<CardTitle>در انتظار تصمیم شما</CardTitle>
|
||||
<ul className="flex flex-col gap-4">
|
||||
{waiting.map((task) => (
|
||||
<li key={task.id}>
|
||||
<DecisionForm orgId={orgId} task={task} onAnswered={onAnswered} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface DecisionFormProps {
|
||||
orgId: string;
|
||||
task: EngineTask;
|
||||
onAnswered: () => void;
|
||||
}
|
||||
|
||||
function DecisionForm({ orgId, task, onAnswered }: DecisionFormProps) {
|
||||
const [answer, setAnswer] = React.useState("");
|
||||
const [sending, setSending] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const content = answer.trim();
|
||||
const fieldId = `decision-${task.id}`;
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!content || sending) return;
|
||||
setSending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await engineApi.answerTask(orgId, task.id, content);
|
||||
setAnswer("");
|
||||
onAnswered();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "پاسخ ارسال نشد. دوباره تلاش کنید.");
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-2">
|
||||
<Link href={`/tasks/${task.id}`} className="text-sm text-slate-100 hover:text-accent">
|
||||
{task.title}
|
||||
</Link>
|
||||
<label htmlFor={fieldId} className="text-xs font-medium text-slate-300">
|
||||
پاسخ شما
|
||||
</label>
|
||||
<textarea
|
||||
id={fieldId}
|
||||
value={answer}
|
||||
onChange={(event) => setAnswer(event.target.value)}
|
||||
rows={3}
|
||||
maxLength={MAX_ANSWER_LENGTH}
|
||||
disabled={sending}
|
||||
placeholder="پاسخ خود را بنویسید…"
|
||||
className="w-full rounded-lg border border-surface-border bg-surface px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
|
||||
/>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" size="sm" loading={sending} disabled={!content}>
|
||||
ارسال پاسخ
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The tasks of the active conversation, shown as compact cards under its messages.
|
||||
*
|
||||
* An engine task shows its Persian stage (``STAGE_LABELS``, toned by ``stageTone``). A legacy
|
||||
* task has no engine stage, so it shows its coarse status instead. Each card links to the task
|
||||
* page. The manager page patches a card's stage in place on ``task.step``, so the badge moves
|
||||
* without a reload.
|
||||
*/
|
||||
import Link from "next/link";
|
||||
import { Badge } from "@agy/ui";
|
||||
import type { EngineTask } from "@/lib/api-engine";
|
||||
import { STAGE_LABELS, type StageTone, stageTone } from "@/lib/engine-labels";
|
||||
|
||||
/** A legacy task has no stage; these are the Persian names of its coarse statuses. */
|
||||
const LEGACY_LABELS: Record<string, string> = {
|
||||
new: "جدید",
|
||||
triaged: "واگذار شده",
|
||||
waiting_approval: "در انتظار تأیید",
|
||||
running: "در حال اجرا",
|
||||
done: "انجام شد",
|
||||
failed: "ناموفق",
|
||||
cancelled: "لغو شد",
|
||||
};
|
||||
|
||||
function legacyTone(status: string): StageTone {
|
||||
if (status === "done") return "success";
|
||||
if (status === "failed" || status === "cancelled") return "danger";
|
||||
if (status === "waiting_approval") return "warning";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
export interface ThreadTaskCardsProps {
|
||||
/** The tasks of the active thread, newest first. Nothing is shown for an empty list. */
|
||||
tasks: EngineTask[];
|
||||
}
|
||||
|
||||
export function ThreadTaskCards({ tasks }: ThreadTaskCardsProps) {
|
||||
if (tasks.length === 0) return null;
|
||||
return (
|
||||
<section className="flex flex-col gap-2">
|
||||
<p className="text-xs font-medium text-slate-400">کارهای این گفتگو</p>
|
||||
<ul className="flex flex-col gap-2">
|
||||
{tasks.map((task) => (
|
||||
<li key={task.id}>
|
||||
<TaskCardLink task={task} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function TaskCardLink({ task }: { task: EngineTask }) {
|
||||
const label =
|
||||
task.stage === null
|
||||
? (LEGACY_LABELS[task.status] ?? task.status)
|
||||
: (STAGE_LABELS[task.stage] ?? task.stage);
|
||||
const tone = task.stage === null ? legacyTone(task.status) : stageTone(task.stage);
|
||||
return (
|
||||
<Link
|
||||
href={`/tasks/${task.id}`}
|
||||
className="flex flex-col gap-1 rounded-lg border border-surface-border bg-surface-overlay px-3 py-2 text-xs transition-colors hover:border-accent/50"
|
||||
>
|
||||
<span className="flex items-start justify-between gap-2">
|
||||
<span className="min-w-0 break-words text-slate-100">{task.title}</span>
|
||||
<Badge tone={tone}>{label}</Badge>
|
||||
</span>
|
||||
{task.assignee_name && <span className="text-slate-500">{task.assignee_name}</span>}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
import type { Employee, Message, Task, TaskStatus, Thread } from "@agy/contracts";
|
||||
import type { Employee, Message, TaskStepEventData, Thread } from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
@@ -15,10 +13,20 @@ import {
|
||||
LoadingState,
|
||||
Select,
|
||||
} from "@agy/ui";
|
||||
import { CreditNotice, useEngineStatus } from "@/components/engine/credit-notice";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { type EngineStage, type EngineTask, engineApi } from "@/lib/api-engine";
|
||||
import { CAN_RUN_TASKS, roleHasCapability } from "@/lib/permissions";
|
||||
import { useLiveChannel } from "@/lib/realtime";
|
||||
import { useSession } from "@/lib/session";
|
||||
import { DecisionBanner } from "./_components/decision-banner";
|
||||
import { ThreadTaskCards } from "./_components/task-cards";
|
||||
|
||||
/** The credit notice of one organization; its own component, so no status call runs without one. */
|
||||
function EngineCreditNotice({ orgId }: { orgId: string }) {
|
||||
const { status } = useEngineStatus(orgId);
|
||||
return <CreditNotice status={status} />;
|
||||
}
|
||||
|
||||
/**
|
||||
* Manager Chat — three columns, matching the plan: conversations, the
|
||||
@@ -32,7 +40,8 @@ export default function ManagerPage() {
|
||||
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 [tasks, setTasks] = React.useState<EngineTask[]>([]);
|
||||
const [tasksError, setTasksError] = React.useState<string | null>(null);
|
||||
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [loadingThread, setLoadingThread] = React.useState(false);
|
||||
@@ -49,7 +58,18 @@ export default function ManagerPage() {
|
||||
|
||||
const bottomRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Live feed: new messages in the active thread, and tasks created from chat.
|
||||
// The engine's task list: the conversation's cards and the decision banner read it.
|
||||
const reloadTasks = React.useCallback(async () => {
|
||||
if (!orgId) return;
|
||||
try {
|
||||
setTasks(await engineApi.listTasks(orgId));
|
||||
setTasksError(null);
|
||||
} catch (err) {
|
||||
setTasksError(err instanceof ApiError ? err.message : "دریافت کارها ناموفق بود.");
|
||||
}
|
||||
}, [orgId]);
|
||||
|
||||
// Live feed: new messages in the active thread, tasks created, and stage moves.
|
||||
const liveChannels = React.useMemo(() => {
|
||||
const base: string[] = [];
|
||||
if (orgId) base.push(`org:${orgId}`);
|
||||
@@ -79,26 +99,18 @@ export default function ManagerPage() {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "task.created": {
|
||||
const data = event.data as { task_id: string; title: string; status: string };
|
||||
setTasks((current) => {
|
||||
if (current.some((t) => t.id === data.task_id)) return current;
|
||||
return [
|
||||
{
|
||||
id: data.task_id,
|
||||
organization_id: orgId ?? "",
|
||||
project_id: null,
|
||||
thread_id: activeThreadId,
|
||||
title: data.title,
|
||||
description: "",
|
||||
status: data.status as TaskStatus,
|
||||
assigned_employee_id: null,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
...current,
|
||||
];
|
||||
});
|
||||
case "task.created":
|
||||
void reloadTasks();
|
||||
break;
|
||||
case "task.step": {
|
||||
const data = event.data as unknown as TaskStepEventData;
|
||||
setTasks((current) =>
|
||||
current.map((task) =>
|
||||
task.id === data.task_id
|
||||
? { ...task, stage: data.to as EngineStage, status: data.status }
|
||||
: task,
|
||||
),
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
@@ -117,11 +129,12 @@ export default function ManagerPage() {
|
||||
const [nextThreads, nextEmployees, nextTasks] = await Promise.all([
|
||||
api.listThreads(orgId),
|
||||
api.listEmployees(orgId),
|
||||
api.listTasks(orgId),
|
||||
engineApi.listTasks(orgId),
|
||||
]);
|
||||
setThreads(nextThreads);
|
||||
setEmployees(nextEmployees);
|
||||
setTasks(nextTasks);
|
||||
setTasksError(null);
|
||||
setActiveThreadId((current) => current ?? nextThreads[0]?.id ?? null);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت گفتگوها ناموفق بود.");
|
||||
@@ -196,13 +209,13 @@ export default function ManagerPage() {
|
||||
setTaskError(null);
|
||||
setCreatingTask(true);
|
||||
try {
|
||||
const task = await api.createTask(orgId, {
|
||||
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]);
|
||||
void reloadTasks();
|
||||
setTaskDraft("");
|
||||
} catch (err) {
|
||||
setTaskError(err instanceof ApiError ? err.message : "ساخت کار ناموفق بود.");
|
||||
@@ -240,6 +253,13 @@ export default function ManagerPage() {
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
{orgId && (
|
||||
<>
|
||||
<EngineCreditNotice orgId={orgId} />
|
||||
<DecisionBanner orgId={orgId} tasks={tasks} onAnswered={() => void reloadTasks()} />
|
||||
</>
|
||||
)}
|
||||
|
||||
<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">
|
||||
@@ -293,6 +313,8 @@ export default function ManagerPage() {
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<ThreadTaskCards tasks={threadTasks} />
|
||||
{tasksError && <ErrorState title="دریافت کارها ناموفق بود" message={tasksError} />}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
@@ -318,29 +340,16 @@ export default function ManagerPage() {
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
{/* Column 3 — the work this conversation produced */}
|
||||
{/* Column 3 — a new task. The conversation's tasks are the cards under its messages. */}
|
||||
<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>
|
||||
<CardTitle>کار جدید</CardTitle>
|
||||
{canRun ? (
|
||||
<CardHint>
|
||||
کار جدید نخست به مدیر هوش مصنوعی میرسد. مدیر آن را بررسی میکند و در صورت نیاز به
|
||||
کارمند مسئول میسپارد.
|
||||
</CardHint>
|
||||
) : (
|
||||
<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>
|
||||
<CardHint>ساخت کار فقط برای اعضایی ممکن است که اجازهٔ اجرای کار دارند.</CardHint>
|
||||
)}
|
||||
|
||||
{canRun && (
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The last diff of one engine task, read-only, and the sha the change was merged and pushed as.
|
||||
*
|
||||
* The diff reads left to right in a monospace block: a line that starts with `+` is green, one that
|
||||
* starts with `-` is red, and a `@@` hunk header is muted. The text is the engine's and renders as
|
||||
* text only. Once the task is merged, the short sha shows with a link to the app's pipeline view.
|
||||
*/
|
||||
import Link from "next/link";
|
||||
import { cx } from "@agy/ui";
|
||||
|
||||
export interface DiffViewerProps {
|
||||
/** The task's last diff in unified format; an empty string when none was recorded. */
|
||||
diff: string;
|
||||
/** The sha the change was merged and pushed as; an empty string before that. */
|
||||
mergedSha: string;
|
||||
}
|
||||
|
||||
function lineClass(line: string): string {
|
||||
if (line.startsWith("+")) return "bg-positive/10 text-positive";
|
||||
if (line.startsWith("-")) return "bg-danger/10 text-danger";
|
||||
if (line.startsWith("@@")) return "text-slate-500";
|
||||
return "text-slate-300";
|
||||
}
|
||||
|
||||
export function DiffViewer({ diff, mergedSha }: DiffViewerProps) {
|
||||
const text = diff.replace(/\n+$/, "");
|
||||
const lines = text ? text.split("\n") : [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{mergedSha && (
|
||||
<p className="flex flex-wrap items-center gap-2 text-xs text-slate-300">
|
||||
<span>ادغام و ارسال شد: </span>
|
||||
<span dir="ltr" className="font-mono text-slate-100">
|
||||
{mergedSha.slice(0, 12)}
|
||||
</span>
|
||||
<Link href="/app" className="text-accent hover:underline">
|
||||
مشاهدهٔ خط لوله در صفحهٔ اپلیکیشن
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
{lines.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">هنوز تغییری ثبت نشده است.</p>
|
||||
) : (
|
||||
<pre
|
||||
dir="ltr"
|
||||
className="max-h-[32rem] overflow-auto rounded-xl border border-surface-border bg-surface p-3 font-mono text-xs leading-5"
|
||||
>
|
||||
{lines.map((line, index) => (
|
||||
<span key={index} className={cx("block whitespace-pre", lineClass(line))}>
|
||||
{line || " "}
|
||||
</span>
|
||||
))}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The gate verdicts of one engine task, one row per verdict in the order the engine recorded them.
|
||||
*
|
||||
* Each row shows the gate's Persian name, a green or red badge, when the verdict was recorded, and
|
||||
* a button that opens its detail. The detail is the engine's own text, shown as text in a `pre`.
|
||||
* When the tests were waived, the reason shows in a warning box.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { Badge } from "@agy/ui";
|
||||
import type { EngineGate } from "@/lib/api-engine";
|
||||
|
||||
/** The Persian name of each gate the engine runs. An unknown gate shows its own key. */
|
||||
const GATE_LABELS: Record<string, string> = {
|
||||
tests: "تستها",
|
||||
diff_guard: "نگهبان تغییرات",
|
||||
no_progress: "بدون پیشرفت",
|
||||
green: "دروازهٔ سبز",
|
||||
lint: "لینت",
|
||||
};
|
||||
|
||||
export interface GatesPanelProps {
|
||||
gates: readonly EngineGate[];
|
||||
/** The reason the tests were waived; an empty string when they were not. */
|
||||
waiverReason: string;
|
||||
}
|
||||
|
||||
function formatTime(value: string): string {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString("fa-IR");
|
||||
}
|
||||
|
||||
function GateRow({ gate }: { gate: EngineGate }) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const detailId = React.useId();
|
||||
const label = GATE_LABELS[gate.gate] ?? gate.gate;
|
||||
|
||||
return (
|
||||
<li className="flex flex-col gap-2 py-3">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<span className="text-sm text-slate-200">{label}</span>
|
||||
<Badge tone={gate.ok ? "success" : "danger"}>{gate.ok ? "قبول شد" : "رد شد"}</Badge>
|
||||
<time dateTime={gate.created_at} className="text-xs text-slate-500">
|
||||
{formatTime(gate.created_at)}
|
||||
</time>
|
||||
{gate.detail && (
|
||||
<button
|
||||
type="button"
|
||||
aria-expanded={open}
|
||||
aria-controls={detailId}
|
||||
onClick={() => setOpen((value) => !value)}
|
||||
className="text-xs text-accent hover:underline"
|
||||
>
|
||||
{open ? "بستن جزئیات" : "جزئیات"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{open && gate.detail && (
|
||||
<pre
|
||||
id={detailId}
|
||||
dir="ltr"
|
||||
className="max-h-72 overflow-auto whitespace-pre-wrap rounded-lg bg-surface-overlay p-3 font-mono text-xs leading-5 text-slate-300"
|
||||
>
|
||||
{gate.detail}
|
||||
</pre>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function GatesPanel({ gates, waiverReason }: GatesPanelProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-3">
|
||||
{waiverReason && (
|
||||
<div
|
||||
role="note"
|
||||
className="rounded-xl border border-warning/50 bg-warning/10 px-4 py-3 text-xs leading-6 text-warning"
|
||||
>
|
||||
صرفنظر از تست با دلیل: {waiverReason}
|
||||
</div>
|
||||
)}
|
||||
{gates.length === 0 ? (
|
||||
<p className="text-xs text-slate-500">هنوز نتیجهٔ دروازهای ثبت نشده است.</p>
|
||||
) : (
|
||||
<ul className="divide-y divide-surface-border">
|
||||
{gates.map((gate) => (
|
||||
<GateRow key={gate.id} gate={gate} />
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The live log of one engine task: what its runs print, and which tools they called, as the
|
||||
* server pushes them on the `task:<id>` channel.
|
||||
*
|
||||
* A `run.log` event appends all of its lines in one state update; a `run.tool` event appends one
|
||||
* line in Persian. The panel keeps the newest 2000 lines. It follows the newest line until the
|
||||
* reader pauses it, with the toggle or by scrolling up by hand. The log body is left-to-right and
|
||||
* monospace. The lines are server text, and they render as text only.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import type { WsEvent } from "@agy/contracts";
|
||||
import { Button } from "@agy/ui";
|
||||
import { useLiveChannel } from "@/lib/realtime";
|
||||
|
||||
/** The most lines the panel keeps; the oldest go first. */
|
||||
const LOG_LIMIT = 2000;
|
||||
|
||||
/** How close to the bottom, in pixels, still counts as following the newest line. */
|
||||
const BOTTOM_SLACK = 8;
|
||||
|
||||
interface LogLine {
|
||||
id: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface LiveLogProps {
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
function keepNewest(lines: LogLine[]): LogLine[] {
|
||||
return lines.length > LOG_LIMIT ? lines.slice(lines.length - LOG_LIMIT) : lines;
|
||||
}
|
||||
|
||||
export function LiveLog({ taskId }: LiveLogProps) {
|
||||
const [lines, setLines] = React.useState<LogLine[]>([]);
|
||||
const [paused, setPaused] = React.useState(false);
|
||||
const boxRef = React.useRef<HTMLDivElement>(null);
|
||||
const nextId = React.useRef(0);
|
||||
|
||||
// One call per event: every line the event carries is added in a single state update.
|
||||
const append = React.useCallback((texts: string[]) => {
|
||||
if (texts.length === 0) return;
|
||||
const added = texts.map((text) => {
|
||||
nextId.current += 1;
|
||||
return { id: nextId.current, text };
|
||||
});
|
||||
setLines((previous) => keepNewest([...previous, ...added]));
|
||||
}, []);
|
||||
|
||||
useLiveChannel([`task:${taskId}`], (event: WsEvent) => {
|
||||
if (event.type === "run.log") {
|
||||
const data = event.data as { task_id: string; lines: string[] };
|
||||
if (data.task_id === taskId) append(data.lines);
|
||||
} else if (event.type === "run.tool") {
|
||||
const data = event.data as { task_id: string; tool: string; ok: boolean; summary: string };
|
||||
if (data.task_id !== taskId) return;
|
||||
const outcome = data.ok ? "موفق" : "ناموفق";
|
||||
append([
|
||||
data.summary
|
||||
? `ابزار ${data.tool}: ${outcome} — ${data.summary}`
|
||||
: `ابزار ${data.tool}: ${outcome}`,
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
// Follow the newest line unless paused. The scroll position is set only here, after the lines
|
||||
// or the pause state change, so a paused panel never moves.
|
||||
React.useEffect(() => {
|
||||
const box = boxRef.current;
|
||||
if (box && !paused) box.scrollTop = box.scrollHeight;
|
||||
}, [lines, paused]);
|
||||
|
||||
// A scroll up by hand pauses following. Returning to the bottom does not resume it: that is the
|
||||
// toggle's job, so the reader keeps the position they chose.
|
||||
const onScroll = () => {
|
||||
const box = boxRef.current;
|
||||
if (!box || paused) return;
|
||||
const distance = box.scrollHeight - box.scrollTop - box.clientHeight;
|
||||
if (distance > BOTTOM_SLACK) setPaused(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="گزارش زندهٔ کار" className="flex flex-col gap-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-xs text-slate-400">گزارش زنده · {lines.length.toLocaleString("fa-IR")} سطر</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" variant="secondary" onClick={() => setPaused((value) => !value)}>
|
||||
{paused ? "ادامهٔ پیمایش" : "توقف پیمایش"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={lines.length === 0}
|
||||
onClick={() => setLines([])}
|
||||
>
|
||||
پاک کردن
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
ref={boxRef}
|
||||
role="log"
|
||||
aria-live="off"
|
||||
dir="ltr"
|
||||
onScroll={onScroll}
|
||||
className="h-72 overflow-auto rounded-xl border border-surface-border bg-surface p-3 font-mono text-xs leading-5 text-slate-300"
|
||||
>
|
||||
{lines.length === 0 ? (
|
||||
<p dir="rtl" className="font-sans text-slate-500">
|
||||
هنوز خروجیای ثبت نشده است.
|
||||
</p>
|
||||
) : (
|
||||
lines.map((line) => (
|
||||
<div key={line.id} className="whitespace-pre-wrap break-all">
|
||||
{line.text}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The stages of one engine task's pipeline, in order, with the task's current stage marked.
|
||||
*
|
||||
* The stages before the current one are check-marked, the current one is highlighted, and the
|
||||
* rest wait. A side state (paused, pending_user_decision, error_review, cancelled, failed) is not
|
||||
* a stage of the pipeline, so no step is highlighted: the state is shown as a badge beside the
|
||||
* steps instead. A completed task has every step check-marked. The labels are the engine's
|
||||
* Persian stage names.
|
||||
*/
|
||||
import { Badge, cx } from "@agy/ui";
|
||||
import { STAGE_LABELS, stageTone } from "@/lib/engine-labels";
|
||||
|
||||
export interface PipelineStepperProps {
|
||||
/** The stages of the task's pipeline in order (``EngineTaskDetail.stages``). */
|
||||
stages: readonly string[];
|
||||
/** The task's current stage (``EngineTask.stage``); null renders nothing. */
|
||||
current: string | null;
|
||||
}
|
||||
|
||||
type StepState = "done" | "current" | "pending";
|
||||
|
||||
const STEP_CLASSES: Record<StepState, string> = {
|
||||
done: "border-positive/40 text-positive",
|
||||
current: "border-accent bg-accent-soft font-medium text-accent",
|
||||
pending: "border-surface-border text-slate-500",
|
||||
};
|
||||
|
||||
function CheckMark() {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden
|
||||
viewBox="0 0 16 16"
|
||||
className="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={2}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M3.5 8.5l3 3 6-7" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function stepState(index: number, position: number, finished: boolean): StepState {
|
||||
if (finished) return "done";
|
||||
if (position === -1) return "pending";
|
||||
if (index < position) return "done";
|
||||
if (index === position) return "current";
|
||||
return "pending";
|
||||
}
|
||||
|
||||
export function PipelineStepper({ stages, current }: PipelineStepperProps) {
|
||||
if (current === null) return null;
|
||||
|
||||
const position = stages.indexOf(current);
|
||||
const finished = current === "completed";
|
||||
const offPath = position === -1 && !finished;
|
||||
|
||||
return (
|
||||
<div dir="rtl" className="flex flex-wrap items-center gap-3">
|
||||
{stages.length > 0 && (
|
||||
<ol aria-label="مراحل خط لوله" className="flex flex-wrap items-center gap-2">
|
||||
{stages.map((stage, index) => {
|
||||
const state = stepState(index, position, finished);
|
||||
return (
|
||||
<li
|
||||
key={stage}
|
||||
data-state={state}
|
||||
aria-current={state === "current" ? "step" : undefined}
|
||||
className={cx(
|
||||
"inline-flex items-center gap-2 rounded-lg border px-2.5 py-1 text-xs",
|
||||
STEP_CLASSES[state],
|
||||
)}
|
||||
>
|
||||
<span aria-hidden className="inline-flex h-4 w-4 items-center justify-center">
|
||||
{state === "done" ? <CheckMark /> : (index + 1).toLocaleString("fa-IR")}
|
||||
</span>
|
||||
<span>{STAGE_LABELS[stage] ?? stage}</span>
|
||||
{state === "done" && <span className="sr-only">(انجام شد)</span>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
{offPath && <Badge tone={stageTone(current)}>{STAGE_LABELS[current] ?? current}</Badge>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The turns of one engine task as a timeline, in the order the API gives them (oldest first).
|
||||
*
|
||||
* Each turn shows who ran (the role in Persian and the employee's name), the stage it ran in, when
|
||||
* it started, how it ended (موفق, or خطا with the engine's error kind), how many tools it called,
|
||||
* and the start of its reply in a block the reader opens on demand. Only the fields the API
|
||||
* defines are read, so a turn never shows a model or provider name, even if one were sent.
|
||||
*
|
||||
* ``Timeline`` from ``@agy/ui`` takes plain-text items only, so this list keeps its look but
|
||||
* can hold the reply block.
|
||||
*/
|
||||
import { Badge, EmptyState, cx } from "@agy/ui";
|
||||
import type { EngineTurn } from "@/lib/api-engine";
|
||||
import { ROLE_LABELS, STAGE_LABELS } from "@/lib/engine-labels";
|
||||
|
||||
export interface TurnTimelineProps {
|
||||
turns: readonly EngineTurn[];
|
||||
}
|
||||
|
||||
function turnTitle(turn: EngineTurn): string {
|
||||
const role = ROLE_LABELS[turn.role] ?? turn.role;
|
||||
return turn.employee_name ? `${role} — ${turn.employee_name}` : role;
|
||||
}
|
||||
|
||||
function formatStart(value: string): string {
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString("fa-IR");
|
||||
}
|
||||
|
||||
export function TurnTimeline({ turns }: TurnTimelineProps) {
|
||||
if (turns.length === 0) {
|
||||
return <EmptyState title="هنوز نوبتی برای این کار ثبت نشده است." />;
|
||||
}
|
||||
|
||||
return (
|
||||
<ol aria-label="نوبتهای کار" className="flex flex-col gap-4">
|
||||
{turns.map((turn) => (
|
||||
<li key={turn.id} className="flex gap-3">
|
||||
<span
|
||||
aria-hidden
|
||||
className={cx(
|
||||
"mt-1.5 h-2 w-2 shrink-0 rounded-full",
|
||||
turn.ok ? "bg-positive" : "bg-danger",
|
||||
)}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<p className="text-xs text-slate-200">{turnTitle(turn)}</p>
|
||||
<p className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-slate-500">
|
||||
<span>{STAGE_LABELS[turn.stage] ?? turn.stage}</span>
|
||||
<time dateTime={turn.started_at}>{formatStart(turn.started_at)}</time>
|
||||
<span>{turn.tool_calls.toLocaleString("fa-IR")} فراخوانی ابزار</span>
|
||||
</p>
|
||||
<div>
|
||||
{turn.ok ? (
|
||||
<Badge tone="success">موفق</Badge>
|
||||
) : (
|
||||
<Badge tone="danger">خطا: {turn.error_kind || "نامشخص"}</Badge>
|
||||
)}
|
||||
</div>
|
||||
{turn.reply_excerpt && (
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-slate-400 hover:text-slate-200">
|
||||
متن پاسخ
|
||||
</summary>
|
||||
<pre
|
||||
dir="auto"
|
||||
className="mt-2 max-h-64 overflow-auto whitespace-pre-wrap rounded-lg bg-surface-overlay p-3 leading-6 text-slate-200"
|
||||
>
|
||||
{turn.reply_excerpt}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -15,10 +15,18 @@ import {
|
||||
Stat,
|
||||
Timeline,
|
||||
} from "@agy/ui";
|
||||
import { TaskActions } from "@/components/engine/task-actions";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { type EngineTaskDetail, engineApi } from "@/lib/api-engine";
|
||||
import { STAGE_LABELS, stageTone } from "@/lib/engine-labels";
|
||||
import { CAN_RUN_TASKS, roleHasCapability } from "@/lib/permissions";
|
||||
import { useLiveChannel } from "@/lib/realtime";
|
||||
import { useSession } from "@/lib/session";
|
||||
import { DiffViewer } from "./_components/diff-viewer";
|
||||
import { GatesPanel } from "./_components/gates-panel";
|
||||
import { LiveLog } from "./_components/live-log";
|
||||
import { PipelineStepper } from "./_components/pipeline-stepper";
|
||||
import { TurnTimeline } from "./_components/turn-timeline";
|
||||
|
||||
const STATUS_LABELS: Record<TaskStatus, string> = {
|
||||
new: "جدید",
|
||||
@@ -45,6 +53,177 @@ const RUN_TONES: Record<string, "neutral" | "info" | "success" | "warning" | "da
|
||||
|
||||
const TERMINAL = new Set(["completed", "failed", "cancelled", "timed_out"]);
|
||||
|
||||
/** The answer box of a task that waits for the person's decision. */
|
||||
function AnswerBox({
|
||||
orgId,
|
||||
taskId,
|
||||
lastReply,
|
||||
onAnswered,
|
||||
}: {
|
||||
orgId: string;
|
||||
taskId: string;
|
||||
lastReply: string;
|
||||
onAnswered: () => void;
|
||||
}) {
|
||||
const [content, setContent] = React.useState("");
|
||||
const [sending, setSending] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const submit = async (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
const answer = content.trim();
|
||||
if (!answer) return;
|
||||
setSending(true);
|
||||
setError(null);
|
||||
try {
|
||||
await engineApi.answerTask(orgId, taskId, answer);
|
||||
setContent("");
|
||||
onAnswered();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "ارسال پاسخ ناموفق بود.");
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="border-warning/40">
|
||||
<CardTitle>این کار منتظر تصمیم شماست</CardTitle>
|
||||
<CardHint>پاسخ خود را بنویسید تا کار از همان مرحله ادامه یابد.</CardHint>
|
||||
{lastReply && (
|
||||
<div className="mt-3">
|
||||
<p className="mb-1 text-xs text-slate-500">آخرین پاسخ ثبتشده در این کار</p>
|
||||
<pre
|
||||
dir="auto"
|
||||
className="max-h-40 overflow-auto whitespace-pre-wrap rounded-lg bg-surface-overlay p-3 text-xs leading-6 text-slate-300"
|
||||
>
|
||||
{lastReply}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
<form onSubmit={submit} className="mt-3 flex flex-col gap-2">
|
||||
<textarea
|
||||
value={content}
|
||||
onChange={(event) => setContent(event.target.value)}
|
||||
rows={3}
|
||||
aria-label="پاسخ شما"
|
||||
className="w-full rounded-lg border border-surface-border bg-surface px-3 py-2 text-sm text-slate-200 outline-none focus-visible:ring-2 focus-visible:ring-accent/60"
|
||||
/>
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button type="submit" size="sm" loading={sending} disabled={!content.trim()}>
|
||||
ارسال پاسخ
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/** The engine view of a task with a pipeline stage: stepper, live log, gates, diff and turns. */
|
||||
function EngineTaskView({
|
||||
orgId,
|
||||
detail,
|
||||
canRun,
|
||||
actionError,
|
||||
onChanged,
|
||||
}: {
|
||||
orgId: string;
|
||||
detail: EngineTaskDetail;
|
||||
canRun: boolean;
|
||||
actionError: string | null;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const lastTurn = detail.turns[detail.turns.length - 1];
|
||||
const assignee = detail.assignee_name
|
||||
? `کارمند مسئول: ${detail.assignee_name}`
|
||||
: "بدون کارمند مسئول — مدیر سازمان اجرا میکند.";
|
||||
|
||||
return (
|
||||
<div className="anim-stagger flex flex-col gap-5">
|
||||
<nav className="text-xs text-slate-500">
|
||||
<Link href="/tasks" className="transition-colors hover:text-slate-300">
|
||||
کارها
|
||||
</Link>
|
||||
<span className="mx-1.5">/</span>
|
||||
<span className="text-slate-400">{detail.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">{detail.title}</h1>
|
||||
{detail.stage !== null && (
|
||||
<Badge tone={stageTone(detail.stage)}>
|
||||
{STAGE_LABELS[detail.stage] ?? detail.stage}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{detail.description && <p className="text-sm text-slate-400">{detail.description}</p>}
|
||||
<p className="text-xs text-slate-500">{assignee}</p>
|
||||
</div>
|
||||
<TaskActions
|
||||
orgId={orgId}
|
||||
task={{ id: detail.id, stage: detail.stage }}
|
||||
canRun={canRun}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
</header>
|
||||
|
||||
{actionError && <ErrorState title="عملیات ناموفق" message={actionError} />}
|
||||
|
||||
{canRun && detail.stage === "pending_user_decision" && (
|
||||
<AnswerBox
|
||||
orgId={orgId}
|
||||
taskId={detail.id}
|
||||
lastReply={lastTurn?.reply_excerpt ?? ""}
|
||||
onAnswered={onChanged}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardTitle>خط لوله</CardTitle>
|
||||
<div className="mt-4">
|
||||
<PipelineStepper stages={detail.stages} current={detail.stage} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardTitle>گزارش زنده</CardTitle>
|
||||
<div className="mt-4">
|
||||
<LiveLog taskId={detail.id} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="grid gap-5 lg:grid-cols-2">
|
||||
<Card>
|
||||
<CardTitle>دروازهها</CardTitle>
|
||||
<div className="mt-4">
|
||||
<GatesPanel gates={detail.gates} waiverReason={detail.waiver_reason} />
|
||||
</div>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardTitle>تغییرات</CardTitle>
|
||||
<div className="mt-4">
|
||||
<DiffViewer diff={detail.diff} mergedSha={detail.merged_sha} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardTitle>نوبتهای کار</CardTitle>
|
||||
<div className="mt-4">
|
||||
<TurnTimeline turns={detail.turns} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TaskDetailPage({
|
||||
params,
|
||||
}: {
|
||||
@@ -64,6 +243,8 @@ export default function TaskDetailPage({
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
const [running, setRunning] = React.useState(false);
|
||||
const [actionError, setActionError] = React.useState<string | null>(null);
|
||||
const [engine, setEngine] = React.useState<EngineTaskDetail | null>(null);
|
||||
const reloadTimer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// Live feed: task status, run steps, approvals, artifacts — all push.
|
||||
const channels = React.useMemo(() => {
|
||||
@@ -74,6 +255,35 @@ export default function TaskDetailPage({
|
||||
return base;
|
||||
}, [orgId, taskId, selectedRun?.id]);
|
||||
|
||||
// The engine view reloads without the loading state, so its live log keeps its lines.
|
||||
const reloadEngine = React.useCallback(async () => {
|
||||
if (!orgId) return;
|
||||
try {
|
||||
setEngine(await engineApi.getTask(orgId, taskId));
|
||||
} catch (err) {
|
||||
setActionError(err instanceof ApiError ? err.message : "بهروزرسانی کار ناموفق بود.");
|
||||
}
|
||||
}, [orgId, taskId]);
|
||||
|
||||
// A burst of stage moves and gate verdicts reloads once, 500 ms after the last of them.
|
||||
const reloadLatest = React.useRef(reloadEngine);
|
||||
React.useEffect(() => {
|
||||
reloadLatest.current = reloadEngine;
|
||||
}, [reloadEngine]);
|
||||
const scheduleEngineReload = React.useCallback(() => {
|
||||
if (reloadTimer.current !== null) clearTimeout(reloadTimer.current);
|
||||
reloadTimer.current = setTimeout(() => {
|
||||
reloadTimer.current = null;
|
||||
void reloadLatest.current();
|
||||
}, 500);
|
||||
}, []);
|
||||
React.useEffect(
|
||||
() => () => {
|
||||
if (reloadTimer.current !== null) clearTimeout(reloadTimer.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useLiveChannel(channels, (event) => {
|
||||
switch (event.type) {
|
||||
case "task.status": {
|
||||
@@ -166,11 +376,36 @@ export default function TaskDetailPage({
|
||||
});
|
||||
break;
|
||||
}
|
||||
case "task.step":
|
||||
case "task.gate": {
|
||||
const data = event.data as { task_id: string };
|
||||
if (data.task_id === taskId) scheduleEngineReload();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// A legacy task (no engine stage): its runs, approvals and employees, as before.
|
||||
const loadLegacy = React.useCallback(async () => {
|
||||
if (!orgId) return;
|
||||
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);
|
||||
}, [orgId, taskId]);
|
||||
|
||||
// An engine task (it has a stage) shows the engine view; anything else is a legacy task.
|
||||
const load = React.useCallback(async () => {
|
||||
if (!orgId) {
|
||||
setLoading(false);
|
||||
@@ -179,25 +414,19 @@ export default function TaskDetailPage({
|
||||
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);
|
||||
const detail = await engineApi.getTask(orgId, taskId);
|
||||
if (detail.stage !== null) {
|
||||
setEngine(detail);
|
||||
return;
|
||||
}
|
||||
setEngine(null);
|
||||
await loadLegacy();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت کار ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orgId, taskId]);
|
||||
}, [orgId, taskId, loadLegacy]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
@@ -217,7 +446,7 @@ export default function TaskDetailPage({
|
||||
delete next[toolCallId];
|
||||
return next;
|
||||
});
|
||||
await load();
|
||||
await loadLegacy();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof ApiError ? err.message : "ثبت تصمیم ناموفق بود.");
|
||||
} finally {
|
||||
@@ -232,7 +461,7 @@ export default function TaskDetailPage({
|
||||
try {
|
||||
const detail = await api.startRun(orgId, taskId, task?.assigned_employee_id ?? null);
|
||||
setSelectedRun(detail);
|
||||
await load();
|
||||
await loadLegacy();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof ApiError ? err.message : "اجرای کار ناموفق بود.");
|
||||
} finally {
|
||||
@@ -246,7 +475,7 @@ export default function TaskDetailPage({
|
||||
setRunning(true);
|
||||
try {
|
||||
setSelectedRun(await api.cancelRun(orgId, selectedRun.id));
|
||||
await load();
|
||||
await loadLegacy();
|
||||
} catch (err) {
|
||||
setActionError(err instanceof ApiError ? err.message : "لغو اجرا ناموفق بود.");
|
||||
} finally {
|
||||
@@ -274,6 +503,17 @@ export default function TaskDetailPage({
|
||||
|
||||
if (loading) return <LoadingState label="در حال دریافت کار…" />;
|
||||
if (error) return <ErrorState message={error} onRetry={load} />;
|
||||
if (engine) {
|
||||
return (
|
||||
<EngineTaskView
|
||||
orgId={activeOrganization.id}
|
||||
detail={engine}
|
||||
canRun={roleHasCapability(activeOrganization.role, CAN_RUN_TASKS)}
|
||||
actionError={actionError}
|
||||
onChanged={reloadEngine}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!task) {
|
||||
return (
|
||||
<EmptyState
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The engine's task board: one column per group of stages (``BOARD_COLUMNS``), and a card per
|
||||
* task with its stage, assignee, cycle and controls. The filters narrow the cards on the client.
|
||||
* A legacy task (no engine stage) sits in the column of its coarse status.
|
||||
*
|
||||
* The board keeps its own copy of the tasks, so a live ``task.step`` moves a card at once. A
|
||||
* fresh list from the page replaces that copy. A ``task.created`` asks the page to reload.
|
||||
*/
|
||||
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
import type { Employee, TaskStatus, TaskStepEventData } from "@agy/contracts";
|
||||
import { Badge, Card, Checkbox, Field, Select } from "@agy/ui";
|
||||
import { TaskActions } from "@/components/engine/task-actions";
|
||||
import type { EngineStage, EngineTask } from "@/lib/api-engine";
|
||||
import {
|
||||
BOARD_COLUMNS,
|
||||
columnFor,
|
||||
groupByColumn,
|
||||
STAGE_LABELS,
|
||||
type StageTone,
|
||||
stageTone,
|
||||
} from "@/lib/engine-labels";
|
||||
import { useLiveChannel } from "@/lib/realtime";
|
||||
|
||||
/** The Persian name of a legacy status, for a task the engine does not own. */
|
||||
const LEGACY_LABELS: Record<TaskStatus, string> = {
|
||||
new: "جدید",
|
||||
triaged: "دستهبندیشده",
|
||||
waiting_approval: "در انتظار تأیید",
|
||||
running: "در حال اجرا",
|
||||
done: "انجامشده",
|
||||
failed: "ناموفق",
|
||||
cancelled: "لغوشده",
|
||||
};
|
||||
|
||||
const LEGACY_TONES: Record<TaskStatus, StageTone> = {
|
||||
new: "neutral",
|
||||
triaged: "info",
|
||||
waiting_approval: "warning",
|
||||
running: "info",
|
||||
done: "success",
|
||||
failed: "danger",
|
||||
cancelled: "neutral",
|
||||
};
|
||||
|
||||
export interface TaskBoardProps {
|
||||
orgId: string;
|
||||
tasks: EngineTask[];
|
||||
employees: Pick<Employee, "id" | "name">[];
|
||||
/** False for a viewer: the cards show no controls. */
|
||||
canRun: boolean;
|
||||
/** Called after a card's control changed a task, and when a task is created. */
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
export function TaskBoard({ orgId, tasks, employees, canRun, onChanged }: TaskBoardProps) {
|
||||
const [items, setItems] = React.useState<EngineTask[]>(tasks);
|
||||
const [query, setQuery] = React.useState("");
|
||||
const [assignee, setAssignee] = React.useState("");
|
||||
const [activeOnly, setActiveOnly] = React.useState(false);
|
||||
|
||||
// A fresh list from the page replaces the board's copy.
|
||||
React.useEffect(() => {
|
||||
setItems(tasks);
|
||||
}, [tasks]);
|
||||
|
||||
useLiveChannel([`org:${orgId}`], (event) => {
|
||||
if (event.type === "task.created") {
|
||||
onChanged();
|
||||
return;
|
||||
}
|
||||
if (event.type !== "task.step") return;
|
||||
const step = event.data as unknown as TaskStepEventData;
|
||||
setItems((current) =>
|
||||
current.map((task) =>
|
||||
task.id === step.task_id
|
||||
? { ...task, stage: step.to as EngineStage, status: step.status }
|
||||
: task,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const needle = query.trim().toLowerCase();
|
||||
const visible = items.filter((task) => {
|
||||
if (needle && !task.title.toLowerCase().includes(needle)) return false;
|
||||
if (assignee === "none" && task.assigned_employee_id !== null) return false;
|
||||
if (assignee !== "" && assignee !== "none" && task.assigned_employee_id !== assignee) {
|
||||
return false;
|
||||
}
|
||||
if (activeOnly && columnFor(task.stage, task.status) === "done") return false;
|
||||
return true;
|
||||
});
|
||||
const groups = groupByColumn(visible);
|
||||
|
||||
const assigneeOptions = [
|
||||
{ value: "", label: "همه" },
|
||||
{ value: "none", label: "بدون مسئول" },
|
||||
...employees.map((employee) => ({ value: employee.id, label: employee.name })),
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<div className="min-w-[12rem] flex-1">
|
||||
<Field
|
||||
label="جستجو در عنوان"
|
||||
type="search"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="عنوان کار…"
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full sm:w-56">
|
||||
<Select
|
||||
label="مسئول"
|
||||
value={assignee}
|
||||
onChange={(e) => setAssignee(e.target.value)}
|
||||
options={assigneeOptions}
|
||||
/>
|
||||
</div>
|
||||
<div className="pb-2">
|
||||
<Checkbox
|
||||
label="فقط کارهای فعال"
|
||||
checked={activeOnly}
|
||||
onChange={(e) => setActiveOnly(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-3 overflow-x-auto pb-3">
|
||||
{BOARD_COLUMNS.map((column) => {
|
||||
const cards = groups[column.key];
|
||||
return (
|
||||
<section
|
||||
key={column.key}
|
||||
aria-label={column.label}
|
||||
className="flex w-64 shrink-0 flex-col gap-3 rounded-xl border border-surface-border bg-surface-raised/50 p-3"
|
||||
>
|
||||
<header className="flex items-center justify-between gap-2">
|
||||
<h2 className="text-xs font-medium text-slate-300">{column.label}</h2>
|
||||
<Badge>{cards.length.toLocaleString("fa-IR")}</Badge>
|
||||
</header>
|
||||
{cards.length === 0 ? (
|
||||
<p className="py-6 text-center text-xs text-slate-500">کاری نیست</p>
|
||||
) : (
|
||||
cards.map((task) => (
|
||||
<BoardCard
|
||||
key={task.id}
|
||||
orgId={orgId}
|
||||
task={task}
|
||||
canRun={canRun}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BoardCard({
|
||||
orgId,
|
||||
task,
|
||||
canRun,
|
||||
onChanged,
|
||||
}: {
|
||||
orgId: string;
|
||||
task: EngineTask;
|
||||
canRun: boolean;
|
||||
onChanged: () => void;
|
||||
}) {
|
||||
const label =
|
||||
task.stage === null ? LEGACY_LABELS[task.status] : (STAGE_LABELS[task.stage] ?? task.stage);
|
||||
const tone = task.stage === null ? LEGACY_TONES[task.status] : stageTone(task.stage);
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col gap-2 p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<Link
|
||||
href={`/tasks/${task.id}`}
|
||||
className="line-clamp-2 min-w-0 text-sm font-medium leading-6 text-slate-100 hover:text-accent"
|
||||
>
|
||||
{task.title}
|
||||
</Link>
|
||||
<Badge tone={tone} className="shrink-0">
|
||||
{label}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400">{task.assignee_name ?? "بدون مسئول"}</p>
|
||||
{task.max_cycles > 0 && (
|
||||
<p className="text-xs text-slate-500">
|
||||
چرخه {task.cycle.toLocaleString("fa-IR")}/{task.max_cycles.toLocaleString("fa-IR")}
|
||||
</p>
|
||||
)}
|
||||
<TaskActions
|
||||
orgId={orgId}
|
||||
task={{ id: task.id, stage: task.stage }}
|
||||
canRun={canRun}
|
||||
onChanged={onChanged}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
import type { Employee, Task, TaskStatus } from "@agy/contracts";
|
||||
import type { Employee } from "@agy/contracts";
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
CardHint,
|
||||
@@ -15,37 +13,39 @@ import {
|
||||
LoadingState,
|
||||
Select,
|
||||
} from "@agy/ui";
|
||||
import { CreditNotice, useEngineStatus } from "@/components/engine/credit-notice";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { type EngineTask, engineApi } from "@/lib/api-engine";
|
||||
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",
|
||||
};
|
||||
import { TaskBoard } from "./_components/task-board";
|
||||
|
||||
export default function TasksPage() {
|
||||
const { activeOrganization } = useSession();
|
||||
const orgId = activeOrganization?.id ?? null;
|
||||
|
||||
const [tasks, setTasks] = React.useState<Task[]>([]);
|
||||
if (!activeOrganization) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="سازمانی انتخاب نشده است"
|
||||
description="برای دیدن کارها ابتدا یک سازمان بسازید."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TasksWorkspace
|
||||
key={activeOrganization.id}
|
||||
orgId={activeOrganization.id}
|
||||
canRun={roleHasCapability(activeOrganization.role, CAN_RUN_TASKS)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function TasksWorkspace({ orgId, canRun }: { orgId: string; canRun: boolean }) {
|
||||
const engine = useEngineStatus(orgId);
|
||||
|
||||
const [tasks, setTasks] = React.useState<EngineTask[]>([]);
|
||||
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);
|
||||
|
||||
@@ -57,46 +57,45 @@ export default function TasksPage() {
|
||||
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),
|
||||
engineApi.listTasks(orgId),
|
||||
api.listEmployees(orgId),
|
||||
]);
|
||||
setTasks(nextTasks);
|
||||
setEmployees(nextEmployees);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت کارها ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orgId, filter]);
|
||||
}, [orgId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const retry = () => {
|
||||
setLoading(true);
|
||||
void load();
|
||||
};
|
||||
|
||||
const onCreate = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (!orgId) return;
|
||||
setFormError(null);
|
||||
setCreating(true);
|
||||
try {
|
||||
const created = await api.createTask(orgId, {
|
||||
await api.createTask(orgId, {
|
||||
title: title.trim(),
|
||||
description: description.trim(),
|
||||
assigned_employee_id: assignedTo || null,
|
||||
});
|
||||
setTasks((current) => [created, ...current]);
|
||||
setTitle("");
|
||||
setDescription("");
|
||||
setAssignedTo("");
|
||||
setShowForm(false);
|
||||
await load();
|
||||
} catch (err) {
|
||||
setFormError(err instanceof ApiError ? err.message : "ساخت کار ناموفق بود.");
|
||||
} finally {
|
||||
@@ -104,19 +103,10 @@ export default function TasksPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!activeOrganization) {
|
||||
return (
|
||||
<EmptyState
|
||||
title="سازمانی انتخاب نشده است"
|
||||
description="برای دیدن کارها ابتدا یک سازمان بسازید."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const canRun = roleHasCapability(activeOrganization.role, CAN_RUN_TASKS);
|
||||
|
||||
return (
|
||||
<div className="anim-stagger flex flex-col gap-5">
|
||||
<CreditNotice status={engine.status} />
|
||||
|
||||
<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>
|
||||
@@ -134,6 +124,7 @@ export default function TasksPage() {
|
||||
{showForm && (
|
||||
<Card>
|
||||
<CardTitle>ساخت کار</CardTitle>
|
||||
<CardHint>کار جدید ابتدا به مدیر AI سپرده میشود.</CardHint>
|
||||
<form onSubmit={onCreate} className="mt-4 flex flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<Field
|
||||
@@ -174,57 +165,26 @@ export default function TasksPage() {
|
||||
</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="یک کار جدید بسازید تا کارمند هوش مصنوعی آن را اجرا کند."
|
||||
/>
|
||||
) : error && tasks.length === 0 ? (
|
||||
<ErrorState message={error} onRetry={retry} />
|
||||
) : (
|
||||
<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 className="flex flex-col gap-4">
|
||||
{error && <ErrorState message={error} onRetry={retry} />}
|
||||
{tasks.length === 0 && (
|
||||
<EmptyState
|
||||
title="کاری یافت نشد"
|
||||
description="یک کار جدید بسازید تا کارمند هوش مصنوعی آن را اجرا کند."
|
||||
/>
|
||||
)}
|
||||
<TaskBoard
|
||||
orgId={orgId}
|
||||
tasks={tasks}
|
||||
employees={employees}
|
||||
canRun={canRun}
|
||||
onChanged={load}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The organization's newest engine turns, one row per employee turn: the task it belongs to,
|
||||
* the role and stage it ran at, its result (a failed turn shows its error kind), the tool calls
|
||||
* it made and how long it took.
|
||||
*
|
||||
* The reply text is never shown here: it is the model's own output. The list is loaded again on
|
||||
* every ``task.step`` of the organization.
|
||||
*/
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
CardHint,
|
||||
CardTitle,
|
||||
EmptyState,
|
||||
Table,
|
||||
type TableColumn,
|
||||
} from "@agy/ui";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { type EngineOrgTurn, engineApi } from "@/lib/api-engine";
|
||||
import { ROLE_LABELS, STAGE_LABELS } from "@/lib/engine-labels";
|
||||
import { useLiveChannel } from "@/lib/realtime";
|
||||
|
||||
/** How many of the newest turns the table shows. */
|
||||
const TURN_LIMIT = 30;
|
||||
|
||||
/** The duration of a turn, in the same units as the run list above it. */
|
||||
function formatDuration(turn: EngineOrgTurn): string {
|
||||
if (turn.finished_at === null) return "—";
|
||||
const ms = new Date(turn.finished_at).getTime() - new Date(turn.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`;
|
||||
}
|
||||
|
||||
const COLUMNS: Array<TableColumn<EngineOrgTurn>> = [
|
||||
{
|
||||
key: "task",
|
||||
header: "کار",
|
||||
render: (turn) => (
|
||||
<Link
|
||||
href={`/tasks/${turn.task_id}`}
|
||||
className="transition-colors text-xs text-slate-200 hover:text-accent"
|
||||
>
|
||||
{turn.task_title || "—"}
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: "role",
|
||||
header: "نقش",
|
||||
render: (turn) => <span className="text-xs">{ROLE_LABELS[turn.role] ?? turn.role}</span>,
|
||||
},
|
||||
{
|
||||
key: "stage",
|
||||
header: "مرحله",
|
||||
render: (turn) => <span className="text-xs">{STAGE_LABELS[turn.stage] ?? turn.stage}</span>,
|
||||
},
|
||||
{
|
||||
key: "result",
|
||||
header: "نتیجه",
|
||||
render: (turn) => {
|
||||
if (turn.finished_at === null) return <Badge tone="info">در جریان</Badge>;
|
||||
if (turn.ok) return <Badge tone="success">موفق</Badge>;
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge tone="danger">ناموفق</Badge>
|
||||
{turn.error_kind && (
|
||||
<span className="ltr-inline text-xs text-slate-400">{turn.error_kind}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "tools",
|
||||
header: "ابزارها",
|
||||
align: "center",
|
||||
render: (turn) => <span className="text-xs">{turn.tool_calls}</span>,
|
||||
},
|
||||
{
|
||||
key: "duration",
|
||||
header: "مدت",
|
||||
render: (turn) => <span className="text-xs text-slate-400">{formatDuration(turn)}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
export function EngineTurns({ orgId }: { orgId: string }) {
|
||||
const [turns, setTurns] = React.useState<EngineOrgTurn[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
try {
|
||||
setTurns(await engineApi.listTurns(orgId, TURN_LIMIT));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "نوبتهای کارمندان دریافت نشد.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orgId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useLiveChannel([`org:${orgId}`], (event) => {
|
||||
if (event.type === "task.step") void load();
|
||||
});
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardTitle>نوبتهای اخیر کارمندان</CardTitle>
|
||||
<CardHint>
|
||||
هر نوبت: کارمند با کدام نقش و در کدام مرحله کار کرد و چه نتیجهای داشت. متن پاسخ مدل در این
|
||||
جدول نمایش داده نمیشود.
|
||||
</CardHint>
|
||||
<div className="mt-4">
|
||||
<Table
|
||||
caption="نوبتهای اخیر کارمندان"
|
||||
columns={COLUMNS}
|
||||
rows={turns}
|
||||
rowKey={(turn) => turn.id}
|
||||
loading={loading}
|
||||
error={error}
|
||||
onRetry={load}
|
||||
empty={<EmptyState title="هنوز نوبتی ثبت نشده است." />}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { useLiveChannel } from "@/lib/realtime";
|
||||
import { useSession } from "@/lib/session";
|
||||
import { EngineTurns } from "./_components/engine-turns";
|
||||
|
||||
/** How many tasks to fan out into. Bounds the request count. */
|
||||
const TASK_SCAN_LIMIT = 12;
|
||||
@@ -427,6 +428,8 @@ export default function TracesPage() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{orgId && <EngineTurns orgId={orgId} />}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button variant="secondary" size="sm" onClick={load}>
|
||||
بازخوانی
|
||||
|
||||
@@ -6,7 +6,6 @@ import type {
|
||||
EmployeeDetail,
|
||||
EmployeeStatus,
|
||||
PromptVersion,
|
||||
RoleKey,
|
||||
RoutingChoice,
|
||||
ToolSpec,
|
||||
} from "@agy/contracts";
|
||||
@@ -25,19 +24,10 @@ import {
|
||||
Timeline,
|
||||
} from "@agy/ui";
|
||||
import { ApiError, api } from "@/lib/api";
|
||||
import { type TeamMember, engineApi } from "@/lib/api-engine";
|
||||
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: "پشتیبان",
|
||||
};
|
||||
import { roleLabel, tierLabel } from "../_components/team-panel";
|
||||
|
||||
const STATUS_LABELS: Record<EmployeeStatus, string> = {
|
||||
active: "فعال",
|
||||
@@ -51,10 +41,13 @@ export default function EmployeeDetailPage({
|
||||
params: Promise<{ id: string }>;
|
||||
}) {
|
||||
const { id: employeeId } = React.use(params);
|
||||
const { activeOrganization } = useSession();
|
||||
const { activeOrganization, user } = useSession();
|
||||
const orgId = activeOrganization?.id ?? null;
|
||||
// Only the platform owner chooses a routing policy. Everyone else sees the employee's tier.
|
||||
const isOwner = user?.is_platform_owner === true;
|
||||
|
||||
const [employee, setEmployee] = React.useState<EmployeeDetail | null>(null);
|
||||
const [member, setMember] = React.useState<TeamMember | null>(null);
|
||||
const [choices, setChoices] = React.useState<RoutingChoice[]>([]);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
@@ -82,14 +75,17 @@ export default function EmployeeDetailPage({
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [nextEmployee, nextChoices, nextTools] = await Promise.all([
|
||||
const [nextEmployee, nextTools, nextChoices, team] = await Promise.all([
|
||||
api.getEmployee(orgId, employeeId),
|
||||
api.listRoutingChoices(orgId),
|
||||
api.listTools(orgId),
|
||||
// The routing choices belong to the owner's settings, so no one else asks for them.
|
||||
isOwner ? api.listRoutingChoices(orgId) : Promise.resolve<RoutingChoice[]>([]),
|
||||
engineApi.getTeam(orgId),
|
||||
]);
|
||||
setEmployee(nextEmployee);
|
||||
setChoices(nextChoices);
|
||||
setTools(nextTools);
|
||||
setMember(team.employees.find((entry) => entry.id === employeeId) ?? null);
|
||||
setName(nextEmployee.name);
|
||||
setStatus(nextEmployee.status);
|
||||
setRoutingPolicyId(nextEmployee.routing_policy_id ?? "");
|
||||
@@ -98,7 +94,7 @@ export default function EmployeeDetailPage({
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orgId, employeeId]);
|
||||
}, [orgId, employeeId, isOwner]);
|
||||
|
||||
const onToggleTool = async (toolKey: string, allowed: boolean) => {
|
||||
if (!orgId) return;
|
||||
@@ -132,7 +128,8 @@ export default function EmployeeDetailPage({
|
||||
await api.updateEmployee(orgId, employeeId, {
|
||||
name: name.trim(),
|
||||
status,
|
||||
routing_policy_id: routingPolicyId || null,
|
||||
// Only the owner sends a routing policy; for anyone else the field is left out.
|
||||
...(isOwner ? { routing_policy_id: routingPolicyId || null } : {}),
|
||||
});
|
||||
setSaved(true);
|
||||
window.setTimeout(() => setSaved(false), 1800);
|
||||
@@ -203,7 +200,8 @@ export default function EmployeeDetailPage({
|
||||
|
||||
<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="info">{roleLabel(employee.role_key)}</Badge>
|
||||
{member && <Badge tone="neutral">سطح: {tierLabel(member.tier)}</Badge>}
|
||||
<Badge tone={employee.status === "active" ? "success" : "neutral"}>
|
||||
{STATUS_LABELS[employee.status]}
|
||||
</Badge>
|
||||
@@ -218,8 +216,9 @@ export default function EmployeeDetailPage({
|
||||
<Card>
|
||||
<CardTitle>تنظیمات کارمند</CardTitle>
|
||||
<CardHint>
|
||||
«سیاست کاری» تعیین میکند کارمند با چه سبکی کار کند. انتخاب مدل و ارائهدهنده در اختیار
|
||||
سامانه است و نمایش داده نمیشود.
|
||||
{isOwner
|
||||
? "«سیاست کاری» تعیین میکند کارمند با چه سبکی کار کند. انتخاب مدل و ارائهدهنده در اختیار سامانه است و نمایش داده نمیشود."
|
||||
: "سطح کارمند نشان میدهد در کدام رده کار میکند. انتخاب مدل و ارائهدهنده در اختیار سامانه است و نمایش داده نمیشود."}
|
||||
</CardHint>
|
||||
<form onSubmit={onSave} className="mt-4 flex flex-col gap-4">
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
@@ -241,16 +240,18 @@ export default function EmployeeDetailPage({
|
||||
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 })),
|
||||
]}
|
||||
/>
|
||||
{isOwner && (
|
||||
<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">
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Hire one employee: a role, a tier from the tiers the organization has been granted, and an
|
||||
* optional name. The platform picks the model behind the tier, so the dialog never names one.
|
||||
* Hiring is disabled when no tier is granted, or when the team is at the owner's cap.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { Button, Field, Modal, Select } from "@agy/ui";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { type TierOption, engineApi } from "@/lib/api-engine";
|
||||
import { ROLE_LABELS } from "@/lib/engine-labels";
|
||||
|
||||
/** The roles a team can be given from this dialog (the engine's hirable roles). */
|
||||
type HirableRole = "manager" | "planner" | "developer" | "reviewer" | "tester";
|
||||
|
||||
const HIRABLE_ROLES: readonly HirableRole[] = [
|
||||
"manager",
|
||||
"planner",
|
||||
"developer",
|
||||
"reviewer",
|
||||
"tester",
|
||||
];
|
||||
|
||||
const DEFAULT_ROLE: HirableRole = "developer";
|
||||
|
||||
export const NO_TIER_HINT = "مالک سامانه هنوز مدلی برای این سازمان تخصیص نداده است.";
|
||||
export const AT_CAP_HINT = "سقف تعداد کارمندان سازمان پر است.";
|
||||
|
||||
export interface HireDialogProps {
|
||||
orgId: string;
|
||||
/** The tiers the organization has been granted, from the team view. */
|
||||
tiers: TierOption[];
|
||||
/** True once the team holds the owner's cap: nobody more can be hired. */
|
||||
atCap: boolean;
|
||||
/** Called after a hire, so the page reloads the team. */
|
||||
onHired: () => void;
|
||||
}
|
||||
|
||||
export function HireDialog({ orgId, tiers, atCap, onHired }: HireDialogProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [role, setRole] = React.useState<HirableRole>(DEFAULT_ROLE);
|
||||
const [tierKey, setTierKey] = React.useState("");
|
||||
const [name, setName] = React.useState("");
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
// The chosen tier, or the first granted one while no choice (or a tier no longer granted) is set.
|
||||
const tier = tiers.find((option) => option.tier === tierKey) ?? tiers[0] ?? null;
|
||||
const noTier = tiers.length === 0;
|
||||
const disabled = noTier || atCap;
|
||||
const hint = noTier ? NO_TIER_HINT : atCap ? AT_CAP_HINT : null;
|
||||
|
||||
const openDialog = () => {
|
||||
setError(null);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const closeDialog = () => {
|
||||
if (busy) return;
|
||||
setOpen(false);
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const onSubmit = async (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (tier === null || disabled) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
const trimmed = name.trim();
|
||||
try {
|
||||
await engineApi.hire(orgId, {
|
||||
role,
|
||||
tier: tier.tier,
|
||||
...(trimmed ? { name: trimmed } : {}),
|
||||
});
|
||||
setOpen(false);
|
||||
setName("");
|
||||
onHired();
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "استخدام کارمند انجام نشد.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<Button onClick={openDialog} disabled={disabled}>
|
||||
کارمند جدید
|
||||
</Button>
|
||||
{hint && <p className="max-w-xs text-xs text-slate-500">{hint}</p>}
|
||||
|
||||
<Modal
|
||||
open={open}
|
||||
title="استخدام کارمند جدید"
|
||||
description="نقش و سطح را انتخاب کنید. نام اختیاری است."
|
||||
onClose={closeDialog}
|
||||
size="sm"
|
||||
>
|
||||
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
||||
<Select
|
||||
label="نقش"
|
||||
value={role}
|
||||
disabled={busy}
|
||||
onChange={(e) => setRole(e.target.value as HirableRole)}
|
||||
options={HIRABLE_ROLES.map((value) => ({ value, label: ROLE_LABELS[value] }))}
|
||||
/>
|
||||
<Select
|
||||
label="سطح"
|
||||
value={tier?.tier ?? ""}
|
||||
disabled={busy || noTier}
|
||||
onChange={(e) => setTierKey(e.target.value)}
|
||||
options={tiers.map((option) => ({ value: option.tier, label: option.label }))}
|
||||
/>
|
||||
<Field
|
||||
label="نام (اختیاری)"
|
||||
value={name}
|
||||
disabled={busy}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="اگر خالی بماند، نام پیشفرض نقش داده میشود"
|
||||
/>
|
||||
|
||||
{hint && <p className="text-xs text-warning">{hint}</p>}
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="secondary" size="sm" onClick={closeDialog} disabled={busy}>
|
||||
انصراف
|
||||
</Button>
|
||||
<Button type="submit" size="sm" loading={busy} disabled={disabled || tier === null}>
|
||||
استخدام
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The organization's AI team: one row per member, with its role, its tier, its status and the
|
||||
* task it is busy with. A member is shown by its neutral tier only; no model or provider name is
|
||||
* ever rendered. Firing a member archives it, and asks first in a confirm dialog.
|
||||
*/
|
||||
import Link from "next/link";
|
||||
import * as React from "react";
|
||||
import type { EmployeeStatus } from "@agy/contracts";
|
||||
import { Badge, Button, ConfirmDialog, EmptyState } from "@agy/ui";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { type TeamMember, type TeamView, engineApi } from "@/lib/api-engine";
|
||||
import { ROLE_LABELS, TIER_LABELS } from "@/lib/engine-labels";
|
||||
|
||||
/** Roles a team can hold that the hire form does not offer; they keep their own labels. */
|
||||
const OTHER_ROLE_LABELS: Record<string, string> = {
|
||||
researcher: "محقق",
|
||||
content_creator: "تولیدکننده محتوا",
|
||||
support: "پشتیبان",
|
||||
};
|
||||
|
||||
const STATUS_LABELS: Record<EmployeeStatus, string> = {
|
||||
active: "فعال",
|
||||
paused: "متوقف",
|
||||
archived: "بایگانی",
|
||||
};
|
||||
|
||||
const STATUS_TONES: Record<EmployeeStatus, "success" | "warning" | "neutral"> = {
|
||||
active: "success",
|
||||
paused: "warning",
|
||||
archived: "neutral",
|
||||
};
|
||||
|
||||
/** The Persian name of a role. A role without a label shows its key rather than nothing. */
|
||||
export function roleLabel(roleKey: string): string {
|
||||
return ROLE_LABELS[roleKey] ?? OTHER_ROLE_LABELS[roleKey] ?? roleKey;
|
||||
}
|
||||
|
||||
/** The neutral name of a tier. ``unknown`` shows while no granted model backs a member. */
|
||||
export function tierLabel(tier: string): string {
|
||||
return TIER_LABELS[tier] ?? TIER_LABELS.unknown;
|
||||
}
|
||||
|
||||
export interface TeamPanelProps {
|
||||
orgId: string;
|
||||
team: TeamView;
|
||||
/** False for anyone who may not manage employees: they see the team but cannot fire from it. */
|
||||
canManage: boolean;
|
||||
/** Called after a member was fired, so the page reloads the team. */
|
||||
onChanged: () => void;
|
||||
}
|
||||
|
||||
export function TeamPanel({ orgId, team, canManage, onChanged }: TeamPanelProps) {
|
||||
const [firing, setFiring] = React.useState<TeamMember | null>(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const confirmFire = async () => {
|
||||
if (firing === null) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await engineApi.fire(orgId, firing.id);
|
||||
setFiring(null);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setFiring(null);
|
||||
setError(err instanceof ApiError ? err.message : "حذف کارمند از تیم انجام نشد.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section aria-label="تیم هوش مصنوعی" className="flex flex-col gap-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="text-sm font-medium text-slate-200">اعضای تیم</h2>
|
||||
<Badge tone="info">
|
||||
{team.count.toLocaleString("fa-IR")} از {team.max_employees.toLocaleString("fa-IR")} نفر
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{team.employees.length === 0 ? (
|
||||
<EmptyState
|
||||
title="هنوز کارمندی در تیم نیست"
|
||||
description={
|
||||
canManage
|
||||
? "با «کارمند جدید» اولین کارمند را استخدام کنید."
|
||||
: "مدیر سازمان میتواند کارمند استخدام کند."
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<ul className="flex flex-col gap-2">
|
||||
{team.employees.map((member) => (
|
||||
<li
|
||||
key={member.id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-surface-border bg-surface-raised px-4 py-3"
|
||||
>
|
||||
<div className="flex min-w-0 flex-col gap-1">
|
||||
<Link
|
||||
href={`/workforce/${member.id}`}
|
||||
className="truncate text-sm font-medium text-slate-100 transition-colors hover:text-accent"
|
||||
>
|
||||
{member.name}
|
||||
</Link>
|
||||
<p className="flex flex-wrap items-center gap-x-2 text-xs text-slate-400">
|
||||
<span>{roleLabel(member.role_key)}</span>
|
||||
<span aria-hidden>·</span>
|
||||
<span>سطح: {tierLabel(member.tier)}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge tone={STATUS_TONES[member.status]}>{STATUS_LABELS[member.status]}</Badge>
|
||||
{member.busy_task_id ? (
|
||||
<Link
|
||||
href={`/tasks/${member.busy_task_id}`}
|
||||
className="max-w-xs truncate text-xs text-accent hover:underline"
|
||||
>
|
||||
{member.busy_task_title ?? "کار جاری"}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-xs text-slate-500">بیکار</span>
|
||||
)}
|
||||
{canManage && (
|
||||
<Button variant="ghost" size="sm" onClick={() => setFiring(member)}>
|
||||
حذف از تیم
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="text-xs text-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={firing !== null}
|
||||
title={firing === null ? "" : `«${firing.name}» از تیم حذف شود؟`}
|
||||
description="کارمند بایگانی میشود و دیگر کاری به او داده نمیشود. آخرین مدیر سازمان را نمیتوان حذف کرد."
|
||||
confirmLabel={busy ? "در حال حذف…" : "حذف کن"}
|
||||
onConfirm={() => void confirmFire()}
|
||||
onCancel={() => setFiring(null)}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,144 +1,48 @@
|
||||
"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 { EmptyState, ErrorState, LoadingState } from "@agy/ui";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { type TeamView, engineApi } from "@/lib/api-engine";
|
||||
import { CAN_MANAGE_EMPLOYEES, roleHasCapability } from "@/lib/permissions";
|
||||
import { useLiveChannel } from "@/lib/realtime";
|
||||
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;
|
||||
import { HireDialog } from "./_components/hire-dialog";
|
||||
import { TeamPanel } from "./_components/team-panel";
|
||||
|
||||
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 orgId = activeOrganization?.id ?? null;
|
||||
|
||||
const [team, setTeam] = React.useState<TeamView | null>(null);
|
||||
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({});
|
||||
}
|
||||
setTeam(await engineApi.getTeam(orgId));
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "دریافت کارمندها ناموفق بود.");
|
||||
setError(err instanceof ApiError ? err.message : "دریافت تیم ناموفق بود.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orgId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
setLoading(true);
|
||||
setTeam(null);
|
||||
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);
|
||||
}
|
||||
};
|
||||
// A hire or a firing by anyone in the organization reloads the team in place.
|
||||
useLiveChannel(orgId ? [`org:${orgId}`] : [], (event) => {
|
||||
if (event.type === "team.changed") void load();
|
||||
});
|
||||
|
||||
if (!activeOrganization) {
|
||||
return (
|
||||
@@ -149,121 +53,41 @@ export default function WorkforcePage() {
|
||||
);
|
||||
}
|
||||
|
||||
if (loading) return <LoadingState label="در حال دریافت تیم…" />;
|
||||
if (team === null) {
|
||||
return <ErrorState message={error ?? "دریافت تیم ناموفق بود."} onRetry={load} />;
|
||||
}
|
||||
|
||||
const canManage = roleHasCapability(activeOrganization.role, CAN_MANAGE_EMPLOYEES);
|
||||
const atCap = team.count >= team.max_employees;
|
||||
|
||||
return (
|
||||
<div className="anim-stagger flex flex-col gap-5">
|
||||
<header className="flex items-start justify-between gap-3">
|
||||
<header className="flex flex-wrap 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>
|
||||
<HireDialog
|
||||
orgId={activeOrganization.id}
|
||||
tiers={team.tiers}
|
||||
atCap={atCap}
|
||||
onHired={load}
|
||||
/>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
{error && <ErrorState title="بهروزرسانی تیم ناموفق بود" message={error} />}
|
||||
|
||||
{loading ? (
|
||||
<LoadingState label="در حال دریافت کارمندها…" />
|
||||
) : error ? (
|
||||
<ErrorState message={error} onRetry={load} />
|
||||
) : employees.length === 0 ? (
|
||||
<EmptyState
|
||||
title="هنوز کارمندی ساخته نشده است"
|
||||
description="یک قالب انتخاب کنید تا اولین کارمند هوش مصنوعی سازمان ساخته شود."
|
||||
/>
|
||||
) : (
|
||||
<div className="anim-stagger 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>
|
||||
)}
|
||||
<TeamPanel
|
||||
orgId={activeOrganization.id}
|
||||
team={team}
|
||||
canManage={canManage}
|
||||
onChanged={load}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
Select,
|
||||
} from "@agy/ui";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { engineApi } from "@/lib/api-engine";
|
||||
import { setupApi } from "@/lib/api-org";
|
||||
import { useSession } from "@/lib/session";
|
||||
|
||||
@@ -369,6 +370,7 @@ export default function OnboardingPage() {
|
||||
«{result.organization.name}» و اپلیکیشن «{result.project.name}» آماده شدند.
|
||||
حالا میتوانید تیم هوشمصنوعی خود را راهبیندازید.
|
||||
</CardHint>
|
||||
<WizardCreditHint orgId={result.organization.id} />
|
||||
<div className="mt-5">
|
||||
<Button className="w-full" onClick={() => router.replace("/manager")}>
|
||||
برو به مدیر AI
|
||||
@@ -440,6 +442,38 @@ export default function OnboardingPage() {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The credit line on the success screen. Work waits for credit when the engine reports none, and
|
||||
* also when the status cannot be read, so the owner's action is named either way. Nothing shows
|
||||
* while the status loads or once credit is there.
|
||||
*/
|
||||
function WizardCreditHint({ orgId }: { orgId: string }) {
|
||||
const [creditOk, setCreditOk] = React.useState<boolean | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
let current = true;
|
||||
const check = async () => {
|
||||
try {
|
||||
const status = await engineApi.status(orgId);
|
||||
if (current) setCreditOk(status.credit_ok);
|
||||
} catch {
|
||||
if (current) setCreditOk(false);
|
||||
}
|
||||
};
|
||||
void check();
|
||||
return () => {
|
||||
current = false;
|
||||
};
|
||||
}, [orgId]);
|
||||
|
||||
if (creditOk !== false) return null;
|
||||
return (
|
||||
<p className="mt-3 text-xs leading-6 text-warning">
|
||||
کار کارمندان پس از افزودن اعتبار توسط مالک سامانه آغاز میشود.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stepper
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The engine's credit state for one organization, and the notice that work has stopped.
|
||||
*
|
||||
* ``useEngineStatus`` loads ``GET /engine/status`` and loads it again when the balance, a
|
||||
* task's stage or the team changes, so the notice and the counts follow the live feed.
|
||||
* ``CreditNotice`` says, in Persian, why work is stopped; it shows nothing while credit is
|
||||
* available or while the status is still loading.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { Card, CardTitle } from "@agy/ui";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { type EngineStatus, engineApi } from "@/lib/api-engine";
|
||||
import { useLiveChannel } from "@/lib/realtime";
|
||||
|
||||
/** Live events that change the credit state or the team the status describes. */
|
||||
const RELOAD_EVENTS: ReadonlySet<string> = new Set(["billing.wallet", "task.step", "team.changed"]);
|
||||
|
||||
export interface EngineStatusState {
|
||||
status: EngineStatus | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
export function useEngineStatus(orgId: string): EngineStatusState {
|
||||
const [status, setStatus] = React.useState<EngineStatus | null>(null);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const load = React.useCallback(async () => {
|
||||
try {
|
||||
setStatus(await engineApi.status(orgId));
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "وضعیت موتور دریافت نشد.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [orgId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useLiveChannel([`org:${orgId}`], (event) => {
|
||||
if (RELOAD_EVENTS.has(event.type)) void load();
|
||||
});
|
||||
|
||||
return { status, loading, error, reload: load };
|
||||
}
|
||||
|
||||
export function CreditNotice({ status }: { status: EngineStatus | null }) {
|
||||
if (status === null || status.credit_ok) return null;
|
||||
return (
|
||||
<Card role="status" className="border-warning/50">
|
||||
<CardTitle>اعتبار سازمان به پایان رسیده است</CardTitle>
|
||||
<p className="text-xs leading-6 text-slate-300">
|
||||
کار کارمندان هوش مصنوعی تا افزودن اعتبار توسط مالک سامانه متوقف است. پس از افزودن اعتبار،
|
||||
کارهای متوقفشده را از صفحهٔ وظایف ادامه دهید.
|
||||
</p>
|
||||
<p className="mt-2 text-xs text-slate-400">
|
||||
موجودی: {status.balance.toLocaleString("fa-IR")}{" "}
|
||||
<span className="ltr-inline">{status.currency}</span>
|
||||
</p>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* The pause, resume and cancel controls of one engine task.
|
||||
*
|
||||
* Each control asks first, in a confirm dialog, and then calls the engine API. A refusal from
|
||||
* the server (for example a resume while the organization has no credit) is shown in Persian
|
||||
* under the buttons, with the server's own message. A legacy task has no engine stage, so it
|
||||
* shows nothing; neither does anyone without the right to run tasks.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { Button, ConfirmDialog } from "@agy/ui";
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { type EngineStage, engineApi } from "@/lib/api-engine";
|
||||
|
||||
type Action = "pause" | "resume" | "cancel";
|
||||
|
||||
/** A finished task takes no action at all. */
|
||||
const FINISHED: ReadonlySet<string> = new Set(["completed", "cancelled", "failed"]);
|
||||
|
||||
interface ActionCopy {
|
||||
button: string;
|
||||
title: string;
|
||||
description: string;
|
||||
confirm: string;
|
||||
}
|
||||
|
||||
const COPY: Record<Action, ActionCopy> = {
|
||||
pause: {
|
||||
button: "توقف",
|
||||
title: "کار متوقف شود؟",
|
||||
description: "کار پیش از نوبت بعدی متوقف میشود و با «ادامه» از همان مرحله برمیگردد.",
|
||||
confirm: "توقف کن",
|
||||
},
|
||||
resume: {
|
||||
button: "ادامه",
|
||||
title: "کار ادامه یابد؟",
|
||||
description: "کار به بررسی مدیر برمیگردد. اگر اعتبار سازمان تمام شده باشد، ادامه ممکن نیست.",
|
||||
confirm: "ادامه بده",
|
||||
},
|
||||
cancel: {
|
||||
button: "لغو",
|
||||
title: "کار لغو شود؟",
|
||||
description:
|
||||
"کار بسته میشود و کارهایی که به آن وابستهاند به تصمیم شما منتقل میشوند. این کار برگشتپذیر نیست.",
|
||||
confirm: "لغو کن",
|
||||
},
|
||||
};
|
||||
|
||||
export interface TaskActionsProps {
|
||||
orgId: string;
|
||||
task: { id: string; stage: EngineStage | null };
|
||||
/** False for a viewer, or for anyone who may not run tasks. */
|
||||
canRun: boolean;
|
||||
/** Called after a control succeeds, so the page reloads the task. */
|
||||
onChanged: () => void;
|
||||
size?: "sm" | "md";
|
||||
}
|
||||
|
||||
export function TaskActions({ orgId, task, canRun, onChanged, size = "sm" }: TaskActionsProps) {
|
||||
const [pending, setPending] = React.useState<Action | null>(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
if (!canRun || task.stage === null) return null;
|
||||
|
||||
const stage: string = task.stage;
|
||||
const finished = FINISHED.has(stage);
|
||||
const paused = stage === "paused";
|
||||
const actions: Action[] = [];
|
||||
if (!finished && !paused) actions.push("pause");
|
||||
if (paused) actions.push("resume");
|
||||
if (!finished) actions.push("cancel");
|
||||
|
||||
const confirm = async () => {
|
||||
if (pending === null) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (pending === "pause") await engineApi.pauseTask(orgId, task.id);
|
||||
else if (pending === "resume") await engineApi.resumeTask(orgId, task.id);
|
||||
else await engineApi.cancelTask(orgId, task.id);
|
||||
setPending(null);
|
||||
onChanged();
|
||||
} catch (err) {
|
||||
setPending(null);
|
||||
setError(err instanceof ApiError ? err.message : "این کار انجام نشد.");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const current = pending === null ? null : COPY[pending];
|
||||
|
||||
return (
|
||||
<div dir="rtl" className="flex flex-wrap items-center gap-2">
|
||||
{actions.map((action) => (
|
||||
<Button
|
||||
key={action}
|
||||
size={size}
|
||||
variant={action === "cancel" ? "danger" : "secondary"}
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setPending(action);
|
||||
}}
|
||||
>
|
||||
{COPY[action].button}
|
||||
</Button>
|
||||
))}
|
||||
{error && (
|
||||
<p role="alert" className="w-full text-xs text-danger">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<ConfirmDialog
|
||||
open={current !== null}
|
||||
title={current?.title ?? ""}
|
||||
description={current?.description}
|
||||
confirmLabel={busy ? "در حال انجام…" : (current?.confirm ?? "تأیید")}
|
||||
onConfirm={confirm}
|
||||
onCancel={() => setPending(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* Typed client for the platform owner's credit console (prepaid billing, D-B).
|
||||
*
|
||||
* Mirrors the owner routes in ``app/interfaces/http/routers/admin_credits.py`` key for key.
|
||||
* Owner-only on the server: a member never calls these and reads its own balance through
|
||||
* the billing and engine status routes. No type here names a model or a provider.
|
||||
*
|
||||
* Add functions to this object only; ``lib/api.ts`` is locked. Use ``apiRequest`` for transport.
|
||||
*/
|
||||
import { apiRequest } from "./api";
|
||||
|
||||
/** One organization's wallet as the platform owner sees it. */
|
||||
export interface OrgBalance {
|
||||
organization_id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
balance: number;
|
||||
currency: string;
|
||||
/** Tasks the engine has paused for this organization. */
|
||||
paused_tasks: number;
|
||||
/** When credit was last added by hand, or null when it never was. */
|
||||
last_credit_at: string | null;
|
||||
}
|
||||
|
||||
/** One credit history row: a manual credit, the starting credit or an adjustment. */
|
||||
export interface CreditTransaction {
|
||||
id: string;
|
||||
kind: string;
|
||||
amount: number;
|
||||
balance_after: number;
|
||||
reason: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** The body of a credit. The server checks the amount range and the reason length. */
|
||||
export interface CreditRequest {
|
||||
amount: number;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
function orgBase(orgId: string): string {
|
||||
return `/api/admin/orgs/${encodeURIComponent(orgId)}`;
|
||||
}
|
||||
|
||||
export const creditApi = {
|
||||
/** Every organization's balance, paused task count and last credit date. */
|
||||
balances: () => apiRequest<OrgBalance[]>("/api/admin/credits"),
|
||||
|
||||
/** Add credit to one organization's wallet; returns the history row it wrote. */
|
||||
credit: (orgId: string, body: CreditRequest) =>
|
||||
apiRequest<CreditTransaction>(`${orgBase(orgId)}/credit`, {
|
||||
method: "POST",
|
||||
body: { amount: body.amount, reason: body.reason },
|
||||
}),
|
||||
|
||||
/** One organization's manual credit history, newest first; ``limit`` only when given. */
|
||||
history: (orgId: string, limit?: number) =>
|
||||
apiRequest<CreditTransaction[]>(
|
||||
`${orgBase(orgId)}/credit-history${limit === undefined ? "" : `?limit=${limit}`}`,
|
||||
),
|
||||
} satisfies Record<string, (...args: never[]) => ReturnType<typeof apiRequest>>;
|
||||
@@ -1,10 +1,203 @@
|
||||
/**
|
||||
* Typed client functions for the AI-workforce workstream (docs/plan/PLAN-v2.md §6).
|
||||
* Typed client functions for the AI-workforce engine (docs/plan/PLAN-v2.md §6).
|
||||
*
|
||||
* Mirrors the engine routes in ``app/interfaces/http/routers/engine.py`` key for key.
|
||||
* No type here names a model or a provider: a team member shows a neutral tier only.
|
||||
*
|
||||
* Add functions to this object only; ``lib/api.ts`` is locked. Use ``apiRequest`` for transport.
|
||||
*/
|
||||
import type { EmployeeStatus, RoleKey, RunStatus, TaskStatus } from "@agy/contracts";
|
||||
import { apiRequest } from "./api";
|
||||
|
||||
/** The thirteen stages a task can be at, in the engine's own names. */
|
||||
export type EngineStage =
|
||||
| "pending_manager_review"
|
||||
| "pending_analysis"
|
||||
| "pending_implementation"
|
||||
| "pending_review"
|
||||
| "pending_test"
|
||||
| "pending_final_review"
|
||||
| "merge_request"
|
||||
| "completed"
|
||||
| "pending_user_decision"
|
||||
| "error_review"
|
||||
| "paused"
|
||||
| "cancelled"
|
||||
| "failed";
|
||||
|
||||
/** A tier the organization may be granted, cheapest first. Never a model name. */
|
||||
export type GrantedTier = "fast" | "balanced" | "strong";
|
||||
|
||||
/** A member's tier; ``unknown`` while no granted model backs the member. */
|
||||
export type EngineTier = GrantedTier | "unknown";
|
||||
|
||||
/** ``GET /status``: the credit, the owner's policy, the team size and the stage counts. */
|
||||
export interface EngineStatus {
|
||||
balance: number;
|
||||
currency: string;
|
||||
credit_ok: boolean;
|
||||
automation_enabled: boolean;
|
||||
max_employees: number;
|
||||
team_size: number;
|
||||
stage_counts: Record<string, number>;
|
||||
}
|
||||
|
||||
/** One task on the board. ``stage`` is null for a legacy task the engine does not own. */
|
||||
export interface EngineTask {
|
||||
id: string;
|
||||
title: string;
|
||||
status: TaskStatus;
|
||||
stage: EngineStage | null;
|
||||
pipeline: string | null;
|
||||
kind: string | null;
|
||||
/** 0 for a legacy task. */
|
||||
cycle: number;
|
||||
/** 0 for a legacy task. */
|
||||
max_cycles: number;
|
||||
thread_id: string | null;
|
||||
assigned_employee_id: string | null;
|
||||
assignee_name: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/** One employee turn: who ran, in which role and stage, and how it ended. */
|
||||
export interface EngineTurn {
|
||||
id: string;
|
||||
run_id: string;
|
||||
role: RoleKey;
|
||||
mode: string;
|
||||
stage: EngineStage;
|
||||
employee_name: string | null;
|
||||
ok: boolean;
|
||||
error_kind: string;
|
||||
tool_calls: number;
|
||||
/** At most 1,000 characters of the reply. */
|
||||
reply_excerpt: string;
|
||||
started_at: string;
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
/** A turn in the organization's recent list, with the task it belongs to. */
|
||||
export interface EngineOrgTurn extends EngineTurn {
|
||||
task_id: string;
|
||||
task_title: string;
|
||||
}
|
||||
|
||||
/** One gate verdict on a task. */
|
||||
export interface EngineGate {
|
||||
id: string;
|
||||
gate: string;
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
/** One run of a task. */
|
||||
export interface EngineRun {
|
||||
id: string;
|
||||
status: RunStatus;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
}
|
||||
|
||||
/** ``GET /tasks/{id}``: the task with its pipeline, turns, gates, runs and last diff. */
|
||||
export interface EngineTaskDetail extends EngineTask {
|
||||
description: string;
|
||||
/** The stages of the task's pipeline in order; empty for a legacy task. */
|
||||
stages: EngineStage[];
|
||||
waiver_reason: string;
|
||||
branch: string;
|
||||
depends_on: string[];
|
||||
merged_sha: string;
|
||||
diff: string;
|
||||
turns: EngineTurn[];
|
||||
gates: EngineGate[];
|
||||
runs: EngineRun[];
|
||||
}
|
||||
|
||||
/** One team member. */
|
||||
export interface TeamMember {
|
||||
id: string;
|
||||
name: string;
|
||||
role_key: RoleKey;
|
||||
status: EmployeeStatus;
|
||||
tier: EngineTier;
|
||||
busy_task_id: string | null;
|
||||
busy_task_title: string | null;
|
||||
hired_by: string;
|
||||
}
|
||||
|
||||
/** A tier the organization has granted models for, with its Persian label. */
|
||||
export interface TierOption {
|
||||
tier: GrantedTier;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/** ``GET /team``: the members (not archived), the tiers, the owner's cap and the headcount. */
|
||||
export interface TeamView {
|
||||
employees: TeamMember[];
|
||||
tiers: TierOption[];
|
||||
max_employees: number;
|
||||
count: number;
|
||||
}
|
||||
|
||||
/** A hire asks for a role and a tier. The platform picks the model. */
|
||||
export interface HireRequest {
|
||||
role: RoleKey;
|
||||
tier: GrantedTier;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
function base(orgId: string): string {
|
||||
return `/api/organizations/${orgId}/engine`;
|
||||
}
|
||||
|
||||
function query(params: Record<string, string | number | undefined>): string {
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== "") search.set(key, String(value));
|
||||
}
|
||||
const text = search.toString();
|
||||
return text ? `?${text}` : "";
|
||||
}
|
||||
|
||||
export const engineApi = {
|
||||
// example: list: (orgId: string) => apiRequest<Foo[]>(`/api/organizations/${orgId}/foo`),
|
||||
/** Credit, policy, team size and stage counts. Every member may read it. */
|
||||
status: (orgId: string) => apiRequest<EngineStatus>(`${base(orgId)}/status`),
|
||||
|
||||
/** The organization's tasks, newest first; ``stage`` narrows the board to one stage. */
|
||||
listTasks: (orgId: string, stage?: EngineStage) =>
|
||||
apiRequest<EngineTask[]>(`${base(orgId)}/tasks${query({ stage })}`),
|
||||
|
||||
getTask: (orgId: string, taskId: string) =>
|
||||
apiRequest<EngineTaskDetail>(`${base(orgId)}/tasks/${taskId}`),
|
||||
|
||||
/** The organization's newest turns, across its tasks. */
|
||||
listTurns: (orgId: string, limit?: number) =>
|
||||
apiRequest<EngineOrgTurn[]>(`${base(orgId)}/turns${query({ limit })}`),
|
||||
|
||||
pauseTask: (orgId: string, taskId: string) =>
|
||||
apiRequest<EngineTask>(`${base(orgId)}/tasks/${taskId}/pause`, { method: "POST" }),
|
||||
|
||||
resumeTask: (orgId: string, taskId: string) =>
|
||||
apiRequest<EngineTask>(`${base(orgId)}/tasks/${taskId}/resume`, { method: "POST" }),
|
||||
|
||||
cancelTask: (orgId: string, taskId: string) =>
|
||||
apiRequest<EngineTask>(`${base(orgId)}/tasks/${taskId}/cancel`, { method: "POST" }),
|
||||
|
||||
/** Answer the question a task is waiting on (only at ``pending_user_decision``). */
|
||||
answerTask: (orgId: string, taskId: string, content: string) =>
|
||||
apiRequest<EngineTask>(`${base(orgId)}/tasks/${taskId}/answer`, {
|
||||
method: "POST",
|
||||
body: { content },
|
||||
}),
|
||||
|
||||
getTeam: (orgId: string) => apiRequest<TeamView>(`${base(orgId)}/team`),
|
||||
|
||||
hire: (orgId: string, body: HireRequest) =>
|
||||
apiRequest<TeamMember>(`${base(orgId)}/team/hire`, { method: "POST", body }),
|
||||
|
||||
fire: (orgId: string, employeeId: string) =>
|
||||
apiRequest<TeamMember>(`${base(orgId)}/team/${employeeId}/fire`, { method: "POST" }),
|
||||
} satisfies Record<string, (...args: never[]) => ReturnType<typeof apiRequest>>;
|
||||
|
||||
@@ -18,6 +18,7 @@ import type {
|
||||
SetupDraft,
|
||||
WizardCompleteBody,
|
||||
WizardCompleteResult,
|
||||
WsEventType,
|
||||
} from "@agy/contracts";
|
||||
import { apiRequest } from "./api";
|
||||
|
||||
@@ -303,11 +304,10 @@ export const memoryApi = {
|
||||
/**
|
||||
* The event the API publishes on ``org:<id>`` whenever memory changes.
|
||||
*
|
||||
* Declared here rather than in ``@agy/contracts``: the event vocabulary is a
|
||||
* frozen, foundation-owned list, so a page compares the raw string instead of
|
||||
* widening the union for one new type.
|
||||
* The type itself is part of ``WsEventType`` in ``@agy/contracts``; this
|
||||
* constant only names it, so a page compares against a literal the union checks.
|
||||
*/
|
||||
export const MEMORY_CHANGED_EVENT = "memory.changed";
|
||||
export const MEMORY_CHANGED_EVENT = "memory.changed" as const satisfies WsEventType;
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* MCP tokens (org admin) + the owner's switches */
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
/**
|
||||
* Persian labels, badge tones and board columns for the AI-workforce engine.
|
||||
*
|
||||
* Pure data and pure functions: no React and no transport. The stage names are the
|
||||
* engine's own (``app/domain/workforce/pipeline.py``). A legacy task has no engine
|
||||
* stage, so it is placed on the board by its coarse status instead. A tier is always
|
||||
* neutral: no label here names a model or a provider.
|
||||
*/
|
||||
import type { EngineStage } from "./api-engine";
|
||||
|
||||
/** The tone of a badge: the ``Badge`` tones of ``@agy/ui``. */
|
||||
export type StageTone = "neutral" | "info" | "success" | "warning" | "danger";
|
||||
|
||||
/** Persian label for each of the thirteen stages. */
|
||||
export const STAGE_LABELS: Record<string, string> = {
|
||||
pending_manager_review: "در انتظار بررسی مدیر",
|
||||
pending_analysis: "در انتظار تحلیل",
|
||||
pending_implementation: "در انتظار پیادهسازی",
|
||||
pending_review: "در انتظار بازبینی",
|
||||
pending_test: "در انتظار تست",
|
||||
pending_final_review: "در انتظار بازبینی نهایی",
|
||||
merge_request: "در انتظار ادغام",
|
||||
completed: "انجام شد",
|
||||
pending_user_decision: "نیازمند تصمیم شما",
|
||||
error_review: "بررسی خطا",
|
||||
paused: "متوقف شده",
|
||||
cancelled: "لغو شد",
|
||||
failed: "ناموفق",
|
||||
};
|
||||
|
||||
/** Green when the task is done, red when it ended badly, amber when a person must act. */
|
||||
export function stageTone(stage: string): StageTone {
|
||||
if (stage === "completed") return "success";
|
||||
if (stage === "failed" || stage === "cancelled") return "danger";
|
||||
if (stage === "paused" || stage === "pending_user_decision" || stage === "error_review") {
|
||||
return "warning";
|
||||
}
|
||||
return "info";
|
||||
}
|
||||
|
||||
export type BoardColumnKey =
|
||||
| "manager_review"
|
||||
| "analysis"
|
||||
| "implementation"
|
||||
| "review"
|
||||
| "test"
|
||||
| "merge"
|
||||
| "done"
|
||||
| "decision";
|
||||
|
||||
export interface BoardColumn {
|
||||
key: BoardColumnKey;
|
||||
label: string;
|
||||
/** The engine stages that land in this column. */
|
||||
stages: readonly EngineStage[];
|
||||
}
|
||||
|
||||
/** The board's columns, left to right. Every stage belongs to exactly one column. */
|
||||
export const BOARD_COLUMNS: readonly BoardColumn[] = [
|
||||
{ key: "manager_review", label: "بررسی مدیر", stages: ["pending_manager_review"] },
|
||||
{ key: "analysis", label: "تحلیل", stages: ["pending_analysis"] },
|
||||
{ key: "implementation", label: "پیادهسازی", stages: ["pending_implementation"] },
|
||||
{ key: "review", label: "بازبینی", stages: ["pending_review", "pending_final_review"] },
|
||||
{ key: "test", label: "تست", stages: ["pending_test"] },
|
||||
{ key: "merge", label: "ادغام", stages: ["merge_request"] },
|
||||
{ key: "done", label: "انجامشده", stages: ["completed", "cancelled", "failed"] },
|
||||
{
|
||||
key: "decision",
|
||||
label: "نیازمند تصمیم",
|
||||
stages: ["pending_user_decision", "error_review", "paused"],
|
||||
},
|
||||
];
|
||||
|
||||
const STAGE_COLUMN: Record<string, BoardColumnKey | undefined> = Object.fromEntries(
|
||||
BOARD_COLUMNS.flatMap((column) => column.stages.map((stage) => [stage, column.key])),
|
||||
);
|
||||
|
||||
/**
|
||||
* Where a legacy task (no engine stage) sits, by its coarse status. ``triaged`` is a task
|
||||
* the manager has already assigned, so it waits for work rather than for a review.
|
||||
*/
|
||||
const LEGACY_COLUMN: Record<string, BoardColumnKey | undefined> = {
|
||||
new: "manager_review",
|
||||
triaged: "implementation",
|
||||
running: "implementation",
|
||||
waiting_approval: "decision",
|
||||
done: "done",
|
||||
failed: "done",
|
||||
cancelled: "done",
|
||||
};
|
||||
|
||||
/**
|
||||
* The column a task belongs to. An unknown stage or status goes to the decision column,
|
||||
* so that a task the board does not recognise is still visible to a person.
|
||||
*/
|
||||
export function columnFor(stage: string | null, status: string): BoardColumnKey {
|
||||
if (stage !== null) return STAGE_COLUMN[stage] ?? "decision";
|
||||
return LEGACY_COLUMN[status] ?? "decision";
|
||||
}
|
||||
|
||||
/** Tasks grouped by column, every column present, each column in the order it was given. */
|
||||
export function groupByColumn<T extends { stage: string | null; status: string }>(
|
||||
tasks: readonly T[],
|
||||
): Record<BoardColumnKey, T[]> {
|
||||
const groups = {} as Record<BoardColumnKey, T[]>;
|
||||
for (const column of BOARD_COLUMNS) groups[column.key] = [];
|
||||
for (const task of tasks) groups[columnFor(task.stage, task.status)].push(task);
|
||||
return groups;
|
||||
}
|
||||
|
||||
/** Neutral tier labels. ``unknown`` shows when no granted model backs a member. */
|
||||
export const TIER_LABELS: Record<string, string> = {
|
||||
fast: "سریع",
|
||||
balanced: "متعادل",
|
||||
strong: "قوی",
|
||||
unknown: "نامشخص",
|
||||
};
|
||||
|
||||
export const ROLE_LABELS: Record<string, string> = {
|
||||
manager: "مدیر",
|
||||
planner: "برنامهریز",
|
||||
developer: "برنامهنویس",
|
||||
reviewer: "بازبین",
|
||||
tester: "آزمونگر",
|
||||
};
|
||||
@@ -39,6 +39,7 @@ export const ADMIN_ITEMS: NavItem[] = [
|
||||
{ href: "/admin/rate-cards", label: "تعرفهها", hint: "قیمت واحدها" },
|
||||
{ href: "/admin/usage", label: "مصرف سراسری", hint: "گزارش مصرف مستأجرها" },
|
||||
{ href: "/admin/organizations", label: "سازمانها", hint: "مستأجرها" },
|
||||
{ href: "/admin/credits", label: "اعتبار سازمانها", hint: "شارژ پیشپرداخت" },
|
||||
{ href: "/admin/model-grants", label: "مدلهای سازمانها", hint: "تخصیص مدل به هر سازمان" },
|
||||
{ href: "/admin/domains", label: "دامنهها", hint: "مسیریابی Caddy" },
|
||||
{ href: "/admin/sandbox", label: "سندباکسها", hint: "کانتینرها و محدودیتها" },
|
||||
|
||||
+35
-10
@@ -67,7 +67,10 @@ export class RealtimeClient {
|
||||
private status: RealtimeStatus = "idle";
|
||||
private readonly listeners = new Set<EventListener>();
|
||||
private readonly statusListeners = new Set<StatusListener>();
|
||||
private readonly subscriptions = new Set<string>();
|
||||
/** Channel -> how many callers hold it. A channel stays on the wire while its
|
||||
* count is above zero, so two live hooks on ``org:<id>`` do not lose their
|
||||
* feed when one of them unmounts. */
|
||||
private readonly subscriptions = new Map<string, number>();
|
||||
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
||||
private pongTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
@@ -164,8 +167,9 @@ export class RealtimeClient {
|
||||
// Re-play subscriptions: a new socket knows nothing about what this
|
||||
// client cared about, and pages do not (and should not) re-run their
|
||||
// effects to fix it.
|
||||
if (this.subscriptions.size > 0) {
|
||||
this.send({ action: "subscribe", channels: [...this.subscriptions] });
|
||||
const held = this.activeSubscriptions();
|
||||
if (held.length > 0) {
|
||||
this.send({ action: "subscribe", channels: held });
|
||||
}
|
||||
resolve();
|
||||
};
|
||||
@@ -316,22 +320,43 @@ export class RealtimeClient {
|
||||
|
||||
// -- subscriptions ---------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Take one reference on each channel. Only a channel that nobody held before
|
||||
* is sent to the server; a second holder just adds to the count.
|
||||
*/
|
||||
subscribe(channels: string[]): void {
|
||||
const fresh = channels.filter((channel) => !this.subscriptions.has(channel));
|
||||
const fresh: string[] = [];
|
||||
for (const channel of channels) {
|
||||
const held = this.subscriptions.get(channel) ?? 0;
|
||||
this.subscriptions.set(channel, held + 1);
|
||||
if (held === 0) fresh.push(channel);
|
||||
}
|
||||
if (fresh.length === 0) return;
|
||||
fresh.forEach((channel) => this.subscriptions.add(channel));
|
||||
this.send({ action: "subscribe", channels: fresh });
|
||||
}
|
||||
|
||||
/**
|
||||
* Release one reference on each channel. The server is told only when the
|
||||
* last holder lets go. A channel nobody holds is ignored.
|
||||
*/
|
||||
unsubscribe(channels: string[]): void {
|
||||
const active = channels.filter((channel) => this.subscriptions.has(channel));
|
||||
if (active.length === 0) return;
|
||||
active.forEach((channel) => this.subscriptions.delete(channel));
|
||||
this.send({ action: "unsubscribe", channels: active });
|
||||
const released: string[] = [];
|
||||
for (const channel of channels) {
|
||||
const held = this.subscriptions.get(channel);
|
||||
if (held === undefined) continue;
|
||||
if (held > 1) {
|
||||
this.subscriptions.set(channel, held - 1);
|
||||
continue;
|
||||
}
|
||||
this.subscriptions.delete(channel);
|
||||
released.push(channel);
|
||||
}
|
||||
if (released.length === 0) return;
|
||||
this.send({ action: "unsubscribe", channels: released });
|
||||
}
|
||||
|
||||
activeSubscriptions(): string[] {
|
||||
return [...this.subscriptions];
|
||||
return [...this.subscriptions.keys()];
|
||||
}
|
||||
|
||||
// -- listeners --------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* The platform owner's credit page: every organization's balance, the add-credit dialog (a valid
|
||||
* amount and reason, then a confirm step) and the history drawer. The API client is mocked, so
|
||||
* the test sees exactly the calls the page makes and the text the owner reads.
|
||||
*/
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
class FakeApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
return {
|
||||
FakeApiError,
|
||||
creditApi: { balances: vi.fn(), credit: vi.fn(), history: vi.fn() },
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/api", () => ({ ApiError: mocks.FakeApiError }));
|
||||
vi.mock("@/lib/api-credit", () => ({ creditApi: mocks.creditApi }));
|
||||
|
||||
import AdminCreditsPage from "@/app/(app)/admin/credits/page";
|
||||
|
||||
const ACME = {
|
||||
organization_id: "org-1",
|
||||
name: "Acme",
|
||||
slug: "acme",
|
||||
balance: 120,
|
||||
currency: "USD",
|
||||
paused_tasks: 0,
|
||||
last_credit_at: "2026-10-01T10:00:00Z",
|
||||
};
|
||||
|
||||
const BETA = {
|
||||
organization_id: "org-2",
|
||||
name: "Beta",
|
||||
slug: "beta",
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
paused_tasks: 3,
|
||||
last_credit_at: null,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(mocks.creditApi)) fn.mockReset();
|
||||
mocks.creditApi.balances.mockResolvedValue([ACME, BETA]);
|
||||
mocks.creditApi.credit.mockResolvedValue({
|
||||
id: "tx-1",
|
||||
kind: "owner_credit",
|
||||
amount: 50,
|
||||
balance_after: 170,
|
||||
reason: "شارژ دستی",
|
||||
created_at: "2026-10-14T09:00:00Z",
|
||||
});
|
||||
mocks.creditApi.history.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
/** The table row that names the organization, so a button can be pressed inside it. */
|
||||
async function rowFor(name: string): Promise<HTMLElement> {
|
||||
const cell = await screen.findByText(name);
|
||||
return cell.closest("tr") as HTMLElement;
|
||||
}
|
||||
|
||||
/** Opens the add-credit dialog for one organization and returns it. */
|
||||
async function openCreditDialog(name: string): Promise<HTMLElement> {
|
||||
fireEvent.click(within(await rowFor(name)).getByRole("button", { name: "افزودن اعتبار" }));
|
||||
return screen.findByRole("dialog", { name: `افزودن اعتبار به «${name}»` });
|
||||
}
|
||||
|
||||
describe("owner credit page: balances", () => {
|
||||
it("lists every organization with its balance and marks the one without credit", async () => {
|
||||
render(<AdminCreditsPage />);
|
||||
|
||||
expect(await screen.findByText("Acme")).toBeInTheDocument();
|
||||
expect(screen.getByText("Beta")).toBeInTheDocument();
|
||||
expect(screen.getByText("120.00 USD")).toBeInTheDocument();
|
||||
|
||||
expect(within(await rowFor("Beta")).getByText("بدون اعتبار")).toBeInTheDocument();
|
||||
expect(within(await rowFor("Acme")).queryByText("بدون اعتبار")).not.toBeInTheDocument();
|
||||
expect(within(await rowFor("Beta")).getByText("3")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("owner credit page: add credit", () => {
|
||||
it("keeps the continue button disabled until the amount and the reason are valid", async () => {
|
||||
render(<AdminCreditsPage />);
|
||||
const dialog = await openCreditDialog("Acme");
|
||||
const next = within(dialog).getByRole("button", { name: "ادامه" });
|
||||
expect(next).toBeDisabled();
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText("مبلغ"), { target: { value: "50" } });
|
||||
expect(next).toBeDisabled();
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText("دلیل"), { target: { value: "ab" } });
|
||||
expect(next).toBeDisabled();
|
||||
expect(within(dialog).getByText(/دلیل باید حداقل/)).toBeInTheDocument();
|
||||
|
||||
fireEvent.change(within(dialog).getByLabelText("دلیل"), { target: { value: "واریز بانکی" } });
|
||||
expect(next).toBeEnabled();
|
||||
|
||||
for (const amount of ["0", "-5", "100001"]) {
|
||||
fireEvent.change(within(dialog).getByLabelText("مبلغ"), { target: { value: amount } });
|
||||
expect(next).toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
it("asks for a confirmation, then calls creditApi.credit with the amount and the reason", async () => {
|
||||
render(<AdminCreditsPage />);
|
||||
const dialog = await openCreditDialog("Acme");
|
||||
fireEvent.change(within(dialog).getByLabelText("مبلغ"), { target: { value: "50" } });
|
||||
fireEvent.change(within(dialog).getByLabelText("دلیل"), { target: { value: "شارژ دستی" } });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "ادامه" }));
|
||||
|
||||
expect(mocks.creditApi.credit).not.toHaveBeenCalled();
|
||||
fireEvent.click(await within(dialog).findByRole("button", { name: "تأیید و افزودن" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.creditApi.credit).toHaveBeenCalledWith("org-1", {
|
||||
amount: 50,
|
||||
reason: "شارژ دستی",
|
||||
}),
|
||||
);
|
||||
expect(await screen.findByText("اعتبار افزوده شد")).toBeInTheDocument();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("dialog", { name: "افزودن اعتبار به «Acme»" })).not.toBeInTheDocument(),
|
||||
);
|
||||
// The balances are read again after the credit, so the row shows the new state.
|
||||
expect(mocks.creditApi.balances).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("shows the server's Persian message when the credit is refused", async () => {
|
||||
mocks.creditApi.credit.mockRejectedValueOnce(
|
||||
new mocks.FakeApiError(422, "validation_error", "دلیل افزودن اعتبار لازم است."),
|
||||
);
|
||||
render(<AdminCreditsPage />);
|
||||
const dialog = await openCreditDialog("Acme");
|
||||
fireEvent.change(within(dialog).getByLabelText("مبلغ"), { target: { value: "10" } });
|
||||
fireEvent.change(within(dialog).getByLabelText("دلیل"), { target: { value: "تست خطا" } });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "ادامه" }));
|
||||
fireEvent.click(await within(dialog).findByRole("button", { name: "تأیید و افزودن" }));
|
||||
|
||||
expect(await within(dialog).findByRole("alert")).toHaveTextContent(
|
||||
"دلیل افزودن اعتبار لازم است.",
|
||||
);
|
||||
expect(screen.getByRole("dialog", { name: "افزودن اعتبار به «Acme»" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("owner credit page: history", () => {
|
||||
it("opens a drawer that lists one organization's credit history", async () => {
|
||||
mocks.creditApi.history.mockResolvedValueOnce([
|
||||
{
|
||||
id: "tx-9",
|
||||
kind: "owner_credit",
|
||||
amount: 20,
|
||||
balance_after: 140,
|
||||
reason: "واریز مرداد",
|
||||
created_at: "2026-10-01T10:00:00Z",
|
||||
},
|
||||
]);
|
||||
render(<AdminCreditsPage />);
|
||||
|
||||
fireEvent.click(within(await rowFor("Acme")).getByRole("button", { name: "تاریخچه" }));
|
||||
|
||||
expect(await screen.findByText("واریز مرداد")).toBeInTheDocument();
|
||||
expect(mocks.creditApi.history).toHaveBeenCalledWith("org-1");
|
||||
expect(screen.getByText("140.00 USD")).toBeInTheDocument();
|
||||
expect(screen.getByRole("dialog", { name: /تاریخچه/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* The owner credit client calls the owner routes it names, with the right method and body.
|
||||
* ``apiRequest`` is mocked, so the test sees the path, the method and the body exactly.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ apiRequest: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/api", () => ({ apiRequest: mocks.apiRequest }));
|
||||
|
||||
import { creditApi } from "@/lib/api-credit";
|
||||
|
||||
const ORG = "org-1";
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.apiRequest.mockReset();
|
||||
mocks.apiRequest.mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe("creditApi reads", () => {
|
||||
it("reads every organization's balance with a plain GET", async () => {
|
||||
await creditApi.balances();
|
||||
expect(mocks.apiRequest).toHaveBeenLastCalledWith("/api/admin/credits");
|
||||
});
|
||||
|
||||
it("reads one organization's history, with the limit only when one is given", async () => {
|
||||
await creditApi.history(ORG);
|
||||
expect(mocks.apiRequest).toHaveBeenLastCalledWith("/api/admin/orgs/org-1/credit-history");
|
||||
|
||||
await creditApi.history(ORG, 20);
|
||||
expect(mocks.apiRequest).toHaveBeenLastCalledWith(
|
||||
"/api/admin/orgs/org-1/credit-history?limit=20",
|
||||
);
|
||||
});
|
||||
|
||||
it("encodes the organization id in the path", async () => {
|
||||
await creditApi.history("a/b");
|
||||
expect(mocks.apiRequest).toHaveBeenLastCalledWith("/api/admin/orgs/a%2Fb/credit-history");
|
||||
});
|
||||
});
|
||||
|
||||
describe("creditApi actions", () => {
|
||||
it("posts the amount and the reason as the body, and nothing else", async () => {
|
||||
await creditApi.credit(ORG, { amount: 50, reason: "شارژ پس از واریز" });
|
||||
expect(mocks.apiRequest).toHaveBeenLastCalledWith("/api/admin/orgs/org-1/credit", {
|
||||
method: "POST",
|
||||
body: { amount: 50, reason: "شارژ پس از واریز" },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,249 @@
|
||||
/**
|
||||
* Prepaid billing (D-B) for the members: the billing page shows the add-credit form only to the
|
||||
* platform owner, tells every other member where credit comes from, and shows the engine's credit
|
||||
* notice while the balance is zero. The wizard's success screen also says that work waits for
|
||||
* credit. The API and the engine status are mocked, so the test reads what each page renders.
|
||||
*/
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
class FakeApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
return {
|
||||
FakeApiError,
|
||||
replace: vi.fn(),
|
||||
session: {
|
||||
ready: true,
|
||||
activeOrganization: { id: "org-1", name: "Acme", slug: "acme", role: "member" },
|
||||
user: null as null | {
|
||||
id: string;
|
||||
email: string;
|
||||
is_platform_owner: boolean;
|
||||
platform_role?: string;
|
||||
},
|
||||
organizations: [] as Array<Record<string, unknown>>,
|
||||
refresh: vi.fn(async () => {}),
|
||||
setActiveOrganization: vi.fn(),
|
||||
},
|
||||
api: {
|
||||
billingSummary: vi.fn(),
|
||||
billingUsageEvents: vi.fn(),
|
||||
billingInvoices: vi.fn(),
|
||||
billingPayments: vi.fn(),
|
||||
billingAddCredit: vi.fn(),
|
||||
billingCheckout: vi.fn(),
|
||||
listEmployees: vi.fn(),
|
||||
},
|
||||
setupApi: {
|
||||
getCatalog: vi.fn(),
|
||||
getDraft: vi.fn(),
|
||||
saveDraft: vi.fn(async () => {}),
|
||||
complete: vi.fn(),
|
||||
},
|
||||
status: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("next/navigation", () => ({ useRouter: () => ({ replace: mocks.replace }) }));
|
||||
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
|
||||
vi.mock("@/lib/api", () => ({ ApiError: mocks.FakeApiError, api: mocks.api }));
|
||||
vi.mock("@/lib/api-org", () => ({ setupApi: mocks.setupApi }));
|
||||
vi.mock("@/lib/api-engine", () => ({ engineApi: { status: mocks.status } }));
|
||||
vi.mock("@/lib/realtime", () => ({ useLiveChannel: () => undefined }));
|
||||
|
||||
import OnboardingPage from "@/app/onboarding/page";
|
||||
import BillingPage from "@/app/(app)/billing/page";
|
||||
|
||||
const SUMMARY = {
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
low_balance: true,
|
||||
low_balance_threshold: 5,
|
||||
total_spend: 12.5,
|
||||
by_unit: [],
|
||||
by_employee: {},
|
||||
};
|
||||
|
||||
const CATALOG = {
|
||||
app_types: ["web_app"],
|
||||
app_type_labels: { web_app: "وباپ" },
|
||||
git_sources: ["platform", "external"],
|
||||
languages: [
|
||||
{
|
||||
key: "node",
|
||||
display: "Node.js",
|
||||
frameworks: [
|
||||
{
|
||||
key: "next",
|
||||
display: "Next.js",
|
||||
app_type: "web_app",
|
||||
defaults: {
|
||||
runtime_version: "20",
|
||||
package_manager: "npm",
|
||||
setup_command: "npm ci",
|
||||
build_command: "npm run build",
|
||||
test_command: "npm test",
|
||||
start_command: "npm start",
|
||||
app_port: 3000,
|
||||
healthcheck_path: "/",
|
||||
docker_image: "node:20",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
function status(overrides: Partial<{ balance: number; credit_ok: boolean }> = {}) {
|
||||
return {
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
credit_ok: false,
|
||||
automation_enabled: true,
|
||||
max_employees: 7,
|
||||
team_size: 4,
|
||||
stage_counts: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Lets the pending status call finish and its state update land before the assertion. */
|
||||
async function settle(): Promise<void> {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of [...Object.values(mocks.api), ...Object.values(mocks.setupApi), mocks.status]) {
|
||||
fn.mockReset();
|
||||
}
|
||||
mocks.api.billingSummary.mockResolvedValue(SUMMARY);
|
||||
mocks.api.billingUsageEvents.mockResolvedValue([]);
|
||||
mocks.api.billingInvoices.mockResolvedValue([]);
|
||||
mocks.api.billingPayments.mockResolvedValue([]);
|
||||
mocks.api.listEmployees.mockResolvedValue([]);
|
||||
mocks.status.mockResolvedValue(status());
|
||||
mocks.setupApi.saveDraft.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
describe("billing page for a member", () => {
|
||||
beforeEach(() => {
|
||||
mocks.session.user = { id: "u2", email: "member@example.com", is_platform_owner: false };
|
||||
});
|
||||
|
||||
it("offers no add-credit form and says the platform owner adds the credit", async () => {
|
||||
render(<BillingPage />);
|
||||
|
||||
expect(await screen.findByText(/توسط مالک سامانه افزوده/)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "افزودن اعتبار" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "پرداخت آنلاین" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText("مبلغ")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the credit notice at zero balance", async () => {
|
||||
render(<BillingPage />);
|
||||
|
||||
expect(await screen.findByText("اعتبار سازمان به پایان رسیده است")).toBeInTheDocument();
|
||||
expect(mocks.status).toHaveBeenCalledWith("org-1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("billing page for the platform owner", () => {
|
||||
beforeEach(() => {
|
||||
mocks.session.user = { id: "u1", email: "owner@example.com", is_platform_owner: true };
|
||||
});
|
||||
|
||||
it("shows the add-credit form and not the member hint", async () => {
|
||||
render(<BillingPage />);
|
||||
|
||||
expect(await screen.findByRole("button", { name: "افزودن اعتبار" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "پرداخت آنلاین" })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/توسط مالک سامانه افزوده/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides the credit notice once the organization has credit", async () => {
|
||||
mocks.status.mockResolvedValue(status({ credit_ok: true, balance: 40 }));
|
||||
render(<BillingPage />);
|
||||
|
||||
await screen.findByRole("button", { name: "افزودن اعتبار" });
|
||||
await waitFor(() => expect(mocks.status).toHaveBeenCalledWith("org-1"));
|
||||
await settle();
|
||||
expect(screen.queryByText("اعتبار سازمان به پایان رسیده است")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("wizard success screen", () => {
|
||||
beforeEach(() => {
|
||||
mocks.session.user = {
|
||||
id: "u1",
|
||||
email: "owner@example.com",
|
||||
is_platform_owner: false,
|
||||
platform_role: "manager",
|
||||
};
|
||||
mocks.setupApi.getCatalog.mockResolvedValue(CATALOG);
|
||||
// Resume the wizard at its last step with every field filled in, so one click completes it.
|
||||
mocks.setupApi.getDraft.mockResolvedValue({
|
||||
step: 5,
|
||||
data: {
|
||||
org_name: "Acme",
|
||||
slug: "acme",
|
||||
app_name: "app",
|
||||
app_type: "web_app",
|
||||
language: "node",
|
||||
framework: "next",
|
||||
runtime_version: "20",
|
||||
package_manager: "npm",
|
||||
setup_command: "npm ci",
|
||||
build_command: "npm run build",
|
||||
test_command: "npm test",
|
||||
start_command: "npm start",
|
||||
app_port: "3000",
|
||||
healthcheck_path: "/",
|
||||
git_source: "platform",
|
||||
},
|
||||
});
|
||||
mocks.setupApi.complete.mockResolvedValue({
|
||||
organization: { id: "org-1", name: "Acme", slug: "acme" },
|
||||
project: { id: "p-1", name: "app", slug: "app" },
|
||||
deploy_target_id: null,
|
||||
});
|
||||
});
|
||||
|
||||
async function completeWizard(): Promise<void> {
|
||||
render(<OnboardingPage />);
|
||||
fireEvent.click(await screen.findByRole("button", { name: "ساخت سازمان" }));
|
||||
expect(await screen.findByText("سازمان ساخته شد")).toBeInTheDocument();
|
||||
}
|
||||
|
||||
it("says that work starts only after credit is added when the engine reports none", async () => {
|
||||
await completeWizard();
|
||||
|
||||
expect(await screen.findByText(/پس از افزودن اعتبار توسط مالک سامانه/)).toBeInTheDocument();
|
||||
expect(mocks.status).toHaveBeenCalledWith("org-1");
|
||||
});
|
||||
|
||||
it("says nothing about credit when the organization already has credit", async () => {
|
||||
mocks.status.mockResolvedValue(status({ credit_ok: true, balance: 50 }));
|
||||
await completeWizard();
|
||||
|
||||
await waitFor(() => expect(mocks.status).toHaveBeenCalledWith("org-1"));
|
||||
await settle();
|
||||
expect(screen.queryByText(/پس از افزودن اعتبار/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("still says it when the engine status cannot be read", async () => {
|
||||
mocks.status.mockRejectedValue(new mocks.FakeApiError(503, "unavailable", "موتور در دسترس نیست."));
|
||||
await completeWizard();
|
||||
|
||||
expect(await screen.findByText(/پس از افزودن اعتبار توسط مالک سامانه/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* The engine client sends each call to the engine route it names, with the right method and
|
||||
* body. ``apiRequest`` is mocked, so the test sees the path, the method and the body exactly.
|
||||
*/
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({ apiRequest: vi.fn() }));
|
||||
|
||||
vi.mock("@/lib/api", () => ({ apiRequest: mocks.apiRequest }));
|
||||
|
||||
import { engineApi } from "@/lib/api-engine";
|
||||
|
||||
const ORG = "org-1";
|
||||
const BASE = "/api/organizations/org-1/engine";
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.apiRequest.mockReset();
|
||||
mocks.apiRequest.mockResolvedValue({});
|
||||
});
|
||||
|
||||
describe("engineApi reads", () => {
|
||||
it("reads the status with a plain GET", async () => {
|
||||
await engineApi.status(ORG);
|
||||
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/status`);
|
||||
});
|
||||
|
||||
it("lists the board, with the stage only when one is given", async () => {
|
||||
await engineApi.listTasks(ORG);
|
||||
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/tasks`);
|
||||
|
||||
await engineApi.listTasks(ORG, "pending_test");
|
||||
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/tasks?stage=pending_test`);
|
||||
});
|
||||
|
||||
it("reads one task by id", async () => {
|
||||
await engineApi.getTask(ORG, "task-9");
|
||||
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/tasks/task-9`);
|
||||
});
|
||||
|
||||
it("lists the recent turns, with the limit only when one is given", async () => {
|
||||
await engineApi.listTurns(ORG);
|
||||
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/turns`);
|
||||
|
||||
await engineApi.listTurns(ORG, 20);
|
||||
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/turns?limit=20`);
|
||||
});
|
||||
|
||||
it("reads the team with a plain GET", async () => {
|
||||
await engineApi.getTeam(ORG);
|
||||
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/team`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("engineApi actions", () => {
|
||||
it("posts pause, resume and cancel with no body", async () => {
|
||||
await engineApi.pauseTask(ORG, "task-1");
|
||||
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/tasks/task-1/pause`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
await engineApi.resumeTask(ORG, "task-1");
|
||||
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/tasks/task-1/resume`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
await engineApi.cancelTask(ORG, "task-1");
|
||||
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/tasks/task-1/cancel`, {
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
|
||||
it("posts the answer as the content field", async () => {
|
||||
await engineApi.answerTask(ORG, "task-2", "نسخهٔ دوم را انجام بده");
|
||||
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/tasks/task-2/answer`, {
|
||||
method: "POST",
|
||||
body: { content: "نسخهٔ دوم را انجام بده" },
|
||||
});
|
||||
});
|
||||
|
||||
it("hires with the role, the tier and the name, and fires by employee id", async () => {
|
||||
await engineApi.hire(ORG, { role: "developer", tier: "strong", name: "علی" });
|
||||
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/team/hire`, {
|
||||
method: "POST",
|
||||
body: { role: "developer", tier: "strong", name: "علی" },
|
||||
});
|
||||
|
||||
await engineApi.fire(ORG, "emp-3");
|
||||
expect(mocks.apiRequest).toHaveBeenLastCalledWith(`${BASE}/team/emp-3/fire`, {
|
||||
method: "POST",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
/**
|
||||
* The task board: one column per group of stages, every stage and a legacy task in the right
|
||||
* column, a live task.step moving its card, a task.created asking the page to reload, the filters
|
||||
* narrowing the cards, and the controls shown only to someone who may run tasks.
|
||||
*/
|
||||
import { act, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
pauseTask: vi.fn(),
|
||||
resumeTask: vi.fn(),
|
||||
cancelTask: vi.fn(),
|
||||
channels: [] as string[][],
|
||||
listener: null as null | ((event: { type: string; data: Record<string, unknown> }) => void),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => {
|
||||
class FakeApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
return { ApiError: FakeApiError };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/api-engine", () => ({
|
||||
engineApi: { pauseTask: mocks.pauseTask, resumeTask: mocks.resumeTask, cancelTask: mocks.cancelTask },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/realtime", () => ({
|
||||
useLiveChannel: (
|
||||
channels: string[],
|
||||
onEvent: (event: { type: string; data: Record<string, unknown> }) => void,
|
||||
) => {
|
||||
mocks.channels.push(channels);
|
||||
mocks.listener = onEvent;
|
||||
},
|
||||
}));
|
||||
|
||||
import { type TaskBoardProps, TaskBoard } from "@/app/(app)/tasks/_components/task-board";
|
||||
import type { EngineStage, EngineTask } from "@/lib/api-engine";
|
||||
import { BOARD_COLUMNS, type BoardColumnKey, STAGE_LABELS } from "@/lib/engine-labels";
|
||||
|
||||
const ORG = "org-1";
|
||||
|
||||
const EMPLOYEES = [
|
||||
{ id: "emp-1", name: "علی" },
|
||||
{ id: "emp-2", name: "سارا" },
|
||||
];
|
||||
|
||||
/** The coarse status a stage implies (a finished stage is a finished task). */
|
||||
const COARSE: Partial<Record<EngineStage, EngineTask["status"]>> = {
|
||||
completed: "done",
|
||||
failed: "failed",
|
||||
cancelled: "cancelled",
|
||||
};
|
||||
|
||||
function engineTask(stage: EngineStage, overrides: Partial<EngineTask> = {}): EngineTask {
|
||||
return {
|
||||
id: `task-${stage}`,
|
||||
title: `کار ${stage}`,
|
||||
status: COARSE[stage] ?? "running",
|
||||
stage,
|
||||
pipeline: "feature",
|
||||
kind: "feature",
|
||||
cycle: 1,
|
||||
max_cycles: 3,
|
||||
thread_id: null,
|
||||
assigned_employee_id: "emp-1",
|
||||
assignee_name: "علی",
|
||||
created_at: "2026-10-01T08:00:00Z",
|
||||
updated_at: "2026-10-01T08:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const LEGACY: EngineTask = {
|
||||
id: "legacy-1",
|
||||
title: "کار قدیمی",
|
||||
status: "running",
|
||||
stage: null,
|
||||
pipeline: null,
|
||||
kind: null,
|
||||
cycle: 0,
|
||||
max_cycles: 0,
|
||||
thread_id: null,
|
||||
assigned_employee_id: null,
|
||||
assignee_name: null,
|
||||
created_at: "2025-01-01T08:00:00Z",
|
||||
updated_at: "2025-01-01T08:00:00Z",
|
||||
};
|
||||
|
||||
/** Every one of the thirteen stages once, and one legacy task after the implementation card. */
|
||||
const FIXTURE: EngineTask[] = [
|
||||
engineTask("pending_manager_review"),
|
||||
engineTask("pending_analysis"),
|
||||
engineTask("pending_implementation"),
|
||||
LEGACY,
|
||||
engineTask("pending_review", { assigned_employee_id: "emp-2", assignee_name: "سارا" }),
|
||||
engineTask("pending_final_review"),
|
||||
engineTask("pending_test", { assigned_employee_id: "emp-2", assignee_name: "سارا" }),
|
||||
engineTask("merge_request"),
|
||||
engineTask("completed"),
|
||||
engineTask("cancelled"),
|
||||
engineTask("failed"),
|
||||
engineTask("pending_user_decision"),
|
||||
engineTask("error_review"),
|
||||
engineTask("paused"),
|
||||
];
|
||||
|
||||
/** The card titles each column shows, in the order of the fixture. */
|
||||
const EXPECTED: Record<BoardColumnKey, string[]> = {
|
||||
manager_review: ["کار pending_manager_review"],
|
||||
analysis: ["کار pending_analysis"],
|
||||
implementation: ["کار pending_implementation", "کار قدیمی"],
|
||||
review: ["کار pending_review", "کار pending_final_review"],
|
||||
test: ["کار pending_test"],
|
||||
merge: ["کار merge_request"],
|
||||
done: ["کار completed", "کار cancelled", "کار failed"],
|
||||
decision: ["کار pending_user_decision", "کار error_review", "کار paused"],
|
||||
};
|
||||
|
||||
function columnLabel(key: BoardColumnKey): string {
|
||||
const column = BOARD_COLUMNS.find((item) => item.key === key);
|
||||
if (!column) throw new Error(`no board column ${key}`);
|
||||
return column.label;
|
||||
}
|
||||
|
||||
function cardTitles(): string[] {
|
||||
return screen.getAllByRole("link").map((link) => link.textContent ?? "");
|
||||
}
|
||||
|
||||
function renderBoard(overrides: Partial<TaskBoardProps> = {}) {
|
||||
const props: TaskBoardProps = {
|
||||
orgId: ORG,
|
||||
tasks: FIXTURE,
|
||||
employees: EMPLOYEES,
|
||||
canRun: true,
|
||||
onChanged: vi.fn(),
|
||||
...overrides,
|
||||
};
|
||||
return { ...render(<TaskBoard {...props} />), props };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.channels = [];
|
||||
mocks.listener = null;
|
||||
});
|
||||
|
||||
describe("TaskBoard columns", () => {
|
||||
it("shows the eight columns in board order", () => {
|
||||
renderBoard();
|
||||
|
||||
const regions = screen.getAllByRole("region");
|
||||
expect(regions.map((region) => region.getAttribute("aria-label"))).toEqual(
|
||||
BOARD_COLUMNS.map((column) => column.label),
|
||||
);
|
||||
});
|
||||
|
||||
it("puts every stage and a legacy task in exactly one column", () => {
|
||||
renderBoard();
|
||||
|
||||
for (const column of BOARD_COLUMNS) {
|
||||
const region = screen.getByRole("region", { name: column.label });
|
||||
const titles = within(region)
|
||||
.getAllByRole("link")
|
||||
.map((link) => link.textContent);
|
||||
expect(titles).toEqual(EXPECTED[column.key]);
|
||||
}
|
||||
expect(cardTitles()).toHaveLength(FIXTURE.length);
|
||||
});
|
||||
|
||||
it("shows the Persian stage on each card", () => {
|
||||
renderBoard();
|
||||
|
||||
const link = within(screen.getByRole("region", { name: columnLabel("test") })).getByRole(
|
||||
"link",
|
||||
{ name: "کار pending_test" },
|
||||
);
|
||||
expect(link.parentElement).toHaveTextContent(STAGE_LABELS.pending_test);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskBoard live updates", () => {
|
||||
it("subscribes to the organization channel", () => {
|
||||
renderBoard();
|
||||
|
||||
expect(mocks.channels).toContainEqual([`org:${ORG}`]);
|
||||
});
|
||||
|
||||
it("moves a card to the column of its new stage on task.step", () => {
|
||||
const { props } = renderBoard();
|
||||
|
||||
act(() => {
|
||||
mocks.listener?.({
|
||||
type: "task.step",
|
||||
data: {
|
||||
task_id: "task-pending_test",
|
||||
from: "pending_test",
|
||||
to: "merge_request",
|
||||
pipeline: "feature",
|
||||
status: "running",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const test = screen.getByRole("region", { name: columnLabel("test") });
|
||||
expect(within(test).queryByRole("link", { name: "کار pending_test" })).toBeNull();
|
||||
const merge = screen.getByRole("region", { name: columnLabel("merge") });
|
||||
const moved = within(merge).getByRole("link", { name: "کار pending_test" });
|
||||
expect(moved.parentElement).toHaveTextContent(STAGE_LABELS.merge_request);
|
||||
expect(props.onChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("asks the page to reload on task.created", () => {
|
||||
const { props } = renderBoard();
|
||||
|
||||
act(() => {
|
||||
mocks.listener?.({ type: "task.created", data: {} });
|
||||
});
|
||||
|
||||
expect(props.onChanged).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("takes a fresh list from the page in place of its moved copy", () => {
|
||||
const { rerender, props } = renderBoard();
|
||||
act(() => {
|
||||
mocks.listener?.({
|
||||
type: "task.step",
|
||||
data: {
|
||||
task_id: "task-pending_test",
|
||||
from: "pending_test",
|
||||
to: "merge_request",
|
||||
pipeline: "feature",
|
||||
status: "running",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
rerender(<TaskBoard {...props} tasks={[...FIXTURE]} />);
|
||||
|
||||
const test = screen.getByRole("region", { name: columnLabel("test") });
|
||||
expect(within(test).getByRole("link", { name: "کار pending_test" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskBoard filters", () => {
|
||||
it("hides the cards whose title does not match the search", () => {
|
||||
renderBoard();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("جستجو در عنوان"), { target: { value: "merge" } });
|
||||
|
||||
expect(cardTitles()).toEqual(["کار merge_request"]);
|
||||
});
|
||||
|
||||
it("shows only the tasks of the chosen assignee", () => {
|
||||
renderBoard();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("مسئول"), { target: { value: "emp-2" } });
|
||||
|
||||
expect(cardTitles()).toEqual(["کار pending_review", "کار pending_test"]);
|
||||
});
|
||||
|
||||
it("shows only the unassigned tasks for the no-assignee choice", () => {
|
||||
renderBoard();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("مسئول"), { target: { value: "none" } });
|
||||
|
||||
expect(cardTitles()).toEqual(["کار قدیمی"]);
|
||||
});
|
||||
|
||||
it("empties the finished column when only active tasks are asked for", () => {
|
||||
renderBoard();
|
||||
|
||||
fireEvent.click(screen.getByLabelText("فقط کارهای فعال"));
|
||||
|
||||
const done = screen.getByRole("region", { name: columnLabel("done") });
|
||||
expect(within(done).queryAllByRole("link")).toHaveLength(0);
|
||||
expect(cardTitles()).toContain("کار pending_test");
|
||||
expect(cardTitles()).not.toContain("کار completed");
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskBoard controls", () => {
|
||||
it("shows the task controls only to someone who may run tasks", () => {
|
||||
const { unmount } = renderBoard({ canRun: false });
|
||||
expect(screen.queryByRole("button", { name: "توقف" })).toBeNull();
|
||||
unmount();
|
||||
|
||||
renderBoard({ canRun: true });
|
||||
expect(screen.getAllByRole("button", { name: "توقف" }).length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* The engine's credit notice shows at zero credit and not otherwise. The status hook loads the
|
||||
* status for the organization's channel, and loads it again on a balance, stage or team change,
|
||||
* but not on any other live event.
|
||||
*/
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
status: vi.fn(),
|
||||
channels: [] as string[][],
|
||||
listener: null as null | ((event: { type: string }) => void),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => {
|
||||
class FakeApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
return { ApiError: FakeApiError };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/api-engine", () => ({ engineApi: { status: mocks.status } }));
|
||||
|
||||
vi.mock("@/lib/realtime", () => ({
|
||||
useLiveChannel: (channels: string[], onEvent: (event: { type: string }) => void) => {
|
||||
mocks.channels.push(channels);
|
||||
mocks.listener = onEvent;
|
||||
},
|
||||
}));
|
||||
|
||||
import { CreditNotice, useEngineStatus } from "@/components/engine/credit-notice";
|
||||
import type { EngineStatus } from "@/lib/api-engine";
|
||||
|
||||
const ORG = "org-1";
|
||||
|
||||
function status(overrides: Partial<EngineStatus> = {}): EngineStatus {
|
||||
return {
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
credit_ok: false,
|
||||
automation_enabled: true,
|
||||
max_employees: 7,
|
||||
team_size: 4,
|
||||
stage_counts: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function Probe({ orgId }: { orgId: string }) {
|
||||
const { status: current, loading } = useEngineStatus(orgId);
|
||||
if (loading) return <p>در حال بارگذاری</p>;
|
||||
return <p>{current?.credit_ok ? "اعتبار موجود" : "بدون اعتبار"}</p>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.status.mockReset();
|
||||
mocks.channels.length = 0;
|
||||
mocks.listener = null;
|
||||
});
|
||||
|
||||
describe("CreditNotice", () => {
|
||||
it("explains in Persian why work stopped, at zero credit", () => {
|
||||
render(<CreditNotice status={status()} />);
|
||||
|
||||
expect(screen.getByText("اعتبار سازمان به پایان رسیده است")).toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toHaveTextContent(
|
||||
"تا افزودن اعتبار توسط مالک سامانه متوقف است",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows nothing while credit is available", () => {
|
||||
const { container } = render(
|
||||
<CreditNotice status={status({ credit_ok: true, balance: 12 })} />,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("shows nothing before the status has loaded", () => {
|
||||
const { container } = render(<CreditNotice status={null} />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useEngineStatus", () => {
|
||||
it("subscribes to the organization and reloads on a balance, stage or team change only", async () => {
|
||||
mocks.status
|
||||
.mockResolvedValueOnce(status())
|
||||
.mockResolvedValue(status({ credit_ok: true, balance: 50 }));
|
||||
|
||||
render(<Probe orgId={ORG} />);
|
||||
|
||||
expect(await screen.findByText("بدون اعتبار")).toBeInTheDocument();
|
||||
expect(mocks.status).toHaveBeenCalledWith(ORG);
|
||||
expect(mocks.channels).toContainEqual([`org:${ORG}`]);
|
||||
|
||||
act(() => mocks.listener?.({ type: "run.log" }));
|
||||
expect(mocks.status).toHaveBeenCalledTimes(1);
|
||||
|
||||
act(() => mocks.listener?.({ type: "billing.wallet" }));
|
||||
expect(await screen.findByText("اعتبار موجود")).toBeInTheDocument();
|
||||
expect(mocks.status).toHaveBeenCalledTimes(2);
|
||||
|
||||
act(() => mocks.listener?.({ type: "task.step" }));
|
||||
await waitFor(() => expect(mocks.status).toHaveBeenCalledTimes(3));
|
||||
|
||||
act(() => mocks.listener?.({ type: "team.changed" }));
|
||||
await waitFor(() => expect(mocks.status).toHaveBeenCalledTimes(4));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,306 @@
|
||||
/**
|
||||
* The dashboard's engine summary: the balance and the credit notice, at most five active engine
|
||||
* tasks (never a finished or a legacy task), the count in each board column, the five newest
|
||||
* completed tasks, a Persian message when the task list fails, and a reload on a task step only.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
status: vi.fn(),
|
||||
listTasks: vi.fn(),
|
||||
listeners: new Set<(event: { type: string }) => void>(),
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => {
|
||||
class FakeApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
return { ApiError: FakeApiError };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/api-engine", () => ({
|
||||
engineApi: { status: mocks.status, listTasks: mocks.listTasks },
|
||||
}));
|
||||
|
||||
// The realtime client keeps one subscription per channel, so every hook registers its own listener
|
||||
// and the test delivers an event to all of them.
|
||||
vi.mock("@/lib/realtime", async () => {
|
||||
const { useEffect, useRef } = await import("react");
|
||||
return {
|
||||
useLiveChannel: (_channels: string[], onEvent: (event: { type: string }) => void) => {
|
||||
const latest = useRef(onEvent);
|
||||
latest.current = onEvent;
|
||||
useEffect(() => {
|
||||
const listener = (event: { type: string }) => latest.current(event);
|
||||
mocks.listeners.add(listener);
|
||||
return () => {
|
||||
mocks.listeners.delete(listener);
|
||||
};
|
||||
}, []);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { ApiError } from "@/lib/api";
|
||||
import type { EngineStage, EngineStatus, EngineTask } from "@/lib/api-engine";
|
||||
import { BOARD_COLUMNS, type BoardColumnKey, STAGE_LABELS } from "@/lib/engine-labels";
|
||||
import { EngineWidgets } from "@/app/(app)/dashboard/_components/engine-widgets";
|
||||
|
||||
const ORG = "org-1";
|
||||
|
||||
/** The Persian digits the widget prints for a number. */
|
||||
function fa(value: number): string {
|
||||
return value.toLocaleString("fa-IR");
|
||||
}
|
||||
|
||||
/** A board column's Persian label, read from the module so the test never retypes it. */
|
||||
function label(key: BoardColumnKey): string {
|
||||
const column = BOARD_COLUMNS.find((item) => item.key === key);
|
||||
if (!column) throw new Error(`no board column ${key}`);
|
||||
return column.label;
|
||||
}
|
||||
|
||||
function status(overrides: Partial<EngineStatus> = {}): EngineStatus {
|
||||
return {
|
||||
balance: 0,
|
||||
currency: "USD",
|
||||
credit_ok: false,
|
||||
automation_enabled: true,
|
||||
max_employees: 7,
|
||||
team_size: 4,
|
||||
stage_counts: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function taskStatus(stage: EngineStage | null): EngineTask["status"] {
|
||||
if (stage === "completed") return "done";
|
||||
if (stage === "failed" || stage === "cancelled") return stage;
|
||||
return "running";
|
||||
}
|
||||
|
||||
function task(
|
||||
id: string,
|
||||
stage: EngineStage | null,
|
||||
updatedAt = "2026-10-14T10:00:00Z",
|
||||
): EngineTask {
|
||||
return {
|
||||
id,
|
||||
title: `کار ${id}`,
|
||||
status: taskStatus(stage),
|
||||
stage,
|
||||
pipeline: stage === null ? null : "default",
|
||||
kind: null,
|
||||
cycle: 0,
|
||||
max_cycles: 0,
|
||||
thread_id: null,
|
||||
assigned_employee_id: null,
|
||||
assignee_name: null,
|
||||
created_at: "2026-10-14T09:00:00Z",
|
||||
updated_at: updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Delivers one live event to every subscribed hook. */
|
||||
function emit(type: string) {
|
||||
act(() => {
|
||||
for (const listener of [...mocks.listeners]) listener({ type });
|
||||
});
|
||||
}
|
||||
|
||||
/** The row of the distribution list for one board column. */
|
||||
function columnRow(key: BoardColumnKey): HTMLElement {
|
||||
const list = screen.getByRole("list", { name: "توزیع مراحل" });
|
||||
const row = within(list).getByText(label(key)).closest("li");
|
||||
if (!row) throw new Error(`no row for ${key}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** The card under a section title (the title sits directly inside its card), once it has rendered. */
|
||||
async function cardOf(title: RegExp): Promise<HTMLElement> {
|
||||
const heading = await screen.findByRole("heading", { name: title });
|
||||
const card = heading.parentElement;
|
||||
if (!card) throw new Error(`no card under ${String(title)}`);
|
||||
return card;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.status.mockReset();
|
||||
mocks.listTasks.mockReset();
|
||||
mocks.listeners.clear();
|
||||
});
|
||||
|
||||
describe("EngineWidgets credit", () => {
|
||||
it("shows the balance and the credit notice at zero credit", async () => {
|
||||
mocks.status.mockResolvedValue(status({ balance: 0, credit_ok: false }));
|
||||
mocks.listTasks.mockResolvedValue([]);
|
||||
|
||||
render(<EngineWidgets orgId={ORG} />);
|
||||
|
||||
expect(await screen.findByText("اعتبار سازمان به پایان رسیده است")).toBeInTheDocument();
|
||||
expect(screen.getByText("موجودی اعتبار")).toBeInTheDocument();
|
||||
expect(screen.getByRole("status")).toHaveTextContent(
|
||||
"تا افزودن اعتبار توسط مالک سامانه متوقف است",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the balance and no notice while credit is available", async () => {
|
||||
mocks.status.mockResolvedValue(status({ balance: 50, credit_ok: true }));
|
||||
mocks.listTasks.mockResolvedValue([]);
|
||||
|
||||
render(<EngineWidgets orgId={ORG} />);
|
||||
|
||||
expect(await screen.findByText("موجودی اعتبار")).toBeInTheDocument();
|
||||
expect(screen.getByText("موجودی اعتبار").parentElement).toHaveTextContent(fa(50));
|
||||
expect(screen.queryByText("اعتبار سازمان به پایان رسیده است")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EngineWidgets board", () => {
|
||||
it("counts the tasks in each board column from the stage counts", async () => {
|
||||
mocks.status.mockResolvedValue(
|
||||
status({
|
||||
balance: 50,
|
||||
credit_ok: true,
|
||||
stage_counts: {
|
||||
pending_analysis: 2,
|
||||
pending_review: 1,
|
||||
pending_final_review: 3,
|
||||
completed: 4,
|
||||
paused: 1,
|
||||
},
|
||||
}),
|
||||
);
|
||||
mocks.listTasks.mockResolvedValue([]);
|
||||
|
||||
render(<EngineWidgets orgId={ORG} />);
|
||||
const list = await screen.findByRole("list", { name: "توزیع مراحل" });
|
||||
|
||||
expect(within(list).getAllByRole("listitem")).toHaveLength(BOARD_COLUMNS.length);
|
||||
const expected: Array<[BoardColumnKey, number]> = [
|
||||
["manager_review", 0],
|
||||
["analysis", 2],
|
||||
["implementation", 0],
|
||||
["review", 4],
|
||||
["test", 0],
|
||||
["merge", 0],
|
||||
["done", 4],
|
||||
["decision", 1],
|
||||
];
|
||||
for (const [key, count] of expected) {
|
||||
expect(columnRow(key).textContent).toBe(`${label(key)}${fa(count)}`);
|
||||
}
|
||||
|
||||
// The bars are relative to the largest column.
|
||||
const bar = (key: BoardColumnKey) =>
|
||||
columnRow(key).querySelector<HTMLElement>('[aria-hidden="true"] > div');
|
||||
expect(bar("review")).toHaveStyle({ width: "100%" });
|
||||
expect(bar("analysis")).toHaveStyle({ width: "50%" });
|
||||
expect(bar("test")).toHaveStyle({ width: "0%" });
|
||||
});
|
||||
|
||||
it("lists at most five active engine tasks, each with its stage and a link", async () => {
|
||||
mocks.status.mockResolvedValue(status({ balance: 50, credit_ok: true }));
|
||||
mocks.listTasks.mockResolvedValue([
|
||||
task("a1", "pending_analysis"),
|
||||
task("a2", "pending_test"),
|
||||
task("a3", "paused"),
|
||||
task("done1", "completed"),
|
||||
task("a4", "pending_manager_review"),
|
||||
task("legacy1", null),
|
||||
task("a5", "pending_implementation"),
|
||||
task("a6", "pending_review"),
|
||||
task("failed1", "failed"),
|
||||
]);
|
||||
|
||||
render(<EngineWidgets orgId={ORG} />);
|
||||
const card = await cardOf(/^کارهای فعال$/);
|
||||
|
||||
expect(await within(card).findByRole("link", { name: "کار a1" })).toHaveAttribute(
|
||||
"href",
|
||||
"/tasks/a1",
|
||||
);
|
||||
expect(within(card).getAllByRole("link").map((link) => link.textContent)).toEqual([
|
||||
"کار a1",
|
||||
"کار a2",
|
||||
"کار a3",
|
||||
"کار a4",
|
||||
"کار a5",
|
||||
]);
|
||||
expect(within(card).getByText(STAGE_LABELS.pending_analysis)).toBeInTheDocument();
|
||||
expect(within(card).getByText(STAGE_LABELS.paused)).toBeInTheDocument();
|
||||
expect(within(card).getByText(`و ${fa(1)} کار دیگر`)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("lists the five newest completed tasks, newest first", async () => {
|
||||
mocks.status.mockResolvedValue(status({ balance: 50, credit_ok: true }));
|
||||
mocks.listTasks.mockResolvedValue([
|
||||
task("c1", "completed", "2026-10-10T10:00:00Z"),
|
||||
task("c2", "completed", "2026-10-11T10:00:00Z"),
|
||||
task("c3", "completed", "2026-10-12T10:00:00Z"),
|
||||
task("c4", "completed", "2026-10-13T10:00:00Z"),
|
||||
task("c5", "completed", "2026-10-14T08:00:00Z"),
|
||||
task("c6", "completed", "2026-10-14T09:00:00Z"),
|
||||
task("a1", "pending_analysis"),
|
||||
]);
|
||||
|
||||
render(<EngineWidgets orgId={ORG} />);
|
||||
const card = await cardOf(/^آخرین کارهای انجام/);
|
||||
|
||||
await waitFor(() => expect(within(card).getAllByRole("link")).toHaveLength(5));
|
||||
expect(within(card).getAllByRole("link").map((link) => link.textContent)).toEqual([
|
||||
"کار c6",
|
||||
"کار c5",
|
||||
"کار c4",
|
||||
"کار c3",
|
||||
"کار c2",
|
||||
]);
|
||||
expect(within(card).queryByRole("link", { name: "کار c1" })).toBeNull();
|
||||
expect(within(card).queryByRole("link", { name: "کار a1" })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the task list's failure in Persian and keeps the balance", async () => {
|
||||
mocks.status.mockResolvedValue(status({ balance: 50, credit_ok: true }));
|
||||
mocks.listTasks.mockRejectedValue(
|
||||
new ApiError(500, "server_error", "فهرست کارها در دسترس نیست."),
|
||||
);
|
||||
|
||||
render(<EngineWidgets orgId={ORG} />);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("فهرست کارها در دسترس نیست.");
|
||||
expect(screen.getByText("موجودی اعتبار")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reloads the status and the task list on a task step, and on no other event", async () => {
|
||||
mocks.status.mockResolvedValue(status({ balance: 50, credit_ok: true }));
|
||||
mocks.listTasks.mockResolvedValue([]);
|
||||
|
||||
render(<EngineWidgets orgId={ORG} />);
|
||||
await screen.findByText("موجودی اعتبار");
|
||||
await waitFor(() => expect(mocks.listTasks).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.listTasks).toHaveBeenCalledWith(ORG);
|
||||
expect(mocks.status).toHaveBeenCalledTimes(1);
|
||||
|
||||
emit("run.log");
|
||||
expect(mocks.status).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.listTasks).toHaveBeenCalledTimes(1);
|
||||
|
||||
emit("task.step");
|
||||
await waitFor(() => expect(mocks.listTasks).toHaveBeenCalledTimes(2));
|
||||
expect(mocks.status).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* The gates panel and the diff viewer: each gate is named in Persian with its verdict, a red gate
|
||||
* opens to its detail, a waiver shows its reason, diff lines carry their colour, and a merged task
|
||||
* shows its short sha with a link to the pipeline view.
|
||||
*/
|
||||
import { fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { DiffViewer } from "@/app/(app)/tasks/[id]/_components/diff-viewer";
|
||||
import { GatesPanel } from "@/app/(app)/tasks/[id]/_components/gates-panel";
|
||||
import type { EngineGate } from "@/lib/api-engine";
|
||||
|
||||
const GATES: EngineGate[] = [
|
||||
{
|
||||
id: "gate-1",
|
||||
gate: "tests",
|
||||
ok: true,
|
||||
detail: "3 passed",
|
||||
created_at: "2026-10-13T08:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "gate-2",
|
||||
gate: "diff_guard",
|
||||
ok: false,
|
||||
detail: "diff touches protected/settings.py",
|
||||
created_at: "2026-10-13T08:05:00Z",
|
||||
},
|
||||
];
|
||||
|
||||
const DIFF = ["@@ -1,2 +1,2 @@", " keep", "-old line", "+new line"].join("\n");
|
||||
|
||||
function rowOf(label: string): HTMLElement {
|
||||
const row = screen.getByText(label).closest("li");
|
||||
if (!row) throw new Error(`no row for ${label}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
describe("GatesPanel", () => {
|
||||
it("names each gate in Persian with a green or red verdict", () => {
|
||||
render(<GatesPanel gates={GATES} waiverReason="" />);
|
||||
|
||||
expect(screen.getByText("تستها")).toBeInTheDocument();
|
||||
expect(screen.getByText("نگهبان تغییرات")).toBeInTheDocument();
|
||||
expect(screen.getByText("قبول شد")).toBeInTheDocument();
|
||||
expect(screen.getByText("رد شد")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("opens a red gate to its detail", () => {
|
||||
render(<GatesPanel gates={GATES} waiverReason="" />);
|
||||
const row = rowOf("نگهبان تغییرات");
|
||||
|
||||
expect(within(row).queryByText("diff touches protected/settings.py")).toBeNull();
|
||||
|
||||
fireEvent.click(within(row).getByRole("button", { name: "جزئیات" }));
|
||||
|
||||
expect(within(row).getByText("diff touches protected/settings.py").tagName).toBe("PRE");
|
||||
expect(within(row).getByRole("button", { name: "بستن جزئیات" })).toHaveAttribute(
|
||||
"aria-expanded",
|
||||
"true",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the waiver reason in a warning box", () => {
|
||||
render(<GatesPanel gates={[]} waiverReason="اجرای تست روی محیط قبلی" />);
|
||||
|
||||
expect(screen.getByRole("note")).toHaveTextContent(
|
||||
"صرفنظر از تست با دلیل: اجرای تست روی محیط قبلی",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows no warning box without a waiver reason", () => {
|
||||
render(<GatesPanel gates={GATES} waiverReason="" />);
|
||||
|
||||
expect(screen.queryByRole("note")).toBeNull();
|
||||
});
|
||||
|
||||
it("says so when no gate has run yet", () => {
|
||||
render(<GatesPanel gates={[]} waiverReason="" />);
|
||||
|
||||
expect(screen.getByText("هنوز نتیجهٔ دروازهای ثبت نشده است.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DiffViewer", () => {
|
||||
it("colours added, removed and hunk lines", () => {
|
||||
render(<DiffViewer diff={DIFF} mergedSha="" />);
|
||||
|
||||
expect(screen.getByText("+new line")).toHaveClass("text-positive");
|
||||
expect(screen.getByText("-old line")).toHaveClass("text-danger");
|
||||
expect(screen.getByText("@@ -1,2 +1,2 @@")).toHaveClass("text-slate-500");
|
||||
});
|
||||
|
||||
it("says so when no change has been recorded", () => {
|
||||
render(<DiffViewer diff="" mergedSha="" />);
|
||||
|
||||
expect(screen.getByText("هنوز تغییری ثبت نشده است.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the short merged sha with a link to the pipeline view", () => {
|
||||
render(<DiffViewer diff={DIFF} mergedSha="0123456789abcdef0123" />);
|
||||
|
||||
const line = screen.getByText(/ادغام و ارسال شد/).closest("p");
|
||||
expect(line).toHaveTextContent("ادغام و ارسال شد: 0123456789ab");
|
||||
expect(screen.queryByText("0123456789abcdef0123")).toBeNull();
|
||||
expect(screen.getByRole("link", { name: "مشاهدهٔ خط لوله در صفحهٔ اپلیکیشن" })).toHaveAttribute(
|
||||
"href",
|
||||
"/app",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows no sha and no link before the change is merged", () => {
|
||||
render(<DiffViewer diff={DIFF} mergedSha="" />);
|
||||
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
expect(screen.queryByText(/ادغام و ارسال شد/)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* The engine's labels and board columns: every stage lands in exactly one column, a legacy
|
||||
* task lands by its status, and grouping keeps the order the tasks came in.
|
||||
*/
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
BOARD_COLUMNS,
|
||||
columnFor,
|
||||
groupByColumn,
|
||||
ROLE_LABELS,
|
||||
STAGE_LABELS,
|
||||
stageTone,
|
||||
TIER_LABELS,
|
||||
} from "@/lib/engine-labels";
|
||||
|
||||
const STAGES = [
|
||||
"pending_manager_review",
|
||||
"pending_analysis",
|
||||
"pending_implementation",
|
||||
"pending_review",
|
||||
"pending_test",
|
||||
"pending_final_review",
|
||||
"merge_request",
|
||||
"completed",
|
||||
"pending_user_decision",
|
||||
"error_review",
|
||||
"paused",
|
||||
"cancelled",
|
||||
"failed",
|
||||
];
|
||||
|
||||
describe("the board", () => {
|
||||
it("places every one of the thirteen stages in exactly one column", () => {
|
||||
for (const stage of STAGES) {
|
||||
const homes = BOARD_COLUMNS.filter((column) => column.stages.includes(stage as never));
|
||||
expect(homes, stage).toHaveLength(1);
|
||||
expect(STAGE_LABELS[stage], stage).toBeTruthy();
|
||||
}
|
||||
const placed = BOARD_COLUMNS.flatMap((column) => column.stages as readonly string[]);
|
||||
expect([...placed].sort()).toEqual([...STAGES].sort());
|
||||
});
|
||||
|
||||
it("uses the stage column whenever the engine owns the task", () => {
|
||||
expect(columnFor("pending_test", "running")).toBe("test");
|
||||
expect(columnFor("pending_final_review", "running")).toBe("review");
|
||||
expect(columnFor("paused", "waiting_approval")).toBe("decision");
|
||||
expect(columnFor("completed", "done")).toBe("done");
|
||||
});
|
||||
|
||||
it("places a legacy task by its coarse status", () => {
|
||||
expect(columnFor(null, "new")).toBe("manager_review");
|
||||
expect(columnFor(null, "triaged")).toBe("implementation");
|
||||
expect(columnFor(null, "running")).toBe("implementation");
|
||||
expect(columnFor(null, "waiting_approval")).toBe("decision");
|
||||
for (const status of ["done", "failed", "cancelled"]) {
|
||||
expect(columnFor(null, status)).toBe("done");
|
||||
}
|
||||
});
|
||||
|
||||
it("sends an unknown stage or status to the decision column", () => {
|
||||
expect(columnFor("some_new_stage", "running")).toBe("decision");
|
||||
expect(columnFor(null, "some_new_status")).toBe("decision");
|
||||
});
|
||||
|
||||
it("groups tasks by column, keeps every column, and keeps the order inside each", () => {
|
||||
const tasks = [
|
||||
{ id: "a", stage: "pending_test", status: "running" },
|
||||
{ id: "b", stage: null, status: "new" },
|
||||
{ id: "c", stage: "pending_test", status: "running" },
|
||||
{ id: "d", stage: "pending_final_review", status: "running" },
|
||||
];
|
||||
|
||||
const groups = groupByColumn(tasks);
|
||||
|
||||
expect(groups.test.map((task) => task.id)).toEqual(["a", "c"]);
|
||||
expect(groups.review.map((task) => task.id)).toEqual(["d"]);
|
||||
expect(groups.manager_review.map((task) => task.id)).toEqual(["b"]);
|
||||
expect(groups.merge).toEqual([]);
|
||||
expect(Object.keys(groups)).toHaveLength(BOARD_COLUMNS.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe("labels and tones", () => {
|
||||
it("tones a stage by how it ended or whether a person must act", () => {
|
||||
expect(stageTone("completed")).toBe("success");
|
||||
expect(stageTone("failed")).toBe("danger");
|
||||
expect(stageTone("cancelled")).toBe("danger");
|
||||
expect(stageTone("paused")).toBe("warning");
|
||||
expect(stageTone("pending_user_decision")).toBe("warning");
|
||||
expect(stageTone("error_review")).toBe("warning");
|
||||
expect(stageTone("pending_test")).toBe("info");
|
||||
});
|
||||
|
||||
it("labels the four tiers in Persian", () => {
|
||||
expect(TIER_LABELS).toEqual({
|
||||
fast: "سریع",
|
||||
balanced: "متعادل",
|
||||
strong: "قوی",
|
||||
unknown: "نامشخص",
|
||||
});
|
||||
});
|
||||
|
||||
it("labels the five hirable roles in Persian", () => {
|
||||
expect(Object.keys(ROLE_LABELS).sort()).toEqual(
|
||||
["developer", "manager", "planner", "reviewer", "tester"].sort(),
|
||||
);
|
||||
expect(ROLE_LABELS.manager).toBe("مدیر");
|
||||
expect(ROLE_LABELS.reviewer).toBe("بازبین");
|
||||
expect(ROLE_LABELS.tester).toBe("آزمونگر");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* The live log: one `run.log` event appends its lines in one render, a `run.tool` event appends one
|
||||
* Persian line, events for another task are ignored, the newest 2000 lines are kept, the panel
|
||||
* follows the newest line until it is paused (by the toggle or by a scroll up by hand), and a paused
|
||||
* panel leaves the scroll position alone.
|
||||
*/
|
||||
import { Profiler } from "react";
|
||||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WsEvent } from "@agy/contracts";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
channels: [] as string[][],
|
||||
listener: null as null | ((event: WsEvent) => void),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/realtime", () => ({
|
||||
useLiveChannel: (channels: string[], onEvent: (event: WsEvent) => void) => {
|
||||
mocks.channels.push(channels);
|
||||
mocks.listener = onEvent;
|
||||
},
|
||||
}));
|
||||
|
||||
import { LiveLog } from "@/app/(app)/tasks/[id]/_components/live-log";
|
||||
|
||||
const TASK = "task-1";
|
||||
|
||||
function emit(type: WsEvent["type"], data: Record<string, unknown>) {
|
||||
act(() => {
|
||||
mocks.listener?.({
|
||||
id: "evt-1",
|
||||
type,
|
||||
data,
|
||||
organization_id: "org-1",
|
||||
at: "2026-10-14T08:00:00Z",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function rowsOf(box: HTMLElement): Array<string | null> {
|
||||
return Array.from(box.children, (row) => row.textContent);
|
||||
}
|
||||
|
||||
/** jsdom has no layout: give the log box a height and a scroll position of its own. */
|
||||
function measure(box: HTMLElement, scrollTop: number) {
|
||||
Object.defineProperty(box, "scrollHeight", { configurable: true, value: 500 });
|
||||
Object.defineProperty(box, "clientHeight", { configurable: true, value: 100 });
|
||||
Object.defineProperty(box, "scrollTop", { configurable: true, writable: true, value: scrollTop });
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.channels.length = 0;
|
||||
mocks.listener = null;
|
||||
});
|
||||
|
||||
describe("LiveLog", () => {
|
||||
it("subscribes to the task's channel", () => {
|
||||
render(<LiveLog taskId={TASK} />);
|
||||
|
||||
expect(mocks.channels).toContainEqual([`task:${TASK}`]);
|
||||
});
|
||||
|
||||
it("appends all three lines of one run.log event in one render", () => {
|
||||
const onRender = vi.fn();
|
||||
render(
|
||||
<Profiler id="live-log" onRender={onRender}>
|
||||
<LiveLog taskId={TASK} />
|
||||
</Profiler>,
|
||||
);
|
||||
const box = screen.getByRole("log");
|
||||
const before = onRender.mock.calls.length;
|
||||
|
||||
emit("run.log", { run_id: "run-1", task_id: TASK, lines: ["npm test", "3 passed", "done"] });
|
||||
|
||||
expect(rowsOf(box)).toEqual(["npm test", "3 passed", "done"]);
|
||||
expect(onRender.mock.calls.length - before).toBe(1);
|
||||
});
|
||||
|
||||
it("appends one Persian line for each tool call, with its outcome", () => {
|
||||
render(<LiveLog taskId={TASK} />);
|
||||
|
||||
emit("run.tool", { run_id: "run-1", task_id: TASK, tool: "read_file", ok: true, summary: "README.md" });
|
||||
emit("run.tool", { run_id: "run-1", task_id: TASK, tool: "run_tests", ok: false, summary: "exit 1" });
|
||||
|
||||
expect(screen.getByText("ابزار read_file: موفق — README.md")).toBeInTheDocument();
|
||||
expect(screen.getByText("ابزار run_tests: ناموفق — exit 1")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("ignores events that belong to another task", () => {
|
||||
render(<LiveLog taskId={TASK} />);
|
||||
|
||||
emit("run.log", { run_id: "run-9", task_id: "task-2", lines: ["foreign line"] });
|
||||
|
||||
expect(screen.queryByText("foreign line")).toBeNull();
|
||||
expect(screen.getByText("هنوز خروجیای ثبت نشده است.")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps only the newest 2000 lines", () => {
|
||||
render(<LiveLog taskId={TASK} />);
|
||||
const first = Array.from({ length: 1500 }, (_, index) => `line ${index}`);
|
||||
const second = Array.from({ length: 1500 }, (_, index) => `line ${index + 1500}`);
|
||||
|
||||
emit("run.log", { run_id: "run-1", task_id: TASK, lines: first });
|
||||
emit("run.log", { run_id: "run-1", task_id: TASK, lines: second });
|
||||
|
||||
const rows = rowsOf(screen.getByRole("log"));
|
||||
expect(rows).toHaveLength(2000);
|
||||
expect(rows[0]).toBe("line 1000");
|
||||
expect(rows[1999]).toBe("line 2999");
|
||||
expect(screen.queryByText("line 999")).toBeNull();
|
||||
});
|
||||
|
||||
it("follows the newest line while it is not paused", () => {
|
||||
render(<LiveLog taskId={TASK} />);
|
||||
const box = screen.getByRole("log");
|
||||
measure(box, 0);
|
||||
|
||||
emit("run.log", { run_id: "run-1", task_id: TASK, lines: ["a fresh line"] });
|
||||
|
||||
expect(box.scrollTop).toBe(500);
|
||||
});
|
||||
|
||||
it("leaves scrollTop alone once paused with the toggle", () => {
|
||||
render(<LiveLog taskId={TASK} />);
|
||||
const box = screen.getByRole("log");
|
||||
measure(box, 0);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "توقف پیمایش" }));
|
||||
emit("run.log", { run_id: "run-1", task_id: TASK, lines: ["while paused"] });
|
||||
|
||||
expect(box.scrollTop).toBe(0);
|
||||
expect(screen.getByText("while paused")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "ادامهٔ پیمایش" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pauses when the reader scrolls up by hand, and not when they stay at the bottom", () => {
|
||||
render(<LiveLog taskId={TASK} />);
|
||||
const box = screen.getByRole("log");
|
||||
|
||||
measure(box, 400);
|
||||
fireEvent.scroll(box);
|
||||
expect(screen.getByRole("button", { name: "توقف پیمایش" })).toBeInTheDocument();
|
||||
|
||||
measure(box, 0);
|
||||
fireEvent.scroll(box);
|
||||
expect(screen.getByRole("button", { name: "ادامهٔ پیمایش" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("clears the panel on request", () => {
|
||||
render(<LiveLog taskId={TASK} />);
|
||||
emit("run.log", { run_id: "run-1", task_id: TASK, lines: ["one", "two"] });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "پاک کردن" }));
|
||||
|
||||
expect(screen.queryByText("one")).toBeNull();
|
||||
expect(screen.getByText("هنوز خروجیای ثبت نشده است.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
/**
|
||||
* The manager page wired to the engine. The conversation's task cards show the live stage, a
|
||||
* ``task.step`` event moves a card's badge in place, a pending decision shows in the banner and
|
||||
* its answer reloads the list, the credit notice shows at zero credit, and a new task still goes
|
||||
* through the same create call with a hint that the AI manager takes it first.
|
||||
*/
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
class FakeApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
return {
|
||||
FakeApiError,
|
||||
api: {
|
||||
listThreads: vi.fn(),
|
||||
listEmployees: vi.fn(),
|
||||
getThread: vi.fn(),
|
||||
createThread: vi.fn(),
|
||||
postMessage: vi.fn(),
|
||||
createTask: vi.fn(),
|
||||
},
|
||||
engineApi: {
|
||||
status: vi.fn(),
|
||||
listTasks: vi.fn(),
|
||||
answerTask: vi.fn(),
|
||||
},
|
||||
session: {
|
||||
activeOrganization: { id: "org-1", name: "Acme", slug: "acme", role: "admin" as const },
|
||||
},
|
||||
/** The newest handler per channel list, as the page and the status hook registered them. */
|
||||
handlers: new Map<string, (event: unknown) => void>(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
|
||||
|
||||
vi.mock("@/lib/api", () => ({ ApiError: mocks.FakeApiError, api: mocks.api }));
|
||||
|
||||
vi.mock("@/lib/api-engine", () => ({ engineApi: mocks.engineApi }));
|
||||
|
||||
vi.mock("@/lib/realtime", () => ({
|
||||
useLiveChannel: (channels: string[], onEvent: (event: unknown) => void) => {
|
||||
mocks.handlers.set(channels.join("|"), onEvent);
|
||||
},
|
||||
}));
|
||||
|
||||
import ManagerPage from "@/app/(app)/manager/page";
|
||||
import type { EngineStatus, EngineTask } from "@/lib/api-engine";
|
||||
|
||||
const ORG = "org-1";
|
||||
|
||||
const THREAD = {
|
||||
id: "thread-1",
|
||||
organization_id: ORG,
|
||||
project_id: null,
|
||||
title: "گفتگوی اول",
|
||||
created_at: "2026-10-01T00:00:00Z",
|
||||
};
|
||||
|
||||
function status(overrides: Partial<EngineStatus> = {}): EngineStatus {
|
||||
return {
|
||||
balance: 50,
|
||||
currency: "USD",
|
||||
credit_ok: true,
|
||||
automation_enabled: true,
|
||||
max_employees: 7,
|
||||
team_size: 4,
|
||||
stage_counts: {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function engineTask(overrides: Partial<EngineTask> = {}): EngineTask {
|
||||
return {
|
||||
id: "task-1",
|
||||
title: "صفحهٔ گزارش فروش",
|
||||
status: "running",
|
||||
stage: "pending_test",
|
||||
pipeline: "default",
|
||||
kind: "feature",
|
||||
cycle: 1,
|
||||
max_cycles: 3,
|
||||
thread_id: "thread-1",
|
||||
assigned_employee_id: null,
|
||||
assignee_name: null,
|
||||
created_at: "2026-10-01T00:00:00Z",
|
||||
updated_at: "2026-10-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** Deliver one live event to every handler the page and its hooks registered. */
|
||||
function emit(type: string, data: Record<string, unknown>) {
|
||||
const event = { id: `evt-${type}`, type, data, organization_id: ORG, at: "2026-10-14T10:00:00Z" };
|
||||
act(() => {
|
||||
for (const handler of mocks.handlers.values()) handler(event);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of [...Object.values(mocks.api), ...Object.values(mocks.engineApi)]) fn.mockReset();
|
||||
mocks.handlers.clear();
|
||||
// jsdom has no scrollIntoView; the page scrolls its message list to the bottom.
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
|
||||
mocks.api.listThreads.mockResolvedValue([THREAD]);
|
||||
mocks.api.listEmployees.mockResolvedValue([]);
|
||||
mocks.api.getThread.mockResolvedValue({ ...THREAD, messages: [] });
|
||||
mocks.engineApi.status.mockResolvedValue(status());
|
||||
mocks.engineApi.listTasks.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("manager page: task cards and live stages", () => {
|
||||
it("shows the conversation's tasks with their Persian stage", async () => {
|
||||
mocks.engineApi.listTasks.mockResolvedValue([engineTask({ stage: "pending_test" })]);
|
||||
|
||||
render(<ManagerPage />);
|
||||
|
||||
expect(await screen.findByText("در انتظار تست")).toBeInTheDocument();
|
||||
expect(screen.getByText("صفحهٔ گزارش فروش").closest("a")).toHaveAttribute("href", "/tasks/task-1");
|
||||
});
|
||||
|
||||
it("moves a card's badge on a task.step event", async () => {
|
||||
mocks.engineApi.listTasks.mockResolvedValue([engineTask({ stage: "pending_test" })]);
|
||||
render(<ManagerPage />);
|
||||
expect(await screen.findByText("در انتظار تست")).toBeInTheDocument();
|
||||
|
||||
emit("task.step", {
|
||||
task_id: "task-1",
|
||||
from: "pending_test",
|
||||
to: "pending_review",
|
||||
pipeline: "default",
|
||||
status: "running",
|
||||
});
|
||||
|
||||
expect(await screen.findByText("در انتظار بازبینی")).toBeInTheDocument();
|
||||
expect(screen.queryByText("در انتظار تست")).toBeNull();
|
||||
});
|
||||
|
||||
it("reloads the engine task list on a task.created event", async () => {
|
||||
mocks.engineApi.listTasks
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValue([engineTask({ id: "task-9", title: "کار تازه", stage: "pending_manager_review" })]);
|
||||
render(<ManagerPage />);
|
||||
await screen.findByRole("button", { name: "ساخت کار" });
|
||||
|
||||
emit("task.created", { task_id: "task-9", title: "کار تازه", status: "new" });
|
||||
|
||||
expect(await screen.findByText("کار تازه")).toBeInTheDocument();
|
||||
expect(screen.getByText("در انتظار بررسی مدیر")).toBeInTheDocument();
|
||||
expect(mocks.engineApi.listTasks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe("manager page: pending decisions and credit", () => {
|
||||
it("shows the banner for a task at a pending decision", async () => {
|
||||
mocks.engineApi.listTasks.mockResolvedValue([
|
||||
engineTask({ id: "task-2", title: "کدام پایگاه داده؟", stage: "pending_user_decision" }),
|
||||
]);
|
||||
|
||||
render(<ManagerPage />);
|
||||
|
||||
expect(await screen.findByText("در انتظار تصمیم شما")).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "ارسال پاسخ" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("posts the answer, then reloads so the banner clears", async () => {
|
||||
mocks.engineApi.listTasks
|
||||
.mockResolvedValueOnce([
|
||||
engineTask({ id: "task-2", title: "کدام پایگاه داده؟", stage: "pending_user_decision" }),
|
||||
])
|
||||
.mockResolvedValue([]);
|
||||
mocks.engineApi.answerTask.mockResolvedValue({});
|
||||
render(<ManagerPage />);
|
||||
|
||||
fireEvent.change(await screen.findByLabelText("پاسخ شما"), {
|
||||
target: { value: "PostgreSQL" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "ارسال پاسخ" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.engineApi.answerTask).toHaveBeenCalledWith(ORG, "task-2", "PostgreSQL"),
|
||||
);
|
||||
await waitFor(() => expect(screen.queryByText("در انتظار تصمیم شما")).toBeNull());
|
||||
expect(mocks.engineApi.listTasks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("shows the credit notice at zero credit", async () => {
|
||||
mocks.engineApi.status.mockResolvedValue(status({ credit_ok: false, balance: 0 }));
|
||||
|
||||
render(<ManagerPage />);
|
||||
|
||||
expect(await screen.findByText("اعتبار سازمان به پایان رسیده است")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no credit notice while credit is available", async () => {
|
||||
render(<ManagerPage />);
|
||||
await screen.findByRole("button", { name: "ساخت کار" });
|
||||
|
||||
expect(screen.queryByText("اعتبار سازمان به پایان رسیده است")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("manager page: new task form", () => {
|
||||
it("keeps the create call and says the AI manager takes the task first", async () => {
|
||||
mocks.api.createTask.mockResolvedValue({ id: "task-7" });
|
||||
mocks.engineApi.listTasks
|
||||
.mockResolvedValueOnce([])
|
||||
.mockResolvedValue([engineTask({ id: "task-7", title: "تحلیل بازار", stage: "pending_manager_review" })]);
|
||||
render(<ManagerPage />);
|
||||
|
||||
expect(await screen.findByText(/نخست به مدیر هوش مصنوعی/)).toBeInTheDocument();
|
||||
fireEvent.change(screen.getByLabelText("عنوان کار جدید"), { target: { value: "تحلیل بازار" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "ساخت کار" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.api.createTask).toHaveBeenCalledWith(
|
||||
ORG,
|
||||
expect.objectContaining({ title: "تحلیل بازار", thread_id: "thread-1" }),
|
||||
),
|
||||
);
|
||||
expect(await screen.findByText("در انتظار بررسی مدیر")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
/**
|
||||
* The manager page's engine parts. The task cards under a conversation show each engine task's
|
||||
* Persian stage (and a legacy task's status), and link to the task page. The decision banner lists
|
||||
* only the tasks that wait for an answer, is hidden when there are none, posts the answer, and
|
||||
* shows a refusal from the server in Persian without losing what was typed.
|
||||
*/
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
answerTask: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => {
|
||||
class FakeApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
return { ApiError: FakeApiError };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/api-engine", () => ({ engineApi: { answerTask: mocks.answerTask } }));
|
||||
|
||||
import { ApiError } from "@/lib/api";
|
||||
import type { EngineTask } from "@/lib/api-engine";
|
||||
import { DecisionBanner } from "@/app/(app)/manager/_components/decision-banner";
|
||||
import { ThreadTaskCards } from "@/app/(app)/manager/_components/task-cards";
|
||||
|
||||
const ORG = "org-1";
|
||||
|
||||
function task(overrides: Partial<EngineTask> = {}): EngineTask {
|
||||
return {
|
||||
id: "task-1",
|
||||
title: "صفحهٔ گزارش فروش",
|
||||
status: "running",
|
||||
stage: "pending_test",
|
||||
pipeline: "default",
|
||||
kind: "feature",
|
||||
cycle: 1,
|
||||
max_cycles: 3,
|
||||
thread_id: "thread-1",
|
||||
assigned_employee_id: null,
|
||||
assignee_name: null,
|
||||
created_at: "2026-10-01T00:00:00Z",
|
||||
updated_at: "2026-10-01T00:00:00Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.answerTask.mockReset();
|
||||
});
|
||||
|
||||
describe("ThreadTaskCards", () => {
|
||||
it("shows each engine task's Persian stage and links the card to the task page", () => {
|
||||
render(<ThreadTaskCards tasks={[task({ stage: "pending_test" })]} />);
|
||||
|
||||
expect(screen.getByText("در انتظار تست")).toBeInTheDocument();
|
||||
expect(screen.getByText("صفحهٔ گزارش فروش").closest("a")).toHaveAttribute(
|
||||
"href",
|
||||
"/tasks/task-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows a legacy task by its status, since the engine has no stage for it", () => {
|
||||
const legacy = task({ id: "task-2", title: "کار قدیمی", stage: null, status: "done" });
|
||||
render(<ThreadTaskCards tasks={[legacy]} />);
|
||||
|
||||
expect(screen.getByText("کار قدیمی")).toBeInTheDocument();
|
||||
expect(screen.getByText("انجام شد")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders nothing for a conversation without tasks", () => {
|
||||
const { container } = render(<ThreadTaskCards tasks={[]} />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe("DecisionBanner", () => {
|
||||
it("is hidden when no task waits for an answer", () => {
|
||||
const { container } = render(
|
||||
<DecisionBanner orgId={ORG} tasks={[task({ stage: "pending_test" })]} onAnswered={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("lists only the tasks at a pending decision", () => {
|
||||
render(
|
||||
<DecisionBanner
|
||||
orgId={ORG}
|
||||
tasks={[
|
||||
task({ id: "task-1", title: "کدام پایگاه داده؟", stage: "pending_user_decision" }),
|
||||
task({ id: "task-2", title: "تست واحد", stage: "pending_test" }),
|
||||
]}
|
||||
onAnswered={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("در انتظار تصمیم شما")).toBeInTheDocument();
|
||||
expect(screen.getByText("کدام پایگاه داده؟")).toBeInTheDocument();
|
||||
expect(screen.queryByText("تست واحد")).toBeNull();
|
||||
});
|
||||
|
||||
it("posts the trimmed answer to the task and reports it", async () => {
|
||||
mocks.answerTask.mockResolvedValue({});
|
||||
const onAnswered = vi.fn();
|
||||
render(
|
||||
<DecisionBanner
|
||||
orgId={ORG}
|
||||
tasks={[task({ id: "task-1", title: "کدام پایگاه داده؟", stage: "pending_user_decision" })]}
|
||||
onAnswered={onAnswered}
|
||||
/>,
|
||||
);
|
||||
const send = screen.getByRole("button", { name: "ارسال پاسخ" });
|
||||
expect(send).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByLabelText("پاسخ شما"), { target: { value: " PostgreSQL " } });
|
||||
expect(send).toBeEnabled();
|
||||
fireEvent.click(send);
|
||||
|
||||
await waitFor(() => expect(onAnswered).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.answerTask).toHaveBeenCalledWith(ORG, "task-1", "PostgreSQL");
|
||||
});
|
||||
|
||||
it("shows the server's refusal in Persian and keeps the typed answer", async () => {
|
||||
mocks.answerTask.mockRejectedValue(
|
||||
new ApiError(409, "conflict", "این کار دیگر منتظر پاسخ نیست."),
|
||||
);
|
||||
const onAnswered = vi.fn();
|
||||
render(
|
||||
<DecisionBanner
|
||||
orgId={ORG}
|
||||
tasks={[task({ id: "task-1", title: "کدام پایگاه داده؟", stage: "pending_user_decision" })]}
|
||||
onAnswered={onAnswered}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("پاسخ شما"), { target: { value: "PostgreSQL" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "ارسال پاسخ" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("این کار دیگر منتظر پاسخ نیست.");
|
||||
expect(onAnswered).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText("پاسخ شما")).toHaveValue("PostgreSQL");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* The task controls: the buttons each stage shows, the confirm step, the API call and what
|
||||
* happens after it, the server's refusal shown in Persian, and nothing for a legacy task or a
|
||||
* viewer.
|
||||
*/
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
pauseTask: vi.fn(),
|
||||
resumeTask: vi.fn(),
|
||||
cancelTask: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => {
|
||||
class FakeApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
return { ApiError: FakeApiError };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/api-engine", () => ({ engineApi: mocks }));
|
||||
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { TaskActions, type TaskActionsProps } from "@/components/engine/task-actions";
|
||||
|
||||
const ORG = "org-1";
|
||||
|
||||
function renderActions(props: Partial<TaskActionsProps> = {}) {
|
||||
const onChanged = vi.fn();
|
||||
const view = render(
|
||||
<TaskActions
|
||||
orgId={ORG}
|
||||
task={{ id: "task-1", stage: "pending_test" }}
|
||||
canRun
|
||||
onChanged={onChanged}
|
||||
{...props}
|
||||
/>,
|
||||
);
|
||||
return { ...view, onChanged };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of Object.values(mocks)) fn.mockReset();
|
||||
});
|
||||
|
||||
describe("TaskActions buttons", () => {
|
||||
it("offers pause and cancel while a task runs", () => {
|
||||
renderActions({ task: { id: "task-1", stage: "pending_test" } });
|
||||
|
||||
expect(screen.getByRole("button", { name: "توقف" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "لغو" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "ادامه" })).toBeNull();
|
||||
});
|
||||
|
||||
it("offers resume and cancel once a task is paused", () => {
|
||||
renderActions({ task: { id: "task-1", stage: "paused" } });
|
||||
|
||||
expect(screen.getByRole("button", { name: "ادامه" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "لغو" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "توقف" })).toBeNull();
|
||||
});
|
||||
|
||||
it("offers nothing for a finished task", () => {
|
||||
renderActions({ task: { id: "task-1", stage: "completed" } });
|
||||
|
||||
expect(screen.queryAllByRole("button")).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskActions confirm and call", () => {
|
||||
it("asks first, then pauses through the API and reports the change", async () => {
|
||||
mocks.pauseTask.mockResolvedValue({});
|
||||
const { onChanged } = renderActions();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "توقف" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "کار متوقف شود؟" });
|
||||
expect(mocks.pauseTask).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "توقف کن" }));
|
||||
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.pauseTask).toHaveBeenCalledWith(ORG, "task-1");
|
||||
});
|
||||
|
||||
it("cancels through the API once confirmed", async () => {
|
||||
mocks.cancelTask.mockResolvedValue({});
|
||||
const { onChanged } = renderActions();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "لغو" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "کار لغو شود؟" });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "لغو کن" }));
|
||||
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.cancelTask).toHaveBeenCalledWith(ORG, "task-1");
|
||||
});
|
||||
|
||||
it("resumes a paused task through the API once confirmed", async () => {
|
||||
mocks.resumeTask.mockResolvedValue({});
|
||||
const { onChanged } = renderActions({ task: { id: "task-1", stage: "paused" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "ادامه" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "کار ادامه یابد؟" });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "ادامه بده" }));
|
||||
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.resumeTask).toHaveBeenCalledWith(ORG, "task-1");
|
||||
});
|
||||
|
||||
it("shows the server's refusal in Persian and does not report a change", async () => {
|
||||
mocks.resumeTask.mockRejectedValue(
|
||||
new ApiError(
|
||||
409,
|
||||
"conflict",
|
||||
"اعتبار سازمان تمام شده است؛ کار تا افزودن اعتبار توسط مالک سامانه متوقف شد.",
|
||||
),
|
||||
);
|
||||
const { onChanged } = renderActions({ task: { id: "task-1", stage: "paused" } });
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "ادامه" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "کار ادامه یابد؟" });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "ادامه بده" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("اعتبار سازمان تمام شده است");
|
||||
expect(onChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TaskActions visibility", () => {
|
||||
it("renders nothing for a legacy task", () => {
|
||||
const { container } = renderActions({ task: { id: "task-1", stage: null } });
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it("renders nothing for someone who may not run tasks", () => {
|
||||
const { container } = renderActions({ canRun: false });
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* The task detail's pipeline and turn timeline. The current stage is highlighted and the stages
|
||||
* before it check-marked; a side state is a badge and highlights no step; a completed task has
|
||||
* every step check-marked; the roles read in Persian; and a field the API never defines (a model
|
||||
* name) never reaches the page.
|
||||
*/
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PipelineStepper } from "@/app/(app)/tasks/[id]/_components/pipeline-stepper";
|
||||
import { TurnTimeline } from "@/app/(app)/tasks/[id]/_components/turn-timeline";
|
||||
import type { EngineTurn } from "@/lib/api-engine";
|
||||
|
||||
const STAGES = [
|
||||
"pending_manager_review",
|
||||
"pending_analysis",
|
||||
"pending_implementation",
|
||||
"pending_test",
|
||||
"pending_final_review",
|
||||
"merge_request",
|
||||
"completed",
|
||||
];
|
||||
|
||||
const TURNS: EngineTurn[] = [
|
||||
{
|
||||
id: "turn-1",
|
||||
run_id: "run-1",
|
||||
role: "developer",
|
||||
mode: "implement",
|
||||
stage: "pending_implementation",
|
||||
employee_name: "علی",
|
||||
ok: true,
|
||||
error_kind: "",
|
||||
tool_calls: 3,
|
||||
reply_excerpt: "کد نوشته شد",
|
||||
started_at: "2026-10-13T08:00:00Z",
|
||||
finished_at: "2026-10-13T08:05:00Z",
|
||||
},
|
||||
{
|
||||
id: "turn-2",
|
||||
run_id: "run-2",
|
||||
role: "tester",
|
||||
mode: "test",
|
||||
stage: "pending_test",
|
||||
employee_name: null,
|
||||
ok: false,
|
||||
error_kind: "tool_error",
|
||||
tool_calls: 1,
|
||||
reply_excerpt: "",
|
||||
started_at: "2026-10-13T08:10:00Z",
|
||||
finished_at: null,
|
||||
},
|
||||
];
|
||||
|
||||
function steps(container: HTMLElement): HTMLElement[] {
|
||||
return Array.from(container.querySelectorAll<HTMLElement>("ol > li"));
|
||||
}
|
||||
|
||||
describe("PipelineStepper", () => {
|
||||
it("highlights the current stage and check-marks the stages before it", () => {
|
||||
const { container } = render(<PipelineStepper stages={STAGES} current="pending_test" />);
|
||||
|
||||
const items = steps(container);
|
||||
expect(items.map((item) => item.dataset.state)).toEqual([
|
||||
"done",
|
||||
"done",
|
||||
"done",
|
||||
"current",
|
||||
"pending",
|
||||
"pending",
|
||||
"pending",
|
||||
]);
|
||||
expect(items[3]).toHaveAttribute("aria-current", "step");
|
||||
expect(items[3]).toHaveTextContent("در انتظار تست");
|
||||
expect(items[0]).toHaveTextContent("در انتظار بررسی مدیر");
|
||||
});
|
||||
|
||||
it("shows a side state as a badge and highlights no step", () => {
|
||||
const { container } = render(<PipelineStepper stages={STAGES} current="paused" />);
|
||||
|
||||
expect(screen.getByText("متوقف شده")).toBeInTheDocument();
|
||||
expect(container.querySelector('[aria-current="step"]')).toBeNull();
|
||||
expect(steps(container).every((item) => item.dataset.state === "pending")).toBe(true);
|
||||
});
|
||||
|
||||
it("check-marks every stage once the task is completed", () => {
|
||||
const { container } = render(<PipelineStepper stages={STAGES} current="completed" />);
|
||||
|
||||
expect(steps(container).every((item) => item.dataset.state === "done")).toBe(true);
|
||||
expect(container.querySelector('[aria-current="step"]')).toBeNull();
|
||||
});
|
||||
|
||||
it("renders nothing for a legacy task", () => {
|
||||
const { container } = render(<PipelineStepper stages={[]} current={null} />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TurnTimeline", () => {
|
||||
it("renders the roles in Persian with the employee, the outcome and the tool count", () => {
|
||||
render(<TurnTimeline turns={TURNS} />);
|
||||
|
||||
expect(screen.getByText("برنامهنویس — علی")).toBeInTheDocument();
|
||||
expect(screen.getByText("آزمونگر")).toBeInTheDocument();
|
||||
expect(screen.getByText("موفق")).toBeInTheDocument();
|
||||
expect(screen.getByText("خطا: tool_error")).toBeInTheDocument();
|
||||
expect(screen.getByText("۳ فراخوانی ابزار")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the reply excerpt in a collapsible block", () => {
|
||||
render(<TurnTimeline turns={TURNS} />);
|
||||
|
||||
expect(screen.getByText("کد نوشته شد").closest("details")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("never renders a model name, even when a turn carries one", () => {
|
||||
const leaked = { ...TURNS[0], model: "gpt-secret-x" };
|
||||
const { container } = render(<TurnTimeline turns={[leaked]} />);
|
||||
|
||||
expect(container.textContent).not.toContain("gpt-secret-x");
|
||||
expect(screen.getByText("برنامهنویس — علی")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says so when no turn has run yet", () => {
|
||||
render(<TurnTimeline turns={[]} />);
|
||||
|
||||
expect(screen.getByText("هنوز نوبتی برای این کار ثبت نشده است.")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* The task detail page. An engine task (it has a pipeline stage) shows the engine view: the
|
||||
* stepper, and no legacy run button. A legacy task keeps its run button. A `task.gate` or
|
||||
* `task.step` event reloads an engine task once, after a 500 ms pause, and an answer to a pending
|
||||
* decision goes to the engine API and reloads the task.
|
||||
*/
|
||||
import { Suspense } from "react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { WsEvent } from "@agy/contracts";
|
||||
import type { EngineTaskDetail, EngineTurn } from "@/lib/api-engine";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
handlers: new Map<string, (event: WsEvent) => void>(),
|
||||
engine: {
|
||||
getTask: vi.fn(),
|
||||
answerTask: vi.fn(),
|
||||
pauseTask: vi.fn(),
|
||||
resumeTask: vi.fn(),
|
||||
cancelTask: vi.fn(),
|
||||
},
|
||||
api: {
|
||||
getTask: vi.fn(),
|
||||
listEmployees: vi.fn(),
|
||||
listPendingApprovals: vi.fn(),
|
||||
getRun: vi.fn(),
|
||||
decideApproval: vi.fn(),
|
||||
startRun: vi.fn(),
|
||||
cancelRun: vi.fn(),
|
||||
},
|
||||
session: { activeOrganization: { id: "org-1", name: "سازمان", role: "member" } },
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => {
|
||||
class FakeApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
return { ApiError: FakeApiError, api: mocks.api };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/api-engine", () => ({ engineApi: mocks.engine }));
|
||||
|
||||
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
|
||||
|
||||
// Each subscriber keeps only its latest handler, keyed by its channels; an event reaches them all.
|
||||
vi.mock("@/lib/realtime", () => ({
|
||||
useLiveChannel: (channels: string[], onEvent: (event: WsEvent) => void) => {
|
||||
mocks.handlers.set(channels.join("|"), onEvent);
|
||||
},
|
||||
}));
|
||||
|
||||
import TaskDetailPage from "@/app/(app)/tasks/[id]/page";
|
||||
|
||||
const ORG = "org-1";
|
||||
const TASK = "task-1";
|
||||
|
||||
function engineTask(overrides: Partial<EngineTaskDetail> = {}): EngineTaskDetail {
|
||||
return {
|
||||
id: TASK,
|
||||
title: "افزودن صفحهٔ ورود",
|
||||
status: "running",
|
||||
stage: "pending_test",
|
||||
pipeline: "plan_code_test",
|
||||
kind: "code",
|
||||
cycle: 1,
|
||||
max_cycles: 3,
|
||||
thread_id: null,
|
||||
assigned_employee_id: null,
|
||||
assignee_name: "علی",
|
||||
created_at: "2026-10-13T08:00:00Z",
|
||||
updated_at: "2026-10-13T08:05:00Z",
|
||||
description: "یک صفحهٔ ورود بسازید",
|
||||
stages: [
|
||||
"pending_manager_review",
|
||||
"pending_implementation",
|
||||
"pending_test",
|
||||
"completed",
|
||||
],
|
||||
waiver_reason: "",
|
||||
branch: "task/1",
|
||||
depends_on: [],
|
||||
merged_sha: "",
|
||||
diff: "",
|
||||
turns: [],
|
||||
gates: [],
|
||||
runs: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function turn(reply: string): EngineTurn {
|
||||
return {
|
||||
id: "turn-1",
|
||||
run_id: "run-1",
|
||||
role: "developer",
|
||||
mode: "implement",
|
||||
stage: "pending_implementation",
|
||||
employee_name: "علی",
|
||||
ok: true,
|
||||
error_kind: "",
|
||||
tool_calls: 2,
|
||||
reply_excerpt: reply,
|
||||
started_at: "2026-10-13T08:00:00Z",
|
||||
finished_at: "2026-10-13T08:04:00Z",
|
||||
};
|
||||
}
|
||||
|
||||
function legacyTask() {
|
||||
return {
|
||||
id: TASK,
|
||||
organization_id: ORG,
|
||||
project_id: null,
|
||||
title: "کار قدیمی",
|
||||
description: "",
|
||||
status: "new",
|
||||
assigned_employee_id: null,
|
||||
created_at: "2026-10-01T08:00:00Z",
|
||||
updated_at: "2026-10-01T08:00:00Z",
|
||||
runs: [],
|
||||
};
|
||||
}
|
||||
|
||||
function emit(type: WsEvent["type"], data: Record<string, unknown>) {
|
||||
act(() => {
|
||||
for (const handler of mocks.handlers.values()) {
|
||||
handler({ id: "evt-1", type, data, organization_id: ORG, at: "2026-10-14T08:00:00Z" });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/** A promise React reads at once (status "fulfilled"), so the first render does not suspend. */
|
||||
function settledParams(id: string): Promise<{ id: string }> {
|
||||
return Object.assign(Promise.resolve({ id }), { status: "fulfilled", value: { id } });
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<Suspense fallback={null}>
|
||||
<TaskDetailPage params={settledParams(TASK)} />
|
||||
</Suspense>,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.handlers.clear();
|
||||
for (const fn of [...Object.values(mocks.engine), ...Object.values(mocks.api)]) fn.mockReset();
|
||||
mocks.api.listEmployees.mockResolvedValue([]);
|
||||
mocks.api.listPendingApprovals.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
describe("the task page for an engine task", () => {
|
||||
it("shows the pipeline stepper and no legacy run button", async () => {
|
||||
mocks.engine.getTask.mockResolvedValue(engineTask());
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByRole("list", { name: "مراحل خط لوله" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "اجرای کار" })).toBeNull();
|
||||
expect(mocks.api.getTask).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reloads once, after the debounce, for a burst of task.gate and task.step events", async () => {
|
||||
mocks.engine.getTask.mockResolvedValue(engineTask());
|
||||
renderPage();
|
||||
await screen.findByRole("list", { name: "مراحل خط لوله" });
|
||||
expect(mocks.engine.getTask).toHaveBeenCalledTimes(1);
|
||||
|
||||
emit("task.gate", { task_id: TASK, gate: "tests", ok: false, detail: "3 failed" });
|
||||
emit("task.step", {
|
||||
task_id: TASK,
|
||||
from: "pending_test",
|
||||
to: "pending_final_review",
|
||||
pipeline: "plan_code_test",
|
||||
status: "running",
|
||||
});
|
||||
expect(mocks.engine.getTask).toHaveBeenCalledTimes(1);
|
||||
|
||||
await waitFor(() => expect(mocks.engine.getTask).toHaveBeenCalledTimes(2), { timeout: 2000 });
|
||||
expect(mocks.engine.getTask).toHaveBeenLastCalledWith(ORG, TASK);
|
||||
});
|
||||
|
||||
it("sends a pending decision's answer through the engine API and reloads", async () => {
|
||||
mocks.engine.getTask.mockResolvedValue(
|
||||
engineTask({
|
||||
stage: "pending_user_decision",
|
||||
turns: [turn("کدام نسخه از کتابخانه را بسازم؟")],
|
||||
}),
|
||||
);
|
||||
mocks.engine.answerTask.mockResolvedValue({});
|
||||
renderPage();
|
||||
|
||||
expect((await screen.findAllByText("کدام نسخه از کتابخانه را بسازم؟")).length).toBeGreaterThan(0);
|
||||
fireEvent.change(screen.getByRole("textbox", { name: "پاسخ شما" }), {
|
||||
target: { value: " نسخهٔ دو " },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: "ارسال پاسخ" }));
|
||||
|
||||
await waitFor(() => expect(mocks.engine.answerTask).toHaveBeenCalledWith(ORG, TASK, "نسخهٔ دو"));
|
||||
await waitFor(() => expect(mocks.engine.getTask).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the task page for a legacy task", () => {
|
||||
it("keeps the legacy run button and shows no pipeline", async () => {
|
||||
mocks.engine.getTask.mockResolvedValue(
|
||||
engineTask({ stage: null, stages: [], turns: [], gates: [] }),
|
||||
);
|
||||
mocks.api.getTask.mockResolvedValue(legacyTask());
|
||||
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByRole("button", { name: "اجرای کار" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("list", { name: "مراحل خط لوله" })).toBeNull();
|
||||
expect(mocks.api.getTask).toHaveBeenCalledWith(ORG, TASK);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,220 @@
|
||||
/**
|
||||
* The traces page's engine turns: one row per turn with its Persian role, stage and result, a
|
||||
* failed turn that shows its error kind, a turn still running that is not shown as failed, no
|
||||
* reply text, and a reload on a task step only.
|
||||
*/
|
||||
import * as React from "react";
|
||||
import { act, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
listTurns: vi.fn(),
|
||||
listeners: new Set<(event: { type: string }) => void>(),
|
||||
}));
|
||||
|
||||
vi.mock("next/link", () => ({
|
||||
default: ({ href, children }: { href: string; children: React.ReactNode }) => (
|
||||
<a href={href}>{children}</a>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => {
|
||||
class FakeApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
return { ApiError: FakeApiError };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/api-engine", () => ({ engineApi: { listTurns: mocks.listTurns } }));
|
||||
|
||||
// The realtime client keeps one subscription per channel, so every hook registers its own listener
|
||||
// and the test delivers an event to all of them.
|
||||
vi.mock("@/lib/realtime", async () => {
|
||||
const { useEffect, useRef } = await import("react");
|
||||
return {
|
||||
useLiveChannel: (_channels: string[], onEvent: (event: { type: string }) => void) => {
|
||||
const latest = useRef(onEvent);
|
||||
latest.current = onEvent;
|
||||
useEffect(() => {
|
||||
const listener = (event: { type: string }) => latest.current(event);
|
||||
mocks.listeners.add(listener);
|
||||
return () => {
|
||||
mocks.listeners.delete(listener);
|
||||
};
|
||||
}, []);
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
import { ApiError } from "@/lib/api";
|
||||
import type { EngineOrgTurn } from "@/lib/api-engine";
|
||||
import { ROLE_LABELS, STAGE_LABELS } from "@/lib/engine-labels";
|
||||
import { EngineTurns } from "@/app/(app)/traces/_components/engine-turns";
|
||||
|
||||
const ORG = "org-1";
|
||||
const REPLY = "reply text written by the model";
|
||||
const EMPTY = "هنوز نوبتی ثبت نشده است.";
|
||||
|
||||
function turn(overrides: Partial<EngineOrgTurn> & { id: string }): EngineOrgTurn {
|
||||
return {
|
||||
run_id: "run-1",
|
||||
role: "developer",
|
||||
mode: "implement",
|
||||
stage: "pending_implementation",
|
||||
employee_name: "employee",
|
||||
ok: true,
|
||||
error_kind: "",
|
||||
tool_calls: 3,
|
||||
reply_excerpt: REPLY,
|
||||
started_at: "2026-10-14T10:00:00Z",
|
||||
finished_at: "2026-10-14T10:00:02Z",
|
||||
task_id: "task-1",
|
||||
task_title: "افزودن ورود",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/** The table row of one task, found through its link. */
|
||||
function rowOf(table: HTMLElement, taskTitle: string): HTMLElement {
|
||||
const row = within(table).getByRole("link", { name: taskTitle }).closest("tr");
|
||||
if (!row) throw new Error(`no row for ${taskTitle}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
/** Delivers one live event to every subscribed hook. */
|
||||
function emit(type: string) {
|
||||
act(() => {
|
||||
for (const listener of [...mocks.listeners]) listener({ type });
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.listTurns.mockReset();
|
||||
mocks.listeners.clear();
|
||||
});
|
||||
|
||||
describe("EngineTurns table", () => {
|
||||
it("renders one row per turn with its Persian role, stage, result and tool calls", async () => {
|
||||
mocks.listTurns.mockResolvedValue([
|
||||
turn({ id: "t1" }),
|
||||
turn({
|
||||
id: "t2",
|
||||
task_id: "task-2",
|
||||
task_title: "ثبت سفارش",
|
||||
role: "reviewer",
|
||||
stage: "pending_review",
|
||||
ok: false,
|
||||
error_kind: "timeout",
|
||||
tool_calls: 0,
|
||||
finished_at: "2026-10-14T10:00:05Z",
|
||||
}),
|
||||
]);
|
||||
|
||||
render(<EngineTurns orgId={ORG} />);
|
||||
const table = await screen.findByRole("table");
|
||||
|
||||
const okRow = rowOf(table, "افزودن ورود");
|
||||
expect(okRow).toHaveTextContent(ROLE_LABELS.developer);
|
||||
expect(okRow).toHaveTextContent(STAGE_LABELS.pending_implementation);
|
||||
expect(within(okRow).getByText("موفق")).toBeInTheDocument();
|
||||
expect(within(okRow).getByText("3")).toBeInTheDocument();
|
||||
expect(within(okRow).getByText("2.0s")).toBeInTheDocument();
|
||||
expect(within(okRow).getByRole("link", { name: "افزودن ورود" })).toHaveAttribute(
|
||||
"href",
|
||||
"/tasks/task-1",
|
||||
);
|
||||
|
||||
const failedRow = rowOf(table, "ثبت سفارش");
|
||||
expect(failedRow).toHaveTextContent(ROLE_LABELS.reviewer);
|
||||
expect(failedRow).toHaveTextContent(STAGE_LABELS.pending_review);
|
||||
expect(within(failedRow).getByText("ناموفق")).toBeInTheDocument();
|
||||
expect(within(failedRow).getByText("0")).toBeInTheDocument();
|
||||
expect(within(failedRow).getByText("5.0s")).toBeInTheDocument();
|
||||
expect(within(failedRow).getByRole("link", { name: "ثبت سفارش" })).toHaveAttribute(
|
||||
"href",
|
||||
"/tasks/task-2",
|
||||
);
|
||||
});
|
||||
|
||||
it("shows the error kind of a failed turn, and a turn still running is not shown as failed", async () => {
|
||||
mocks.listTurns.mockResolvedValue([
|
||||
turn({
|
||||
id: "t1",
|
||||
task_id: "task-1",
|
||||
task_title: "افزودن ورود",
|
||||
ok: false,
|
||||
error_kind: "rate_limit",
|
||||
finished_at: "2026-10-14T10:00:03Z",
|
||||
}),
|
||||
turn({
|
||||
id: "t2",
|
||||
task_id: "task-2",
|
||||
task_title: "ثبت سفارش",
|
||||
ok: false,
|
||||
error_kind: "",
|
||||
finished_at: null,
|
||||
}),
|
||||
]);
|
||||
|
||||
render(<EngineTurns orgId={ORG} />);
|
||||
const table = await screen.findByRole("table");
|
||||
|
||||
const failedRow = rowOf(table, "افزودن ورود");
|
||||
expect(within(failedRow).getByText("ناموفق")).toBeInTheDocument();
|
||||
expect(within(failedRow).getByText("rate_limit")).toBeInTheDocument();
|
||||
|
||||
const runningRow = rowOf(table, "ثبت سفارش");
|
||||
expect(within(runningRow).getByText("در جریان")).toBeInTheDocument();
|
||||
expect(within(runningRow).queryByText("ناموفق")).toBeNull();
|
||||
expect(within(runningRow).getByText("—")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("never shows the model's reply text", async () => {
|
||||
mocks.listTurns.mockResolvedValue([turn({ id: "t1" })]);
|
||||
|
||||
render(<EngineTurns orgId={ORG} />);
|
||||
await screen.findByRole("table");
|
||||
|
||||
expect(document.body).not.toHaveTextContent(REPLY);
|
||||
});
|
||||
|
||||
it("shows a Persian message when there are no turns yet", async () => {
|
||||
mocks.listTurns.mockResolvedValue([]);
|
||||
|
||||
render(<EngineTurns orgId={ORG} />);
|
||||
|
||||
expect(await screen.findByText(EMPTY)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows the failure in Persian when the turns cannot be loaded", async () => {
|
||||
mocks.listTurns.mockRejectedValue(new ApiError(500, "server_error", "خطای موقت سرور."));
|
||||
|
||||
render(<EngineTurns orgId={ORG} />);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("خطای موقت سرور.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("EngineTurns reload", () => {
|
||||
it("loads the newest 30 turns and loads them again on a task step, and on no other event", async () => {
|
||||
mocks.listTurns.mockResolvedValue([]);
|
||||
|
||||
render(<EngineTurns orgId={ORG} />);
|
||||
expect(await screen.findByText(EMPTY)).toBeInTheDocument();
|
||||
expect(mocks.listTurns).toHaveBeenCalledTimes(1);
|
||||
expect(mocks.listTurns).toHaveBeenCalledWith(ORG, 30);
|
||||
|
||||
emit("run.log");
|
||||
expect(mocks.listTurns).toHaveBeenCalledTimes(1);
|
||||
|
||||
emit("task.step");
|
||||
await waitFor(() => expect(mocks.listTurns).toHaveBeenCalledTimes(2));
|
||||
expect(mocks.listTurns).toHaveBeenLastCalledWith(ORG, 30);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,180 @@
|
||||
/**
|
||||
* The employee page for a member and for the platform owner. Both see the employee's role and
|
||||
* tier. Only the owner sees the routing-policy select and the routing choices, and only the owner's
|
||||
* save sends a routing policy; for a member the update leaves the field out.
|
||||
*/
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import * as React from "react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
class FakeApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
return {
|
||||
FakeApiError,
|
||||
api: {
|
||||
getEmployee: vi.fn(),
|
||||
listTools: vi.fn(),
|
||||
listRoutingChoices: vi.fn(),
|
||||
updateEmployee: vi.fn(),
|
||||
setToolPermission: vi.fn(),
|
||||
addPromptVersion: vi.fn(),
|
||||
},
|
||||
engineApi: { getTeam: vi.fn() },
|
||||
session: {
|
||||
activeOrganization: { id: "org-1", name: "Acme", role: "manager" },
|
||||
user: { is_platform_owner: false },
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/session", () => ({ useSession: () => mocks.session }));
|
||||
|
||||
vi.mock("@/lib/api", () => ({ ApiError: mocks.FakeApiError, api: mocks.api }));
|
||||
|
||||
vi.mock("@/lib/api-engine", () => ({ engineApi: mocks.engineApi }));
|
||||
|
||||
import type { EmployeeDetail, RoutingChoice } from "@agy/contracts";
|
||||
import type { TeamView } from "@/lib/api-engine";
|
||||
import { TIER_LABELS } from "@/lib/engine-labels";
|
||||
import EmployeeDetailPage from "@/app/(app)/workforce/[id]/page";
|
||||
import { roleLabel, tierLabel } from "@/app/(app)/workforce/_components/team-panel";
|
||||
|
||||
const EMPLOYEE: EmployeeDetail = {
|
||||
id: "emp-1",
|
||||
organization_id: "org-1",
|
||||
name: "علی",
|
||||
role_key: "developer",
|
||||
status: "active",
|
||||
routing_policy_id: "policy-1",
|
||||
created_at: "2026-10-01T00:00:00Z",
|
||||
prompt_versions: [],
|
||||
tool_permissions: [],
|
||||
open_task_count: 0,
|
||||
};
|
||||
|
||||
const CHOICES: RoutingChoice[] = [
|
||||
{ id: "policy-1", name: "مسیر تهاجمی", description: "" },
|
||||
{ id: "policy-2", name: "مسیر محتاطانه", description: "" },
|
||||
];
|
||||
|
||||
const TEAM: TeamView = {
|
||||
employees: [
|
||||
{
|
||||
id: "emp-1",
|
||||
name: "علی",
|
||||
role_key: "developer",
|
||||
status: "active",
|
||||
tier: "strong",
|
||||
busy_task_id: null,
|
||||
busy_task_title: null,
|
||||
hired_by: "manager",
|
||||
},
|
||||
],
|
||||
tiers: [
|
||||
{ tier: "fast", label: TIER_LABELS.fast },
|
||||
{ tier: "strong", label: TIER_LABELS.strong },
|
||||
],
|
||||
max_employees: 7,
|
||||
count: 1,
|
||||
};
|
||||
|
||||
// A promise that is already settled: React.use reads its value at once, without suspending.
|
||||
const PARAMS = Object.assign(Promise.resolve({ id: "emp-1" }), {
|
||||
status: "fulfilled" as const,
|
||||
value: { id: "emp-1" },
|
||||
});
|
||||
|
||||
function setOwner(owner: boolean) {
|
||||
mocks.session.user = { is_platform_owner: owner };
|
||||
}
|
||||
|
||||
function renderPage() {
|
||||
return render(
|
||||
<React.Suspense fallback={<p>در حال بارگذاری</p>}>
|
||||
<EmployeeDetailPage params={PARAMS} />
|
||||
</React.Suspense>,
|
||||
);
|
||||
}
|
||||
|
||||
function stubLoad() {
|
||||
mocks.api.getEmployee.mockResolvedValue(EMPLOYEE);
|
||||
mocks.api.listTools.mockResolvedValue([]);
|
||||
mocks.api.updateEmployee.mockResolvedValue(EMPLOYEE);
|
||||
mocks.engineApi.getTeam.mockResolvedValue(TEAM);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
for (const fn of [...Object.values(mocks.api), mocks.engineApi.getTeam]) fn.mockReset();
|
||||
setOwner(false);
|
||||
});
|
||||
|
||||
describe("employee page for a member", () => {
|
||||
it("shows the role and the tier, with no routing select and no routing choice", async () => {
|
||||
stubLoad();
|
||||
mocks.api.listRoutingChoices.mockResolvedValue(CHOICES);
|
||||
renderPage();
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "علی" })).toBeInTheDocument();
|
||||
expect(screen.getByText(roleLabel("developer"))).toBeInTheDocument();
|
||||
expect(screen.getByText(`سطح: ${tierLabel("strong")}`)).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByLabelText("سیاست کاری")).toBeNull();
|
||||
expect(mocks.api.listRoutingChoices).not.toHaveBeenCalled();
|
||||
expect(document.body.textContent).not.toContain("مسیر تهاجمی");
|
||||
expect(document.body.textContent).not.toContain("مسیر محتاطانه");
|
||||
});
|
||||
|
||||
it("leaves the routing policy out of its save", async () => {
|
||||
stubLoad();
|
||||
renderPage();
|
||||
|
||||
fireEvent.click(await screen.findByRole("button", { name: /ذخیره/ }));
|
||||
|
||||
await waitFor(() => expect(mocks.api.updateEmployee).toHaveBeenCalledTimes(1));
|
||||
const [orgId, employeeId, body] = mocks.api.updateEmployee.mock.calls[0];
|
||||
expect(orgId).toBe("org-1");
|
||||
expect(employeeId).toBe("emp-1");
|
||||
expect(body).toEqual({ name: "علی", status: "active" });
|
||||
expect(body).not.toHaveProperty("routing_policy_id");
|
||||
});
|
||||
});
|
||||
|
||||
describe("employee page for the platform owner", () => {
|
||||
it("shows the routing select with its choices, and the tier", async () => {
|
||||
setOwner(true);
|
||||
stubLoad();
|
||||
mocks.api.listRoutingChoices.mockResolvedValue(CHOICES);
|
||||
renderPage();
|
||||
|
||||
const select = await screen.findByLabelText("سیاست کاری");
|
||||
expect(select).toHaveValue("policy-1");
|
||||
expect(within(select).getByRole("option", { name: "مسیر تهاجمی" })).toBeInTheDocument();
|
||||
expect(within(select).getByRole("option", { name: "مسیر محتاطانه" })).toBeInTheDocument();
|
||||
expect(screen.getByText(`سطح: ${tierLabel("strong")}`)).toBeInTheDocument();
|
||||
expect(mocks.api.listRoutingChoices).toHaveBeenCalledWith("org-1");
|
||||
});
|
||||
|
||||
it("sends the routing policy with its save", async () => {
|
||||
setOwner(true);
|
||||
stubLoad();
|
||||
mocks.api.listRoutingChoices.mockResolvedValue(CHOICES);
|
||||
renderPage();
|
||||
|
||||
fireEvent.change(await screen.findByLabelText("سیاست کاری"), {
|
||||
target: { value: "policy-2" },
|
||||
});
|
||||
fireEvent.click(screen.getByRole("button", { name: /ذخیره/ }));
|
||||
|
||||
await waitFor(() => expect(mocks.api.updateEmployee).toHaveBeenCalledTimes(1));
|
||||
const [, , body] = mocks.api.updateEmployee.mock.calls[0];
|
||||
expect(body).toEqual({ name: "علی", status: "active", routing_policy_id: "policy-2" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* The team panel and the hire dialog. The panel shows the headcount and each member's role, tier
|
||||
* and busy task, and never a model name, even when a record carries one. Firing asks first. The
|
||||
* dialog hires with a role and a tier (a name only when one is typed), and it is disabled, with
|
||||
* the reason shown, when no tier is granted or the team is at the owner's cap.
|
||||
*/
|
||||
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
fire: vi.fn(),
|
||||
hire: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/api", () => {
|
||||
class FakeApiError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
return { ApiError: FakeApiError };
|
||||
});
|
||||
|
||||
vi.mock("@/lib/api-engine", () => ({ engineApi: mocks }));
|
||||
|
||||
import { ApiError } from "@/lib/api";
|
||||
import type { TeamMember, TeamView, TierOption } from "@/lib/api-engine";
|
||||
import { TIER_LABELS } from "@/lib/engine-labels";
|
||||
import {
|
||||
AT_CAP_HINT,
|
||||
HireDialog,
|
||||
NO_TIER_HINT,
|
||||
} from "@/app/(app)/workforce/_components/hire-dialog";
|
||||
import { TeamPanel, roleLabel, tierLabel } from "@/app/(app)/workforce/_components/team-panel";
|
||||
|
||||
const ORG = "org-1";
|
||||
|
||||
const TIERS: TierOption[] = [
|
||||
{ tier: "fast", label: TIER_LABELS.fast },
|
||||
{ tier: "strong", label: TIER_LABELS.strong },
|
||||
];
|
||||
|
||||
function member(overrides: Partial<TeamMember> = {}): TeamMember {
|
||||
return {
|
||||
id: "emp-1",
|
||||
name: "علی",
|
||||
role_key: "developer",
|
||||
status: "active",
|
||||
tier: "strong",
|
||||
busy_task_id: null,
|
||||
busy_task_title: null,
|
||||
hired_by: "manager",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function teamOf(employees: TeamMember[], overrides: Partial<TeamView> = {}): TeamView {
|
||||
return { employees, tiers: TIERS, max_employees: 7, count: employees.length, ...overrides };
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.fire.mockReset();
|
||||
mocks.hire.mockReset();
|
||||
});
|
||||
|
||||
describe("TeamPanel rows", () => {
|
||||
it("shows the headcount and each member's role, tier and busy task", () => {
|
||||
const team = teamOf([
|
||||
member({
|
||||
id: "emp-1",
|
||||
name: "علی",
|
||||
role_key: "developer",
|
||||
tier: "strong",
|
||||
busy_task_id: "task-9",
|
||||
busy_task_title: "صفحه ورود",
|
||||
}),
|
||||
member({ id: "emp-2", name: "مریم", role_key: "tester", tier: "fast" }),
|
||||
]);
|
||||
render(<TeamPanel orgId={ORG} team={team} canManage onChanged={vi.fn()} />);
|
||||
|
||||
expect(screen.getByText("۲ از ۷ نفر")).toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "علی" })).toHaveAttribute("href", "/workforce/emp-1");
|
||||
|
||||
const rows = screen.getAllByRole("listitem");
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(within(rows[0]).getByText(roleLabel("developer"))).toBeInTheDocument();
|
||||
expect(within(rows[0]).getByText(`سطح: ${tierLabel("strong")}`)).toBeInTheDocument();
|
||||
expect(within(rows[0]).getByRole("link", { name: "صفحه ورود" })).toHaveAttribute(
|
||||
"href",
|
||||
"/tasks/task-9",
|
||||
);
|
||||
expect(within(rows[1]).getByText(roleLabel("tester"))).toBeInTheDocument();
|
||||
expect(within(rows[1]).getByText(`سطح: ${tierLabel("fast")}`)).toBeInTheDocument();
|
||||
expect(within(rows[1]).getByText("بیکار")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("never shows a model name, even when the record carries one", () => {
|
||||
const leaked = { ai_model_id: "m-123", model_name: "gpt-secret-x" };
|
||||
const team = teamOf([{ ...member(), ...leaked }]);
|
||||
render(<TeamPanel orgId={ORG} team={team} canManage onChanged={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole("link", { name: "علی" })).toBeInTheDocument();
|
||||
expect(document.body.textContent).not.toContain("m-123");
|
||||
expect(document.body.textContent).not.toContain("gpt-secret-x");
|
||||
});
|
||||
|
||||
it("offers no fire control to someone who may not manage employees", () => {
|
||||
render(
|
||||
<TeamPanel orgId={ORG} team={teamOf([member()])} canManage={false} onChanged={vi.fn()} />,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole("button", { name: "حذف از تیم" })).toBeNull();
|
||||
expect(screen.getByRole("link", { name: "علی" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TeamPanel fire", () => {
|
||||
it("asks first, then fires through the API and reports the change", async () => {
|
||||
mocks.fire.mockResolvedValue({});
|
||||
const onChanged = vi.fn();
|
||||
render(<TeamPanel orgId={ORG} team={teamOf([member()])} canManage onChanged={onChanged} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "حذف از تیم" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
expect(mocks.fire).not.toHaveBeenCalled();
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "حذف کن" }));
|
||||
|
||||
await waitFor(() => expect(onChanged).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.fire).toHaveBeenCalledWith(ORG, "emp-1");
|
||||
});
|
||||
|
||||
it("shows the server's refusal in Persian and does not report a change", async () => {
|
||||
mocks.fire.mockRejectedValue(new ApiError(409, "conflict", "آخرین مدیر سازمان حذف نشد."));
|
||||
const onChanged = vi.fn();
|
||||
render(<TeamPanel orgId={ORG} team={teamOf([member()])} canManage onChanged={onChanged} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "حذف از تیم" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "حذف کن" }));
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("آخرین مدیر سازمان حذف نشد.");
|
||||
expect(onChanged).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("HireDialog", () => {
|
||||
it("hires with the chosen role and tier", async () => {
|
||||
mocks.hire.mockResolvedValue({});
|
||||
const onHired = vi.fn();
|
||||
render(<HireDialog orgId={ORG} tiers={TIERS} atCap={false} onHired={onHired} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "کارمند جدید" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "استخدام کارمند جدید" });
|
||||
fireEvent.change(within(dialog).getByLabelText("نقش"), { target: { value: "tester" } });
|
||||
fireEvent.change(within(dialog).getByLabelText("سطح"), { target: { value: "fast" } });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "استخدام" }));
|
||||
|
||||
await waitFor(() => expect(onHired).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.hire).toHaveBeenCalledWith(ORG, { role: "tester", tier: "fast" });
|
||||
});
|
||||
|
||||
it("sends the name only when one is typed", async () => {
|
||||
mocks.hire.mockResolvedValue({});
|
||||
render(<HireDialog orgId={ORG} tiers={TIERS} atCap={false} onHired={vi.fn()} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "کارمند جدید" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "استخدام کارمند جدید" });
|
||||
fireEvent.change(within(dialog).getByLabelText("نام (اختیاری)"), {
|
||||
target: { value: " رضا " },
|
||||
});
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "استخدام" }));
|
||||
|
||||
await waitFor(() => expect(mocks.hire).toHaveBeenCalledTimes(1));
|
||||
expect(mocks.hire).toHaveBeenCalledWith(ORG, { role: "developer", tier: "fast", name: "رضا" });
|
||||
});
|
||||
|
||||
it("shows the server's refusal and keeps the dialog open", async () => {
|
||||
mocks.hire.mockRejectedValue(new ApiError(409, "conflict", "سقف تعداد کارمندان پر شده است."));
|
||||
const onHired = vi.fn();
|
||||
render(<HireDialog orgId={ORG} tiers={TIERS} atCap={false} onHired={onHired} />);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "کارمند جدید" }));
|
||||
const dialog = await screen.findByRole("dialog", { name: "استخدام کارمند جدید" });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "استخدام" }));
|
||||
|
||||
expect(await within(dialog).findByRole("alert")).toHaveTextContent(
|
||||
"سقف تعداد کارمندان پر شده است.",
|
||||
);
|
||||
expect(onHired).not.toHaveBeenCalled();
|
||||
expect(screen.getByRole("dialog", { name: "استخدام کارمند جدید" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is disabled, with the reason, when no tier is granted", () => {
|
||||
render(<HireDialog orgId={ORG} tiers={[]} atCap={false} onHired={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "کارمند جدید" })).toBeDisabled();
|
||||
expect(screen.getByText(NO_TIER_HINT)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("is disabled, with the reason, at the owner's cap", () => {
|
||||
render(<HireDialog orgId={ORG} tiers={TIERS} atCap onHired={vi.fn()} />);
|
||||
|
||||
expect(screen.getByRole("button", { name: "کارمند جدید" })).toBeDisabled();
|
||||
expect(screen.getByText(AT_CAP_HINT)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,181 @@
|
||||
/**
|
||||
* Channel reference counts in the realtime client.
|
||||
*
|
||||
* Two live hooks on the same channel (``org:<id>``) must not lose their feed
|
||||
* when one of them unmounts: the server hears about a channel only when its
|
||||
* last holder leaves, and a reconnect replays only what is still held.
|
||||
*
|
||||
* The browser WebSocket is replaced by a small fake that records every frame
|
||||
* the client sends and lets the test open or drop the link by hand.
|
||||
*/
|
||||
|
||||
import { createElement } from "react";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useLiveChannel } from "@/lib/realtime";
|
||||
import { RealtimeClient, getRealtimeClient } from "@/lib/ws";
|
||||
|
||||
interface Frame {
|
||||
action: string;
|
||||
channels?: string[];
|
||||
}
|
||||
|
||||
class FakeSocket {
|
||||
static CONNECTING = 0;
|
||||
static OPEN = 1;
|
||||
static CLOSING = 2;
|
||||
static CLOSED = 3;
|
||||
/** Every socket the client has dialled, oldest first. */
|
||||
static instances: FakeSocket[] = [];
|
||||
|
||||
readyState = FakeSocket.CONNECTING;
|
||||
readonly sent: Frame[] = [];
|
||||
onopen: (() => void) | null = null;
|
||||
onmessage: ((message: { data: unknown }) => void) | null = null;
|
||||
onclose: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
|
||||
constructor(readonly url: string) {
|
||||
FakeSocket.instances.push(this);
|
||||
}
|
||||
|
||||
send(data: string): void {
|
||||
this.sent.push(JSON.parse(data) as Frame);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.readyState = FakeSocket.CLOSED;
|
||||
}
|
||||
|
||||
/** The handshake finished: the client's onopen runs (the replay happens there). */
|
||||
open(): void {
|
||||
this.readyState = FakeSocket.OPEN;
|
||||
this.onopen?.();
|
||||
}
|
||||
|
||||
/** The link died: the client's onclose runs and schedules a reconnect. */
|
||||
drop(): void {
|
||||
this.readyState = FakeSocket.CLOSED;
|
||||
this.onclose?.();
|
||||
}
|
||||
}
|
||||
|
||||
function latestSocket(): FakeSocket {
|
||||
const socket = FakeSocket.instances.at(-1);
|
||||
if (!socket) throw new Error("the client has not dialled a socket");
|
||||
return socket;
|
||||
}
|
||||
|
||||
function framesOf(socket: FakeSocket, action: string): Frame[] {
|
||||
return socket.sent.filter((frame) => frame.action === action);
|
||||
}
|
||||
|
||||
describe("RealtimeClient channel reference counts", () => {
|
||||
let client: RealtimeClient;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
FakeSocket.instances = [];
|
||||
vi.stubGlobal("WebSocket", FakeSocket);
|
||||
client = new RealtimeClient(() => "token-1");
|
||||
client.connect();
|
||||
latestSocket().open();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
client.disconnect();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("keeps a channel on the wire while another holder still has it", () => {
|
||||
client.subscribe(["org:1"]);
|
||||
client.subscribe(["org:1"]);
|
||||
client.unsubscribe(["org:1"]);
|
||||
|
||||
expect(client.activeSubscriptions()).toEqual(["org:1"]);
|
||||
expect(framesOf(latestSocket(), "subscribe")).toEqual([
|
||||
{ action: "subscribe", channels: ["org:1"] },
|
||||
]);
|
||||
expect(framesOf(latestSocket(), "unsubscribe")).toEqual([]);
|
||||
});
|
||||
|
||||
it("tells the server only when the last holder leaves", () => {
|
||||
client.subscribe(["org:1"]);
|
||||
client.subscribe(["org:1"]);
|
||||
client.unsubscribe(["org:1"]);
|
||||
client.unsubscribe(["org:1"]);
|
||||
|
||||
expect(client.activeSubscriptions()).toEqual([]);
|
||||
expect(framesOf(latestSocket(), "unsubscribe")).toEqual([
|
||||
{ action: "unsubscribe", channels: ["org:1"] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores an unsubscribe for a channel nobody holds", () => {
|
||||
client.unsubscribe(["org:9"]);
|
||||
|
||||
expect(client.activeSubscriptions()).toEqual([]);
|
||||
expect(framesOf(latestSocket(), "unsubscribe")).toEqual([]);
|
||||
});
|
||||
|
||||
it("counts a channel listed twice in one call as two holders", () => {
|
||||
client.subscribe(["org:1", "org:1"]);
|
||||
expect(framesOf(latestSocket(), "subscribe")).toEqual([
|
||||
{ action: "subscribe", channels: ["org:1"] },
|
||||
]);
|
||||
|
||||
client.unsubscribe(["org:1"]);
|
||||
expect(client.activeSubscriptions()).toEqual(["org:1"]);
|
||||
client.unsubscribe(["org:1"]);
|
||||
expect(client.activeSubscriptions()).toEqual([]);
|
||||
});
|
||||
|
||||
it("replays only the channels still held after a reconnect", () => {
|
||||
client.subscribe(["org:1", "task:7"]);
|
||||
client.subscribe(["org:1"]);
|
||||
client.unsubscribe(["org:1"]); // the other hook on org:1 is still mounted
|
||||
client.unsubscribe(["task:7"]); // this one is gone for good
|
||||
|
||||
const first = latestSocket();
|
||||
first.drop(); // the client schedules a reconnect after its backoff
|
||||
vi.advanceTimersByTime(1_000);
|
||||
const next = latestSocket();
|
||||
expect(next).not.toBe(first);
|
||||
next.open();
|
||||
|
||||
expect(framesOf(next, "subscribe")).toEqual([
|
||||
{ action: "subscribe", channels: ["org:1"] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
function OrgFeed(): null {
|
||||
useLiveChannel(["org:1"], () => undefined);
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("useLiveChannel on a channel two components share", () => {
|
||||
beforeEach(() => {
|
||||
FakeSocket.instances = [];
|
||||
vi.stubGlobal("WebSocket", FakeSocket);
|
||||
getRealtimeClient().connect();
|
||||
latestSocket().open();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
getRealtimeClient().disconnect();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("keeps the org feed when one of the two components unmounts", () => {
|
||||
const first = render(createElement(OrgFeed));
|
||||
render(createElement(OrgFeed));
|
||||
|
||||
first.unmount();
|
||||
|
||||
expect(getRealtimeClient().activeSubscriptions()).toEqual(["org:1"]);
|
||||
expect(framesOf(latestSocket(), "unsubscribe")).toEqual([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user