R31-1 R31-4: 收口详情页工作台化与 E2E 契约
这个提交包含在:
@@ -0,0 +1,51 @@
|
|||||||
|
export const TEST_IDS = {
|
||||||
|
login: {
|
||||||
|
identifier: "login-identifier",
|
||||||
|
password: "login-password",
|
||||||
|
submit: "login-submit",
|
||||||
|
},
|
||||||
|
topbar: {
|
||||||
|
profileTrigger: "topbar-profile-trigger",
|
||||||
|
},
|
||||||
|
workflow: {
|
||||||
|
tasksTable: "workflow-tasks-table",
|
||||||
|
taskRow: "workflow-task-row",
|
||||||
|
detailLink: "workflow-task-detail-link",
|
||||||
|
summary: "workflow-task-summary",
|
||||||
|
approve: "workflow-task-approve",
|
||||||
|
reject: "workflow-task-reject",
|
||||||
|
rejectComment: "workflow-task-reject-comment",
|
||||||
|
returnBtn: "workflow-task-return",
|
||||||
|
returnComment: "workflow-task-return-comment",
|
||||||
|
transferApprover: "workflow-task-transfer-approver",
|
||||||
|
transferReason: "workflow-task-transfer-reason",
|
||||||
|
transferBtn: "workflow-task-transfer",
|
||||||
|
actionError: "workflow-task-action-error",
|
||||||
|
overview: "workflow-task-overview",
|
||||||
|
},
|
||||||
|
reimbursement: {
|
||||||
|
summary: "reimbursement-summary",
|
||||||
|
items: "reimbursement-items",
|
||||||
|
itemAdd: "reimbursement-item-add",
|
||||||
|
submit: "reimbursement-submit",
|
||||||
|
withdraw: "reimbursement-withdraw",
|
||||||
|
pay: "reimbursement-pay",
|
||||||
|
budgetHint: "reimbursement-budget-hint",
|
||||||
|
erpExport: "reimbursement-erp-export",
|
||||||
|
overview: "reimbursement-overview",
|
||||||
|
},
|
||||||
|
weeklyReport: {
|
||||||
|
save: "weekly-report-save",
|
||||||
|
submit: "weekly-report-submit",
|
||||||
|
createTodo: "weekly-report-create-todo",
|
||||||
|
},
|
||||||
|
todo: {
|
||||||
|
createTitle: "todo-create-title",
|
||||||
|
createSubmit: "todo-create-submit",
|
||||||
|
row: "todo-row",
|
||||||
|
complete: "todo-complete",
|
||||||
|
},
|
||||||
|
audit: {
|
||||||
|
export: "audit-export",
|
||||||
|
},
|
||||||
|
} as const;
|
||||||
@@ -23,11 +23,15 @@ import { useAuth } from "../auth/AuthContext";
|
|||||||
import { hasPermission } from "../auth/permissions";
|
import { hasPermission } from "../auth/permissions";
|
||||||
import { CardSkeleton } from "../components/common/CardSkeleton";
|
import { CardSkeleton } from "../components/common/CardSkeleton";
|
||||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
|
||||||
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
|
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
|
||||||
|
import { StatusBadge } from "../components/common/StatusBadge";
|
||||||
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
|
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||||
|
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
|
||||||
|
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
|
||||||
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||||
|
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||||
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
|
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
|
||||||
import {
|
import {
|
||||||
ReimbursementItemEditor,
|
ReimbursementItemEditor,
|
||||||
@@ -80,7 +84,7 @@ export function ReimbursementDetailPage() {
|
|||||||
|
|
||||||
const erpPreviewQuery = useQuery({
|
const erpPreviewQuery = useQuery({
|
||||||
enabled: Boolean(reimbursementId) && canManageIntegration && detailQuery.data?.status === "paid",
|
enabled: Boolean(reimbursementId) && canManageIntegration && detailQuery.data?.status === "paid",
|
||||||
queryKey: ["reimbursements", reimbursementId, "erp-journal-preview"],
|
queryKey: ["reimbursements", reimbursementId, "integration-journal-preview"],
|
||||||
queryFn: () => previewReimbursementJournal(reimbursementId),
|
queryFn: () => previewReimbursementJournal(reimbursementId),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -247,7 +251,7 @@ export function ReimbursementDetailPage() {
|
|||||||
},
|
},
|
||||||
onSuccess: async (result) => {
|
onSuccess: async (result) => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "erp-journal-preview"] }),
|
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "integration-journal-preview"] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
||||||
]);
|
]);
|
||||||
setActionError("");
|
setActionError("");
|
||||||
@@ -390,128 +394,91 @@ export function ReimbursementDetailPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<PageScaffold>
|
||||||
<PageHeader
|
<EntityOverviewHero
|
||||||
title={`报销详情 ${detail.formNumber ?? detail.id}`}
|
title={`报销详情 ${detail.formNumber ?? detail.id}`}
|
||||||
subtitle={`状态:${detail.status}`}
|
subtitle="在同一工作台内查看概览、处理明细、跟进风险并进入财务桥接。"
|
||||||
actions={
|
meta={(
|
||||||
|
<>
|
||||||
|
<StatusBadge status={detail.status} />
|
||||||
|
<span className="info-chip">报销日期:{detail.reimbursementDate ?? "-"}</span>
|
||||||
|
<span className="info-chip">报销人:{detail.owner?.name ?? "-"}</span>
|
||||||
|
<span className="info-chip">提交人:{detail.submitter?.name ?? "-"}</span>
|
||||||
|
<span className="info-chip">金额:{detail.totalAmount ?? "-"}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
actions={(
|
||||||
<Button asChild variant="secondary" data-testid="reimbursement-back">
|
<Button asChild variant="secondary" data-testid="reimbursement-back">
|
||||||
<Link to="/reimbursements">返回列表</Link>
|
<Link to="/reimbursements">返回列表</Link>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="card-std space-y-2 p-6 text-sm" data-testid="reimbursement-summary">
|
<div className="detail-shell-grid">
|
||||||
<div>报销ID:{detail.id}</div>
|
<div className="space-y-6">
|
||||||
<div>报销日期:{detail.reimbursementDate ?? "-"}</div>
|
<SectionPanel title="概览与关联" description="先看主档和关联项目,再决定是否继续补明细或提交流程。">
|
||||||
<div>金额:{detail.totalAmount ?? "-"}</div>
|
<div className="space-y-6" data-testid="reimbursement-overview">
|
||||||
<div>提交人:{detail.submitter?.name ?? "-"}</div>
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<div>报销人:{detail.owner?.name ?? "-"}</div>
|
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
|
||||||
<div>说明:{detail.description || "-"}</div>
|
<div className="text-zinc-500">当前状态</div>
|
||||||
</div>
|
<div className="mt-2"><StatusBadge status={detail.status} /></div>
|
||||||
|
</div>
|
||||||
<div className="card-std space-y-3 p-6 text-sm">
|
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
|
||||||
<div className="flex items-center justify-between gap-3">
|
<div className="text-zinc-500">关联项目</div>
|
||||||
<div>
|
<div className="mt-2 font-medium text-zinc-900">{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}</div>
|
||||||
<h3 className="text-base font-semibold text-zinc-900">关联项目</h3>
|
</div>
|
||||||
<p className="text-xs text-zinc-500">报销可选挂到项目,用于项目执行面板汇总。</p>
|
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
|
||||||
</div>
|
<div className="text-zinc-500">明细数</div>
|
||||||
{detail.project ? <Link to={`/projects/${detail.project.id}`} className="text-sm text-primary hover:underline">查看项目</Link> : null}
|
<div className="mt-2 font-medium text-zinc-900">{detail.items.length}</div>
|
||||||
</div>
|
</div>
|
||||||
<select
|
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
|
||||||
className="select-std max-w-xl"
|
<div className="text-zinc-500">关联票据</div>
|
||||||
value={detail.projectId ?? ""}
|
<div className="mt-2 font-medium text-zinc-900">{detail.invoices.length}</div>
|
||||||
onChange={(event) => projectLinkMutation.mutate(event.target.value || null)}
|
</div>
|
||||||
disabled={projectLinkMutation.isPending}
|
|
||||||
>
|
|
||||||
<option value="">不关联项目</option>
|
|
||||||
{(projectsQuery.data?.items ?? []).map((item) => (
|
|
||||||
<option key={item.id} value={item.id}>
|
|
||||||
{item.projectNo} · {item.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{budgetHint ? (
|
|
||||||
<div className="card-std space-y-2 p-6 text-sm" data-testid="reimbursement-budget-hint">
|
|
||||||
<h3 className="text-base font-semibold text-zinc-900">预算提示</h3>
|
|
||||||
<div>预算池:{budgetHint.budgetCode} / {budgetHint.budgetName}</div>
|
|
||||||
<div>
|
|
||||||
本次提交后预计占用:{budgetHint.nextUsedAmount} / {budgetHint.allocatedAmount}
|
|
||||||
({budgetHint.nextUsagePercent.toFixed(2)}%)
|
|
||||||
</div>
|
|
||||||
<div>控制模式:{budgetHint.controlMode}</div>
|
|
||||||
{budgetHint.exceedsBudget && budgetHint.controlMode === "block" ? (
|
|
||||||
<p className="rounded-md border border-red-200 bg-red-50 px-2 py-1 text-red-600">
|
|
||||||
当前金额将超出预算上限,提交时会被阻断(block 模式)。
|
|
||||||
</p>
|
|
||||||
) : budgetHint.exceedsThreshold ? (
|
|
||||||
<p className="rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-amber-700">
|
|
||||||
当前金额已达到预算预警阈值,提交后会记录预算预警审计。
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<p className="text-zinc-500">当前预算充足,可继续提交。</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
|
|
||||||
{canManageIntegration && detail.status === "paid" ? (
|
|
||||||
<div className="card-std space-y-4 p-6" data-testid="reimbursement-erp-export">
|
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<h3 className="text-base font-semibold text-zinc-900">财务桥接</h3>
|
|
||||||
<p className="text-sm text-zinc-500">已付款报销单会按标准凭证切片通过 Outbox 异步发送到财务桥接出口。</p>
|
|
||||||
</div>
|
|
||||||
<Button onClick={() => erpExportMutation.mutate()} disabled={erpExportMutation.isPending}>
|
|
||||||
{erpExportMutation.isPending ? "出队中..." : "发送到财务桥接"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{erpPreviewQuery.isLoading ? <p className="text-sm text-zinc-500">正在加载凭证切片预览...</p> : null}
|
|
||||||
{erpPreviewQuery.isError ? (
|
|
||||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
|
||||||
{isApiError(erpPreviewQuery.error)
|
|
||||||
? `${erpPreviewQuery.error.code}: ${mapApiErrorMessage(
|
|
||||||
erpPreviewQuery.error.code,
|
|
||||||
erpPreviewQuery.error.details,
|
|
||||||
erpPreviewQuery.error.message,
|
|
||||||
)}`
|
|
||||||
: "加载财务桥接预览失败"}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{erpPreviewQuery.data ? (
|
|
||||||
<div className="grid gap-4 lg:grid-cols-2 text-sm">
|
|
||||||
<div className="rounded-lg border border-zinc-200 p-4 space-y-2">
|
|
||||||
<h4 className="font-medium text-zinc-900">凭证切片</h4>
|
|
||||||
<div>类型:{erpPreviewQuery.data.journal.journalType}</div>
|
|
||||||
<div>单号:{erpPreviewQuery.data.journal.documentNo ?? "-"}</div>
|
|
||||||
<div>金额:{erpPreviewQuery.data.journal.totalAmount ?? "-"}</div>
|
|
||||||
<div>法体:{erpPreviewQuery.data.journal.dimensions.legalEntityCode ?? "-"}</div>
|
|
||||||
<div>成本中心:{erpPreviewQuery.data.journal.dimensions.costCenterCode ?? "-"}</div>
|
|
||||||
<div>付款凭证号:{erpPreviewQuery.data.journal.paymentVoucherNo ?? "-"}</div>
|
|
||||||
<div>付款日期:{erpPreviewQuery.data.journal.paymentDate ?? "-"}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-lg border border-zinc-200 p-4 space-y-2">
|
|
||||||
<h4 className="font-medium text-zinc-900">导出状态</h4>
|
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="reimbursement-summary">
|
||||||
<div>外部ID:{erpPreviewQuery.data.exportState.externalId ?? "-"}</div>
|
<div>状态:{detail.status}</div>
|
||||||
<div>Outbox:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}</div>
|
<div>报销ID:{detail.id}</div>
|
||||||
<div>队列状态:{erpPreviewQuery.data.exportState.lastOutboxStatus ?? "-"}</div>
|
<div>报销日期:{detail.reimbursementDate ?? "-"}</div>
|
||||||
<div>最近导出:{formatDateTime(erpPreviewQuery.data.exportState.exportedAt)}</div>
|
<div>金额:{detail.totalAmount ?? "-"}</div>
|
||||||
<div className="break-all">最近错误:{erpPreviewQuery.data.exportState.lastError ?? "-"}</div>
|
<div>提交人:{detail.submitter?.name ?? "-"}</div>
|
||||||
|
<div>报销人:{detail.owner?.name ?? "-"}</div>
|
||||||
|
<div>说明:{detail.description || "-"}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-zinc-200 p-4 text-sm">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h4 className="text-base font-semibold text-zinc-900">关联项目</h4>
|
||||||
|
<p className="text-xs text-zinc-500">报销可选挂到项目,用于项目执行面板汇总。</p>
|
||||||
|
</div>
|
||||||
|
{detail.project ? (
|
||||||
|
<Link to={`/projects/${detail.project.id}`} className="text-sm text-primary hover:underline">
|
||||||
|
查看项目
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="select-std mt-4 max-w-xl"
|
||||||
|
value={detail.projectId ?? ""}
|
||||||
|
onChange={(event) => projectLinkMutation.mutate(event.target.value || null)}
|
||||||
|
disabled={projectLinkMutation.isPending}
|
||||||
|
>
|
||||||
|
<option value="">不关联项目</option>
|
||||||
|
{(projectsQuery.data?.items ?? []).map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.projectNo} · {item.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</SectionPanel>
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
|
<SectionPanel title="执行 · 明细与票据" description="明细、票据和编辑器在同一层处理,避免长页面往返。">
|
||||||
|
<div className="space-y-6">
|
||||||
<AiSuggestionCard targetType="reimbursement" targetId={detail.id} />
|
<div className="space-y-4" data-testid="reimbursement-items">
|
||||||
|
|
||||||
<div className="card-std space-y-4 p-6" data-testid="reimbursement-items">
|
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">报销明细</h3>
|
<h3 className="text-base font-semibold text-zinc-900">报销明细</h3>
|
||||||
@@ -616,34 +583,130 @@ export function ReimbursementDetailPage() {
|
|||||||
isPending={itemSaveMutation.isPending}
|
isPending={itemSaveMutation.isPending}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
|
||||||
|
|
||||||
<ReimbursementActions
|
|
||||||
status={detail.status}
|
|
||||||
canPay={canPay}
|
|
||||||
actionError={actionError}
|
|
||||||
isPending={mutation.isPending}
|
|
||||||
onSubmit={() => void handleSubmitAction()}
|
|
||||||
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}
|
|
||||||
onPay={(payload) => mutation.mutate({ action: "pay", payload })}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="card-std space-y-2 p-6" data-testid="reimbursement-invoices">
|
|
||||||
<h3 className="text-base font-semibold text-zinc-900">关联票据</h3>
|
|
||||||
{(detail.invoices ?? []).length ? (
|
|
||||||
<div className="space-y-2 text-sm">
|
|
||||||
{detail.invoices.map((item) => (
|
|
||||||
<div className="rounded-lg border border-zinc-200 px-3 py-2" key={item.id}>
|
|
||||||
<div className="font-medium text-zinc-900">{item.id}</div>
|
|
||||||
<div className="text-zinc-600">
|
|
||||||
状态 {item.status} / 发票 {item.invoiceCode || "-"}-{item.invoiceNumber || "-"}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</div>
|
<div className="space-y-2 text-sm" data-testid="reimbursement-invoices">
|
||||||
) : (
|
<h3 className="text-base font-semibold text-zinc-900">关联票据</h3>
|
||||||
<p className="text-sm text-zinc-500">暂无关联票据</p>
|
{(detail.invoices ?? []).length ? (
|
||||||
)}
|
<div className="space-y-2">
|
||||||
|
{detail.invoices.map((item) => (
|
||||||
|
<div className="rounded-lg border border-zinc-200 px-3 py-2" key={item.id}>
|
||||||
|
<div className="font-medium text-zinc-900">{item.id}</div>
|
||||||
|
<div className="text-zinc-600">
|
||||||
|
状态 {item.status} / 发票 {item.invoiceCode || "-"}-{item.invoiceNumber || "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-zinc-500">暂无关联票据</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionPanel>
|
||||||
|
|
||||||
|
<SectionPanel title="风险与桥接" description="预算提示、风险信号、AI 建议与财务桥接统一查看。">
|
||||||
|
<div className="space-y-6">
|
||||||
|
{budgetHint ? (
|
||||||
|
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="reimbursement-budget-hint">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">预算提示</h3>
|
||||||
|
<div className="mt-3">预算池:{budgetHint.budgetCode} / {budgetHint.budgetName}</div>
|
||||||
|
<div>
|
||||||
|
本次提交后预计占用:{budgetHint.nextUsedAmount} / {budgetHint.allocatedAmount}
|
||||||
|
({budgetHint.nextUsagePercent.toFixed(2)}%)
|
||||||
|
</div>
|
||||||
|
<div>控制模式:{budgetHint.controlMode}</div>
|
||||||
|
{budgetHint.exceedsBudget && budgetHint.controlMode === "block" ? (
|
||||||
|
<p className="mt-3 rounded-md border border-red-200 bg-red-50 px-2 py-1 text-red-600">
|
||||||
|
当前金额将超出预算上限,提交时会被阻断(block 模式)。
|
||||||
|
</p>
|
||||||
|
) : budgetHint.exceedsThreshold ? (
|
||||||
|
<p className="mt-3 rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-amber-700">
|
||||||
|
当前金额已达到预算预警阈值,提交后会记录预算预警审计。
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 text-zinc-500">当前预算充足,可继续提交。</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
|
||||||
|
<AiSuggestionCard targetType="reimbursement" targetId={detail.id} />
|
||||||
|
|
||||||
|
{canManageIntegration && detail.status === "paid" ? (
|
||||||
|
<div className="space-y-4 rounded-lg border border-zinc-200 p-4" data-testid="reimbursement-erp-export">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">财务桥接</h3>
|
||||||
|
<p className="text-sm text-zinc-500">已付款报销单会按标准凭证切片通过 Outbox 异步发送到财务桥接出口。</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => erpExportMutation.mutate()} disabled={erpExportMutation.isPending}>
|
||||||
|
{erpExportMutation.isPending ? "出队中..." : "发送到财务桥接"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{erpPreviewQuery.isLoading ? <p className="text-sm text-zinc-500">正在加载凭证切片预览...</p> : null}
|
||||||
|
{erpPreviewQuery.isError ? (
|
||||||
|
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
|
||||||
|
{isApiError(erpPreviewQuery.error)
|
||||||
|
? `${erpPreviewQuery.error.code}: ${mapApiErrorMessage(
|
||||||
|
erpPreviewQuery.error.code,
|
||||||
|
erpPreviewQuery.error.details,
|
||||||
|
erpPreviewQuery.error.message,
|
||||||
|
)}`
|
||||||
|
: "加载财务桥接预览失败"}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{erpPreviewQuery.data ? (
|
||||||
|
<div className="grid gap-4 text-sm lg:grid-cols-2">
|
||||||
|
<div className="rounded-lg border border-zinc-200 p-4 space-y-2">
|
||||||
|
<h4 className="font-medium text-zinc-900">凭证切片</h4>
|
||||||
|
<div>类型:{erpPreviewQuery.data.journal.journalType}</div>
|
||||||
|
<div>单号:{erpPreviewQuery.data.journal.documentNo ?? "-"}</div>
|
||||||
|
<div>金额:{erpPreviewQuery.data.journal.totalAmount ?? "-"}</div>
|
||||||
|
<div>法体:{erpPreviewQuery.data.journal.dimensions.legalEntityCode ?? "-"}</div>
|
||||||
|
<div>成本中心:{erpPreviewQuery.data.journal.dimensions.costCenterCode ?? "-"}</div>
|
||||||
|
<div>付款凭证号:{erpPreviewQuery.data.journal.paymentVoucherNo ?? "-"}</div>
|
||||||
|
<div>付款日期:{erpPreviewQuery.data.journal.paymentDate ?? "-"}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-zinc-200 p-4 space-y-2">
|
||||||
|
<h4 className="font-medium text-zinc-900">导出状态</h4>
|
||||||
|
<div>外部ID:{erpPreviewQuery.data.exportState.externalId ?? "-"}</div>
|
||||||
|
<div>Outbox:{erpPreviewQuery.data.exportState.lastOutboxId ?? "-"}</div>
|
||||||
|
<div>队列状态:{erpPreviewQuery.data.exportState.lastOutboxStatus ?? "-"}</div>
|
||||||
|
<div>最近导出:{formatDateTime(erpPreviewQuery.data.exportState.exportedAt)}</div>
|
||||||
|
<div className="break-all">最近错误:{erpPreviewQuery.data.exportState.lastError ?? "-"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</SectionPanel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<DetailSideMeta title="报销摘要" description="帮助处理人先判断单据状态、关联量和当前审批位置。">
|
||||||
|
<div>报销编号:{detail.formNumber ?? detail.id}</div>
|
||||||
|
<div>报销人:{detail.owner?.name ?? "-"}</div>
|
||||||
|
<div>提交人:{detail.submitter?.name ?? "-"}</div>
|
||||||
|
<div>当前审批:{detail.pendingWorkflow?.task?.step_name ?? "-"}</div>
|
||||||
|
<div>明细数:{detail.items.length}</div>
|
||||||
|
<div>票据数:{detail.invoices.length}</div>
|
||||||
|
<div>关联项目:{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}</div>
|
||||||
|
</DetailSideMeta>
|
||||||
|
|
||||||
|
<ReimbursementActions
|
||||||
|
status={detail.status}
|
||||||
|
canPay={canPay}
|
||||||
|
actionError={actionError}
|
||||||
|
isPending={mutation.isPending}
|
||||||
|
onSubmit={() => void handleSubmitAction()}
|
||||||
|
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}
|
||||||
|
onPay={(payload) => mutation.mutate({ action: "pay", payload })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
@@ -654,6 +717,6 @@ export function ReimbursementDetailPage() {
|
|||||||
confirmLabel="删除"
|
confirmLabel="删除"
|
||||||
onConfirm={() => { itemDeleteMutation.mutate(confirmDeleteItemId); }}
|
onConfirm={() => { itemDeleteMutation.mutate(confirmDeleteItemId); }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</PageScaffold>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ".
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
|
import { TEST_IDS } from "../lib/testIds";
|
||||||
|
|
||||||
function errorMessage(error: unknown, fallback: string) {
|
function errorMessage(error: unknown, fallback: string) {
|
||||||
if (isApiError(error)) {
|
if (isApiError(error)) {
|
||||||
@@ -140,7 +141,7 @@ export function TodosPage() {
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
) : todoItems.length ? (
|
) : todoItems.length ? (
|
||||||
todoItems.map((item) => (
|
todoItems.map((item) => (
|
||||||
<TableRow key={item.id}>
|
<TableRow data-testid={TEST_IDS.todo.row} key={item.id}>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||||
<div className="text-xs text-zinc-500">{item.description ?? "-"}</div>
|
<div className="text-xs text-zinc-500">{item.description ?? "-"}</div>
|
||||||
@@ -151,7 +152,13 @@ export function TodosPage() {
|
|||||||
<TableCell>{item.status}</TableCell>
|
<TableCell>{item.status}</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button size="sm" type="button" disabled={completeMutation.isPending || item.status === "done"} onClick={() => completeMutation.mutate(item.id)}>
|
<Button
|
||||||
|
data-testid={TEST_IDS.todo.complete}
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
disabled={completeMutation.isPending || item.status === "done"}
|
||||||
|
onClick={() => completeMutation.mutate(item.id)}
|
||||||
|
>
|
||||||
完成
|
完成
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="secondary" type="button" disabled={cancelMutation.isPending || item.status === "cancelled"} onClick={() => cancelMutation.mutate(item.id)}>
|
<Button size="sm" variant="secondary" type="button" disabled={cancelMutation.isPending || item.status === "cancelled"} onClick={() => cancelMutation.mutate(item.id)}>
|
||||||
@@ -177,7 +184,7 @@ export function TodosPage() {
|
|||||||
<SectionPanel title="新增待办" description="建议把周报计划、项目跟进或临时事项都拆成清晰动作。">
|
<SectionPanel title="新增待办" description="建议把周报计划、项目跟进或临时事项都拆成清晰动作。">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Input placeholder="待办标题" value={title} onChange={(event) => setTitle(event.target.value)} />
|
<Input data-testid={TEST_IDS.todo.createTitle} placeholder="待办标题" value={title} onChange={(event) => setTitle(event.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Input placeholder="补充说明(可选)" value={description} onChange={(event) => setDescription(event.target.value)} />
|
<Input placeholder="补充说明(可选)" value={description} onChange={(event) => setDescription(event.target.value)} />
|
||||||
@@ -192,7 +199,7 @@ export function TodosPage() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<Button type="button" onClick={() => createMutation.mutate()} disabled={createMutation.isPending || !title.trim()}>
|
<Button data-testid={TEST_IDS.todo.createSubmit} type="button" onClick={() => createMutation.mutate()} disabled={createMutation.isPending || !title.trim()}>
|
||||||
{createMutation.isPending ? "新建中..." : "新建待办"}
|
{createMutation.isPending ? "新建中..." : "新建待办"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ".
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
|
import { TEST_IDS } from "../lib/testIds";
|
||||||
|
|
||||||
function errorMessage(error: unknown, fallback: string) {
|
function errorMessage(error: unknown, fallback: string) {
|
||||||
if (isApiError(error)) {
|
if (isApiError(error)) {
|
||||||
@@ -135,7 +136,13 @@ export function WeeklyReportsPage() {
|
|||||||
<div className="text-sm font-medium text-zinc-900">按周归档</div>
|
<div className="text-sm font-medium text-zinc-900">按周归档</div>
|
||||||
<div className="text-sm text-zinc-500">默认按周一归档;提交后会同步更新 Portal 与项目摘要。</div>
|
<div className="text-sm text-zinc-500">默认按周一归档;提交后会同步更新 Portal 与项目摘要。</div>
|
||||||
</div>
|
</div>
|
||||||
<input className="input-std w-[220px]" type="date" value={weekStart} onChange={(event) => setWeekStart(event.target.value)} />
|
<input
|
||||||
|
aria-label="周报归档周"
|
||||||
|
className="input-std w-[220px]"
|
||||||
|
type="date"
|
||||||
|
value={weekStart}
|
||||||
|
onChange={(event) => setWeekStart(event.target.value)}
|
||||||
|
/>
|
||||||
</FilterBar>
|
</FilterBar>
|
||||||
|
|
||||||
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
@@ -151,16 +158,16 @@ export function WeeklyReportsPage() {
|
|||||||
{reportQuery.isError ? <div className="text-sm text-red-600">{errorMessage(reportQuery.error, "加载周报失败")}</div> : null}
|
{reportQuery.isError ? <div className="text-sm text-red-600">{errorMessage(reportQuery.error, "加载周报失败")}</div> : null}
|
||||||
<div className="grid gap-4">
|
<div className="grid gap-4">
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<label className="text-sm text-zinc-500">本周完成</label>
|
<label className="text-sm text-zinc-500" htmlFor="weekly-report-highlights">本周完成</label>
|
||||||
<Textarea value={draft.highlightsText} onChange={(event) => setDraft((prev) => ({ ...prev, highlightsText: event.target.value }))} />
|
<Textarea id="weekly-report-highlights" value={draft.highlightsText} onChange={(event) => setDraft((prev) => ({ ...prev, highlightsText: event.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<label className="text-sm text-zinc-500">当前阻塞</label>
|
<label className="text-sm text-zinc-500" htmlFor="weekly-report-blockers">当前阻塞</label>
|
||||||
<Textarea value={draft.blockersText} onChange={(event) => setDraft((prev) => ({ ...prev, blockersText: event.target.value }))} />
|
<Textarea id="weekly-report-blockers" value={draft.blockersText} onChange={(event) => setDraft((prev) => ({ ...prev, blockersText: event.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-2">
|
<div className="grid gap-2">
|
||||||
<label className="text-sm text-zinc-500">下周计划</label>
|
<label className="text-sm text-zinc-500" htmlFor="weekly-report-next-week">下周计划</label>
|
||||||
<Textarea value={draft.nextWeekText} onChange={(event) => setDraft((prev) => ({ ...prev, nextWeekText: event.target.value }))} />
|
<Textarea id="weekly-report-next-week" value={draft.nextWeekText} onChange={(event) => setDraft((prev) => ({ ...prev, nextWeekText: event.target.value }))} />
|
||||||
</div>
|
</div>
|
||||||
<label className="flex items-center gap-2 text-sm text-zinc-700">
|
<label className="flex items-center gap-2 text-sm text-zinc-700">
|
||||||
<input type="checkbox" checked={draft.needsHelp} onChange={(event) => setDraft((prev) => ({ ...prev, needsHelp: event.target.checked }))} />
|
<input type="checkbox" checked={draft.needsHelp} onChange={(event) => setDraft((prev) => ({ ...prev, needsHelp: event.target.checked }))} />
|
||||||
@@ -193,9 +200,9 @@ export function WeeklyReportsPage() {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
<Button type="button" variant="secondary" onClick={() => createTodoMutation.mutate()} disabled={createTodoMutation.isPending}>转成待办</Button>
|
<Button data-testid={TEST_IDS.weeklyReport.createTodo} type="button" variant="secondary" onClick={() => createTodoMutation.mutate()} disabled={createTodoMutation.isPending}>转成待办</Button>
|
||||||
<Button type="button" variant="secondary" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>保存草稿</Button>
|
<Button data-testid={TEST_IDS.weeklyReport.save} type="button" variant="secondary" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>保存草稿</Button>
|
||||||
<Button type="button" onClick={() => submitMutation.mutate()} disabled={submitMutation.isPending}>提交周报</Button>
|
<Button data-testid={TEST_IDS.weeklyReport.submit} type="button" onClick={() => submitMutation.mutate()} disabled={submitMutation.isPending}>提交周报</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</SectionPanel>
|
</SectionPanel>
|
||||||
|
|||||||
@@ -16,8 +16,11 @@ import { useAuth } from "../auth/AuthContext";
|
|||||||
import { hasPermission } from "../auth/permissions";
|
import { hasPermission } from "../auth/permissions";
|
||||||
import { buildMobileApprovalsPath, shouldPreferMobileApprovalExperience } from "../pwa";
|
import { buildMobileApprovalsPath, shouldPreferMobileApprovalExperience } from "../pwa";
|
||||||
import { CardSkeleton } from "../components/common/CardSkeleton";
|
import { CardSkeleton } from "../components/common/CardSkeleton";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
|
||||||
import { StatusBadge } from "../components/common/StatusBadge";
|
import { StatusBadge } from "../components/common/StatusBadge";
|
||||||
|
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
|
||||||
|
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
|
||||||
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||||
|
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Textarea } from "../components/ui/textarea";
|
import { Textarea } from "../components/ui/textarea";
|
||||||
|
|
||||||
@@ -37,6 +40,29 @@ function entityTitle(entityType: string | undefined) {
|
|||||||
return entityType || "业务单据";
|
return entityType || "业务单据";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatDateTime(value: string | null | undefined) {
|
||||||
|
if (!value) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
return value.slice(0, 19).replace("T", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function workflowStatusLabel(status: string | undefined) {
|
||||||
|
if (!status) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
const labels: Record<string, string> = {
|
||||||
|
pending: "待派发",
|
||||||
|
completed: "已完成",
|
||||||
|
rejected: "已驳回",
|
||||||
|
cancelled: "已取消",
|
||||||
|
skipped: "已跳过",
|
||||||
|
};
|
||||||
|
|
||||||
|
return labels[status] ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
export function WorkflowTaskDetailPage() {
|
export function WorkflowTaskDetailPage() {
|
||||||
const { taskId = "" } = useParams();
|
const { taskId = "" } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -156,199 +182,244 @@ export function WorkflowTaskDetailPage() {
|
|||||||
const canTransfer = allowedActions.has("transfer") && detail.workflowInstance?.entityType === "reimbursement";
|
const canTransfer = allowedActions.has("transfer") && detail.workflowInstance?.entityType === "reimbursement";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<PageScaffold>
|
||||||
<PageHeader
|
<EntityOverviewHero
|
||||||
title={`任务详情 ${detail.task.stepName}`}
|
title={`任务详情 ${detail.task.stepName}`}
|
||||||
subtitle={`任务ID: ${detail.task.id}`}
|
subtitle="在同一工作台内查看审批对象摘要、处理动作和历史留痕。"
|
||||||
actions={
|
meta={(
|
||||||
|
<>
|
||||||
|
<StatusBadge status={detail.task.status} />
|
||||||
|
<span className="info-chip">任务ID:{detail.task.id}</span>
|
||||||
|
<span className="info-chip">对象类型:{entityTitle(detail.workflowInstance?.entityType)}</span>
|
||||||
|
<span className="info-chip">实例:{detail.workflowInstance?.id ?? "-"}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
actions={(
|
||||||
<Button asChild variant="secondary" data-testid="workflow-task-back">
|
<Button asChild variant="secondary" data-testid="workflow-task-back">
|
||||||
<Link to="/workflow/tasks">返回待办列表</Link>
|
<Link to="/workflow/tasks">返回待办列表</Link>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="card-std space-y-2 p-6 text-sm" data-testid="workflow-task-summary">
|
<div className="detail-shell-grid">
|
||||||
<div>状态:<StatusBadge status={detail.task.status} /></div>
|
<div className="space-y-6">
|
||||||
<div>实例:{detail.workflowInstance?.id ?? "-"}</div>
|
<SectionPanel title="审批对象摘要" description="先看对象主信息,再决定通过、驳回、退回或转审。">
|
||||||
<div>对象类型:{entityTitle(detail.workflowInstance?.entityType)}</div>
|
<div className="space-y-4" data-testid="workflow-task-overview">
|
||||||
{detail.reimbursement ? (
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<>
|
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
|
||||||
<div>报销单号:{detail.reimbursement.formNumber ?? "-"}</div>
|
<div className="text-zinc-500">当前状态</div>
|
||||||
<div>报销金额:{detail.reimbursement.totalAmount ?? "-"}</div>
|
<div className="mt-2"><StatusBadge status={detail.task.status} /></div>
|
||||||
<div>提交人:{detail.reimbursement.submitter?.name ?? "-"}</div>
|
</div>
|
||||||
<div>报销人:{detail.reimbursement.owner?.name ?? "-"}</div>
|
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
|
||||||
</>
|
<div className="text-zinc-500">当前节点</div>
|
||||||
) : null}
|
<div className="mt-2 font-medium text-zinc-900">{detail.task.stepName}</div>
|
||||||
{detail.leaveRequest ? (
|
</div>
|
||||||
<>
|
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
|
||||||
<div>假期类型:{detail.leaveRequest.leaveType?.name ?? "-"}</div>
|
<div className="text-zinc-500">对象类型</div>
|
||||||
<div>申请人:{detail.leaveRequest.owner?.name ?? "-"}</div>
|
<div className="mt-2 font-medium text-zinc-900">{entityTitle(detail.workflowInstance?.entityType)}</div>
|
||||||
<div>
|
</div>
|
||||||
时间段:
|
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
|
||||||
{detail.leaveRequest.startDate ?? "-"} {detail.leaveRequest.startSession === "am" ? "上午" : "下午"}
|
<div className="text-zinc-500">可执行动作</div>
|
||||||
{" → "}
|
<div className="mt-2 font-medium text-zinc-900">{detail.allowedActions.length ? detail.allowedActions.join(" / ") : "-"}</div>
|
||||||
{detail.leaveRequest.endDate ?? "-"} {detail.leaveRequest.endSession === "am" ? "上午" : "下午"}
|
|
||||||
</div>
|
|
||||||
<div>请假天数:{detail.leaveRequest.requestedDays}</div>
|
|
||||||
<div>说明:{detail.leaveRequest.reason ?? "-"}</div>
|
|
||||||
<div>附件:{detail.leaveRequest.attachment?.originalName ?? "-"}</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{detail.purchaseRequest ? (
|
|
||||||
<>
|
|
||||||
<div>采购单号:{detail.purchaseRequest.requestNo ?? detail.purchaseRequest.id}</div>
|
|
||||||
<div>标题:{detail.purchaseRequest.title || "-"}</div>
|
|
||||||
<div>申请人:{detail.purchaseRequest.applicant?.name ?? "-"}</div>
|
|
||||||
<div>供应商:{detail.purchaseRequest.supplier?.name ?? "-"}</div>
|
|
||||||
<div>法体:{detail.purchaseRequest.legalEntity?.name ?? "-"}</div>
|
|
||||||
<div>成本中心:{detail.purchaseRequest.costCenter?.name ?? "-"}</div>
|
|
||||||
<div>单据分类:{detail.purchaseRequest.documentCategory?.name ?? "-"}</div>
|
|
||||||
<div>金额:{detail.purchaseRequest.totalAmount ?? "-"} {detail.purchaseRequest.currencyCode}</div>
|
|
||||||
<div>预计到货:{detail.purchaseRequest.expectedDeliveryDate ?? "-"}</div>
|
|
||||||
<div>说明:{detail.purchaseRequest.reason ?? "-"}</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{detail.travelRequest ? (
|
|
||||||
<>
|
|
||||||
<div>差旅单号:{detail.travelRequest.requestNo ?? detail.travelRequest.id}</div>
|
|
||||||
<div>申请人:{detail.travelRequest.applicant?.name ?? "-"}</div>
|
|
||||||
<div>出差类型:{detail.travelRequest.tripType === "international" ? "国际" : "国内"}</div>
|
|
||||||
<div>法体:{detail.travelRequest.legalEntity?.name ?? "-"}</div>
|
|
||||||
<div>成本中心:{detail.travelRequest.costCenter?.name ?? "-"}</div>
|
|
||||||
<div>起止日期:{detail.travelRequest.startDate ?? "-"} → {detail.travelRequest.endDate ?? "-"}</div>
|
|
||||||
<div>预估金额:{detail.travelRequest.estimatedAmount ?? "-"}</div>
|
|
||||||
<div>出差目的:{detail.travelRequest.purpose || "-"}</div>
|
|
||||||
<div>
|
|
||||||
行程段:
|
|
||||||
{detail.travelRequest.segments.length
|
|
||||||
? detail.travelRequest.segments
|
|
||||||
.map((item) => `${item.fromCity}→${item.toCity}(${item.segmentRole})`)
|
|
||||||
.join(";")
|
|
||||||
: "-"}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card-std space-y-4 p-6">
|
|
||||||
<h3 className="text-base font-semibold text-zinc-900">审批动作</h3>
|
|
||||||
|
|
||||||
{actionError ? (
|
|
||||||
<p
|
|
||||||
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
|
|
||||||
data-testid="workflow-task-action-error"
|
|
||||||
>
|
|
||||||
{actionError}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<Button
|
|
||||||
data-testid="workflow-task-approve"
|
|
||||||
disabled={!allowedActions.has("approve") || mutation.isPending}
|
|
||||||
onClick={() => mutation.mutate("approve")}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
通过
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
|
|
||||||
<p className="text-sm font-medium text-zinc-800">驳回</p>
|
|
||||||
<Textarea
|
|
||||||
data-testid="workflow-task-reject-comment"
|
|
||||||
onChange={(event) => setRejectComment(event.target.value)}
|
|
||||||
rows={2}
|
|
||||||
value={rejectComment}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
data-testid="workflow-task-reject"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={!allowedActions.has("reject") || mutation.isPending || rejectComment.trim().length < 1}
|
|
||||||
onClick={() => mutation.mutate("reject")}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
提交驳回
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
|
|
||||||
<p className="text-sm font-medium text-zinc-800">退回</p>
|
|
||||||
<Textarea
|
|
||||||
data-testid="workflow-task-return-comment"
|
|
||||||
onChange={(event) => setReturnComment(event.target.value)}
|
|
||||||
rows={2}
|
|
||||||
value={returnComment}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
data-testid="workflow-task-return"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={!allowedActions.has("return") || mutation.isPending || returnComment.trim().length < 1}
|
|
||||||
onClick={() => mutation.mutate("return")}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
提交退回
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{canTransfer && !isLeaveTask ? (
|
|
||||||
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
|
|
||||||
<p className="text-sm font-medium text-zinc-800">转审</p>
|
|
||||||
<select
|
|
||||||
className="select-std"
|
|
||||||
data-testid="workflow-task-transfer-to-approver"
|
|
||||||
onChange={(event) => setSelectedApprover(event.target.value)}
|
|
||||||
value={selectedApprover}
|
|
||||||
>
|
|
||||||
<option value="">请选择目标审批人</option>
|
|
||||||
{(approverQuery.data?.items ?? []).map((item) => (
|
|
||||||
<option key={item.id} value={item.id}>
|
|
||||||
{item.name} ({item.employeeNo})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<Textarea
|
|
||||||
data-testid="workflow-task-transfer-reason"
|
|
||||||
onChange={(event) => setTransferReason(event.target.value)}
|
|
||||||
rows={2}
|
|
||||||
value={transferReason}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
data-testid="workflow-task-transfer"
|
|
||||||
variant="secondary"
|
|
||||||
disabled={
|
|
||||||
!allowedActions.has("transfer") ||
|
|
||||||
mutation.isPending ||
|
|
||||||
!selectedApprover ||
|
|
||||||
transferReason.trim().length < 10
|
|
||||||
}
|
|
||||||
onClick={() => mutation.mutate("transfer")}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
提交转审
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card-std space-y-3 p-6">
|
|
||||||
<h3 className="text-base font-semibold text-zinc-900">处理历史</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{detail.history.length ? (
|
|
||||||
detail.history.map((item) => (
|
|
||||||
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm" key={item.id}>
|
|
||||||
<div className="font-medium text-zinc-900">
|
|
||||||
{item.action} · {item.actorName || item.actorId}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-zinc-600">{item.comment || "-"}</div>
|
|
||||||
<div className="text-xs text-zinc-500">{item.createdAt || "-"}</div>
|
|
||||||
</div>
|
</div>
|
||||||
))
|
|
||||||
) : (
|
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="workflow-task-summary">
|
||||||
<p className="text-sm text-zinc-500">暂无历史记录</p>
|
<div>状态:{workflowStatusLabel(detail.task.status)}</div>
|
||||||
)}
|
<div>实例:{detail.workflowInstance?.id ?? "-"}</div>
|
||||||
|
<div>对象类型:{entityTitle(detail.workflowInstance?.entityType)}</div>
|
||||||
|
{detail.reimbursement ? (
|
||||||
|
<>
|
||||||
|
<div>报销单号:{detail.reimbursement.formNumber ?? "-"}</div>
|
||||||
|
<div>报销金额:{detail.reimbursement.totalAmount ?? "-"}</div>
|
||||||
|
<div>提交人:{detail.reimbursement.submitter?.name ?? "-"}</div>
|
||||||
|
<div>报销人:{detail.reimbursement.owner?.name ?? "-"}</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{detail.leaveRequest ? (
|
||||||
|
<>
|
||||||
|
<div>假期类型:{detail.leaveRequest.leaveType?.name ?? "-"}</div>
|
||||||
|
<div>申请人:{detail.leaveRequest.owner?.name ?? "-"}</div>
|
||||||
|
<div>
|
||||||
|
时间段:
|
||||||
|
{detail.leaveRequest.startDate ?? "-"} {detail.leaveRequest.startSession === "am" ? "上午" : "下午"}
|
||||||
|
{" → "}
|
||||||
|
{detail.leaveRequest.endDate ?? "-"} {detail.leaveRequest.endSession === "am" ? "上午" : "下午"}
|
||||||
|
</div>
|
||||||
|
<div>请假天数:{detail.leaveRequest.requestedDays}</div>
|
||||||
|
<div>说明:{detail.leaveRequest.reason ?? "-"}</div>
|
||||||
|
<div>附件:{detail.leaveRequest.attachment?.originalName ?? "-"}</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{detail.purchaseRequest ? (
|
||||||
|
<>
|
||||||
|
<div>采购单号:{detail.purchaseRequest.requestNo ?? detail.purchaseRequest.id}</div>
|
||||||
|
<div>标题:{detail.purchaseRequest.title || "-"}</div>
|
||||||
|
<div>申请人:{detail.purchaseRequest.applicant?.name ?? "-"}</div>
|
||||||
|
<div>供应商:{detail.purchaseRequest.supplier?.name ?? "-"}</div>
|
||||||
|
<div>法体:{detail.purchaseRequest.legalEntity?.name ?? "-"}</div>
|
||||||
|
<div>成本中心:{detail.purchaseRequest.costCenter?.name ?? "-"}</div>
|
||||||
|
<div>单据分类:{detail.purchaseRequest.documentCategory?.name ?? "-"}</div>
|
||||||
|
<div>金额:{detail.purchaseRequest.totalAmount ?? "-"} {detail.purchaseRequest.currencyCode}</div>
|
||||||
|
<div>预计到货:{detail.purchaseRequest.expectedDeliveryDate ?? "-"}</div>
|
||||||
|
<div>说明:{detail.purchaseRequest.reason ?? "-"}</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{detail.travelRequest ? (
|
||||||
|
<>
|
||||||
|
<div>差旅单号:{detail.travelRequest.requestNo ?? detail.travelRequest.id}</div>
|
||||||
|
<div>申请人:{detail.travelRequest.applicant?.name ?? "-"}</div>
|
||||||
|
<div>出差类型:{detail.travelRequest.tripType === "international" ? "国际" : "国内"}</div>
|
||||||
|
<div>法体:{detail.travelRequest.legalEntity?.name ?? "-"}</div>
|
||||||
|
<div>成本中心:{detail.travelRequest.costCenter?.name ?? "-"}</div>
|
||||||
|
<div>起止日期:{detail.travelRequest.startDate ?? "-"} → {detail.travelRequest.endDate ?? "-"}</div>
|
||||||
|
<div>预估金额:{detail.travelRequest.estimatedAmount ?? "-"}</div>
|
||||||
|
<div>出差目的:{detail.travelRequest.purpose || "-"}</div>
|
||||||
|
<div>
|
||||||
|
行程段:
|
||||||
|
{detail.travelRequest.segments.length
|
||||||
|
? detail.travelRequest.segments
|
||||||
|
.map((item) => `${item.fromCity}→${item.toCity}(${item.segmentRole})`)
|
||||||
|
.join(";")
|
||||||
|
: "-"}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</SectionPanel>
|
||||||
|
|
||||||
|
<SectionPanel title="审批动作" description="把通过、驳回、退回和转审统一收口在同一块操作区。">
|
||||||
|
<div className="space-y-4">
|
||||||
|
{actionError ? (
|
||||||
|
<p
|
||||||
|
className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600"
|
||||||
|
data-testid="workflow-task-action-error"
|
||||||
|
>
|
||||||
|
{actionError}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
data-testid="workflow-task-approve"
|
||||||
|
disabled={!allowedActions.has("approve") || mutation.isPending}
|
||||||
|
onClick={() => mutation.mutate("approve")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
通过
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
|
||||||
|
<p className="text-sm font-medium text-zinc-800">驳回</p>
|
||||||
|
<Textarea
|
||||||
|
data-testid="workflow-task-reject-comment"
|
||||||
|
onChange={(event) => setRejectComment(event.target.value)}
|
||||||
|
rows={2}
|
||||||
|
value={rejectComment}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
data-testid="workflow-task-reject"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!allowedActions.has("reject") || mutation.isPending || rejectComment.trim().length < 1}
|
||||||
|
onClick={() => mutation.mutate("reject")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
提交驳回
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
|
||||||
|
<p className="text-sm font-medium text-zinc-800">退回</p>
|
||||||
|
<Textarea
|
||||||
|
data-testid="workflow-task-return-comment"
|
||||||
|
onChange={(event) => setReturnComment(event.target.value)}
|
||||||
|
rows={2}
|
||||||
|
value={returnComment}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
data-testid="workflow-task-return"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={!allowedActions.has("return") || mutation.isPending || returnComment.trim().length < 1}
|
||||||
|
onClick={() => mutation.mutate("return")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
提交退回
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{canTransfer && !isLeaveTask ? (
|
||||||
|
<div className="space-y-2 rounded-lg border border-zinc-200 p-4">
|
||||||
|
<p className="text-sm font-medium text-zinc-800">转审</p>
|
||||||
|
<select
|
||||||
|
className="select-std"
|
||||||
|
data-testid="workflow-task-transfer-approver"
|
||||||
|
onChange={(event) => setSelectedApprover(event.target.value)}
|
||||||
|
value={selectedApprover}
|
||||||
|
>
|
||||||
|
<option value="">请选择目标审批人</option>
|
||||||
|
{(approverQuery.data?.items ?? []).map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name} ({item.employeeNo})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Textarea
|
||||||
|
data-testid="workflow-task-transfer-reason"
|
||||||
|
onChange={(event) => setTransferReason(event.target.value)}
|
||||||
|
rows={2}
|
||||||
|
value={transferReason}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
data-testid="workflow-task-transfer"
|
||||||
|
variant="secondary"
|
||||||
|
disabled={
|
||||||
|
!allowedActions.has("transfer") ||
|
||||||
|
mutation.isPending ||
|
||||||
|
!selectedApprover ||
|
||||||
|
transferReason.trim().length < 10
|
||||||
|
}
|
||||||
|
onClick={() => mutation.mutate("transfer")}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
提交转审
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</SectionPanel>
|
||||||
|
|
||||||
|
<SectionPanel title="处理历史" description="所有审批动作、意见和处理人统一留痕。">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{detail.history.length ? (
|
||||||
|
detail.history.map((item) => (
|
||||||
|
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm" key={item.id}>
|
||||||
|
<div className="font-medium text-zinc-900">
|
||||||
|
{item.action} · {item.actorName || item.actorId}
|
||||||
|
</div>
|
||||||
|
<div className="text-zinc-600">{item.comment || "-"}</div>
|
||||||
|
<div className="text-xs text-zinc-500">{item.createdAt || "-"}</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-zinc-500">暂无历史记录</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SectionPanel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-6">
|
||||||
|
<DetailSideMeta title="任务摘要" description="帮助审批人先判断节点、对象和时间,再决定处理动作。">
|
||||||
|
<div>节点:{detail.task.stepName}</div>
|
||||||
|
<div>实例状态:{detail.workflowInstance?.currentStatus ?? "-"}</div>
|
||||||
|
<div>当前审批角色:{detail.task.assigneeRole ?? "-"}</div>
|
||||||
|
<div>创建时间:{formatDateTime(detail.task.createdAt)}</div>
|
||||||
|
<div>完成时间:{formatDateTime(detail.task.completedAt)}</div>
|
||||||
|
<div>发起时间:{formatDateTime(detail.workflowInstance?.startedAt)}</div>
|
||||||
|
</DetailSideMeta>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PageScaffold>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,15 @@ export function WorkspaceSetupPage() {
|
|||||||
const moduleEntries = useMemo(() => Object.entries(workspace?.moduleVisibility ?? {}), [workspace?.moduleVisibility]);
|
const moduleEntries = useMemo(() => Object.entries(workspace?.moduleVisibility ?? {}), [workspace?.moduleVisibility]);
|
||||||
|
|
||||||
if (workspaceQuery.isLoading || !workspace) {
|
if (workspaceQuery.isLoading || !workspace) {
|
||||||
return <div className="card-std p-6 text-sm text-zinc-500">加载工作区设置中...</div>;
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="工作区设置"
|
||||||
|
subtitle="按 10 人以下小团队模式收敛主导航与默认入口。"
|
||||||
|
/>
|
||||||
|
<div className="card-std p-6 text-sm text-zinc-500">加载工作区设置中...</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -34,6 +34,12 @@ export const FINANCE_MGR: E2EUser = {
|
|||||||
name: "Finance Manager",
|
name: "Finance Manager",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ADMIN: E2EUser = {
|
||||||
|
identifier: "[email protected]",
|
||||||
|
password: "AdminPassword1234",
|
||||||
|
name: "Admin",
|
||||||
|
};
|
||||||
|
|
||||||
export type MeResponse = {
|
export type MeResponse = {
|
||||||
id: string;
|
id: string;
|
||||||
employeeNo: string;
|
employeeNo: string;
|
||||||
@@ -80,6 +86,7 @@ export async function login(page: Page, user: E2EUser) {
|
|||||||
await page.getByTestId(TEST_IDS.login.password).fill(user.password);
|
await page.getByTestId(TEST_IDS.login.password).fill(user.password);
|
||||||
await page.getByTestId(TEST_IDS.login.submit).click();
|
await page.getByTestId(TEST_IDS.login.submit).click();
|
||||||
await expect(page).toHaveURL(/\/portal/);
|
await expect(page).toHaveURL(/\/portal/);
|
||||||
|
await expect(page.getByTestId(TEST_IDS.topbar.profileTrigger).first()).toBeVisible();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function logout(page: Page, userDisplayName?: string) {
|
export async function logout(page: Page, userDisplayName?: string) {
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { test as base } from "@playwright/test";
|
||||||
|
|
||||||
|
import { AppShell } from "../pageObjects/AppShell";
|
||||||
|
import { ReimbursementDetailPageObject } from "../pageObjects/ReimbursementDetailPage";
|
||||||
|
import { WorkflowTaskDetailPageObject } from "../pageObjects/WorkflowTaskDetailPage";
|
||||||
|
|
||||||
|
export const test = base.extend<{
|
||||||
|
shell: AppShell;
|
||||||
|
reimbursementDetail: ReimbursementDetailPageObject;
|
||||||
|
workflowDetail: WorkflowTaskDetailPageObject;
|
||||||
|
}>({
|
||||||
|
shell: async ({ page }, use) => {
|
||||||
|
await use(new AppShell(page));
|
||||||
|
},
|
||||||
|
reimbursementDetail: async ({ page }, use) => {
|
||||||
|
await use(new ReimbursementDetailPageObject(page));
|
||||||
|
},
|
||||||
|
workflowDetail: async ({ page }, use) => {
|
||||||
|
await use(new WorkflowTaskDetailPageObject(page));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export { expect } from "@playwright/test";
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import { openBackupModulesIfNeeded, openNavLink, openProfileSettings } from "../navigation";
|
||||||
|
import { TEST_IDS } from "../testIds";
|
||||||
|
|
||||||
|
export class AppShell {
|
||||||
|
readonly page: Page;
|
||||||
|
|
||||||
|
constructor(page: Page) {
|
||||||
|
this.page = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
get sidebar() {
|
||||||
|
return this.page.locator("nav").first();
|
||||||
|
}
|
||||||
|
|
||||||
|
get topbarProfileTrigger() {
|
||||||
|
return this.page.getByTestId(TEST_IDS.topbar.profileTrigger).first();
|
||||||
|
}
|
||||||
|
|
||||||
|
async navigate(name: string | RegExp) {
|
||||||
|
return openNavLink(this.page, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
async openBackupModules() {
|
||||||
|
return openBackupModulesIfNeeded(this.page);
|
||||||
|
}
|
||||||
|
|
||||||
|
async openProfile(userDisplayName?: string) {
|
||||||
|
return openProfileSettings(this.page, userDisplayName);
|
||||||
|
}
|
||||||
|
|
||||||
|
async assertMainNavVisible() {
|
||||||
|
await expect(this.page.getByRole("link", { name: "工作台" }).or(this.page.getByRole("link", { name: "Portal" })).first()).toBeVisible();
|
||||||
|
await expect(this.page.getByRole("link", { name: /项目/ }).first()).toBeVisible();
|
||||||
|
}
|
||||||
|
|
||||||
|
async assertBackupModulesCollapsed() {
|
||||||
|
const summary = this.page.locator("summary").filter({ hasText: "备用模块" }).first();
|
||||||
|
if (await summary.count()) {
|
||||||
|
const details = summary.locator("xpath=ancestor::details[1]");
|
||||||
|
await expect(details).not.toHaveAttribute("open", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import { TEST_IDS } from "../testIds";
|
||||||
|
|
||||||
|
export class ReimbursementDetailPageObject {
|
||||||
|
readonly page: Page;
|
||||||
|
|
||||||
|
constructor(page: Page) {
|
||||||
|
this.page = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
get overview() {
|
||||||
|
return this.page.getByTestId(TEST_IDS.reimbursement.overview);
|
||||||
|
}
|
||||||
|
|
||||||
|
get summary() {
|
||||||
|
return this.page.getByTestId(TEST_IDS.reimbursement.summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
get itemsTable() {
|
||||||
|
return this.page.getByTestId(TEST_IDS.reimbursement.items);
|
||||||
|
}
|
||||||
|
|
||||||
|
get addItemBtn() {
|
||||||
|
return this.page.getByTestId(TEST_IDS.reimbursement.itemAdd);
|
||||||
|
}
|
||||||
|
|
||||||
|
get budgetHint() {
|
||||||
|
return this.page.getByTestId(TEST_IDS.reimbursement.budgetHint);
|
||||||
|
}
|
||||||
|
|
||||||
|
get erpExport() {
|
||||||
|
return this.page.getByTestId(TEST_IDS.reimbursement.erpExport);
|
||||||
|
}
|
||||||
|
|
||||||
|
get submitBtn() {
|
||||||
|
return this.page.getByTestId(TEST_IDS.reimbursement.submit);
|
||||||
|
}
|
||||||
|
|
||||||
|
get withdrawBtn() {
|
||||||
|
return this.page.getByTestId(TEST_IDS.reimbursement.withdraw);
|
||||||
|
}
|
||||||
|
|
||||||
|
get payBtn() {
|
||||||
|
return this.page.getByTestId(TEST_IDS.reimbursement.pay);
|
||||||
|
}
|
||||||
|
|
||||||
|
async assertLoaded() {
|
||||||
|
await expect(this.overview).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(this.summary).toBeVisible({ timeout: 15_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async assertStatus(statusText: string) {
|
||||||
|
await expect(this.summary).toContainText(statusText);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当前 seed 保证 precheck 不触发 warn/manual_review;若 seed 变更,此处需相应调整。
|
||||||
|
async submitReimbursement(acceptConfirm = true) {
|
||||||
|
const dialogPromise = acceptConfirm
|
||||||
|
? this.page.waitForEvent("dialog", { timeout: 2_000 }).then((dialog) => dialog.accept()).catch(() => undefined)
|
||||||
|
: Promise.resolve();
|
||||||
|
|
||||||
|
await this.submitBtn.click();
|
||||||
|
await dialogPromise;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
import { TEST_IDS } from "../testIds";
|
||||||
|
|
||||||
|
export class WorkflowTaskDetailPageObject {
|
||||||
|
readonly page: Page;
|
||||||
|
|
||||||
|
constructor(page: Page) {
|
||||||
|
this.page = page;
|
||||||
|
}
|
||||||
|
|
||||||
|
get overview() {
|
||||||
|
return this.page.getByTestId(TEST_IDS.workflow.overview);
|
||||||
|
}
|
||||||
|
|
||||||
|
get summary() {
|
||||||
|
return this.page.getByTestId(TEST_IDS.workflow.summary);
|
||||||
|
}
|
||||||
|
|
||||||
|
get approveBtn() {
|
||||||
|
return this.page.getByTestId(TEST_IDS.workflow.approve);
|
||||||
|
}
|
||||||
|
|
||||||
|
get rejectCommentInput() {
|
||||||
|
return this.page.getByTestId(TEST_IDS.workflow.rejectComment);
|
||||||
|
}
|
||||||
|
|
||||||
|
get rejectBtn() {
|
||||||
|
return this.page.getByTestId(TEST_IDS.workflow.reject);
|
||||||
|
}
|
||||||
|
|
||||||
|
get returnCommentInput() {
|
||||||
|
return this.page.getByTestId(TEST_IDS.workflow.returnComment);
|
||||||
|
}
|
||||||
|
|
||||||
|
get returnBtn() {
|
||||||
|
return this.page.getByTestId(TEST_IDS.workflow.returnBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
get actionError() {
|
||||||
|
return this.page.getByTestId(TEST_IDS.workflow.actionError);
|
||||||
|
}
|
||||||
|
|
||||||
|
get transferApproverSelect() {
|
||||||
|
return this.page.getByTestId(TEST_IDS.workflow.transferApprover);
|
||||||
|
}
|
||||||
|
|
||||||
|
async assertLoaded() {
|
||||||
|
await expect(this.overview).toBeVisible({ timeout: 15_000 });
|
||||||
|
await expect(this.summary).toBeVisible({ timeout: 15_000 });
|
||||||
|
}
|
||||||
|
|
||||||
|
async approve() {
|
||||||
|
await this.approveBtn.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async reject(comment: string) {
|
||||||
|
await this.rejectCommentInput.fill(comment);
|
||||||
|
await this.rejectBtn.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async returnTask(comment: string) {
|
||||||
|
await this.returnCommentInput.fill(comment);
|
||||||
|
await this.returnBtn.click();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,20 +1 @@
|
|||||||
export const TEST_IDS = {
|
export { TEST_IDS } from "../../../src/lib/testIds";
|
||||||
login: {
|
|
||||||
identifier: "login-identifier",
|
|
||||||
password: "login-password",
|
|
||||||
submit: "login-submit",
|
|
||||||
},
|
|
||||||
topbar: {
|
|
||||||
profileTrigger: "topbar-profile-trigger",
|
|
||||||
},
|
|
||||||
workflow: {
|
|
||||||
tasksTable: "workflow-tasks-table",
|
|
||||||
taskRow: "workflow-task-row",
|
|
||||||
detailLink: "workflow-task-detail-link",
|
|
||||||
summary: "workflow-task-summary",
|
|
||||||
approve: "workflow-task-approve",
|
|
||||||
},
|
|
||||||
audit: {
|
|
||||||
export: "audit-export",
|
|
||||||
},
|
|
||||||
} as const;
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { expect, test, type Locator, type Page } from "@playwright/test";
|
import { test, expect } from "./helpers/fixtures";
|
||||||
|
|
||||||
import { EMPLOYEE, MANAGER, apiPostData, createAuthenticatedApiClient, login, logout } from "./helpers/auth";
|
import { EMPLOYEE, MANAGER, apiPostData, createAuthenticatedApiClient, login, logout } from "./helpers/auth";
|
||||||
import { openNavLink } from "./helpers/navigation";
|
import { openNavLink } from "./helpers/navigation";
|
||||||
import { TEST_IDS } from "./helpers/testIds";
|
import { TEST_IDS } from "./helpers/testIds";
|
||||||
|
import type { Locator, Page } from "@playwright/test";
|
||||||
|
|
||||||
function formatDate(offsetDays: number) {
|
function formatDate(offsetDays: number) {
|
||||||
const value = new Date();
|
const value = new Date();
|
||||||
@@ -69,7 +69,7 @@ async function withdrawLeave(api: Awaited<ReturnType<typeof createAuthenticatedA
|
|||||||
return apiPostData<{ leaveRequestId: string; leaveStatus: string }>(api, `/api/leave-requests/${leaveRequestId}/withdraw`, {});
|
return apiPostData<{ leaveRequestId: string; leaveStatus: string }>(api, `/api/leave-requests/${leaveRequestId}/withdraw`, {});
|
||||||
}
|
}
|
||||||
|
|
||||||
test("E2E 请假主链路:草稿 → 提交 → 撤回 + 提交 → 审批", async ({ page }) => {
|
test("E2E 请假主链路:草稿 → 提交 → 撤回 + 提交 → 审批", async ({ page, workflowDetail }) => {
|
||||||
const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
|
const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
|
||||||
const baseOffset = 30 + Number(String(Date.now()).slice(-2));
|
const baseOffset = 30 + Number(String(Date.now()).slice(-2));
|
||||||
const firstDate = formatDate(baseOffset);
|
const firstDate = formatDate(baseOffset);
|
||||||
@@ -77,42 +77,43 @@ test("E2E 请假主链路:草稿 → 提交 → 撤回 + 提交 → 审批", a
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await login(page, EMPLOYEE);
|
await login(page, EMPLOYEE);
|
||||||
await (await openNavLink(page, "请假")).click();
|
await (await openNavLink(page, "请假")).click();
|
||||||
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "请假申请" })).toBeVisible();
|
||||||
|
|
||||||
const firstLeaveId = await createLeaveDraft(page, firstDate, firstDate, `Playwright 撤回请假 ${Date.now()}`);
|
const firstLeaveId = await createLeaveDraft(page, firstDate, firstDate, `Playwright 撤回请假 ${Date.now()}`);
|
||||||
await submitLeave(employeeClient.api, firstLeaveId);
|
await submitLeave(employeeClient.api, firstLeaveId);
|
||||||
await page.goto("/leave-requests");
|
await page.goto("/leave-requests");
|
||||||
const firstRow = page.getByRole("row").filter({ hasText: firstDate }).first();
|
const firstRow = page.getByRole("row").filter({ hasText: firstDate }).first();
|
||||||
await expect(firstRow).toContainText("审批中", { timeout: 30_000 });
|
await expect(firstRow).toContainText("审批中", { timeout: 30_000 });
|
||||||
|
|
||||||
await withdrawLeave(employeeClient.api, firstLeaveId);
|
await withdrawLeave(employeeClient.api, firstLeaveId);
|
||||||
await page.goto("/leave-requests");
|
await page.goto("/leave-requests");
|
||||||
await expect(firstRow).toContainText("草稿", { timeout: 30_000 });
|
await expect(firstRow).toContainText("草稿", { timeout: 30_000 });
|
||||||
|
|
||||||
const secondLeaveId = await createLeaveDraft(page, secondDate, secondDate, `Playwright 审批请假 ${Date.now()}`);
|
const secondLeaveId = await createLeaveDraft(page, secondDate, secondDate, `Playwright 审批请假 ${Date.now()}`);
|
||||||
const secondSubmit = await submitLeave(employeeClient.api, secondLeaveId);
|
const secondSubmit = await submitLeave(employeeClient.api, secondLeaveId);
|
||||||
await page.goto("/leave-requests");
|
await page.goto("/leave-requests");
|
||||||
const secondRow = page.getByRole("row").filter({ hasText: secondDate }).first();
|
const secondRow = page.getByRole("row").filter({ hasText: secondDate }).first();
|
||||||
await expect(secondRow).toContainText("审批中", { timeout: 30_000 });
|
await expect(secondRow).toContainText("审批中", { timeout: 30_000 });
|
||||||
|
|
||||||
await logout(page, EMPLOYEE.name);
|
await logout(page, EMPLOYEE.name);
|
||||||
|
|
||||||
await login(page, MANAGER);
|
await login(page, MANAGER);
|
||||||
await (await openNavLink(page, "待我处理")).click();
|
await (await openNavLink(page, "待我处理")).click();
|
||||||
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
|
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
|
||||||
const taskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: secondSubmit.currentTaskId }).first();
|
const taskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: secondSubmit.currentTaskId }).first();
|
||||||
await expect(taskRow).toBeVisible({ timeout: 30_000 });
|
await expect(taskRow).toBeVisible({ timeout: 30_000 });
|
||||||
await taskRow.getByTestId(TEST_IDS.workflow.detailLink).click();
|
await taskRow.getByTestId(TEST_IDS.workflow.detailLink).click();
|
||||||
await page.getByTestId(TEST_IDS.workflow.approve).click();
|
await workflowDetail.assertLoaded();
|
||||||
await expect(page.getByTestId(TEST_IDS.workflow.summary)).toContainText(/状态:(completed|已完成)/);
|
await workflowDetail.approve();
|
||||||
|
await expect(workflowDetail.summary).toContainText(/状态:(completed|已完成)/);
|
||||||
|
|
||||||
await logout(page, MANAGER.name);
|
await logout(page, MANAGER.name);
|
||||||
|
|
||||||
await login(page, EMPLOYEE);
|
await login(page, EMPLOYEE);
|
||||||
await page.goto("/leave-requests");
|
await page.goto("/leave-requests");
|
||||||
await expect(page.getByRole("row").filter({ hasText: secondDate }).first()).toContainText("已通过", { timeout: 30_000 });
|
await expect(page.getByRole("row").filter({ hasText: secondDate }).first()).toContainText("已通过", { timeout: 30_000 });
|
||||||
await expect(page.getByRole("row").filter({ hasText: firstDate }).first()).toContainText("草稿", { timeout: 30_000 });
|
await expect(page.getByRole("row").filter({ hasText: firstDate }).first()).toContainText("草稿", { timeout: 30_000 });
|
||||||
} finally {
|
} finally {
|
||||||
await employeeClient.dispose();
|
await employeeClient.dispose();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,7 @@
|
|||||||
import { expect, test, type Page } from "@playwright/test";
|
import { test, expect } from "./helpers/fixtures";
|
||||||
|
import { EMPLOYEE, FINANCE, MANAGER, login, logout } from "./helpers/auth";
|
||||||
import { openNavLink } from "./helpers/navigation";
|
import { openNavLink } from "./helpers/navigation";
|
||||||
|
import { TEST_IDS } from "./helpers/testIds";
|
||||||
const EMPLOYEE = { identifier: "[email protected]", password: "EmployeePassword1234", name: "Employee" };
|
|
||||||
const MANAGER = { identifier: "[email protected]", password: "ManagerPassword1234", name: "Manager" };
|
|
||||||
const FINANCE = { identifier: "[email protected]", password: "FinancePassword1234", name: "Finance" };
|
|
||||||
|
|
||||||
async function login(page: Page, user: { identifier: string; password: string }) {
|
|
||||||
await page.goto("/login");
|
|
||||||
await page.getByTestId("login-identifier").fill(user.identifier);
|
|
||||||
await page.getByTestId("login-password").fill(user.password);
|
|
||||||
await page.getByTestId("login-submit").click();
|
|
||||||
await expect(page).toHaveURL(/\/portal/);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function logout(page: Page, userDisplayName: string) {
|
|
||||||
await page.getByRole("button", { name: userDisplayName }).click();
|
|
||||||
await page.getByRole("menuitem", { name: "退出登录" }).click();
|
|
||||||
await expect(page).toHaveURL(/\/login/);
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractId(text: string, label: string) {
|
function extractId(text: string, label: string) {
|
||||||
const match = text.match(new RegExp(`${label}:([0-9a-f-]{36})`));
|
const match = text.match(new RegExp(`${label}:([0-9a-f-]{36})`));
|
||||||
@@ -28,11 +11,13 @@ function extractId(text: string, label: string) {
|
|||||||
return match[1];
|
return match[1];
|
||||||
}
|
}
|
||||||
|
|
||||||
test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登出", async ({ page }) => {
|
test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登出", async ({ page, reimbursementDetail, workflowDetail }) => {
|
||||||
// 1) employee: create reimbursement
|
// 1) employee: create reimbursement
|
||||||
await login(page, EMPLOYEE);
|
await login(page, EMPLOYEE);
|
||||||
|
|
||||||
await page.locator("nav").getByRole("link", { name: "我的报销", exact: true }).click();
|
await (await openNavLink(page, "我的报销")).click();
|
||||||
|
await expect(page).toHaveURL(/\/reimbursements/);
|
||||||
|
await expect(page.getByTestId("reimbursements-create")).toBeVisible();
|
||||||
const createResponsePromise = page.waitForResponse((response) => {
|
const createResponsePromise = page.waitForResponse((response) => {
|
||||||
const url = new URL(response.url());
|
const url = new URL(response.url());
|
||||||
return response.request().method() === "POST" && url.pathname === "/api/reimbursements" && response.ok();
|
return response.request().method() === "POST" && url.pathname === "/api/reimbursements" && response.ok();
|
||||||
@@ -44,9 +29,7 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
|
|||||||
throw new Error(`创建报销单后未返回 id: ${JSON.stringify(createPayload)}`);
|
throw new Error(`创建报销单后未返回 id: ${JSON.stringify(createPayload)}`);
|
||||||
}
|
}
|
||||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||||
await expect(page.getByTestId("reimbursement-summary")).toBeVisible();
|
await reimbursementDetail.assertLoaded();
|
||||||
|
|
||||||
const reimbursementSummaryText = await page.getByTestId("reimbursement-summary").innerText();
|
|
||||||
|
|
||||||
// 2) employee: upload invoice and wait OCR
|
// 2) employee: upload invoice and wait OCR
|
||||||
await (await openNavLink(page, "票据中心")).click();
|
await (await openNavLink(page, "票据中心")).click();
|
||||||
@@ -82,7 +65,8 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
|
|||||||
|
|
||||||
// 4) submit reimbursement
|
// 4) submit reimbursement
|
||||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||||
await page.getByTestId("reimbursement-submit").click();
|
await reimbursementDetail.assertLoaded();
|
||||||
|
await reimbursementDetail.submitReimbursement();
|
||||||
await expect(page.getByText(/状态:(reviewing|审批中)/)).toBeVisible();
|
await expect(page.getByText(/状态:(reviewing|审批中)/)).toBeVisible();
|
||||||
|
|
||||||
// 5) manager approve
|
// 5) manager approve
|
||||||
@@ -90,28 +74,31 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
|
|||||||
await login(page, MANAGER);
|
await login(page, MANAGER);
|
||||||
|
|
||||||
await (await openNavLink(page, "待我处理")).click();
|
await (await openNavLink(page, "待我处理")).click();
|
||||||
await expect(page.getByTestId("workflow-tasks-table")).toBeVisible();
|
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
|
||||||
const managerTaskRow = page.getByTestId("workflow-task-row").filter({ hasText: reimbursementId }).first();
|
const managerTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementId }).first();
|
||||||
await expect(managerTaskRow).toBeVisible();
|
await expect(managerTaskRow).toBeVisible();
|
||||||
await managerTaskRow.getByTestId("workflow-task-detail-link").click();
|
await managerTaskRow.getByTestId(TEST_IDS.workflow.detailLink).click();
|
||||||
await page.getByTestId("workflow-task-approve").click();
|
await workflowDetail.assertLoaded();
|
||||||
await expect(page.getByTestId("workflow-task-summary")).toContainText(/状态:(completed|已完成)/);
|
await workflowDetail.approve();
|
||||||
|
await expect(workflowDetail.summary).toContainText(/状态:(completed|已完成)/);
|
||||||
|
|
||||||
await logout(page, MANAGER.name);
|
await logout(page, MANAGER.name);
|
||||||
|
|
||||||
// 6) finance approve + pay
|
// 6) finance approve + pay
|
||||||
await login(page, FINANCE);
|
await login(page, FINANCE);
|
||||||
await (await openNavLink(page, "待我处理")).click();
|
await (await openNavLink(page, "待我处理")).click();
|
||||||
const financeTaskRow = page.getByTestId("workflow-task-row").filter({ hasText: reimbursementId }).first();
|
const financeTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementId }).first();
|
||||||
await expect(financeTaskRow).toBeVisible();
|
await expect(financeTaskRow).toBeVisible();
|
||||||
await financeTaskRow.getByTestId("workflow-task-detail-link").click();
|
await financeTaskRow.getByTestId(TEST_IDS.workflow.detailLink).click();
|
||||||
await page.getByTestId("workflow-task-approve").click();
|
await workflowDetail.assertLoaded();
|
||||||
await expect(page.getByTestId("workflow-task-summary")).toContainText(/状态:(completed|已完成)/);
|
await workflowDetail.approve();
|
||||||
|
await expect(workflowDetail.summary).toContainText(/状态:(completed|已完成)/);
|
||||||
|
|
||||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||||
|
await reimbursementDetail.assertLoaded();
|
||||||
await expect(page.getByText(/状态:(approved|已批准)/)).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText(/状态:(approved|已批准)/)).toBeVisible({ timeout: 30_000 });
|
||||||
await page.getByTestId("reimbursement-pay-voucher-no").fill(`E2E-${Date.now()}`);
|
await page.getByTestId("reimbursement-pay-voucher-no").fill(`E2E-${Date.now()}`);
|
||||||
await page.getByTestId("reimbursement-pay").click();
|
await reimbursementDetail.payBtn.click();
|
||||||
await expect(page.getByText(/状态:(paid|已支付)/)).toBeVisible({ timeout: 30_000 });
|
await expect(page.getByText(/状态:(paid|已支付)/)).toBeVisible({ timeout: 30_000 });
|
||||||
|
|
||||||
await logout(page, FINANCE.name);
|
await logout(page, FINANCE.name);
|
||||||
|
|||||||
@@ -14,15 +14,22 @@ async function waitForWorkflowTaskCache(page: import("@playwright/test").Page, t
|
|||||||
request.onerror = () => reject(request.error ?? new Error("indexeddb_open_failed"));
|
request.onerror = () => reject(request.error ?? new Error("indexeddb_open_failed"));
|
||||||
});
|
});
|
||||||
|
|
||||||
const store = db.transaction("workflow_task_cache", "readonly").objectStore("workflow_task_cache");
|
try {
|
||||||
const detail = await new Promise<unknown>((resolve, reject) => {
|
if (!db.objectStoreNames.contains("workflow_task_cache")) {
|
||||||
const query = store.get(currentTaskId);
|
return false;
|
||||||
query.onsuccess = () => resolve(query.result ?? null);
|
}
|
||||||
query.onerror = () => reject(query.error ?? new Error("indexeddb_read_failed"));
|
|
||||||
});
|
|
||||||
|
|
||||||
db.close();
|
const store = db.transaction("workflow_task_cache", "readonly").objectStore("workflow_task_cache");
|
||||||
return Boolean(detail);
|
const detail = await new Promise<unknown>((resolve, reject) => {
|
||||||
|
const query = store.get(currentTaskId);
|
||||||
|
query.onsuccess = () => resolve(query.result ?? null);
|
||||||
|
query.onerror = () => reject(query.error ?? new Error("indexeddb_read_failed"));
|
||||||
|
});
|
||||||
|
|
||||||
|
return Boolean(detail);
|
||||||
|
} finally {
|
||||||
|
db.close();
|
||||||
|
}
|
||||||
}, taskId);
|
}, taskId);
|
||||||
},
|
},
|
||||||
{ timeout: 15_000 },
|
{ timeout: 15_000 },
|
||||||
|
|||||||
@@ -1,37 +1,35 @@
|
|||||||
import { expect, test } from "@playwright/test";
|
import { test, expect } from "./helpers/fixtures";
|
||||||
|
import { ADMIN, login } from "./helpers/auth";
|
||||||
import { login } from "./helpers/auth";
|
|
||||||
import { assertBackupModulesCollapsed } from "./helpers/smokeAssertions";
|
|
||||||
import { TEST_IDS } from "./helpers/testIds";
|
import { TEST_IDS } from "./helpers/testIds";
|
||||||
|
|
||||||
const ADMIN = {
|
test("E2E 管理域 smoke:工作区设置、系统设置、功能开关、流程定义、审计日志", async ({ page, shell }) => {
|
||||||
identifier: "[email protected]",
|
|
||||||
password: "AdminPassword1234",
|
|
||||||
name: "Admin",
|
|
||||||
};
|
|
||||||
|
|
||||||
test("E2E 管理域 smoke:工作区设置、系统设置、功能开关、流程定义、审计日志", async ({ page }) => {
|
|
||||||
await login(page, ADMIN);
|
await login(page, ADMIN);
|
||||||
await page.goto("/portal");
|
await page.goto("/portal");
|
||||||
await assertBackupModulesCollapsed(page);
|
await expect(shell.topbarProfileTrigger).toBeVisible();
|
||||||
|
await shell.assertBackupModulesCollapsed();
|
||||||
|
|
||||||
await page.goto("/system/workspace");
|
await (await shell.navigate("工作区设置")).click();
|
||||||
|
await expect(page).toHaveURL(/\/system\/workspace/);
|
||||||
await expect(page.getByRole("heading", { name: "工作区设置" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "工作区设置" })).toBeVisible();
|
||||||
await expect(page.getByText("产品模式")).toBeVisible();
|
await expect(page.getByText("产品模式")).toBeVisible();
|
||||||
|
|
||||||
await page.goto("/system/settings");
|
await (await shell.navigate("系统设置")).click();
|
||||||
|
await expect(page).toHaveURL(/\/system\/settings/);
|
||||||
await expect(page.getByRole("heading", { name: "系统设置" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "系统设置" })).toBeVisible();
|
||||||
await expect(page.getByRole("link", { name: "工作区设置", exact: true }).first()).toBeVisible();
|
await expect(page.getByRole("link", { name: "工作区设置", exact: true }).first()).toBeVisible();
|
||||||
|
|
||||||
await page.goto("/system/feature-flags");
|
await (await shell.navigate("功能开关")).click();
|
||||||
|
await expect(page).toHaveURL(/\/system\/feature-flags/);
|
||||||
await expect(page.getByRole("heading", { name: "功能开关" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "功能开关" })).toBeVisible();
|
||||||
await expect(page.getByTestId("feature-flags-table")).toBeVisible();
|
await expect(page.getByTestId("feature-flags-table")).toBeVisible();
|
||||||
|
|
||||||
await page.goto("/system/workflow-definitions");
|
await page.goto("/system/workflow-definitions");
|
||||||
|
await expect(page).toHaveURL(/\/system\/workflow-definitions/);
|
||||||
await expect(page.getByRole("heading", { name: "流程定义" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "流程定义" })).toBeVisible();
|
||||||
await expect(page.getByText("版本列表")).toBeVisible();
|
await expect(page.getByText("版本列表")).toBeVisible();
|
||||||
|
|
||||||
await page.goto("/audit-logs");
|
await (await shell.navigate("审计日志")).click();
|
||||||
|
await expect(page).toHaveURL(/\/audit-logs/);
|
||||||
await expect(page.getByRole("heading", { name: "审计工作台" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "审计工作台" })).toBeVisible();
|
||||||
await expect(page.getByTestId(TEST_IDS.audit.export)).toBeVisible();
|
await expect(page.getByTestId(TEST_IDS.audit.export)).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { test, expect } from "./helpers/fixtures";
|
||||||
|
|
||||||
|
import { EMPLOYEE, login, logout } from "./helpers/auth";
|
||||||
|
import { openNavLink } from "./helpers/navigation";
|
||||||
|
import { TEST_IDS } from "./helpers/testIds";
|
||||||
|
|
||||||
|
function formatWeekStart(offsetDays: number) {
|
||||||
|
const value = new Date();
|
||||||
|
value.setUTCDate(value.getUTCDate() + offsetDays);
|
||||||
|
return value.toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("E2E 周报待办联动:保存 → 生成待办 → 提交 → 完成待办", async ({ page }) => {
|
||||||
|
const uniqueToken = `Playwright 周报联动 ${Date.now()}`;
|
||||||
|
const weekStart = formatWeekStart(35 + Number(String(Date.now()).slice(-1)));
|
||||||
|
|
||||||
|
await login(page, EMPLOYEE);
|
||||||
|
await (await openNavLink(page, "周报")).click();
|
||||||
|
await expect(page.getByRole("heading", { name: "周报", exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
const weeklyLoadResponse = page.waitForResponse((response) => {
|
||||||
|
const url = new URL(response.url());
|
||||||
|
return response.request().method() === "GET" && url.pathname === "/api/weekly-reports/me" && url.searchParams.get("weekStart") === weekStart && response.ok();
|
||||||
|
});
|
||||||
|
await page.getByLabel("周报归档周").fill(weekStart);
|
||||||
|
await weeklyLoadResponse;
|
||||||
|
await page.getByLabel("本周完成").fill(`${uniqueToken} 已完成`);
|
||||||
|
await page.getByLabel("当前阻塞").fill(`${uniqueToken} 阻塞`);
|
||||||
|
await page.getByLabel("下周计划").fill(`${uniqueToken} 下周计划`);
|
||||||
|
|
||||||
|
const saveResponse = page.waitForResponse((response) => {
|
||||||
|
const url = new URL(response.url());
|
||||||
|
return response.request().method() === "PUT" && url.pathname === "/api/weekly-reports/me" && response.ok();
|
||||||
|
});
|
||||||
|
await page.getByTestId(TEST_IDS.weeklyReport.save).click();
|
||||||
|
await saveResponse;
|
||||||
|
await expect(page.getByText("周报已保存")).toBeVisible();
|
||||||
|
|
||||||
|
const createTodoResponse = page.waitForResponse((response) => {
|
||||||
|
const url = new URL(response.url());
|
||||||
|
return response.request().method() === "POST" && url.pathname === "/api/todos" && response.ok();
|
||||||
|
});
|
||||||
|
await page.getByTestId(TEST_IDS.weeklyReport.createTodo).click();
|
||||||
|
await createTodoResponse;
|
||||||
|
await expect(page.getByText("已从周报生成待办")).toBeVisible();
|
||||||
|
|
||||||
|
const submitResponse = page.waitForResponse((response) => {
|
||||||
|
const url = new URL(response.url());
|
||||||
|
return response.request().method() === "POST" && url.pathname === "/api/weekly-reports/me/submit" && response.ok();
|
||||||
|
});
|
||||||
|
await page.getByTestId(TEST_IDS.weeklyReport.submit).click();
|
||||||
|
await submitResponse;
|
||||||
|
await expect(page.getByText("周报已提交")).toBeVisible();
|
||||||
|
await expect(page.getByText("已提交", { exact: true })).toBeVisible();
|
||||||
|
|
||||||
|
await (await openNavLink(page, "待办")).click();
|
||||||
|
const todoRow = page.getByTestId(TEST_IDS.todo.row).filter({ hasText: uniqueToken }).first();
|
||||||
|
await expect(todoRow).toBeVisible({ timeout: 30_000 });
|
||||||
|
await todoRow.getByTestId(TEST_IDS.todo.complete).click();
|
||||||
|
await expect(todoRow).toContainText("done", { timeout: 30_000 });
|
||||||
|
|
||||||
|
await logout(page, EMPLOYEE.name);
|
||||||
|
});
|
||||||
在新工单中引用
屏蔽一个用户