修复报销详情时区并按 KISS 收敛页面(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -10,6 +10,7 @@ export function formatDisplayDate(value: string | null | undefined) {
|
||||
const timestamp = new Date(value).getTime();
|
||||
if (Number.isNaN(timestamp)) return "-";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
@@ -23,6 +24,7 @@ export function formatDisplayDateTime(value: string | null | undefined) {
|
||||
if (Number.isNaN(timestamp)) return formatDisplayDate(value);
|
||||
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
timeZone: "Asia/Shanghai",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric",
|
||||
|
||||
@@ -59,6 +59,7 @@ import {
|
||||
} from "../lib/invoiceStatus";
|
||||
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
||||
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||
import { formatDisplayDateTime } from "../lib/displayFormat";
|
||||
import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
|
||||
import { TEST_IDS } from "../lib/testIds";
|
||||
import { withUploadTimeout } from "../lib/uploadTimeout";
|
||||
@@ -122,13 +123,6 @@ function formatDate(value: string | null | undefined) {
|
||||
return value || "未填写";
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
return value.slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
function workflowActionLabel(action: string) {
|
||||
const labels: Record<string, string> = {
|
||||
start: "提交",
|
||||
@@ -171,34 +165,6 @@ function itemLabel(item: ReimbursementItem) {
|
||||
return desc ? `${typeName} · ${desc}` : typeName;
|
||||
}
|
||||
|
||||
function resolveMobileReimbursementNextStep(input: {
|
||||
status: ReimbursementStatus;
|
||||
canEdit: boolean;
|
||||
itemCount: number;
|
||||
invoiceCount: number;
|
||||
attachmentCount: number;
|
||||
}) {
|
||||
if (input.canEdit && input.itemCount === 0) {
|
||||
return "先添加报销明细,再绑定发票或补充附件。";
|
||||
}
|
||||
if (input.canEdit && input.invoiceCount === 0) {
|
||||
return "建议先添加或上传发票,提交前会做完整性检查。";
|
||||
}
|
||||
if (input.status === "draft" || input.status === "rejected") {
|
||||
return "核对金额、发票和附件后,使用底部按钮提交审批。";
|
||||
}
|
||||
if (input.status === "reviewing") {
|
||||
return "正在审批中,可查看节点和历史;需要补材料时按审批意见处理。";
|
||||
}
|
||||
if (input.status === "approved") {
|
||||
return "已通过审批,等待财务付款和归档。";
|
||||
}
|
||||
if (input.status === "paid") {
|
||||
return "已完成付款,本页用于查看发票、附件和审批留痕。";
|
||||
}
|
||||
return input.attachmentCount > 0 ? "查看附件、发票和审批记录。" : "查看当前报销状态和审批记录。";
|
||||
}
|
||||
|
||||
export function MobileReimbursementDetailPage() {
|
||||
const { reimbursementId = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -848,14 +814,7 @@ export function MobileReimbursementDetailPage() {
|
||||
const displayCode = resolveReimbursementDisplayCode(detail);
|
||||
const showSubmitAction = Boolean(canEdit && canSubmitExpense);
|
||||
const showStickyActions = Boolean(showSubmitAction || canOpenInvoiceDialog);
|
||||
const showSecondaryActions = Boolean(actionError || canDeleteDraft || !canEdit);
|
||||
const detailNextStep = resolveMobileReimbursementNextStep({
|
||||
status: detail.status,
|
||||
canEdit,
|
||||
itemCount: detail.items.length,
|
||||
invoiceCount: detail.invoices.length,
|
||||
attachmentCount: detail.attachments?.length ?? 0,
|
||||
});
|
||||
const showSecondaryActions = Boolean(actionError || canDeleteDraft);
|
||||
|
||||
return (
|
||||
<PageScaffold
|
||||
@@ -883,31 +842,23 @@ export function MobileReimbursementDetailPage() {
|
||||
|
||||
<section className="mobile-card mobile-focus-card mobile-page-stack" data-testid="mobile-reimbursement-detail-focus">
|
||||
<div className="mobile-section-header mobile-section-header--top">
|
||||
<div className="min-w-0">
|
||||
<h2 className="mobile-section-title">当前办理摘要</h2>
|
||||
<p className="mobile-list-card__subtitle mt-1">{detailNextStep}</p>
|
||||
<div>
|
||||
<h2 className="mobile-section-title">报销概况</h2>
|
||||
</div>
|
||||
<span className={cn("rounded-full px-2.5 py-1 text-xs font-medium", STATUS_TONE[detail.status])}>
|
||||
{STATUS_LABEL[detail.status]}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div data-testid="mobile-reimbursement-summary">
|
||||
<div className="text-xs text-zinc-500">合计金额</div>
|
||||
<div className="mt-1 text-3xl font-semibold text-zinc-900">{formatCurrency(detail.totalAmount)}</div>
|
||||
</div>
|
||||
<div className="mobile-metric-grid mt-4 text-sm">
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="rounded-lg bg-zinc-50 px-3 py-2">
|
||||
<div className="text-xs text-zinc-500">明细</div>
|
||||
<div className="mt-1 font-semibold text-zinc-900">{detail.items.length}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-zinc-50 px-3 py-2">
|
||||
<div className="text-xs text-zinc-500">发票</div>
|
||||
<div className="mt-1 font-semibold text-zinc-900">{detail.invoices.length}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-zinc-50 px-3 py-2">
|
||||
<div className="text-xs text-zinc-500">日期</div>
|
||||
<div className="text-xs text-zinc-500">报销日期</div>
|
||||
<div className="mt-1 truncate font-semibold text-zinc-900">{formatDate(detail.reimbursementDate)}</div>
|
||||
</div>
|
||||
<div className="rounded-lg bg-zinc-50 px-3 py-2">
|
||||
<div className="text-xs text-zinc-500">报销人</div>
|
||||
<div className="mt-1 truncate font-semibold text-zinc-900">{detail.owner?.name ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
{detail.description ? <p className="mt-3 text-sm leading-6 text-zinc-600">{detail.description}</p> : null}
|
||||
</section>
|
||||
@@ -1092,7 +1043,7 @@ export function MobileReimbursementDetailPage() {
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-zinc-900">附件材料</h2>
|
||||
<p className="mt-1 text-xs text-zinc-500">PDF/JPG/PNG/WebP,单次最多 5 个</p>
|
||||
{canManageAttachments ? <p className="mt-1 text-xs text-zinc-500">PDF/JPG/PNG/WebP,单次最多 5 个</p> : null}
|
||||
</div>
|
||||
{canManageAttachments ? (
|
||||
<Button
|
||||
@@ -1154,7 +1105,7 @@ export function MobileReimbursementDetailPage() {
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-sm font-semibold text-zinc-900">{attachment.originalName}</div>
|
||||
<div className="mt-1 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>
|
||||
@@ -1218,7 +1169,7 @@ export function MobileReimbursementDetailPage() {
|
||||
<article key={item.id} className="mobile-list-card">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-semibold text-zinc-900">{workflowActionLabel(item.action)}</div>
|
||||
<div className="shrink-0 text-xs text-zinc-500">{formatDateTime(item.createdAt)}</div>
|
||||
<div className="shrink-0 text-xs text-zinc-500">{formatDisplayDateTime(item.createdAt)}</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{item.stepName ?? "-"} · {item.actorName || "系统"}
|
||||
@@ -1253,7 +1204,6 @@ export function MobileReimbursementDetailPage() {
|
||||
删除草稿
|
||||
</Button>
|
||||
) : null}
|
||||
{!canEdit ? <div className="text-sm text-zinc-500">当前状态不可编辑。</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -33,6 +33,7 @@ import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { resolvePurchaseDisplayCode } from "../lib/approvalDisplay";
|
||||
import { formatDisplayDateTime } from "../lib/displayFormat";
|
||||
import { getWorkflowConfirmCopy, type WorkflowConfirmAction } from "../lib/workflowActionConfirm";
|
||||
|
||||
function entityTitle(entityType: string | undefined) {
|
||||
@@ -61,13 +62,6 @@ function correctionKindLabel(kind: string | undefined | null) {
|
||||
return "考勤修正";
|
||||
}
|
||||
|
||||
function formatDateTime(value: string | null | undefined) {
|
||||
if (!value) {
|
||||
return "-";
|
||||
}
|
||||
return value.slice(0, 19).replace("T", " ");
|
||||
}
|
||||
|
||||
function workflowInstanceStatusLabel(status: string | undefined | null) {
|
||||
if (!status) {
|
||||
return "-";
|
||||
@@ -311,7 +305,7 @@ export function WorkflowTaskDetailPage() {
|
||||
<MetricCard title="当前节点" value={<span className="text-base">{detail.task.stepName}</span>} />
|
||||
<MetricCard title="业务类型" value={<span className="text-base">{entityTitle(detail.workflowInstance?.entityType)}</span>} />
|
||||
<MetricCard title="可执行动作" value={<span className="text-base">{allowedActionLabels || "-"}</span>} />
|
||||
<MetricCard title="创建时间" value={<span className="text-base">{formatDateTime(detail.task.createdAt)}</span>} />
|
||||
<MetricCard title="创建时间" value={<span className="text-base">{formatDisplayDateTime(detail.task.createdAt)}</span>} />
|
||||
</StatStrip>
|
||||
|
||||
<dl className="detail-fact-grid" data-testid="workflow-task-summary">
|
||||
@@ -352,8 +346,8 @@ export function WorkflowTaskDetailPage() {
|
||||
label="时间"
|
||||
value={
|
||||
detail.attendanceCorrection.kind === "missed_punch"
|
||||
? formatDateTime(detail.attendanceCorrection.missedPunchAt)
|
||||
: `${formatDateTime(detail.attendanceCorrection.startAt)} → ${formatDateTime(detail.attendanceCorrection.endAt)}`
|
||||
? formatDisplayDateTime(detail.attendanceCorrection.missedPunchAt)
|
||||
: `${formatDisplayDateTime(detail.attendanceCorrection.startAt)} → ${formatDisplayDateTime(detail.attendanceCorrection.endAt)}`
|
||||
}
|
||||
/>
|
||||
<DetailField label="外出地点" value={detail.attendanceCorrection.locationSummary ?? "-"} />
|
||||
@@ -507,9 +501,9 @@ export function WorkflowTaskDetailPage() {
|
||||
<div>节点:{detail.task.stepName}</div>
|
||||
<div>实例状态:{workflowInstanceStatusLabel(detail.workflowInstance?.currentStatus)}</div>
|
||||
<div>当前审批角色:{detail.task.assigneeRole ?? "-"}</div>
|
||||
<div>创建时间:{formatDateTime(detail.task.createdAt)}</div>
|
||||
<div>完成时间:{formatDateTime(detail.task.completedAt)}</div>
|
||||
<div>发起时间:{formatDateTime(detail.workflowInstance?.startedAt)}</div>
|
||||
<div>创建时间:{formatDisplayDateTime(detail.task.createdAt)}</div>
|
||||
<div>完成时间:{formatDisplayDateTime(detail.task.completedAt)}</div>
|
||||
<div>发起时间:{formatDisplayDateTime(detail.workflowInstance?.startedAt)}</div>
|
||||
</>
|
||||
)}
|
||||
testId="workflow-task-action-panel"
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { Page, Route } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "./helpers/fixtures";
|
||||
|
||||
test.use({ serviceWorkers: "block" });
|
||||
test.use({ serviceWorkers: "block", timezoneId: "America/Los_Angeles" });
|
||||
|
||||
const outputDir = path.resolve(process.cwd(), "../output/playwright/pc-detail-mobile-visual-wave3");
|
||||
const taskOutputDir = path.resolve(
|
||||
@@ -1075,6 +1075,30 @@ test.beforeEach(async ({ page }) => {
|
||||
await login(page);
|
||||
});
|
||||
|
||||
test("报销详情固定使用上海时区并收敛只读信息", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
await page.goto("/reimbursements/reim-wave3");
|
||||
await expect(page.getByRole("heading", { name: "报销概况" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "当前状态与下一步" })).toHaveCount(0);
|
||||
await expect(page.getByRole("heading", { name: "业务摘要" })).toHaveCount(0);
|
||||
await expect(page.getByRole("heading", { name: "补充附件" })).toHaveCount(0);
|
||||
await expect(page.getByRole("heading", { name: "预算与审核提示" })).toHaveCount(0);
|
||||
await expect(page.getByTestId("reimbursement-attachments")).toContainText("2026年5月27日 17:10");
|
||||
|
||||
await page.goto("/workflow/tasks/task-wave3");
|
||||
await expect(page.getByTestId("workflow-task-overview")).toContainText("2026年5月27日 17:05");
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/mobile/reimbursements/reim-wave3");
|
||||
await expect(page.getByRole("heading", { name: "报销概况" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "当前办理摘要" })).toHaveCount(0);
|
||||
await expect(page.getByTestId("mobile-reimbursement-detail-focus")).toContainText(visualMe.name);
|
||||
await expect(page.getByTestId("mobile-reimbursement-attachments")).toContainText("2026年5月27日 17:10");
|
||||
await expect(page.getByTestId("mobile-reimbursement-actions")).toHaveCount(0);
|
||||
await expect(page.getByText("PDF/JPG/PNG/WebP,单次最多 5 个")).toHaveCount(0);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
|
||||
test.setTimeout(300_000);
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
@@ -1083,6 +1107,12 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
|
||||
await expect(page.getByRole("heading", { name: /报销详情/ })).toBeVisible();
|
||||
await expect(page.getByTestId("reimbursement-overview")).toBeVisible();
|
||||
await expect(page.getByTestId("reimbursement-summary")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "报销概况" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "当前状态与下一步" })).toHaveCount(0);
|
||||
await expect(page.getByRole("heading", { name: "业务摘要" })).toHaveCount(0);
|
||||
await expect(page.getByRole("heading", { name: "补充附件" })).toHaveCount(0);
|
||||
await expect(page.getByRole("heading", { name: "预算与审核提示" })).toHaveCount(0);
|
||||
await expect(page.getByTestId("reimbursement-attachments")).toContainText("2026年5月27日 17:10");
|
||||
await expect(page.getByTestId("reimbursement-first-screen-actions").locator("a,button")).toHaveCount(3);
|
||||
await expect(page.getByTestId("reimbursement-first-screen-actions")).toContainText("看审批进度");
|
||||
await expect(page.getByTestId("reimbursement-payment-status")).toHaveCount(0);
|
||||
@@ -1097,6 +1127,7 @@ test("Wave3 PC 详情和通知中心视觉截图", async ({ page }) => {
|
||||
await expect(page.getByText("审批动作")).toHaveCount(0);
|
||||
await expect(page.getByRole("heading", { name: "处理", exact: true })).toBeVisible();
|
||||
await expect(page.getByTestId("workflow-task-overview")).toBeVisible();
|
||||
await expect(page.getByTestId("workflow-task-overview")).toContainText("2026年5月27日 17:05");
|
||||
await page.getByTestId("workflow-task-reimbursement-detail-link").click();
|
||||
await expect(page).toHaveURL(/\/reimbursements\/reim-wave3$/);
|
||||
await expect(page.getByTestId("reimbursement-overview")).toBeVisible();
|
||||
@@ -1220,6 +1251,12 @@ test("Wave3 移动关键页视觉截图", async ({ page }) => {
|
||||
|
||||
await page.goto("/mobile/reimbursements/reim-wave3");
|
||||
await expect(page.getByTestId("mobile-reimbursement-detail-focus")).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "报销概况" })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "当前办理摘要" })).toHaveCount(0);
|
||||
await expect(page.getByTestId("mobile-reimbursement-detail-focus")).toContainText(visualMe.name);
|
||||
await expect(page.getByTestId("mobile-reimbursement-attachments")).toContainText("2026年5月27日 17:10");
|
||||
await expect(page.getByTestId("mobile-reimbursement-actions")).toHaveCount(0);
|
||||
await expect(page.getByText("PDF/JPG/PNG/WebP,单次最多 5 个")).toHaveCount(0);
|
||||
await expect(page.getByTestId("mobile-reimbursement-items")).toBeVisible();
|
||||
await expect(page.locator("table")).toHaveCount(0);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
|
||||
@@ -521,7 +521,7 @@ test.describe("R111 国内报销发票与附件双端体验", () => {
|
||||
await page.setViewportSize({ width: 1440, height: 960 });
|
||||
await page.goto(`/reimbursements/${reimbursementId}`);
|
||||
await expect(page.getByText("报销详情 BX20260508001")).toBeVisible();
|
||||
await expect(page.getByText("预算与审核提示")).toBeVisible();
|
||||
await expect(page.getByText("预算与审核提示")).toHaveCount(0);
|
||||
await expect(page.getByTestId("reimbursement-attachments")).toBeVisible();
|
||||
await expect(page.getByTestId("reimbursement-related-records")).toBeVisible();
|
||||
await expect(page.getByText("ERP")).toHaveCount(0);
|
||||
|
||||
在新工单中引用
屏蔽一个用户