feat(detail-page-structure-progress-actions): 统一详情处理进度与操作区

这个提交包含在:
2026-08-03 16:00:42 +08:00
父节点 8d428bd51d
当前提交 b7d5761067
共修改 31 个文件,包含 1453 行新增和 383 行删除
+6 -1
查看文件
@@ -1,6 +1,7 @@
import { apiRequestBlob, apiRequestData, type ApiBlobResponse } from "./http";
import { buildAttachmentFileUrl, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import type { RiskFlag } from "./feeControl";
import type { ProcessingProgress } from "../types/processingProgress";
export type PaymentBatchEntityType = "reimbursement" | "travel_order";
export type PaymentBatchStatus = "draft" | "reviewed" | "executed" | "failed" | "cancelled";
@@ -75,6 +76,10 @@ export type PaymentBatch = {
items: PaymentBatchItem[];
};
export type PaymentBatchDetail = PaymentBatch & {
processingProgress: ProcessingProgress;
};
export type PaymentBatchCandidate = {
entityId: string;
entityType: "reimbursement";
@@ -123,7 +128,7 @@ export async function fetchPaymentBatches(query: { entityType?: PaymentBatchEnti
}
export async function fetchPaymentBatchDetail(batchId: string) {
return apiRequestData<{ item: PaymentBatch }>(`/api/payment-batches/${batchId}`);
return apiRequestData<{ item: PaymentBatchDetail }>(`/api/payment-batches/${batchId}`);
}
export async function fetchPaymentBatchCandidates(query: { q?: string; page?: number; pageSize?: number } = {}) {
+3 -15
查看文件
@@ -1,6 +1,7 @@
import { apiRequestBlob, apiRequestData } from "./http";
import type { ExpenseStandardCheck, PrecheckResponse, RiskFlag } from "./feeControl";
import { buildAttachmentFileUrl, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import type { ProcessingProgress, ProcessingStage } from "../types/processingProgress";
export type ReimbursementStatus =
| "draft"
@@ -112,21 +113,8 @@ export type ReimbursementWorkflowProgress = {
}>;
};
export type ReimbursementProcessingStage = {
key: string;
label: string;
state: "completed" | "current" | "pending" | "exception";
actorName: string | null;
occurredAt: string | null;
result: string | null;
comment: string | null;
detail: string | null;
targetHref: string | null;
};
export type ReimbursementProcessingProgress = {
stages: ReimbursementProcessingStage[];
};
export type ReimbursementProcessingStage = ProcessingStage;
export type ReimbursementProcessingProgress = ProcessingProgress;
export type ReimbursementApprovalHistoryItem = {
id: string;
@@ -98,7 +98,7 @@ export function BusinessDetailActionPanel({
const hasContentBeforeSummary = Boolean(primaryAction || secondaryActions || moreActions || hasChildren);
return (
<SectionPanel title={title} actions={status} className={cn("detail-action-panel xl:sticky xl:top-24", className)}>
<SectionPanel title={title} actions={status} className={cn("detail-action-panel lg:sticky lg:top-24", className)}>
<div className="detail-action-stack" data-testid={testId}>
{primaryAction ? <div className="grid gap-2">{primaryAction}</div> : null}
{secondaryActions ? <div className="workflow-action-bar">{secondaryActions}</div> : null}
@@ -1,12 +1,15 @@
import { AlertCircle, Check, Circle, Clock, ExternalLink } from "lucide-react";
import { AlertCircle, Ban, Check, Circle, Clock, ExternalLink } from "lucide-react";
import { Link } from "react-router-dom";
import type { ReimbursementProcessingProgress as ProcessingProgress } from "../../api/reimbursements";
import type { ProcessingProgress as ProcessingProgressValue } from "../../types/processingProgress";
import { formatDisplayDateTime } from "../../lib/displayFormat";
import { cn } from "../../lib/utils";
type Props = {
progress: ProcessingProgress;
progress: ProcessingProgressValue;
ariaLabel: string;
testId: string;
mobile?: boolean;
targetLinkLabel?: string;
};
const STATE_TEXT = {
@@ -14,15 +17,18 @@ const STATE_TEXT = {
current: "处理中",
pending: "待处理",
exception: "需处理",
terminated: "已终止",
} as const;
export function ReimbursementProcessingProgress({ progress, mobile = false }: Props) {
export function ProcessingProgress({
progress,
ariaLabel,
testId,
mobile = false,
targetLinkLabel = "查看详情",
}: Props) {
return (
<ol
className="space-y-0"
data-testid={mobile ? "mobile-reimbursement-processing-progress" : "reimbursement-workflow-progress"}
aria-label="报销处理进度"
>
<ol className="space-y-0" data-testid={testId} aria-label={ariaLabel}>
{progress.stages.map((stage, index) => {
const expanded = stage.state === "current" || stage.state === "exception" || Boolean(stage.comment);
const Icon = stage.state === "completed"
@@ -31,7 +37,9 @@ export function ReimbursementProcessingProgress({ progress, mobile = false }: Pr
? Clock
: stage.state === "exception"
? AlertCircle
: Circle;
: stage.state === "terminated"
? Ban
: Circle;
const facts = [stage.actorName, stage.occurredAt ? formatDisplayDateTime(stage.occurredAt) : null, stage.result]
.filter(Boolean);
@@ -47,6 +55,7 @@ export function ReimbursementProcessingProgress({ progress, mobile = false }: Pr
stage.state === "current" && "border-blue-300 bg-blue-50 text-blue-700",
stage.state === "exception" && "border-red-300 bg-red-50 text-red-700",
stage.state === "pending" && "border-zinc-300 text-zinc-400",
stage.state === "terminated" && "border-zinc-300 bg-zinc-100 text-zinc-500",
)}
aria-hidden="true"
>
@@ -69,7 +78,7 @@ export function ReimbursementProcessingProgress({ progress, mobile = false }: Pr
className="mt-2 inline-flex min-h-11 items-center gap-1 text-sm font-medium text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-600 focus-visible:ring-offset-2"
to={stage.targetHref}
>
查看所在付款批次
{targetLinkLabel}
<ExternalLink aria-hidden="true" className="size-4" />
</Link>
) : null}
+2 -2
查看文件
@@ -585,7 +585,7 @@
}
.detail-workspace-grid {
@apply grid gap-5 xl:grid-cols-[minmax(0,1.45fr),minmax(300px,0.72fr)];
@apply grid gap-5 lg:grid-cols-[minmax(0,1.45fr),minmax(280px,0.72fr)];
}
.detail-command-panel {
@@ -666,7 +666,7 @@
}
.workflow-action-panel {
@apply relative z-30 xl:sticky xl:top-20;
@apply relative z-30 lg:sticky lg:top-20;
}
.workflow-action-bar {
@@ -44,7 +44,7 @@ import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { MobileIcon } from "../components/mobile/MobileIcon";
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
import { ReimbursementProcessingProgress } from "../components/reimbursements/ReimbursementProcessingProgress";
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { Button } from "../components/ui/button";
import {
@@ -944,6 +944,34 @@ export function MobileReimbursementDetailPage() {
) : null}
</section>
<section className="mobile-card" data-testid="mobile-reimbursement-processing-section">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-zinc-900">处理进度</h2>
</div>
{canRemindPendingWorkflow && pendingTaskId ? (
<Button
size="sm"
type="button"
variant="secondary"
disabled={remindMutation.isPending}
onClick={() => remindMutation.mutate(pendingTaskId)}
>
{remindMutation.isPending ? "催办中..." : "催办"}
</Button>
) : null}
</div>
<div className="mt-3">
<ProcessingProgress
progress={detail.processingProgress ?? { stages: [] }}
ariaLabel="报销处理进度"
testId="mobile-reimbursement-processing-progress"
targetLinkLabel="查看所在付款批次"
mobile
/>
</div>
</section>
<section className="mobile-card" data-testid="mobile-reimbursement-items">
<div className="flex items-center justify-between gap-3">
<h2 className="text-base font-semibold text-zinc-900">报销明细</h2>
@@ -1253,28 +1281,6 @@ export function MobileReimbursementDetailPage() {
)}
</section>
<section className="mobile-card" data-testid="mobile-reimbursement-processing-section">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-base font-semibold text-zinc-900">处理进度</h2>
</div>
{canRemindPendingWorkflow && pendingTaskId ? (
<Button
size="sm"
type="button"
variant="secondary"
disabled={remindMutation.isPending}
onClick={() => remindMutation.mutate(pendingTaskId)}
>
{remindMutation.isPending ? "催办中..." : "催办"}
</Button>
) : null}
</div>
<div className="mt-3">
<ReimbursementProcessingProgress progress={detail.processingProgress ?? { stages: [] }} mobile />
</div>
</section>
{showSecondaryActions ? (
<section className="mobile-card" data-testid="mobile-reimbursement-actions">
<h2 className="text-base font-semibold text-zinc-900">操作</h2>
+188 -143
查看文件
@@ -24,6 +24,13 @@ import { mapApiErrorMessage } from "../api/errorMessages";
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { HeaderActionGroup, ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
import {
BusinessDetailActionPanel,
BusinessDetailAside,
BusinessDetailMain,
BusinessDetailWorkspace,
} from "../components/patterns/BusinessDetailTemplate";
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
import {
BusinessListToolbar,
BusinessMobileList,
@@ -44,6 +51,12 @@ import {
DialogTitle,
} from "../components/ui/dialog";
import { Textarea } from "../components/ui/textarea";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../components/ui/dropdown-menu";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { StatusBadge } from "../components/common/StatusBadge";
import { DateTextInput } from "../components/common/DateTextInput";
@@ -54,6 +67,14 @@ import { toast } from "sonner";
const PAYMENT_CHANNEL_OPTIONS: PaymentChannel[] = ["bank", "alipay", "wechat", "cash", "other"];
type PaymentActionKind =
| "review"
| "downloadOrder"
| "uploadReceipt"
| "execute"
| "cancel"
| "redownloadOrder";
function entityTypeLabel(value: string) {
if (value === "reimbursement") return "报销";
if (value === "travel_order") return "差旅订单";
@@ -185,7 +206,6 @@ export function PaymentBatchesPage() {
canManageBatch &&
!!selectedBatch &&
["reviewed", "failed"].includes(selectedBatch.status);
const canUploadReceipt = canEditReceiptAttachments && receiptAttachmentsQuery.isSuccess;
const canReviewSelectedBatch =
canReviewBatch &&
!!selectedBatch &&
@@ -202,6 +222,8 @@ export function PaymentBatchesPage() {
["pending", "failed"].includes(item.status) &&
(item.paymentChannelOverride ?? selectedBatch.defaultPaymentChannel) === "bank",
));
const canUploadReceipt =
canEditReceiptAttachments && requiresBankReceipt && receiptAttachmentsQuery.isSuccess;
const activeReceiptCount = (receiptAttachmentsQuery.data?.items ?? []).filter((item) => !item.archivedAt).length;
const needsBankReceipt =
canExecuteSelectedBatch && requiresBankReceipt && receiptAttachmentsQuery.isSuccess && activeReceiptCount === 0;
@@ -213,6 +235,33 @@ export function PaymentBatchesPage() {
!!selectedBatch &&
!["executed", "cancelled"].includes(selectedBatch.status);
const paymentActionModel = useMemo(() => {
const primary = canReviewSelectedBatch
? "review"
: needsBankReceipt
? "uploadReceipt"
: canRegisterPaymentComplete
? "execute"
: null;
const more: PaymentActionKind[] = [];
if (
selectedBatch?.status === "reviewed" &&
selectedBatch.canExportPaymentOrder &&
canExportPaymentOrder
) {
more.push(selectedBatch.paymentOrderExportCount > 0 ? "redownloadOrder" : "downloadOrder");
}
if (canCancelSelectedBatch) more.push("cancel");
return { primary: primary as PaymentActionKind | null, more };
}, [
canCancelSelectedBatch,
canExportPaymentOrder,
canRegisterPaymentComplete,
canReviewSelectedBatch,
needsBankReceipt,
selectedBatch,
]);
const refresh = async () => {
await queryClient.invalidateQueries({ queryKey: ["payment-batches"] });
await queryClient.invalidateQueries({ queryKey: ["payment-batch-candidates"] });
@@ -421,6 +470,68 @@ export function PaymentBatchesPage() {
},
});
const paymentActionLabel = (kind: PaymentActionKind) => {
if (kind === "review") return reviewMutation.isPending ? "复核中..." : "复核通过";
if (kind === "downloadOrder") return paymentOrderMutation.isPending ? "生成中..." : "下载银行制单";
if (kind === "uploadReceipt") return uploadReceiptMutation.isPending ? "上传中..." : "上传银行回单";
if (kind === "execute") return executeMutation.isPending ? "登记中..." : "登记付款完成";
if (kind === "redownloadOrder") return "重新下载制单";
return "取消批次";
};
const paymentActionDisabled = (kind: PaymentActionKind) => {
if (kind === "review") return reviewMutation.isPending;
if (["downloadOrder", "redownloadOrder"].includes(kind)) return paymentOrderMutation.isPending;
if (kind === "uploadReceipt") return uploadReceiptMutation.isPending;
if (kind === "execute") return executeMutation.isPending;
return cancelMutation.isPending || !canCancelSelectedBatch;
};
const activatePaymentAction = (kind: PaymentActionKind) => {
if (!selectedBatch) return;
if (kind === "review" || kind === "execute") {
setConfirmPaymentAction({
kind: kind === "review" ? "financeReview" : "financePayment",
batchId: selectedBatch.id,
batchNo: selectedBatch.batchNo,
highRisk: selectedBatch.highRisk,
totalAmount: selectedBatch.totalAmount,
});
return;
}
if (kind === "uploadReceipt") {
receiptInputRef.current?.click();
return;
}
setActionReason("");
if (kind === "cancel") {
setReasonAction({ kind: "cancel", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo });
return;
}
setReasonAction({
kind: "paymentOrder",
batchId: selectedBatch.id,
batchNo: selectedBatch.batchNo,
reexport: kind === "redownloadOrder",
});
};
const renderPaymentActionButton = (
kind: PaymentActionKind,
variant: "default" | "secondary" | "destructive" = "default",
) => (
<Button
key={kind}
type="button"
variant={variant}
disabled={paymentActionDisabled(kind)}
data-testid={`payment-batch-action-${kind}`}
onClick={() => activatePaymentAction(kind)}
>
{paymentActionLabel(kind)}
</Button>
);
return (
<ListWorkspace>
{downloadReasonDialog}
@@ -543,7 +654,11 @@ export function PaymentBatchesPage() {
</Button>
</div>
) : selectedBatch ? (
<div className="space-y-5">
<div
className={paymentActionModel.primary || paymentActionModel.more.length ? "pb-[calc(var(--mobile-sticky-action-height,78px)+env(safe-area-inset-bottom,0px)+1.25rem)] lg:pb-0" : ""}
>
<BusinessDetailWorkspace testId="payment-batch-detail-workspace">
<BusinessDetailMain>
<div className="finance-command-panel space-y-3" data-testid="payment-batch-detail-panel">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
@@ -555,89 +670,7 @@ export function PaymentBatchesPage() {
) : null}
</div>
</div>
<div className="flex flex-wrap gap-2">
{canReviewSelectedBatch ? (
<Button
type="button"
disabled={reviewMutation.isPending}
onClick={() => setConfirmPaymentAction({
kind: "financeReview",
batchId: selectedBatch.id,
batchNo: selectedBatch.batchNo,
highRisk: selectedBatch.highRisk,
totalAmount: selectedBatch.totalAmount,
})}
>
复核通过
</Button>
) : null}
{selectedBatch.status === "reviewed" && selectedBatch.canExportPaymentOrder && canExportPaymentOrder && selectedBatch.paymentOrderExportCount === 0 ? (
<Button
type="button"
disabled={paymentOrderMutation.isPending}
onClick={() => {
setActionReason("");
setReasonAction({ kind: "paymentOrder", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, reexport: false });
}}
>
{paymentOrderMutation.isPending ? "生成中..." : "下载银行制单"}
</Button>
) : null}
{needsBankReceipt ? (
<Button
type="button"
disabled={uploadReceiptMutation.isPending}
onClick={() => receiptInputRef.current?.click()}
>
{uploadReceiptMutation.isPending ? "上传中..." : "上传银行回单"}
</Button>
) : null}
{canRegisterPaymentComplete ? (
<Button
type="button"
disabled={executeMutation.isPending}
onClick={() => setConfirmPaymentAction({
kind: "financePayment",
batchId: selectedBatch.id,
batchNo: selectedBatch.batchNo,
highRisk: selectedBatch.highRisk,
totalAmount: selectedBatch.totalAmount,
})}
>
登记付款完成
</Button>
) : null}
{canManageBatch ? (
<Button
type="button"
variant="secondary"
disabled={cancelMutation.isPending || !canCancelSelectedBatch}
onClick={() => {
setActionReason("");
setReasonAction({
kind: "cancel",
batchId: selectedBatch.id,
batchNo: selectedBatch.batchNo,
});
}}
>
取消
</Button>
) : null}
{selectedBatch.status === "reviewed" && selectedBatch.canExportPaymentOrder && canExportPaymentOrder && selectedBatch.paymentOrderExportCount > 0 ? (
<Button
type="button"
variant="secondary"
disabled={paymentOrderMutation.isPending}
onClick={() => {
setActionReason("");
setReasonAction({ kind: "paymentOrder", batchId: selectedBatch.id, batchNo: selectedBatch.batchNo, reexport: true });
}}
>
重新下载制单
</Button>
) : null}
</div>
<StatusBadge status={selectedBatch.status} label={paymentBatchStatusLabel(selectedBatch.status)} />
</div>
<div className="detail-fact-grid text-sm xl:grid-cols-4">
<div className="detail-fact-card"><div className="detail-fact-card__label">状态</div><div className="detail-fact-card__value">{paymentBatchStatusLabel(selectedBatch.status)}</div></div>
@@ -661,59 +694,13 @@ export function PaymentBatchesPage() {
) : null}
</div>
<div className="finance-command-panel space-y-3" data-testid="payment-batch-current-processing">
<h3 className="text-base font-semibold text-zinc-900">当前处理</h3>
<div className="detail-fact-grid text-sm md:grid-cols-2">
<div className="detail-fact-card">
<div className="detail-fact-card__label">当前责任</div>
<div className="detail-fact-card__value">
{selectedBatch.status === "draft"
? "负责人(非制单人)"
: ["reviewed", "failed"].includes(selectedBatch.status)
? "财务"
: selectedBatch.status === "executed"
? "已完成"
: "已结束"}
</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">下一步</div>
<div className="detail-fact-card__value">
{selectedBatch.status === "draft"
? "复核通过"
: selectedBatch.status === "reviewed" && requiresBankReceipt && !receiptAttachmentsQuery.isSuccess
? "核对银行回单状态"
: selectedBatch.status === "reviewed" && needsBankReceipt
? selectedBatch.paymentOrderExportCount > 0
? "制单已生成,完成付款并上传银行回单"
: "线下付款并上传银行回单"
: selectedBatch.status === "reviewed"
? "登记付款完成"
: selectedBatch.status === "failed"
? "核对失败原因并重试"
: "-"}
</div>
</div>
</div>
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-3 text-sm text-zinc-600">
{selectedBatch.status === "draft"
? "已提交付款复核,等待负责人处理;制单人不能复核自己的批次。"
: selectedBatch.status === "reviewed" && requiresBankReceipt && !receiptAttachmentsQuery.isSuccess
? "付款复核已通过,正在核对银行回单状态。"
: selectedBatch.status === "reviewed" && needsBankReceipt
? selectedBatch.paymentOrderExportCount > 0
? "已生成银行制单,等待付款完成并上传回单。"
: "付款复核已通过。等待财务线下付款并上传回单。"
: selectedBatch.status === "reviewed"
? "付款复核已通过。请财务核对付款结果并登记付款完成。"
: selectedBatch.status === "executed"
? "付款已登记完成。"
: selectedBatch.status === "failed"
? "付款登记失败,请财务核对失败原因后重试。"
: "该付款批次已取消。"}
{selectedBatch.highRisk ? ` 批次金额达到 ${formatMoney(selectedBatch.reviewRequirement.thresholdAmount)},处理前需再次核对付款对象、金额和回单,但不增加审批层级。` : ""}
{selectedBatch.cancelledReason ? ` 取消原因:${selectedBatch.cancelledReason}` : ""}
</div>
<div className="finance-command-panel space-y-3" data-testid="payment-batch-processing-panel">
<h3 className="text-base font-semibold text-zinc-900">处理进度</h3>
<ProcessingProgress
progress={selectedBatch.processingProgress}
ariaLabel="付款批次处理进度"
testId="payment-batch-processing-progress"
/>
</div>
<div className="finance-command-panel space-y-4" data-testid="payment-batch-items-panel">
@@ -775,9 +762,11 @@ export function PaymentBatchesPage() {
<div>
<h3 className="text-base font-semibold text-zinc-900">银行回单</h3>
<p className="mt-1 text-sm text-zinc-500">
{selectedBatch.status === "draft"
? "付款复核通过后,由财务在线下完成付款并上传银行回单。"
: "先在线下银行完成付款,再上传回单;系统不会连接银行自动转账。"}
{!requiresBankReceipt
? "当前付款渠道不要求银行回单,核对付款结果后可直接登记完成。"
: selectedBatch.status === "draft"
? "付款复核通过后,由财务在线下完成付款并上传银行回单。"
: "先在线下银行完成付款,再上传回单;系统不会连接银行自动转账。"}
</p>
</div>
{canUploadReceipt ? (
@@ -866,14 +855,70 @@ export function PaymentBatchesPage() {
</div>
</div>
))}
{receiptAttachmentsQuery.isSuccess && !(receiptAttachmentsQuery.data?.items ?? []).length ? (
{requiresBankReceipt && receiptAttachmentsQuery.isSuccess && !(receiptAttachmentsQuery.data?.items ?? []).length ? (
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm text-zinc-500">
暂无银行回单;银行转账必须先上传回单,才能登记付款完成。
</div>
) : null}
</div>
</div>
</div>
</BusinessDetailMain>
<BusinessDetailAside className="hidden lg:block">
<BusinessDetailActionPanel
status={<StatusBadge status={selectedBatch.status} label={paymentBatchStatusLabel(selectedBatch.status)} />}
primaryAction={paymentActionModel.primary ? renderPaymentActionButton(paymentActionModel.primary) : undefined}
moreActions={paymentActionModel.more.length ? (
<details className="detail-disclosure">
<summary>更多</summary>
<div className="mt-3 grid gap-2">
{paymentActionModel.more.map((kind) =>
renderPaymentActionButton(kind, kind === "cancel" ? "destructive" : "secondary"),
)}
</div>
</details>
) : undefined}
summary={(
<>
<p>批次:{selectedBatch.batchNo}</p>
<p>金额:{formatMoney(selectedBatch.totalAmount)}</p>
</>
)}
testId="payment-batch-action-panel"
/>
</BusinessDetailAside>
</BusinessDetailWorkspace>
{paymentActionModel.primary || paymentActionModel.more.length ? (
<div
className="mobile-sticky-action-surface fixed inset-x-0 bottom-0 z-50 border-t border-zinc-200 bg-white/95 shadow-[0_-10px_24px_rgba(15,23,42,0.10)] backdrop-blur lg:hidden"
data-testid="payment-batch-mobile-actions"
>
<div className="mobile-sticky-action-grid">
{paymentActionModel.primary ? renderPaymentActionButton(paymentActionModel.primary) : <span />}
{paymentActionModel.more.length ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button type="button" variant="secondary">更多</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{paymentActionModel.more.map((kind) => (
<DropdownMenuItem
key={kind}
disabled={paymentActionDisabled(kind)}
className={kind === "cancel" ? "text-red-600 focus:text-red-600" : undefined}
onSelect={() => activatePaymentAction(kind)}
>
{paymentActionLabel(kind)}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
) : null}
</div>
</div>
) : null}
</div>
) : (
<div className="finance-command-panel text-sm text-zinc-500">未找到付款批次</div>
)
+60 -80
查看文件
@@ -47,7 +47,7 @@ import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDe
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { ReimbursementTravelSummary } from "../components/reimbursements/ReimbursementTravelSummary";
import { ReimbursementProcessingProgress } from "../components/reimbursements/ReimbursementProcessingProgress";
import { ProcessingProgress } from "../components/patterns/ProcessingProgress";
import { StatusBadge } from "../components/common/StatusBadge";
import { Button } from "../components/ui/button";
import {
@@ -60,6 +60,12 @@ import {
import { Input } from "../components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
import {
BusinessDetailActionPanel,
BusinessDetailAside,
BusinessDetailMain,
BusinessDetailWorkspace,
} from "../components/patterns/BusinessDetailTemplate";
import { PageScaffold } from "../components/patterns/PageScaffold";
import { SectionPanel } from "../components/patterns/SectionPanel";
import {
@@ -1028,30 +1034,6 @@ export function ReimbursementDetailPage() {
);
}
if (detail.status === "reviewing") {
return (
<Button asChild data-testid="reimbursement-primary-action">
<a href="#reimbursement-workflow-progress-panel">看审批进度</a>
</Button>
);
}
if (detail.status === "approved") {
return (
<Button asChild data-testid="reimbursement-primary-action">
<a href="#reimbursement-processing-progress-panel">看处理进度</a>
</Button>
);
}
if (detail.status === "paid") {
return (
<Button asChild data-testid="reimbursement-primary-action">
<a href="#reimbursement-processing-progress-panel">查看付款结果</a>
</Button>
);
}
return null;
})();
@@ -1105,16 +1087,10 @@ export function ReimbursementDetailPage() {
<StatusBadge status={detail.status} />
</>
)}
actions={(
<div className="flex flex-wrap gap-2" data-testid="reimbursement-first-screen-actions">
{primaryAction}
{secondaryActions}
</div>
)}
/>
<div className="mx-auto w-full max-w-5xl space-y-5">
<div className="space-y-5">
<BusinessDetailWorkspace className="mx-auto w-full max-w-7xl">
<BusinessDetailMain>
<SectionPanel title="报销概况">
<div className="detail-command-panel space-y-4" data-testid="reimbursement-overview">
<div>
@@ -1227,35 +1203,17 @@ export function ReimbursementDetailPage() {
) : null}
</div>
{showSecondaryDetail ? (
<div className="space-y-4 pt-4" data-testid="reimbursement-secondary-detail">
<div className="detail-command-panel">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<h4 className="text-base font-semibold text-zinc-900">更多</h4>
<div className="flex flex-wrap gap-2">
{detail.status !== "paid" ? (
<Button asChild variant="secondary">
<Link to={`/reimbursements/${detail.id}/print`}>打印 / PDF</Link>
</Button>
) : null}
{canDeleteReimbursement ? (
<Button
data-testid="reimbursement-delete-draft"
disabled={deleteReimbursementMutation.isPending}
onClick={() => setConfirmDeleteReimbursement(true)}
type="button"
variant="destructive"
>
删除草稿
</Button>
) : null}
</div>
</div>
</div>
</div>
) : null}
</SectionPanel>
<SectionPanel title="处理进度">
<ProcessingProgress
progress={processingProgress}
ariaLabel="报销处理进度"
testId="reimbursement-workflow-progress"
targetLinkLabel="查看所在付款批次"
/>
</SectionPanel>
<SectionPanel title="报销明细">
<div className="space-y-4">
<div className="space-y-4" data-testid="reimbursement-items">
@@ -1574,27 +1532,49 @@ export function ReimbursementDetailPage() {
</div>
</SectionPanel>
<SectionPanel title="处理进度">
<div id="reimbursement-processing-progress-panel">
<ReimbursementProcessingProgress progress={processingProgress} />
</div>
</SectionPanel>
</div>
</BusinessDetailMain>
<div className="space-y-5">
<ReimbursementActions
status={detail.status}
showSubmitAction={false}
actionError={["reviewing", "approved"].includes(detail.status) ? actionError : ""}
submitDisabled={blockStandardChecks.length > 0}
submitHint=""
submitNotice=""
isPending={mutation.isPending}
onSubmit={() => void handleSubmitAction()}
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}
/>
</div>
</div>
<BusinessDetailAside>
<BusinessDetailActionPanel
status={<StatusBadge status={detail.status} />}
primaryAction={primaryAction}
secondaryActions={secondaryActions}
moreActions={showSecondaryDetail ? (
<div className="grid gap-2" data-testid="reimbursement-secondary-detail">
{detail.status !== "paid" ? (
<Button asChild variant="secondary">
<Link to={`/reimbursements/${detail.id}/print`}>打印 / PDF</Link>
</Button>
) : null}
{canDeleteReimbursement ? (
<Button
data-testid="reimbursement-delete-draft"
disabled={deleteReimbursementMutation.isPending}
onClick={() => setConfirmDeleteReimbursement(true)}
type="button"
variant="destructive"
>
删除草稿
</Button>
) : null}
</div>
) : null}
testId="reimbursement-action-panel"
>
<ReimbursementActions
status={detail.status}
showSubmitAction={false}
actionError={["reviewing", "approved"].includes(detail.status) ? actionError : ""}
submitDisabled={blockStandardChecks.length > 0}
submitHint=""
submitNotice=""
isPending={mutation.isPending}
onSubmit={() => void handleSubmitAction()}
onWithdraw={(comment) => mutation.mutate({ action: "withdraw", comment })}
/>
</BusinessDetailActionPanel>
</BusinessDetailAside>
</BusinessDetailWorkspace>
<Dialog open={isInvoiceDialogOpen} onOpenChange={handleInvoiceDialogOpenChange}>
<DialogContent
+1 -1
查看文件
@@ -444,7 +444,7 @@ export function WorkflowTaskDetailPage() {
<BusinessDetailAside>
<BusinessDetailActionPanel
title="处理"
className="workflow-action-panel xl:top-20"
className="workflow-action-panel lg:top-20"
secondaryActions={(
<>
<Button
@@ -37,9 +37,7 @@ export function ReimbursementActions({
}
return (
<div id="reimbursement-action-panel" className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">可执行动作</h3>
<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"
+17
查看文件
@@ -0,0 +1,17 @@
export type ProcessingStageState = "completed" | "current" | "pending" | "exception" | "terminated";
export type ProcessingStage = {
key: string;
label: string;
state: ProcessingStageState;
actorName: string | null;
occurredAt: string | null;
result: string | null;
comment: string | null;
detail: string | null;
targetHref: string | null;
};
export type ProcessingProgress = {
stages: ProcessingStage[];
};