修复报销详情时区并按 KISS 收敛页面(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -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>
|
||||
|
||||
在新工单中引用
屏蔽一个用户