修复报销详情时区并按 KISS 收敛页面(07-27-yang-jun-reimbursement-readiness)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-07-31 16:44:31 +08:00
父节点 35b44749a5
当前提交 39c3e808a3
共修改 10 个文件,包含 223 行新增和 227 行删除
+78 -148
查看文件
@@ -21,7 +21,6 @@ import {
withdrawReimbursement,
linkReimbursementProject,
type ReimbursementItem,
type ReimbursementStatus,
} from "../api/reimbursements";
import { remindWorkflowTask } from "../api/workflow";
import {
@@ -41,7 +40,6 @@ import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDe
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
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 {
Dialog,
@@ -65,7 +63,7 @@ import {
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
import { formatDisplayDate } from "../lib/displayFormat";
import { formatDisplayDate, formatDisplayDateTime } from "../lib/displayFormat";
import { TEST_IDS } from "../lib/testIds";
import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles";
import { ReimbursementActions } from "./reimbursement-detail/ReimbursementActions";
@@ -79,11 +77,6 @@ import { buildPrecheckDialogMessage } from "../lib/precheck";
type ItemEditorState = { mode: "create" } | { mode: "edit"; itemId: string } | null;
type SelectedInvoiceMap = Record<string, string>;
function formatDateTime(value: string | null) {
if (!value) return "-";
return value.slice(0, 19).replace("T", " ");
}
function buildActionErrorMessage(error: unknown, fallbackMessage: string) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
@@ -119,18 +112,6 @@ function budgetControlModeLabel(controlMode: string | null | undefined) {
return controlMode ? labels[controlMode] ?? "按制度处理" : "按制度处理";
}
function reimbursementStatusLabel(status: ReimbursementStatus) {
const labels: Record<ReimbursementStatus, string> = {
draft: "草稿",
reviewing: "审批中",
approved: "已批准",
paid: "已支付",
rejected: "已驳回",
cancelled: "已取消",
};
return labels[status] ?? status;
}
function materialCompletenessLabel(count: number, noun: string) {
return count > 0 ? `${count} ${noun}` : `缺${noun}`;
}
@@ -981,6 +962,9 @@ export function ReimbursementDetailPage() {
}
return "查看记录";
})();
const showRelatedRecords = canEditItems || Boolean(detail.project || detail.linkedTravelRequest);
const showReviewHints = Boolean(budgetHint || detail.riskFlags.length);
const showApprovalRecords = Boolean(detail.workflowProgress || detail.approvalHistory.length);
const primaryAction = (() => {
if (["draft", "rejected"].includes(detail.status) && canEditItems) {
if (!itemCount) {
@@ -1090,10 +1074,6 @@ export function ReimbursementDetailPage() {
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={(
@@ -1106,37 +1086,49 @@ export function ReimbursementDetailPage() {
<div className="detail-workspace-grid">
<div className="space-y-5">
<SectionPanel title="当前状态与下一步">
<SectionPanel title="报销概况">
<div className="detail-command-panel space-y-4" data-testid="reimbursement-overview">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-xs font-semibold text-zinc-500">下一步</div>
<div className="mt-1 text-2xl font-semibold text-zinc-900">{nextStepText}</div>
</div>
<div className="shrink-0">
<StatusBadge status={detail.status} />
</div>
<div>
<div className="text-xs font-semibold text-zinc-500">下一步</div>
<div className="mt-1 text-xl font-semibold text-zinc-900">{nextStepText}</div>
</div>
<div className="detail-fact-grid xl:grid-cols-4" data-testid="reimbursement-summary">
<div className="detail-fact-card">
<div className="detail-fact-card__label">状态</div>
<div className="detail-fact-card__value">{reimbursementStatusLabel(detail.status)}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">处理人</div>
<div className="detail-fact-card__value">{currentHandlerText}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">金额</div>
<div className="detail-fact-card__value">¥{detail.totalAmount ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">材料完整性</div>
<div className="detail-fact-card__value">
{materialCompletenessLabel(itemCount, "条明细")} / {materialCompletenessLabel(invoiceCount, "张发票")}
<div data-testid="reimbursement-business-summary">
<div className="detail-fact-grid xl:grid-cols-3" data-testid="reimbursement-summary">
<div className="detail-fact-card">
<div className="detail-fact-card__label">金额</div>
<div className="detail-fact-card__value">¥{detail.totalAmount ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">当前处理</div>
<div className="detail-fact-card__value">{currentHandlerText}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">材料</div>
<div className="detail-fact-card__value">
{materialCompletenessLabel(itemCount, "条明细")} / {materialCompletenessLabel(invoiceCount, "张发票")}
</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">报销日期</div>
<div className="detail-fact-card__value">{detail.reimbursementDate ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">提交人 / 报销人</div>
<div className="detail-fact-card__value">{detail.submitter?.name ?? "-"} / {detail.owner?.name ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">关联项目</div>
<div className="detail-fact-card__value">
{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}
</div>
</div>
</div>
{detail.description ? (
<div className="mt-3 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
{detail.description}
</div>
) : null}
</div>
{actionError ? (
@@ -1189,7 +1181,7 @@ export function ReimbursementDetailPage() {
<div className="detail-fact-card">
<div className="detail-fact-card__label">操作人 / 时间</div>
<div className="detail-fact-card__value">
{payment.paidBy?.name ?? "-"} · {formatDateTime(payment.paidAt)}
{payment.paidBy?.name ?? "-"} · {formatDisplayDateTime(payment.paidAt)}
</div>
</div>
</div>
@@ -1200,10 +1192,7 @@ export function ReimbursementDetailPage() {
<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">
<div>
<h4 className="text-base font-semibold text-zinc-900">更多入口</h4>
<p className="text-xs text-zinc-500">低频操作集中在这里,材料和关联事项已放在主内容区。</p>
</div>
<h4 className="text-base font-semibold text-zinc-900">更多</h4>
<div className="flex flex-wrap gap-2">
{detail.status !== "paid" ? (
<Button asChild variant="secondary">
@@ -1231,42 +1220,11 @@ export function ReimbursementDetailPage() {
) : null}
</SectionPanel>
<SectionPanel title="业务摘要">
<div className="detail-command-panel" data-testid="reimbursement-business-summary">
<div className="detail-fact-grid xl:grid-cols-4">
<div className="detail-fact-card">
<div className="detail-fact-card__label">报销编号</div>
<div className="detail-fact-card__value">{displayCode}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">报销日期</div>
<div className="detail-fact-card__value">{detail.reimbursementDate ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">提交人 / 报销人</div>
<div className="detail-fact-card__value">{detail.submitter?.name ?? "-"} / {detail.owner?.name ?? "-"}</div>
</div>
<div className="detail-fact-card">
<div className="detail-fact-card__label">关联项目</div>
<div className="detail-fact-card__value">
{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联"}
</div>
</div>
</div>
<div className="mt-3 rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2 text-sm text-zinc-600">
{detail.description || "未填写报销说明"}
</div>
</div>
</SectionPanel>
<SectionPanel title="明细与发票" description="明细决定金额,发票决定提交完整性。">
<SectionPanel title="明细与发票">
<div className="space-y-4">
<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>
<p className="text-xs text-zinc-500">明细变更会自动重算总金额。</p>
</div>
<h3 className="text-base font-semibold text-zinc-900">报销明细</h3>
{canEditItems ? (
<Button
@@ -1278,9 +1236,7 @@ export function ReimbursementDetailPage() {
>
新增明细
</Button>
) : (
<p className="text-xs text-zinc-500">当前状态不可编辑明细</p>
)}
) : null}
</div>
{itemError ? (
@@ -1411,9 +1367,9 @@ export function ReimbursementDetailPage() {
<div className="space-y-4 text-sm" data-testid={TEST_IDS.reimbursement.invoices}>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="space-y-1">
<div>
<h3 className="text-base font-semibold text-zinc-900">发票</h3>
<p className="mt-1 text-xs text-zinc-500">提交前至少添加 1 张发票;重复或不可用发票会给出原因。</p>
{canEditItems ? <p className="mt-1 text-xs text-zinc-500">提交前至少添加 1 张发票。</p> : null}
</div>
{canOpenInvoiceBindDialog ? (
<Button
@@ -1495,14 +1451,11 @@ export function ReimbursementDetailPage() {
</div>
</SectionPanel>
<SectionPanel title="附件材料" description="订单、付款截图、情况说明等补充材料;PDF 发票仍在发票区处理。">
<SectionPanel title="附件材料">
<div className="space-y-4 text-sm" data-testid="reimbursement-attachments">
<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>
<p className="text-xs text-zinc-500">合同、订单、支付截图或情况说明;PDF/JPG/PNG/WebP,单次最多 5 个。</p>
</div>
{canEditItems ? (
{canEditItems ? (
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<p className="text-xs text-zinc-500">支持 PDF/JPG/PNG/WebP,单次最多 5 个。</p>
<div className="flex flex-col gap-2 md:flex-row md:items-center">
<label
htmlFor="reimbursement-attachment-upload"
@@ -1540,8 +1493,8 @@ export function ReimbursementDetailPage() {
{uploadAttachmentsMutation.isPending ? "上传中..." : "上传附件"}
</Button>
</div>
) : null}
</div>
</div>
) : null}
{(detail.attachments ?? []).length ? (
<div className="space-y-2">
@@ -1550,7 +1503,7 @@ export function ReimbursementDetailPage() {
<div className="min-w-0">
<div className="truncate font-medium text-zinc-900">{attachment.originalName}</div>
<div className="text-xs text-zinc-500">
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDateTime(attachment.createdAt)}
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDisplayDateTime(attachment.createdAt)}
</div>
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
</div>
@@ -1585,13 +1538,13 @@ export function ReimbursementDetailPage() {
</div>
</SectionPanel>
<SectionPanel title="关联事项" description="只显示项目、差旅等关联摘要;具体流程回到对应模块处理。">
{showRelatedRecords ? (
<SectionPanel title="关联事项">
<div className="space-y-4" data-testid="reimbursement-related-records">
<div className="detail-command-panel">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-base font-semibold text-zinc-900">关联项目</h4>
<p className="text-xs text-zinc-500">用于项目执行面板汇总,不在报销详情复制项目流程。</p>
</div>
{detail.project ? (
<Link to={`/projects/${detail.project.id}`} className="text-sm text-primary hover:underline">
@@ -1655,14 +1608,12 @@ export function ReimbursementDetailPage() {
</div>
</div>
</div>
) : (
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm text-zinc-500">
未关联差旅申请。
</div>
)}
) : null}
</div>
</SectionPanel>
) : null}
{showReviewHints ? (
<SectionPanel title="预算与审核提示">
<div className="space-y-4">
{budgetHint ? (
@@ -1688,12 +1639,13 @@ export function ReimbursementDetailPage() {
</div>
) : null}
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
<AiSuggestionCard targetType="reimbursement" targetId={detail.id} />
{detail.riskFlags.length ? <RiskFlagsCard items={detail.riskFlags} title="待处理风险项" /> : null}
</div>
</SectionPanel>
) : null}
<SectionPanel title="审批与处理记录" description="审批进度和历史只用于追踪,不在这里复制审批或付款动作。">
{showApprovalRecords ? (
<SectionPanel title="审批与处理记录">
<div id="reimbursement-workflow-progress-panel" className="space-y-4">
{detail.workflowProgress ? (
<div className="detail-command-panel">
@@ -1724,21 +1676,17 @@ export function ReimbursementDetailPage() {
))}
</div>
</div>
) : (
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm text-zinc-500">
当前没有进行中的审批节点。
</div>
)}
) : null}
{detail.approvalHistory.length ? (
<div className="detail-command-panel">
<h3 className="text-base font-semibold text-zinc-900">审批记录</h3>
{detail.approvalHistory.length ? (
<div className="detail-timeline mt-3" data-testid="reimbursement-approval-history">
{detail.approvalHistory.map((item) => (
<div key={item.id} className="detail-timeline-item">
<div className="flex items-center justify-between gap-3">
<span className="font-medium text-zinc-900">{workflowActionLabel(item.action)}</span>
<span className="text-xs text-zinc-500">{formatDateTime(item.createdAt)}</span>
<span className="text-xs text-zinc-500">{formatDisplayDateTime(item.createdAt)}</span>
</div>
<div className="mt-1 text-xs text-zinc-500">
{item.stepName ?? "-"} · {item.actorName || "系统"}
@@ -1747,39 +1695,21 @@ export function ReimbursementDetailPage() {
</div>
))}
</div>
) : (
<div className="mt-3 text-sm text-zinc-500">暂无审批记录。</div>
)}
</div>
) : null}
</div>
</SectionPanel>
) : null}
</div>
<div className="space-y-5">
<DetailSideMeta title="当前可做">
{["draft", "rejected"].includes(detail.status) ? (
<div>补齐明细和 PDF 发票后提交审批;附件作为补充材料放在主内容区。</div>
) : null}
{detail.status === "reviewing" ? (
<div>审批动作只在“待我处理”或任务详情执行;如需修改,可撤回后补正。</div>
) : null}
{detail.status === "approved" ? (
<div className="space-y-3">
<div>本页只显示付款状态;付款登记、批量付款和回单处理请进入付款上下文。</div>
{canPay ? (
<Button asChild variant="secondary" size="sm">
<Link to="/payment-batches">进入付款批次</Link>
</Button>
) : null}
</div>
) : null}
{detail.status === "paid" ? (
<div>已付款报销用于查证据、看回单状态和打印归档,不再编辑或重复付款。</div>
) : null}
{detail.status === "cancelled" ? (
<div>该报销已取消,仅保留查看和归档记录。</div>
) : null}
</DetailSideMeta>
{detail.status === "approved" && canPay ? (
<DetailSideMeta title="付款">
<Button asChild variant="secondary" size="sm">
<Link to="/payment-batches">进入付款批次</Link>
</Button>
</DetailSideMeta>
) : null}
<ReimbursementActions
status={detail.status}
@@ -1858,7 +1788,7 @@ export function ReimbursementDetailPage() {
{item.sellerName || "未识别销售方"} · {item.invoiceDate ? formatDisplayDate(item.invoiceDate) : "未识别日期"} · 金额 ¥{item.totalAmount ?? "-"}
</div>
<div className="text-xs text-zinc-500">
原始文件:{item.originalName || "未记录原始文件"} · 上传时间 {formatDateTime(item.createdAt)}
原始文件:{item.originalName || "未记录原始文件"} · 上传时间 {formatDisplayDateTime(item.createdAt)}
</div>
</div>
</div>