R32-1 R32-4 R33-2: 收口工作台交互与报销链路

这个提交包含在:
2026-03-24 20:26:22 +08:00
父节点 5aaa628d54
当前提交 1056c81940
共修改 20 个文件,包含 856 行新增和 141 行删除
+27 -40
查看文件
@@ -270,26 +270,6 @@ export function PortalHomePage() {
tone: payload.weeklySummary.missingCount > 0 ? "warning" as const : "normal" as const,
}
: null,
payload.projectSummary
? {
key: "project-summary",
href: payload.projectSummary.href,
label: "待跟进 / 待收口项目",
value: `${payload.projectSummary.followUpDueTodayCount} / ${payload.projectSummary.closurePendingProjectCount}`,
note: `逾期里程碑 ${payload.projectSummary.overdueMilestonesCount},风险 ${payload.projectSummary.openRiskCount}`,
tone: payload.projectSummary.openRiskCount > 0 || payload.projectSummary.closurePendingProjectCount > 0 ? "warning" as const : "normal" as const,
}
: null,
payload.peopleLifecycle
? {
key: "people-lifecycle",
href: payload.peopleLifecycle.href,
label: "入离职 / 资产待归还",
value: `${payload.peopleLifecycle.onboardingCount + payload.peopleLifecycle.offboardingCount} / ${payload.peopleLifecycle.assetReturnPendingCount}`,
note: "用于跟进入职、离职和资产交接闭环。",
tone: payload.peopleLifecycle.offboardingCount > 0 || payload.peopleLifecycle.assetReturnPendingCount > 0 ? "warning" as const : "normal" as const,
}
: null,
] as Array<SummaryItem | null>).filter(isSummaryItem);
return (
@@ -329,30 +309,37 @@ export function PortalHomePage() {
))}
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr),minmax(0,0.9fr)]">
<SummaryPanel title={isManagerView ? "今日管理重点" : "我的申请与资产"} items={isManagerView ? managerSummaryItems : employeeSummaryItems} />
<AnnouncementsPanel announcements={payload.announcements} />
</div>
{isManagerView ? (
<>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.05fr),minmax(0,0.95fr)]">
<SummaryPanel title="今日管理重点" items={managerSummaryItems} />
<QuickLinksPanel items={payload.quickLinks} />
</div>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
{isManagerView ? (
<SecondaryCardsPanel
title="项目跟进与组织协同"
description="把需要继续跟进的项目、人员与风险放到第二层。"
cards={secondaryCards}
/>
) : (
<RecentVisitsPanel />
)}
<div className="grid gap-6 xl:grid-cols-[minmax(0,0.95fr),minmax(0,1.05fr)]">
<AnnouncementsPanel announcements={payload.announcements} />
<SecondaryCardsPanel
title="项目跟进与组织协同"
description="把项目、组织和风险类信息下移到第二阅读层。"
cards={secondaryCards}
/>
</div>
{isManagerView ? (
<AdoptionSummaryPanel adoptionSummary={payload.adoptionSummary} />
) : (
<QuickLinksPanel items={payload.quickLinks} />
)}
</div>
</>
) : (
<>
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.1fr),minmax(0,0.9fr)]">
<SummaryPanel title="我的申请与资产" items={employeeSummaryItems} />
<AnnouncementsPanel announcements={payload.announcements} />
</div>
{isManagerView ? <QuickLinksPanel items={payload.quickLinks} /> : null}
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
<RecentVisitsPanel />
<QuickLinksPanel items={payload.quickLinks} />
</div>
</>
)}
</PageScaffold>
);
}
@@ -32,6 +32,7 @@ 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 { TEST_IDS } from "../lib/testIds";
import { ReimbursementActions, type PayPayload } from "./reimbursement-detail/ReimbursementActions";
import {
ReimbursementItemEditor,
@@ -697,6 +698,37 @@ export function ReimbursementDetailPage() {
<div>关联项目:{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}</div>
</DetailSideMeta>
{detail.status === "reviewing" && detail.workflowProgress ? (
<DetailSideMeta
title="审批进度"
description={`当前位于第 ${detail.workflowProgress.currentStepIndex + 1} / ${detail.workflowProgress.totalSteps} 步。`}
>
<div className="space-y-3" data-testid={TEST_IDS.reimbursement.workflowProgress}>
{detail.workflowProgress.steps.map((step, index) => (
<div
key={step.id}
className={
step.status === "current"
? "rounded-lg border border-primary-200 bg-primary-50 px-3 py-2"
: step.status === "completed"
? "rounded-lg border border-emerald-200 bg-emerald-50 px-3 py-2"
: "rounded-lg border border-zinc-200 bg-white px-3 py-2"
}
>
<div className="flex items-center justify-between gap-3 text-sm">
<span className="font-medium text-zinc-900">
第 {index + 1} 步 · {step.name}
</span>
<span className="text-xs text-zinc-500">
{step.status === "completed" ? "已完成" : step.status === "current" ? "当前节点" : "待处理"}
</span>
</div>
</div>
))}
</div>
</DetailSideMeta>
) : null}
<ReimbursementActions
status={detail.status}
canPay={canPay}
+46 -10
查看文件
@@ -1,8 +1,9 @@
import { Link, useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchWorkflowTasks } from "../api/workflow";
import { approveWorkflowTask, fetchWorkflowTasks } from "../api/workflow";
import { PageHeader } from "../components/common/PageHeader";
import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge";
@@ -10,6 +11,7 @@ import { TableSkeleton } from "../components/common/TableSkeleton";
import { Button } from "../components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import { TEST_IDS } from "../lib/testIds";
const PAGE_SIZE = 20;
@@ -31,6 +33,7 @@ function entityLabel(entityType: string | undefined) {
export function WorkflowTasksPage() {
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
const status =
(searchParams.get("status") as
@@ -47,6 +50,26 @@ export function WorkflowTasksPage() {
queryFn: () => fetchWorkflowTasks({ status, page, pageSize: PAGE_SIZE }),
});
const approveMutation = useMutation({
mutationFn: (taskId: string) => approveWorkflowTask(taskId),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
queryClient.invalidateQueries({ queryKey: ["notifications"] }),
]);
toast.success("审批已通过");
},
onError: (error) => {
const message = isApiError(error)
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
: error instanceof Error
? error.message
: "审批失败,请稍后重试";
toast.error(message);
},
});
const totalPages = Math.max(1, Math.ceil((query.data?.total ?? 0) / PAGE_SIZE));
return (
@@ -123,14 +146,27 @@ export function WorkflowTasksPage() {
<TableCell><StatusBadge status={task.status} /></TableCell>
<TableCell>{new Date(task.createdAt).toLocaleString()}</TableCell>
<TableCell>
<Button
asChild
size="sm"
variant="secondary"
data-testid="workflow-task-detail-link"
>
<Link to={`/workflow/tasks/${task.id}`}>详情</Link>
</Button>
<div className="flex flex-wrap gap-2">
{status === "pending" && task.allowedActions.includes("approve") ? (
<Button
size="sm"
type="button"
data-testid={TEST_IDS.workflow.quickApprove}
disabled={approveMutation.isPending && approveMutation.variables === task.id}
onClick={() => approveMutation.mutate(task.id)}
>
{approveMutation.isPending && approveMutation.variables === task.id ? "通过中..." : "通过"}
</Button>
) : null}
<Button
asChild
size="sm"
variant="secondary"
data-testid={TEST_IDS.workflow.detailLink}
>
<Link to={`/workflow/tasks/${task.id}`}>详情</Link>
</Button>
</div>
</TableCell>
</TableRow>
))