优化工作台与报销任务优先页面(portal-task-first-redesign)

这个提交包含在:
2026-05-29 22:31:59 +08:00
父节点 b75fa86e4e
当前提交 a9d91d3259
共修改 15 个文件,包含 988 行新增和 1149 行删除
+101
查看文件
@@ -793,6 +793,107 @@
@apply h-full;
}
.portal-work-summary {
background: rgba(255, 255, 255, 0.96);
border: 1px solid rgba(226, 232, 240, 0.92);
border-radius: var(--radius-panel);
box-shadow: var(--shadow-xs);
@apply flex flex-col gap-3 px-4 py-3.5 md:flex-row md:items-center md:justify-between md:px-5;
}
.portal-work-summary__eyebrow {
color: var(--color-accent-primary);
@apply text-xs font-bold;
}
.portal-work-summary__title {
color: var(--color-text-primary);
@apply mt-1 text-lg font-semibold leading-7 md:text-xl;
}
.portal-work-summary__meta {
color: var(--color-text-tertiary);
@apply mt-1 text-xs font-medium;
}
.portal-task-list {
border: 1px solid rgba(226, 232, 240, 0.94);
background: rgba(255, 255, 255, 0.72);
@apply overflow-hidden rounded-lg divide-y divide-slate-200/80;
}
.portal-task-row {
background: rgba(255, 255, 255, 0.88);
color: var(--color-text-primary);
@apply grid min-h-[68px] grid-cols-[auto,minmax(0,1fr)] items-center gap-3 px-3.5 py-3 transition-colors hover:bg-white md:grid-cols-[auto,minmax(0,1fr),auto,auto];
}
.portal-task-row--warning {
background: rgba(255, 251, 235, 0.74);
}
.portal-task-row--high {
background: rgba(254, 242, 242, 0.78);
}
.portal-task-row__icon {
background: rgba(0, 57, 138, 0.08);
color: var(--color-accent-primary);
@apply flex size-9 items-center justify-center rounded-lg;
}
.portal-task-row--warning .portal-task-row__icon {
@apply bg-amber-50 text-amber-700;
}
.portal-task-row--high .portal-task-row__icon {
@apply bg-red-50 text-red-700;
}
.portal-task-row__title {
color: var(--color-text-primary);
@apply block truncate text-sm font-semibold;
}
.portal-task-row__reason {
color: var(--color-text-secondary);
@apply mt-1 block text-xs leading-5;
}
.portal-task-row__meta {
color: var(--color-text-secondary);
@apply ml-12 inline-flex w-fit rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold md:ml-0;
}
.portal-task-row__action {
color: var(--color-accent-primary);
@apply ml-12 text-sm font-semibold md:ml-0;
}
.portal-empty-start {
border: 1px dashed rgba(203, 213, 225, 0.9);
background: rgba(248, 250, 252, 0.78);
@apply flex flex-col gap-4 rounded-lg px-4 py-4 md:flex-row md:items-center md:justify-between;
}
.portal-empty-start__title {
color: var(--color-text-primary);
@apply text-base font-semibold;
}
.portal-empty-start__desc {
color: var(--color-text-secondary);
@apply mt-1 text-sm leading-6;
}
.portal-inline-actions {
@apply flex flex-wrap gap-2;
}
.portal-task-secondary-grid {
@apply grid gap-4 xl:grid-cols-2;
}
.portal-list-card {
background: rgba(255, 255, 255, 0.92);
border: 1px solid rgba(226, 232, 240, 0.94);
文件差异内容过多而无法显示 加载差异
+227 -58
查看文件
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowRight, Clock3, FilePenLine, RotateCcw, Wrench } from "lucide-react";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchMyDelegations } from "../api/delegations";
@@ -74,6 +75,40 @@ const REIMBURSEMENT_STATUS_OPTIONS: Array<{ value: ReimbursementStatus; label: s
{ value: "cancelled", label: "已取消" },
];
const REIMBURSEMENT_TASK_STATUS_OPTIONS = [
{
value: "rejected",
title: "被驳回待补正",
idleNote: "暂无需要补正的报销",
activeNote: "优先补齐说明或附件后重新提交",
icon: RotateCcw,
accentClass: "border-red-200 bg-red-50 text-red-700",
},
{
value: "draft",
title: "草稿待完善",
idleNote: "还没有报销草稿",
activeNote: "继续完善明细、发票和附件",
icon: FilePenLine,
accentClass: "border-amber-200 bg-amber-50 text-amber-700",
},
{
value: "reviewing",
title: "审批中进度",
idleNote: "暂无审批中的报销",
activeNote: "查看当前节点和处理进度",
icon: Clock3,
accentClass: "border-sky-200 bg-sky-50 text-sky-700",
},
] satisfies Array<{
value: ReimbursementStatus;
title: string;
idleNote: string;
activeNote: string;
icon: typeof RotateCcw;
accentClass: string;
}>;
function formatCurrency(value: number) {
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: "CNY" }).format(value);
}
@@ -101,6 +136,56 @@ function formatBatchOutcome(action: BatchAction, outcome: string) {
return action === "approve" ? "已通过" : "已驳回";
}
function resolveEmptyStateCopy(status: ReimbursementStatus) {
if (status === "draft") {
return {
title: "还没有报销草稿",
description: "需要报销时先新建单据,再补充明细、发票和附件。",
};
}
if (status === "rejected") {
return {
title: "暂无需要补正的报销",
description: "后续有驳回单据时,会在这里继续编辑并重新提交。",
};
}
if (status === "reviewing") {
return {
title: "暂无审批中的报销",
description: "提交后的报销会在这里显示当前审批进度。",
};
}
if (status === "approved") {
return {
title: "暂无已通过记录",
description: "审批通过但未支付的报销会在这里留存。",
};
}
if (status === "paid") {
return {
title: "暂无已支付记录",
description: "财务支付完成后会在这里查看付款结果。",
};
}
return {
title: "暂无已取消记录",
description: "撤回或取消的报销记录会在这里留存。",
};
}
function resolveWorkflowHint(item: ReimbursementListItem) {
const stepName = item.pendingWorkflow?.task?.stepName;
if (!stepName) {
return null;
}
const actionableByCurrentUser = item.pendingWorkflow?.allowedActions.length ? true : false;
return {
label: `${actionableByCurrentUser ? "待你处理" : "当前节点"}:${stepName}`,
className: actionableByCurrentUser ? "text-amber-700" : "text-zinc-500",
};
}
export function ReimbursementsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
@@ -126,14 +211,37 @@ export function ReimbursementsPage() {
const canViewAll = hasPermission(permissions, "expense.view_all");
const canApproveExpense = hasPermission(permissions, "expense.approve");
const canProxySubmit = hasPermission(permissions, "expense.proxy_submit");
const canUseFinanceTools =
hasPermission(permissions, "expense.finance.pay") ||
hasPermission(permissions, "payment.batch.manage") ||
hasPermission(permissions, "risk.review.manage") ||
hasPermission(permissions, "invoice.manage");
const isAdministration = (me?.roleCodes ?? []).includes("administration");
const showProxyOwnerSelector = canProxySubmit && isAdministration;
const canShowLedgerEntry = canViewAll || showProxyOwnerSelector || canUseFinanceTools;
const showAdvancedTools = canShowLedgerEntry && (showLedgerTools || scope === "all" || batchMode || showProxyOwnerSelector);
const shouldLoadCreateOptions = showAdvancedTools && canCreateExpense;
const listQuery = useQuery({
queryKey: ["reimbursements", "list", status, page, scope],
queryFn: () => fetchReimbursements({ status, page, pageSize: PAGE_SIZE, scope }),
});
const rejectedSummaryQuery = useQuery({
queryKey: ["reimbursements", "task-summary", "own", "rejected"],
queryFn: () => fetchReimbursements({ status: "rejected", page: 1, pageSize: 1, scope: "own" }),
});
const draftSummaryQuery = useQuery({
queryKey: ["reimbursements", "task-summary", "own", "draft"],
queryFn: () => fetchReimbursements({ status: "draft", page: 1, pageSize: 1, scope: "own" }),
});
const reviewingSummaryQuery = useQuery({
queryKey: ["reimbursements", "task-summary", "own", "reviewing"],
queryFn: () => fetchReimbursements({ status: "reviewing", page: 1, pageSize: 1, scope: "own" }),
});
const proxyDelegationsQuery = useQuery({
enabled: showProxyOwnerSelector,
queryKey: ["delegations", "mine", "active"],
@@ -141,13 +249,13 @@ export function ReimbursementsPage() {
});
const templateOptionsQuery = useQuery({
enabled: canCreateExpense,
enabled: shouldLoadCreateOptions,
queryKey: ["reimbursement-templates", "enabled", "create-menu"],
queryFn: () => fetchReimbursementTemplates({ enabled: true }),
});
const linkableTravelRequestsQuery = useQuery({
enabled: canCreateExpense,
enabled: shouldLoadCreateOptions,
queryKey: ["reimbursements", "linkable-travel-requests", proxyOwnerEmployeeId || me?.id || "self"],
queryFn: () =>
fetchLinkableTravelRequests({
@@ -187,10 +295,17 @@ export function ReimbursementsPage() {
);
const canShowBatchEntry =
status === "reviewing" && scope === "all" && canApproveExpense && actionableItems.length > 0;
const showAdvancedTools = showLedgerTools || scope === "all" || batchMode || showProxyOwnerSelector;
const selectedItems = actionableItems.filter((item) => selectedReimbursementIds.includes(item.id));
const allActionableSelected =
actionableItems.length > 0 && actionableItems.every((item) => selectedReimbursementIds.includes(item.id));
const taskSummaryTotalByStatus = {
rejected: rejectedSummaryQuery.data?.total,
draft: draftSummaryQuery.data?.total,
reviewing: reviewingSummaryQuery.data?.total,
} satisfies Record<(typeof REIMBURSEMENT_TASK_STATUS_OPTIONS)[number]["value"], number | undefined>;
const canShowCreateOptionsMenu =
canCreateExpense && shouldLoadCreateOptions && (templateOptions.length > 0 || linkableTravelRequests.length > 0);
const emptyState = resolveEmptyStateCopy(status);
useEffect(() => {
setSelectedReimbursementIds((current) => current.filter((id) => actionableIdSet.has(id)));
@@ -399,18 +514,6 @@ export function ReimbursementsPage() {
</p>
) : null}
{templateOptionsQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(templateOptionsQuery.error, "加载报销模板失败")}
</p>
) : null}
{linkableTravelRequestsQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(linkableTravelRequestsQuery.error, "加载可关联差旅申请失败")}
</p>
) : null}
{exportError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{exportError}
@@ -423,25 +526,65 @@ export function ReimbursementsPage() {
</p>
) : null}
<section className="grid gap-3 md:grid-cols-3" aria-label="我的报销任务">
{REIMBURSEMENT_TASK_STATUS_OPTIONS.map((item) => {
const Icon = item.icon;
const count = taskSummaryTotalByStatus[item.value];
const active = status === item.value && scope === "own";
const displayCount = typeof count === "number" ? count : "-";
return (
<button
className={`card-std min-h-[124px] p-4 text-left transition hover:border-primary/40 hover:shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-200 ${
active ? "border-primary/40 bg-primary/5" : ""
}`}
data-testid={`reimbursements-task-card-${item.value}`}
key={item.value}
onClick={() => setSearchParams({ status: item.value, page: "1", scope: "own" })}
type="button"
>
<div className="flex items-start justify-between gap-3">
<span className={`flex size-9 items-center justify-center rounded-lg border ${item.accentClass}`}>
<Icon aria-hidden className="size-4" />
</span>
<ArrowRight aria-hidden className="mt-1 size-4 text-zinc-300" />
</div>
<div className="mt-4 flex items-end justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-semibold text-zinc-900">{item.title}</div>
<div className="mt-1 text-xs leading-5 text-zinc-500">
{typeof count === "number" && count > 0 ? item.activeNote : item.idleNote}
</div>
</div>
<div className="text-3xl font-semibold tabular-nums text-zinc-950">{displayCount}</div>
</div>
</button>
);
})}
</section>
<FilterBar className="ops-command-bar space-y-3 !p-3 md:!p-3.5">
<div className="ops-command-bar__row">
<Tabs
data-testid="reimbursements-status-tabs"
value={status}
onValueChange={(value) => setSearchParams({ status: value, page: "1", scope })}
>
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
{REIMBURSEMENT_STATUS_OPTIONS.map((item) => (
<TabsTrigger
data-testid={`reimbursements-tab-${item.value}`}
key={item.value}
value={item.value}
>
{item.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="min-w-0 space-y-2">
<div className="text-xs font-semibold text-zinc-500">报销记录</div>
<Tabs
data-testid="reimbursements-status-tabs"
value={status}
onValueChange={(value) => setSearchParams({ status: value, page: "1", scope })}
>
<TabsList className="ops-command-bar__tabs !h-auto !justify-start !bg-slate-50">
{REIMBURSEMENT_STATUS_OPTIONS.map((item) => (
<TabsTrigger
data-testid={`reimbursements-tab-${item.value}`}
key={item.value}
value={item.value}
>
{item.label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
<div className="ops-command-bar__actions">
{canApproveExpense ? (
@@ -455,14 +598,17 @@ export function ReimbursementsPage() {
待我处理
</Button>
) : null}
<Button
size="sm"
variant={showAdvancedTools ? "default" : "secondary"}
onClick={() => setShowLedgerTools((current) => !current)}
type="button"
>
高级 / 台账
</Button>
{canShowLedgerEntry ? (
<Button
size="sm"
variant={showAdvancedTools ? "default" : "secondary"}
onClick={() => setShowLedgerTools((current) => !current)}
type="button"
>
<Wrench aria-hidden className="mr-1 size-4" />
台账工具
</Button>
) : null}
</div>
</div>
@@ -506,7 +652,7 @@ export function ReimbursementsPage() {
</select>
) : null}
{canCreateExpense ? (
{canShowCreateOptionsMenu ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" variant="secondary" type="button">
@@ -517,9 +663,7 @@ export function ReimbursementsPage() {
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
从模板创建
</div>
{templateOptionsQuery.isLoading ? (
<DropdownMenuItem disabled>正在加载模板...</DropdownMenuItem>
) : templateOptions.length ? (
{templateOptions.length ? (
templateOptions.map((template) => (
<DropdownMenuItem
key={template.id}
@@ -529,16 +673,14 @@ export function ReimbursementsPage() {
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>暂无可用模板</DropdownMenuItem>
<DropdownMenuItem disabled>当前无可用模板</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
关联差旅申请
</div>
{linkableTravelRequestsQuery.isLoading ? (
<DropdownMenuItem disabled>正在加载差旅申请...</DropdownMenuItem>
) : linkableTravelRequests.length ? (
{linkableTravelRequests.length ? (
linkableTravelRequests.map((travelRequest) => (
<DropdownMenuItem
key={travelRequest.id}
@@ -548,7 +690,7 @@ export function ReimbursementsPage() {
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>暂无可关联的差旅申请</DropdownMenuItem>
<DropdownMenuItem disabled>当前无可关联申请</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
@@ -660,6 +802,7 @@ export function ReimbursementsPage() {
const actionLabel = ["draft", "rejected"].includes(item.status) ? "继续编辑" : "查看详情";
const displayCode = resolveReimbursementDisplayCode(item);
const canDeleteDraft = item.status === "draft" && canDeleteExpense;
const workflowHint = resolveWorkflowHint(item);
return (
<article
@@ -700,9 +843,9 @@ export function ReimbursementsPage() {
</div>
</div>
{item.pendingWorkflow?.task?.id ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
待办:{item.pendingWorkflow.task.stepName}
{workflowHint ? (
<div className={`rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs ${workflowHint.className}`}>
{workflowHint.label}
</div>
) : null}
@@ -750,7 +893,19 @@ export function ReimbursementsPage() {
})
) : (
<div className="rounded-xl border border-dashed border-zinc-300 px-4 py-8 text-center text-sm text-zinc-500">
暂无数据
<div className="font-semibold text-zinc-900">{emptyState.title}</div>
<div className="mt-1 leading-6 text-zinc-500">{emptyState.description}</div>
{status === "draft" && canCreateExpense ? (
<Button
className="mt-3"
disabled={createMutation.isPending}
onClick={() => startCreateReimbursement()}
size="sm"
type="button"
>
{createMutation.isPending ? "创建中..." : "新建报销"}
</Button>
) : null}
</div>
)}
</div>
@@ -776,6 +931,7 @@ export function ReimbursementsPage() {
);
const displayCode = resolveReimbursementDisplayCode(item);
const canDeleteDraft = item.status === "draft" && canDeleteExpense;
const workflowHint = resolveWorkflowHint(item);
return (
<tr className="table-row" data-testid="reimbursement-row" key={item.id}>
@@ -800,9 +956,9 @@ export function ReimbursementsPage() {
项目:{item.project.projectNo} · {item.project.name}
</div>
) : null}
{item.pendingWorkflow?.task?.id ? (
<div className="text-xs text-amber-700">
待办:{item.pendingWorkflow.task.stepName}
{workflowHint ? (
<div className={`text-xs ${workflowHint.className}`}>
{workflowHint.label}
</div>
) : null}
</td>
@@ -869,7 +1025,21 @@ export function ReimbursementsPage() {
) : (
<tr>
<td className="py-8 text-center text-sm text-zinc-500" colSpan={batchMode ? 7 : 6}>
暂无数据
<div className="mx-auto max-w-sm py-3">
<div className="font-semibold text-zinc-900">{emptyState.title}</div>
<div className="mt-1 leading-6 text-zinc-500">{emptyState.description}</div>
{status === "draft" && canCreateExpense ? (
<Button
className="mt-3"
disabled={createMutation.isPending}
onClick={() => startCreateReimbursement()}
size="sm"
type="button"
>
{createMutation.isPending ? "创建中..." : "新建报销"}
</Button>
) : null}
</div>
</td>
</tr>
)}
@@ -983,7 +1153,6 @@ export function ReimbursementsPage() {
<div className="font-medium text-zinc-900">
{resolveReimbursementDisplayCode({ formNumber: item.formNumber, id: item.reimbursementId })}
</div>
<div className="text-xs text-zinc-500">taskId: {item.taskId}</div>
</div>
<div className="text-sm font-medium text-zinc-700">
{formatBatchOutcome(batchResult.action, item.outcome)}