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 { CardSkeleton } from "../components/common/CardSkeleton";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
|
||||
import { Button } from "../components/ui/button";
|
||||
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 {
|
||||
ReimbursementItemEditor,
|
||||
@@ -80,7 +84,7 @@ export function ReimbursementDetailPage() {
|
||||
|
||||
const erpPreviewQuery = useQuery({
|
||||
enabled: Boolean(reimbursementId) && canManageIntegration && detailQuery.data?.status === "paid",
|
||||
queryKey: ["reimbursements", reimbursementId, "erp-journal-preview"],
|
||||
queryKey: ["reimbursements", reimbursementId, "integration-journal-preview"],
|
||||
queryFn: () => previewReimbursementJournal(reimbursementId),
|
||||
});
|
||||
|
||||
@@ -247,7 +251,7 @@ export function ReimbursementDetailPage() {
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "erp-journal-preview"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId, "integration-journal-preview"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
||||
]);
|
||||
setActionError("");
|
||||
@@ -390,18 +394,51 @@ export function ReimbursementDetailPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
<PageScaffold>
|
||||
<EntityOverviewHero
|
||||
title={`报销详情 ${detail.formNumber ?? detail.id}`}
|
||||
subtitle={`状态:${detail.status}`}
|
||||
actions={
|
||||
subtitle="在同一工作台内查看概览、处理明细、跟进风险并进入财务桥接。"
|
||||
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">
|
||||
<Link to="/reimbursements">返回列表</Link>
|
||||
</Button>
|
||||
}
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="card-std space-y-2 p-6 text-sm" data-testid="reimbursement-summary">
|
||||
<div className="detail-shell-grid">
|
||||
<div className="space-y-6">
|
||||
<SectionPanel title="概览与关联" description="先看主档和关联项目,再决定是否继续补明细或提交流程。">
|
||||
<div className="space-y-6" data-testid="reimbursement-overview">
|
||||
<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 className="text-zinc-500">当前状态</div>
|
||||
<div className="mt-2"><StatusBadge status={detail.status} /></div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
|
||||
<div className="text-zinc-500">关联项目</div>
|
||||
<div className="mt-2 font-medium text-zinc-900">{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
|
||||
<div className="text-zinc-500">明细数</div>
|
||||
<div className="mt-2 font-medium text-zinc-900">{detail.items.length}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
|
||||
<div className="text-zinc-500">关联票据</div>
|
||||
<div className="mt-2 font-medium text-zinc-900">{detail.invoices.length}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="reimbursement-summary">
|
||||
<div>状态:{detail.status}</div>
|
||||
<div>报销ID:{detail.id}</div>
|
||||
<div>报销日期:{detail.reimbursementDate ?? "-"}</div>
|
||||
<div>金额:{detail.totalAmount ?? "-"}</div>
|
||||
@@ -410,16 +447,20 @@ export function ReimbursementDetailPage() {
|
||||
<div>说明:{detail.description || "-"}</div>
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-3 p-6 text-sm">
|
||||
<div className="rounded-lg border border-zinc-200 p-4 text-sm">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-base font-semibold text-zinc-900">关联项目</h3>
|
||||
<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}
|
||||
{detail.project ? (
|
||||
<Link to={`/projects/${detail.project.id}`} className="text-sm text-primary hover:underline">
|
||||
查看项目
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
<select
|
||||
className="select-std max-w-xl"
|
||||
className="select-std mt-4 max-w-xl"
|
||||
value={detail.projectId ?? ""}
|
||||
onChange={(event) => projectLinkMutation.mutate(event.target.value || null)}
|
||||
disabled={projectLinkMutation.isPending}
|
||||
@@ -432,86 +473,12 @@ export function ReimbursementDetailPage() {
|
||||
))}
|
||||
</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}
|
||||
</SectionPanel>
|
||||
|
||||
|
||||
{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 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}
|
||||
|
||||
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
|
||||
|
||||
<AiSuggestionCard targetType="reimbursement" targetId={detail.id} />
|
||||
|
||||
<div className="card-std space-y-4 p-6" data-testid="reimbursement-items">
|
||||
<SectionPanel title="执行 · 明细与票据" description="明细、票据和编辑器在同一层处理,避免长页面往返。">
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4" data-testid="reimbursement-items">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-base font-semibold text-zinc-900">报销明细</h3>
|
||||
@@ -618,20 +585,10 @@ export function ReimbursementDetailPage() {
|
||||
) : 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">
|
||||
<div className="space-y-2 text-sm" data-testid="reimbursement-invoices">
|
||||
<h3 className="text-base font-semibold text-zinc-900">关联票据</h3>
|
||||
{(detail.invoices ?? []).length ? (
|
||||
<div className="space-y-2 text-sm">
|
||||
<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>
|
||||
@@ -645,6 +602,112 @@ export function ReimbursementDetailPage() {
|
||||
<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>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmDeleteItemId)}
|
||||
@@ -654,6 +717,6 @@ export function ReimbursementDetailPage() {
|
||||
confirmLabel="删除"
|
||||
onConfirm={() => { itemDeleteMutation.mutate(confirmDeleteItemId); }}
|
||||
/>
|
||||
</div>
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ".
|
||||
import { toast } from "sonner";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { TEST_IDS } from "../lib/testIds";
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
if (isApiError(error)) {
|
||||
@@ -140,7 +141,7 @@ export function TodosPage() {
|
||||
</TableRow>
|
||||
) : todoItems.length ? (
|
||||
todoItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableRow data-testid={TEST_IDS.todo.row} key={item.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||
<div className="text-xs text-zinc-500">{item.description ?? "-"}</div>
|
||||
@@ -151,7 +152,13 @@ export function TodosPage() {
|
||||
<TableCell>{item.status}</TableCell>
|
||||
<TableCell>
|
||||
<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 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="建议把周报计划、项目跟进或临时事项都拆成清晰动作。">
|
||||
<div className="space-y-4">
|
||||
<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 className="space-y-2">
|
||||
<Input placeholder="补充说明(可选)" value={description} onChange={(event) => setDescription(event.target.value)} />
|
||||
@@ -192,7 +199,7 @@ export function TodosPage() {
|
||||
))}
|
||||
</select>
|
||||
</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 ? "新建中..." : "新建待办"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -15,6 +15,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ".
|
||||
import { toast } from "sonner";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { TEST_IDS } from "../lib/testIds";
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
if (isApiError(error)) {
|
||||
@@ -135,7 +136,13 @@ export function WeeklyReportsPage() {
|
||||
<div className="text-sm font-medium text-zinc-900">按周归档</div>
|
||||
<div className="text-sm text-zinc-500">默认按周一归档;提交后会同步更新 Portal 与项目摘要。</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>
|
||||
|
||||
<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}
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">本周完成</label>
|
||||
<Textarea value={draft.highlightsText} onChange={(event) => setDraft((prev) => ({ ...prev, highlightsText: event.target.value }))} />
|
||||
<label className="text-sm text-zinc-500" htmlFor="weekly-report-highlights">本周完成</label>
|
||||
<Textarea id="weekly-report-highlights" value={draft.highlightsText} onChange={(event) => setDraft((prev) => ({ ...prev, highlightsText: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">当前阻塞</label>
|
||||
<Textarea value={draft.blockersText} onChange={(event) => setDraft((prev) => ({ ...prev, blockersText: event.target.value }))} />
|
||||
<label className="text-sm text-zinc-500" htmlFor="weekly-report-blockers">当前阻塞</label>
|
||||
<Textarea id="weekly-report-blockers" value={draft.blockersText} onChange={(event) => setDraft((prev) => ({ ...prev, blockersText: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">下周计划</label>
|
||||
<Textarea value={draft.nextWeekText} onChange={(event) => setDraft((prev) => ({ ...prev, nextWeekText: event.target.value }))} />
|
||||
<label className="text-sm text-zinc-500" htmlFor="weekly-report-next-week">下周计划</label>
|
||||
<Textarea id="weekly-report-next-week" value={draft.nextWeekText} onChange={(event) => setDraft((prev) => ({ ...prev, nextWeekText: event.target.value }))} />
|
||||
</div>
|
||||
<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 }))} />
|
||||
@@ -193,9 +200,9 @@ export function WeeklyReportsPage() {
|
||||
</div>
|
||||
))}
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => createTodoMutation.mutate()} disabled={createTodoMutation.isPending}>转成待办</Button>
|
||||
<Button 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.createTodo} type="button" variant="secondary" onClick={() => createTodoMutation.mutate()} disabled={createTodoMutation.isPending}>转成待办</Button>
|
||||
<Button data-testid={TEST_IDS.weeklyReport.save} type="button" variant="secondary" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>保存草稿</Button>
|
||||
<Button data-testid={TEST_IDS.weeklyReport.submit} type="button" onClick={() => submitMutation.mutate()} disabled={submitMutation.isPending}>提交周报</Button>
|
||||
</div>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
@@ -16,8 +16,11 @@ import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { buildMobileApprovalsPath, shouldPreferMobileApprovalExperience } from "../pwa";
|
||||
import { CardSkeleton } from "../components/common/CardSkeleton";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
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 { Textarea } from "../components/ui/textarea";
|
||||
|
||||
@@ -37,6 +40,29 @@ function entityTitle(entityType: string | undefined) {
|
||||
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() {
|
||||
const { taskId = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -156,19 +182,50 @@ export function WorkflowTaskDetailPage() {
|
||||
const canTransfer = allowedActions.has("transfer") && detail.workflowInstance?.entityType === "reimbursement";
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
<PageScaffold>
|
||||
<EntityOverviewHero
|
||||
title={`任务详情 ${detail.task.stepName}`}
|
||||
subtitle={`任务ID: ${detail.task.id}`}
|
||||
actions={
|
||||
subtitle="在同一工作台内查看审批对象摘要、处理动作和历史留痕。"
|
||||
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">
|
||||
<Link to="/workflow/tasks">返回待办列表</Link>
|
||||
</Button>
|
||||
}
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="card-std space-y-2 p-6 text-sm" data-testid="workflow-task-summary">
|
||||
<div>状态:<StatusBadge status={detail.task.status} /></div>
|
||||
<div className="detail-shell-grid">
|
||||
<div className="space-y-6">
|
||||
<SectionPanel title="审批对象摘要" description="先看对象主信息,再决定通过、驳回、退回或转审。">
|
||||
<div className="space-y-4" data-testid="workflow-task-overview">
|
||||
<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 className="text-zinc-500">当前状态</div>
|
||||
<div className="mt-2"><StatusBadge status={detail.task.status} /></div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
|
||||
<div className="text-zinc-500">当前节点</div>
|
||||
<div className="mt-2 font-medium text-zinc-900">{detail.task.stepName}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
|
||||
<div className="text-zinc-500">对象类型</div>
|
||||
<div className="mt-2 font-medium text-zinc-900">{entityTitle(detail.workflowInstance?.entityType)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-zinc-200 px-4 py-3 text-sm">
|
||||
<div className="text-zinc-500">可执行动作</div>
|
||||
<div className="mt-2 font-medium text-zinc-900">{detail.allowedActions.length ? detail.allowedActions.join(" / ") : "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-zinc-200 p-4 text-sm" data-testid="workflow-task-summary">
|
||||
<div>状态:{workflowStatusLabel(detail.task.status)}</div>
|
||||
<div>实例:{detail.workflowInstance?.id ?? "-"}</div>
|
||||
<div>对象类型:{entityTitle(detail.workflowInstance?.entityType)}</div>
|
||||
{detail.reimbursement ? (
|
||||
@@ -229,10 +286,11 @@ export function WorkflowTaskDetailPage() {
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">审批动作</h3>
|
||||
|
||||
<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"
|
||||
@@ -296,7 +354,7 @@ export function WorkflowTaskDetailPage() {
|
||||
<p className="text-sm font-medium text-zinc-800">转审</p>
|
||||
<select
|
||||
className="select-std"
|
||||
data-testid="workflow-task-transfer-to-approver"
|
||||
data-testid="workflow-task-transfer-approver"
|
||||
onChange={(event) => setSelectedApprover(event.target.value)}
|
||||
value={selectedApprover}
|
||||
>
|
||||
@@ -330,9 +388,9 @@ export function WorkflowTaskDetailPage() {
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
<div className="card-std space-y-3 p-6">
|
||||
<h3 className="text-base font-semibold text-zinc-900">处理历史</h3>
|
||||
<SectionPanel title="处理历史" description="所有审批动作、意见和处理人统一留痕。">
|
||||
<div className="space-y-2">
|
||||
{detail.history.length ? (
|
||||
detail.history.map((item) => (
|
||||
@@ -348,7 +406,20 @@ export function WorkflowTaskDetailPage() {
|
||||
<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>
|
||||
</PageScaffold>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -62,7 +62,15 @@ export function WorkspaceSetupPage() {
|
||||
const moduleEntries = useMemo(() => Object.entries(workspace?.moduleVisibility ?? {}), [workspace?.moduleVisibility]);
|
||||
|
||||
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 (
|
||||
|
||||
@@ -34,6 +34,12 @@ export const FINANCE_MGR: E2EUser = {
|
||||
name: "Finance Manager",
|
||||
};
|
||||
|
||||
export const ADMIN: E2EUser = {
|
||||
identifier: "[email protected]",
|
||||
password: "AdminPassword1234",
|
||||
name: "Admin",
|
||||
};
|
||||
|
||||
export type MeResponse = {
|
||||
id: 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.submit).click();
|
||||
await expect(page).toHaveURL(/\/portal/);
|
||||
await expect(page.getByTestId(TEST_IDS.topbar.profileTrigger).first()).toBeVisible();
|
||||
}
|
||||
|
||||
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 = {
|
||||
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;
|
||||
export { TEST_IDS } from "../../../src/lib/testIds";
|
||||
|
||||
@@ -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 { openNavLink } from "./helpers/navigation";
|
||||
import { TEST_IDS } from "./helpers/testIds";
|
||||
import type { Locator, Page } from "@playwright/test";
|
||||
|
||||
function formatDate(offsetDays: number) {
|
||||
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`, {});
|
||||
}
|
||||
|
||||
test("E2E 请假主链路:草稿 → 提交 → 撤回 + 提交 → 审批", async ({ page }) => {
|
||||
test("E2E 请假主链路:草稿 → 提交 → 撤回 + 提交 → 审批", async ({ page, workflowDetail }) => {
|
||||
const employeeClient = await createAuthenticatedApiClient(EMPLOYEE);
|
||||
const baseOffset = 30 + Number(String(Date.now()).slice(-2));
|
||||
const firstDate = formatDate(baseOffset);
|
||||
@@ -104,8 +104,9 @@ test("E2E 请假主链路:草稿 → 提交 → 撤回 + 提交 → 审批", a
|
||||
const taskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: secondSubmit.currentTaskId }).first();
|
||||
await expect(taskRow).toBeVisible({ timeout: 30_000 });
|
||||
await taskRow.getByTestId(TEST_IDS.workflow.detailLink).click();
|
||||
await page.getByTestId(TEST_IDS.workflow.approve).click();
|
||||
await expect(page.getByTestId(TEST_IDS.workflow.summary)).toContainText(/状态:(completed|已完成)/);
|
||||
await workflowDetail.assertLoaded();
|
||||
await workflowDetail.approve();
|
||||
await expect(workflowDetail.summary).toContainText(/状态:(completed|已完成)/);
|
||||
|
||||
await logout(page, MANAGER.name);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
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/);
|
||||
}
|
||||
import { TEST_IDS } from "./helpers/testIds";
|
||||
|
||||
function extractId(text: string, label: string) {
|
||||
const match = text.match(new RegExp(`${label}:([0-9a-f-]{36})`));
|
||||
@@ -28,11 +11,13 @@ function extractId(text: string, label: string) {
|
||||
return match[1];
|
||||
}
|
||||
|
||||
test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登出", async ({ page }) => {
|
||||
test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登出", async ({ page, reimbursementDetail, workflowDetail }) => {
|
||||
// 1) employee: create reimbursement
|
||||
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 url = new URL(response.url());
|
||||
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)}`);
|
||||
}
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
await expect(page.getByTestId("reimbursement-summary")).toBeVisible();
|
||||
|
||||
const reimbursementSummaryText = await page.getByTestId("reimbursement-summary").innerText();
|
||||
await reimbursementDetail.assertLoaded();
|
||||
|
||||
// 2) employee: upload invoice and wait OCR
|
||||
await (await openNavLink(page, "票据中心")).click();
|
||||
@@ -82,7 +65,8 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
|
||||
|
||||
// 4) submit reimbursement
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
await page.getByTestId("reimbursement-submit").click();
|
||||
await reimbursementDetail.assertLoaded();
|
||||
await reimbursementDetail.submitReimbursement();
|
||||
await expect(page.getByText(/状态:(reviewing|审批中)/)).toBeVisible();
|
||||
|
||||
// 5) manager approve
|
||||
@@ -90,28 +74,31 @@ test("E2E 主链路:登录→报销→待办审批→票据OCR→支付→登
|
||||
await login(page, MANAGER);
|
||||
|
||||
await (await openNavLink(page, "待我处理")).click();
|
||||
await expect(page.getByTestId("workflow-tasks-table")).toBeVisible();
|
||||
const managerTaskRow = page.getByTestId("workflow-task-row").filter({ hasText: reimbursementId }).first();
|
||||
await expect(page.getByTestId(TEST_IDS.workflow.tasksTable)).toBeVisible();
|
||||
const managerTaskRow = page.getByTestId(TEST_IDS.workflow.taskRow).filter({ hasText: reimbursementId }).first();
|
||||
await expect(managerTaskRow).toBeVisible();
|
||||
await managerTaskRow.getByTestId("workflow-task-detail-link").click();
|
||||
await page.getByTestId("workflow-task-approve").click();
|
||||
await expect(page.getByTestId("workflow-task-summary")).toContainText(/状态:(completed|已完成)/);
|
||||
await managerTaskRow.getByTestId(TEST_IDS.workflow.detailLink).click();
|
||||
await workflowDetail.assertLoaded();
|
||||
await workflowDetail.approve();
|
||||
await expect(workflowDetail.summary).toContainText(/状态:(completed|已完成)/);
|
||||
|
||||
await logout(page, MANAGER.name);
|
||||
|
||||
// 6) finance approve + pay
|
||||
await login(page, FINANCE);
|
||||
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 financeTaskRow.getByTestId("workflow-task-detail-link").click();
|
||||
await page.getByTestId("workflow-task-approve").click();
|
||||
await expect(page.getByTestId("workflow-task-summary")).toContainText(/状态:(completed|已完成)/);
|
||||
await financeTaskRow.getByTestId(TEST_IDS.workflow.detailLink).click();
|
||||
await workflowDetail.assertLoaded();
|
||||
await workflowDetail.approve();
|
||||
await expect(workflowDetail.summary).toContainText(/状态:(completed|已完成)/);
|
||||
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
await reimbursementDetail.assertLoaded();
|
||||
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").click();
|
||||
await reimbursementDetail.payBtn.click();
|
||||
await expect(page.getByText(/状态:(paid|已支付)/)).toBeVisible({ timeout: 30_000 });
|
||||
|
||||
await logout(page, FINANCE.name);
|
||||
|
||||
@@ -14,6 +14,11 @@ async function waitForWorkflowTaskCache(page: import("@playwright/test").Page, t
|
||||
request.onerror = () => reject(request.error ?? new Error("indexeddb_open_failed"));
|
||||
});
|
||||
|
||||
try {
|
||||
if (!db.objectStoreNames.contains("workflow_task_cache")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const store = db.transaction("workflow_task_cache", "readonly").objectStore("workflow_task_cache");
|
||||
const detail = await new Promise<unknown>((resolve, reject) => {
|
||||
const query = store.get(currentTaskId);
|
||||
@@ -21,8 +26,10 @@ async function waitForWorkflowTaskCache(page: import("@playwright/test").Page, t
|
||||
query.onerror = () => reject(query.error ?? new Error("indexeddb_read_failed"));
|
||||
});
|
||||
|
||||
db.close();
|
||||
return Boolean(detail);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}, taskId);
|
||||
},
|
||||
{ timeout: 15_000 },
|
||||
|
||||
@@ -1,37 +1,35 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
import { login } from "./helpers/auth";
|
||||
import { assertBackupModulesCollapsed } from "./helpers/smokeAssertions";
|
||||
import { test, expect } from "./helpers/fixtures";
|
||||
import { ADMIN, login } from "./helpers/auth";
|
||||
import { TEST_IDS } from "./helpers/testIds";
|
||||
|
||||
const ADMIN = {
|
||||
identifier: "[email protected]",
|
||||
password: "AdminPassword1234",
|
||||
name: "Admin",
|
||||
};
|
||||
|
||||
test("E2E 管理域 smoke:工作区设置、系统设置、功能开关、流程定义、审计日志", async ({ page }) => {
|
||||
test("E2E 管理域 smoke:工作区设置、系统设置、功能开关、流程定义、审计日志", async ({ page, shell }) => {
|
||||
await login(page, ADMIN);
|
||||
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.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("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.getByTestId("feature-flags-table")).toBeVisible();
|
||||
|
||||
await page.goto("/system/workflow-definitions");
|
||||
await expect(page).toHaveURL(/\/system\/workflow-definitions/);
|
||||
await expect(page.getByRole("heading", { name: "流程定义" })).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.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);
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户