收敛报销详情并展示五阶段进度(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -112,6 +112,22 @@ export type ReimbursementWorkflowProgress = {
|
||||
}>;
|
||||
};
|
||||
|
||||
export type ReimbursementProcessingStage = {
|
||||
key: "submitted" | "manager_approval" | "finance_review" | "payment_batch_review" | "payment_and_receipt";
|
||||
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 ReimbursementApprovalHistoryItem = {
|
||||
id: string;
|
||||
stepName: string | null;
|
||||
@@ -215,6 +231,7 @@ export type ReimbursementDetail = {
|
||||
} | null;
|
||||
} | null;
|
||||
workflowProgress: ReimbursementWorkflowProgress | null;
|
||||
processingProgress: ReimbursementProcessingProgress;
|
||||
approvalHistory: ReimbursementApprovalHistoryItem[];
|
||||
riskFlags: RiskFlag[];
|
||||
items: ReimbursementItem[];
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { AlertCircle, Check, Circle, Clock, ExternalLink } from "lucide-react";
|
||||
import { Link } from "react-router-dom";
|
||||
import type { ReimbursementProcessingProgress as ProcessingProgress } from "../../api/reimbursements";
|
||||
import { formatDisplayDateTime } from "../../lib/displayFormat";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
type Props = {
|
||||
progress: ProcessingProgress;
|
||||
mobile?: boolean;
|
||||
};
|
||||
|
||||
const STATE_TEXT = {
|
||||
completed: "已完成",
|
||||
current: "处理中",
|
||||
pending: "待处理",
|
||||
exception: "需处理",
|
||||
} as const;
|
||||
|
||||
export function ReimbursementProcessingProgress({ progress, mobile = false }: Props) {
|
||||
return (
|
||||
<ol
|
||||
className="space-y-0"
|
||||
data-testid={mobile ? "mobile-reimbursement-processing-progress" : "reimbursement-workflow-progress"}
|
||||
aria-label="报销处理进度"
|
||||
>
|
||||
{progress.stages.map((stage, index) => {
|
||||
const expanded = stage.state === "current" || stage.state === "exception" || Boolean(stage.comment);
|
||||
const Icon = stage.state === "completed"
|
||||
? Check
|
||||
: stage.state === "current"
|
||||
? Clock
|
||||
: stage.state === "exception"
|
||||
? AlertCircle
|
||||
: Circle;
|
||||
const facts = [stage.actorName, stage.occurredAt ? formatDisplayDateTime(stage.occurredAt) : null, stage.result]
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<li className="relative flex gap-3" data-state={stage.state} key={stage.key}>
|
||||
{index < progress.stages.length - 1 ? (
|
||||
<span className="absolute left-[15px] top-8 h-[calc(100%-1rem)] w-px bg-zinc-200" aria-hidden="true" />
|
||||
) : null}
|
||||
<span
|
||||
className={cn(
|
||||
"relative z-10 mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-full border bg-white",
|
||||
stage.state === "completed" && "border-emerald-300 text-emerald-700",
|
||||
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",
|
||||
)}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon className="size-4" />
|
||||
</span>
|
||||
<div className={cn("min-w-0 flex-1 pb-5", mobile ? "pt-1" : "pt-1.5")}>
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-sm">
|
||||
<span className="font-semibold text-zinc-900">{stage.label}</span>
|
||||
{facts.length ? <span className="text-zinc-500">· {facts.join(" · ")}</span> : null}
|
||||
{!stage.result ? <span className="text-xs text-zinc-500">· {STATE_TEXT[stage.state]}</span> : null}
|
||||
</div>
|
||||
{expanded && stage.detail ? <p className="mt-1 text-sm text-zinc-600">{stage.detail}</p> : null}
|
||||
{stage.comment ? (
|
||||
<blockquote className="mt-2 rounded-md border-l-2 border-zinc-300 bg-zinc-50 px-3 py-2 text-sm leading-5 text-zinc-700">
|
||||
{stage.comment}
|
||||
</blockquote>
|
||||
) : null}
|
||||
{stage.targetHref ? (
|
||||
<Link
|
||||
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}
|
||||
>
|
||||
查看所在付款批次
|
||||
<ExternalLink aria-hidden="true" className="size-4" />
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -7,10 +7,10 @@ import { isTravelReimbursement } from "../../lib/reimbursementTravel";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
|
||||
type Props = { detail: ReimbursementDetail; reimbursementId: string; editable: boolean; mobile?: boolean };
|
||||
type Props = { detail: ReimbursementDetail; reimbursementId: string; editable: boolean; mobile?: boolean; embedded?: boolean };
|
||||
const emptyForm: ReimbursementTravelSummaryPayload = { travelPurpose: null, travelLocation: null, travelStartDate: null, travelEndDate: null };
|
||||
|
||||
export function ReimbursementTravelSummary({ detail, reimbursementId, editable, mobile = false }: Props) {
|
||||
export function ReimbursementTravelSummary({ detail, reimbursementId, editable, mobile = false, embedded = false }: Props) {
|
||||
const queryClient = useQueryClient();
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
@@ -22,7 +22,10 @@ export function ReimbursementTravelSummary({ detail, reimbursementId, editable,
|
||||
travelStartDate: detail.travelSummary?.startDate ?? null,
|
||||
travelEndDate: detail.travelSummary?.endDate ?? null,
|
||||
});
|
||||
}, [detail.travelSummary]);
|
||||
if (editable && !detail.travelSummary?.purpose?.trim()) setEditing(true);
|
||||
else if (editable && !detail.travelSummary?.location?.trim()) setEditing(true);
|
||||
else if (editable && (!detail.travelSummary?.startDate || !detail.travelSummary?.endDate)) setEditing(true);
|
||||
}, [detail.travelSummary, editable]);
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => updateReimbursementTravelSummary(reimbursementId, form),
|
||||
onSuccess: async () => {
|
||||
@@ -34,10 +37,11 @@ export function ReimbursementTravelSummary({ detail, reimbursementId, editable,
|
||||
});
|
||||
if (!isTravel) return null;
|
||||
const summary = detail.travelSummary ?? { purpose: null, location: null, startDate: null, endDate: null };
|
||||
const Container = embedded ? "div" : "section";
|
||||
return (
|
||||
<section className={mobile ? "mobile-card" : "rounded-xl border border-zinc-200 bg-white p-5"} data-testid={mobile ? "mobile-reimbursement-travel-summary" : "reimbursement-travel-summary"}>
|
||||
<Container className={embedded ? "mt-4 border-t border-zinc-200 pt-4" : mobile ? "mobile-card" : "rounded-xl border border-zinc-200 bg-white p-5"} data-testid={mobile ? "mobile-reimbursement-travel-summary" : "reimbursement-travel-summary"}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h2 className="text-base font-semibold text-zinc-900">差旅概要</h2>
|
||||
<h3 className="text-sm font-semibold text-zinc-900">出差信息</h3>
|
||||
{editable && !editing ? <Button size="sm" type="button" variant="secondary" onClick={() => setEditing(true)}>编辑</Button> : null}
|
||||
</div>
|
||||
{editing ? (
|
||||
@@ -61,6 +65,6 @@ export function ReimbursementTravelSummary({ detail, reimbursementId, editable,
|
||||
<div><dt className="text-xs text-zinc-500">结束日期</dt><dd className="mt-1 text-zinc-900">{summary.endDate || "未填写"}</dd></div>
|
||||
</dl>
|
||||
)}
|
||||
</section>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -40,9 +40,11 @@ import { AttachmentGovernanceBadges } from "../components/common/AttachmentGover
|
||||
import { AttachmentDeleteConfirmDialog } from "../components/common/AttachmentDeleteConfirmDialog";
|
||||
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
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 { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
@@ -132,20 +134,6 @@ function formatDate(value: string | null | undefined) {
|
||||
return value || "未填写";
|
||||
}
|
||||
|
||||
function workflowActionLabel(action: string) {
|
||||
const labels: Record<string, string> = {
|
||||
start: "提交",
|
||||
approve: "通过",
|
||||
reject: "驳回",
|
||||
return: "退回",
|
||||
transfer: "转审",
|
||||
withdraw: "撤回",
|
||||
remind: "催办",
|
||||
overdue_remind: "超时提醒",
|
||||
};
|
||||
return labels[action] ?? action;
|
||||
}
|
||||
|
||||
function matchInvoiceSearch(
|
||||
invoice: {
|
||||
invoiceCode?: string | null;
|
||||
@@ -933,17 +921,40 @@ export function MobileReimbursementDetailPage() {
|
||||
<div className="text-xs text-zinc-500">报销人</div>
|
||||
<div className="mt-1 truncate font-semibold text-zinc-900">{detail.owner?.name ?? "-"}</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.department?.name ?? "-"}</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.submitter?.name ?? "-"}</div>
|
||||
</div>
|
||||
</div>
|
||||
{detail.description ? <p className="mt-3 text-sm leading-6 text-zinc-600">{detail.description}</p> : null}
|
||||
<ReimbursementTravelSummary
|
||||
detail={detail}
|
||||
reimbursementId={reimbursementId}
|
||||
editable={Boolean(canEdit && isParticipant)}
|
||||
mobile
|
||||
embedded
|
||||
/>
|
||||
{detail.project || detail.linkedTravelRequest ? (
|
||||
<div className="mt-4 border-t border-zinc-200 pt-4 text-sm text-zinc-600">
|
||||
{detail.project ? <p>项目:{detail.project.projectNo} · {detail.project.name}</p> : null}
|
||||
{detail.linkedTravelRequest ? (
|
||||
<Link className="mt-2 inline-flex min-h-11 items-center text-primary hover:underline" to={`/travel-requests?tab=requests&id=${detail.linkedTravelRequest.id}`}>
|
||||
差旅申请:{detail.linkedTravelRequest.requestNo ?? "未编号"}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{detail.riskFlags.length ? (
|
||||
<div className="mt-4 border-t border-zinc-200 pt-4">
|
||||
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<ReimbursementTravelSummary
|
||||
detail={detail}
|
||||
reimbursementId={reimbursementId}
|
||||
editable={Boolean(canEdit && isParticipant)}
|
||||
mobile
|
||||
/>
|
||||
|
||||
<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>
|
||||
@@ -1164,7 +1175,6 @@ export function MobileReimbursementDetailPage() {
|
||||
) : null}
|
||||
|
||||
{detail.invoices.length || (detail.attachments ?? []).length ? (
|
||||
canOpenInvoiceDialog || canManageAttachments || Boolean(materialPreviewError) ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
<div data-testid={TEST_IDS.reimbursement.invoices} className="space-y-2">
|
||||
{detail.invoices.map((invoice) => {
|
||||
@@ -1247,7 +1257,6 @@ export function MobileReimbursementDetailPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null
|
||||
) : (
|
||||
<div className="mt-3 rounded-lg border border-dashed border-zinc-200 p-5 text-center text-sm text-zinc-500">
|
||||
暂无报销材料
|
||||
@@ -1255,14 +1264,10 @@ export function MobileReimbursementDetailPage() {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{detail.status === "reviewing" || detail.approvalHistory.length ? (
|
||||
<section className="mobile-card" data-testid="mobile-reimbursement-approval-history">
|
||||
<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>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
当前节点:{detail.pendingWorkflow?.task?.step_name ?? "-"}
|
||||
</p>
|
||||
<h2 className="text-base font-semibold text-zinc-900">处理进度</h2>
|
||||
</div>
|
||||
{canRemindPendingWorkflow && pendingTaskId ? (
|
||||
<Button
|
||||
@@ -1276,27 +1281,10 @@ export function MobileReimbursementDetailPage() {
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{detail.approvalHistory.length ? (
|
||||
<div className="mt-3 space-y-2">
|
||||
{detail.approvalHistory.map((item) => (
|
||||
<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">{formatDisplayDateTime(item.createdAt)}</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{item.stepName ?? "-"} · {item.actorName || "系统"}
|
||||
</div>
|
||||
{item.comment ? <p className="mt-2 text-sm leading-5 text-zinc-600">{item.comment}</p> : null}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-zinc-500">暂无审批记录。</p>
|
||||
)}
|
||||
<div className="mt-3">
|
||||
<ReimbursementProcessingProgress progress={detail.processingProgress ?? { stages: [] }} mobile />
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{showSecondaryActions ? (
|
||||
<section className="mobile-card" data-testid="mobile-reimbursement-actions">
|
||||
|
||||
@@ -47,6 +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 { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { Button } from "../components/ui/button";
|
||||
import {
|
||||
@@ -58,7 +59,6 @@ import {
|
||||
} from "../components/ui/dialog";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
|
||||
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
@@ -98,42 +98,6 @@ function buildActionErrorMessage(error: unknown, fallbackMessage: string) {
|
||||
return fallbackMessage;
|
||||
}
|
||||
|
||||
function workflowActionLabel(action: string) {
|
||||
const labels: Record<string, string> = {
|
||||
start: "提交",
|
||||
approve: "通过",
|
||||
reject: "驳回",
|
||||
return: "退回",
|
||||
transfer: "转审",
|
||||
withdraw: "撤回",
|
||||
remind: "催办",
|
||||
overdue_remind: "超时提醒",
|
||||
};
|
||||
return labels[action] ?? action;
|
||||
}
|
||||
|
||||
function budgetControlModeLabel(controlMode: string | null | undefined) {
|
||||
const labels: Record<string, string> = {
|
||||
block: "超额阻断",
|
||||
warn: "超额提醒",
|
||||
manual_review: "人工复核",
|
||||
};
|
||||
return controlMode ? labels[controlMode] ?? "按制度处理" : "按制度处理";
|
||||
}
|
||||
|
||||
function materialCompletenessLabel(count: number, noun: string) {
|
||||
return count > 0 ? `${count} ${noun}` : `缺${noun}`;
|
||||
}
|
||||
|
||||
function paymentChannelLabel(value: string | null | undefined) {
|
||||
if (value === "bank") return "银行转账";
|
||||
if (value === "alipay") return "支付宝";
|
||||
if (value === "wechat") return "微信";
|
||||
if (value === "cash") return "现金";
|
||||
if (value === "other") return "其他";
|
||||
return value || "-";
|
||||
}
|
||||
|
||||
export function ReimbursementDetailPage() {
|
||||
const { reimbursementId = "" } = useParams();
|
||||
const navigate = useNavigate();
|
||||
@@ -792,16 +756,8 @@ export function ReimbursementDetailPage() {
|
||||
}
|
||||
|
||||
const detail = detailQuery.data;
|
||||
const payment = detail.payment ?? {
|
||||
paymentVoucherNo: null,
|
||||
paymentDate: null,
|
||||
paymentChannel: null,
|
||||
paidAt: null,
|
||||
paidBy: null,
|
||||
};
|
||||
const displayCode = resolveReimbursementDisplayCode(detail);
|
||||
const budgetHint = detail.budgetHint;
|
||||
const canPay = hasPermission(permissions, "expense.finance.pay");
|
||||
const canEditItems = ["draft", "rejected"].includes(detail.status);
|
||||
const isReimbursementParticipant = Boolean(me?.id) && [detail.ownerId, detail.submitterId].includes(me?.id ?? "");
|
||||
const pendingTaskId = detail.pendingWorkflow?.task?.id ?? null;
|
||||
@@ -992,21 +948,13 @@ export function ReimbursementDetailPage() {
|
||||
const itemCount = detail.items.length;
|
||||
const invoiceCount = detail.invoices.length;
|
||||
const hasBlockingPrecheck = blockStandardChecks.length > 0;
|
||||
const currentHandlerText = (() => {
|
||||
if (detail.status === "reviewing") {
|
||||
return detail.pendingWorkflow?.task?.step_name ?? "当前审批节点";
|
||||
}
|
||||
if (detail.status === "approved") {
|
||||
return canPay ? "财务处理" : "等待财务";
|
||||
}
|
||||
if (detail.status === "paid") {
|
||||
return payment.paidBy?.name ?? "财务已处理";
|
||||
}
|
||||
if (["draft", "rejected"].includes(detail.status)) {
|
||||
return detail.owner?.name ?? "报销人";
|
||||
}
|
||||
return "-";
|
||||
})();
|
||||
const processingProgress = detail.processingProgress ?? { stages: [] };
|
||||
const currentProcessingStage = processingProgress.stages.find((stage) =>
|
||||
stage.state === "current" || stage.state === "exception",
|
||||
);
|
||||
const currentHandlerText = currentProcessingStage
|
||||
? `${currentProcessingStage.label}${currentProcessingStage.actorName ? ` · ${currentProcessingStage.actorName}` : ""}`
|
||||
: detail.status === "paid" ? "已完成" : "待处理";
|
||||
const nextStepText = (() => {
|
||||
if (["draft", "rejected"].includes(detail.status)) {
|
||||
if (!itemCount) {
|
||||
@@ -1021,9 +969,7 @@ export function ReimbursementDetailPage() {
|
||||
return "提交审批";
|
||||
}
|
||||
if (detail.status === "reviewing") {
|
||||
return detail.workflowProgress
|
||||
? `查看第 ${detail.workflowProgress.currentStepIndex + 1} / ${detail.workflowProgress.totalSteps} 步`
|
||||
: "查看审批进度";
|
||||
return currentProcessingStage?.detail ?? "查看处理进度";
|
||||
}
|
||||
if (detail.status === "approved") {
|
||||
return "等待财务处理";
|
||||
@@ -1033,9 +979,7 @@ 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) {
|
||||
@@ -1085,7 +1029,7 @@ export function ReimbursementDetailPage() {
|
||||
if (detail.status === "approved") {
|
||||
return (
|
||||
<Button asChild data-testid="reimbursement-primary-action">
|
||||
<a href="#reimbursement-payment-panel">看付款状态</a>
|
||||
<a href="#reimbursement-processing-progress-panel">看处理进度</a>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1093,7 +1037,7 @@ export function ReimbursementDetailPage() {
|
||||
if (detail.status === "paid") {
|
||||
return (
|
||||
<Button asChild data-testid="reimbursement-primary-action">
|
||||
<a href="#reimbursement-payment-panel">查看付款结果</a>
|
||||
<a href="#reimbursement-processing-progress-panel">查看付款结果</a>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -1155,7 +1099,7 @@ export function ReimbursementDetailPage() {
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="detail-workspace-grid">
|
||||
<div className="mx-auto w-full max-w-5xl space-y-5">
|
||||
<div className="space-y-5">
|
||||
<SectionPanel title="报销概况">
|
||||
<div className="detail-command-panel space-y-4" data-testid="reimbursement-overview">
|
||||
@@ -1175,9 +1119,9 @@ export function ReimbursementDetailPage() {
|
||||
<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__label">部门</div>
|
||||
<div className="detail-fact-card__value">
|
||||
{materialCompletenessLabel(itemCount, "条明细")} / {materialCompletenessLabel(invoiceCount, "张发票")}
|
||||
{detail.department?.name ?? "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="detail-fact-card">
|
||||
@@ -1200,6 +1144,42 @@ export function ReimbursementDetailPage() {
|
||||
{detail.description}
|
||||
</div>
|
||||
) : null}
|
||||
<ReimbursementTravelSummary
|
||||
detail={detail}
|
||||
reimbursementId={reimbursementId}
|
||||
editable={Boolean(canEditItems && isReimbursementParticipant)}
|
||||
embedded
|
||||
/>
|
||||
{canEditItems || detail.project || detail.linkedTravelRequest ? (
|
||||
<div className="mt-4 border-t border-zinc-200 pt-4 text-sm" data-testid="reimbursement-related-records">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<span className="font-medium text-zinc-900">关联事项</span>
|
||||
{detail.linkedTravelRequest ? (
|
||||
<Link to={`/travel-requests?tab=requests&id=${detail.linkedTravelRequest.id}`} className="text-primary hover:underline">
|
||||
差旅申请 {detail.linkedTravelRequest.requestNo ?? "未编号"}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
{canEditItems ? (
|
||||
<select
|
||||
className="select-std mt-3 max-w-xl"
|
||||
value={detail.projectId ?? ""}
|
||||
onChange={(event) => projectLinkMutation.mutate(event.target.value || null)}
|
||||
disabled={projectLinkMutation.isPending}
|
||||
aria-label="关联项目"
|
||||
>
|
||||
<option value="">不关联项目</option>
|
||||
{(projectsQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>{item.projectNo} · {item.name}</option>
|
||||
))}
|
||||
</select>
|
||||
) : detail.project ? (
|
||||
<Link className="mt-2 inline-block text-primary hover:underline" to={`/projects/${detail.project.id}`}>
|
||||
项目 {detail.project.projectNo} · {detail.project.name}
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{actionError ? (
|
||||
@@ -1220,45 +1200,19 @@ export function ReimbursementDetailPage() {
|
||||
请修改超标明细后提交。
|
||||
</p>
|
||||
) : null}
|
||||
{showReviewHints ? (
|
||||
<div className="space-y-3" data-testid="reimbursement-review-hints">
|
||||
{budgetHint ? (
|
||||
<p className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
||||
预算 {budgetHint.budgetCode}:提交后预计使用 {budgetHint.nextUsagePercent.toFixed(2)}%
|
||||
{budgetHint.exceedsThreshold ? ",已达到预警阈值。" : "。"}
|
||||
</p>
|
||||
) : null}
|
||||
{detail.riskFlags.length ? <RiskFlagsCard items={detail.riskFlags} title="待处理风险项" /> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{["approved", "paid"].includes(detail.status) ? (
|
||||
<div id="reimbursement-payment-panel" className="detail-command-panel mt-4" data-testid="reimbursement-payment-status">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h4 className="text-base font-semibold text-zinc-900">付款状态</h4>
|
||||
<span
|
||||
className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${
|
||||
detail.status === "paid"
|
||||
? "border-emerald-200 bg-emerald-50 text-emerald-700"
|
||||
: "border-amber-200 bg-amber-50 text-amber-700"
|
||||
}`}
|
||||
>
|
||||
{detail.status === "paid" ? "已付款" : "未付款"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="detail-fact-grid mt-3 xl:grid-cols-4">
|
||||
<div className="detail-fact-card">
|
||||
<div className="detail-fact-card__label">付款日期</div>
|
||||
<div className="detail-fact-card__value">{payment.paymentDate ?? "-"}</div>
|
||||
</div>
|
||||
<div className="detail-fact-card">
|
||||
<div className="detail-fact-card__label">付款凭证号</div>
|
||||
<div className="detail-fact-card__value">{payment.paymentVoucherNo ?? "-"}</div>
|
||||
</div>
|
||||
<div className="detail-fact-card">
|
||||
<div className="detail-fact-card__label">付款渠道</div>
|
||||
<div className="detail-fact-card__value">{paymentChannelLabel(payment.paymentChannel)}</div>
|
||||
</div>
|
||||
<div className="detail-fact-card">
|
||||
<div className="detail-fact-card__label">操作人 / 时间</div>
|
||||
<div className="detail-fact-card__value">
|
||||
{payment.paidBy?.name ?? "-"} · {formatDisplayDateTime(payment.paidAt)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{showSecondaryDetail ? (
|
||||
<div className="space-y-4 pt-4" data-testid="reimbursement-secondary-detail">
|
||||
<div className="detail-command-panel">
|
||||
@@ -1291,12 +1245,6 @@ export function ReimbursementDetailPage() {
|
||||
) : null}
|
||||
</SectionPanel>
|
||||
|
||||
<ReimbursementTravelSummary
|
||||
detail={detail}
|
||||
reimbursementId={reimbursementId}
|
||||
editable={Boolean(canEditItems && isReimbursementParticipant)}
|
||||
/>
|
||||
|
||||
<SectionPanel title="报销明细">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-4" data-testid="reimbursement-items">
|
||||
@@ -1489,7 +1437,6 @@ export function ReimbursementDetailPage() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{canOpenInvoiceBindDialog || materialPreviewError ? (
|
||||
<div className="space-y-2" data-testid={TEST_IDS.reimbursement.invoices}>
|
||||
{detail.invoices.map((invoice) => {
|
||||
const invoiceHint = resolveInvoiceNextStepText(invoice);
|
||||
@@ -1529,7 +1476,6 @@ export function ReimbursementDetailPage() {
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-4" data-testid="reimbursement-attachments">
|
||||
{canEditItems ? (
|
||||
@@ -1575,7 +1521,7 @@ export function ReimbursementDetailPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(detail.attachments ?? []).length && (canEditItems || Boolean(materialPreviewError)) ? (
|
||||
{(detail.attachments ?? []).length ? (
|
||||
<div className="space-y-2">
|
||||
{(detail.attachments ?? []).map((attachment) => (
|
||||
<div key={attachment.id} className="detail-evidence-card flex flex-col gap-3 md:flex-row md:items-center">
|
||||
@@ -1617,179 +1563,14 @@ export function ReimbursementDetailPage() {
|
||||
</div>
|
||||
</SectionPanel>
|
||||
|
||||
{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>
|
||||
</div>
|
||||
{detail.project ? (
|
||||
<Link to={`/projects/${detail.project.id}`} className="text-sm text-primary hover:underline">
|
||||
查看项目
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
{canEditItems ? (
|
||||
<select
|
||||
className="select-std mt-4 max-w-xl"
|
||||
value={detail.projectId ?? ""}
|
||||
onChange={(event) => projectLinkMutation.mutate(event.target.value || null)}
|
||||
disabled={projectLinkMutation.isPending}
|
||||
>
|
||||
<option value="">不关联项目</option>
|
||||
{(projectsQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.projectNo} · {item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
<div className="mt-3 text-sm text-zinc-600">
|
||||
{detail.project ? `${detail.project.projectNo} · ${detail.project.name}` : "未关联项目"}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detail.linkedTravelRequest ? (
|
||||
<div className="rounded-lg border border-sky-200 bg-sky-50/70 p-4 text-sm">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h4 className="text-base font-semibold text-zinc-900">
|
||||
关联差旅申请:{detail.linkedTravelRequest.requestNo ?? "未生成申请编号"}
|
||||
</h4>
|
||||
<p className="mt-1 text-zinc-600">{detail.linkedTravelRequest.purpose || "未填写出差目的"}</p>
|
||||
</div>
|
||||
<Link
|
||||
to={`/travel-requests?tab=requests&id=${detail.linkedTravelRequest.id}`}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
查看申请
|
||||
</Link>
|
||||
</div>
|
||||
<div className="mt-3 grid gap-3 md:grid-cols-3">
|
||||
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
|
||||
<div className="text-xs text-zinc-500">日期范围</div>
|
||||
<div className="mt-1 font-medium text-zinc-900">
|
||||
{detail.linkedTravelRequest.startDate ?? "-"} 至 {detail.linkedTravelRequest.endDate ?? "-"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
|
||||
<div className="text-xs text-zinc-500">预估金额</div>
|
||||
<div className="mt-1 font-medium text-zinc-900">¥{detail.linkedTravelRequest.estimatedAmount}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
|
||||
<div className="text-xs text-zinc-500">申请编号</div>
|
||||
<div className="mt-1 font-medium text-zinc-900">
|
||||
{detail.linkedTravelRequest.requestNo ?? "未生成申请编号"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<SectionPanel title="处理进度">
|
||||
<div id="reimbursement-processing-progress-panel">
|
||||
<ReimbursementProcessingProgress progress={processingProgress} />
|
||||
</div>
|
||||
</SectionPanel>
|
||||
) : null}
|
||||
|
||||
{showReviewHints ? (
|
||||
<SectionPanel title="预算与审核提示">
|
||||
<div className="space-y-4">
|
||||
{budgetHint ? (
|
||||
<div className="detail-command-panel text-sm" data-testid="reimbursement-budget-hint">
|
||||
<h3 className="text-base font-semibold text-zinc-900">预算提示</h3>
|
||||
<div className="mt-3">预算池:{budgetHint.budgetCode} / {budgetHint.budgetName}</div>
|
||||
<div>
|
||||
本次提交后预计占用:{budgetHint.nextUsedAmount} / {budgetHint.allocatedAmount}
|
||||
({budgetHint.nextUsagePercent.toFixed(2)}%)
|
||||
</div>
|
||||
<div>控制方式:{budgetControlModeLabel(budgetHint.controlMode)}</div>
|
||||
{budgetHint.exceedsBudget && budgetHint.controlMode === "block" ? (
|
||||
<p className="mt-3 rounded-md border border-red-200 bg-red-50 px-2 py-1 text-red-600">
|
||||
当前金额将超出预算上限,提交时会被阻断。
|
||||
</p>
|
||||
) : budgetHint.exceedsThreshold ? (
|
||||
<p className="mt-3 rounded-md border border-amber-200 bg-amber-50 px-2 py-1 text-amber-700">
|
||||
当前金额已达到预算预警阈值,提交后会记录预算预警审计。
|
||||
</p>
|
||||
) : (
|
||||
<p className="mt-3 text-zinc-500">当前预算充足,可继续提交。</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{detail.riskFlags.length ? <RiskFlagsCard items={detail.riskFlags} title="待处理风险项" /> : null}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
) : null}
|
||||
|
||||
{showApprovalRecords ? (
|
||||
<SectionPanel title="审批与处理记录">
|
||||
<div id="reimbursement-workflow-progress-panel" className="space-y-4">
|
||||
{detail.workflowProgress ? (
|
||||
<div className="detail-command-panel">
|
||||
<div className="mb-3 text-sm text-zinc-500">
|
||||
当前位于第 {detail.workflowProgress.currentStepIndex + 1} / {detail.workflowProgress.totalSteps} 步。
|
||||
</div>
|
||||
<div className="detail-timeline" data-testid={TEST_IDS.reimbursement.workflowProgress}>
|
||||
{detail.workflowProgress.steps.map((step, index) => (
|
||||
<div
|
||||
key={step.id}
|
||||
className={
|
||||
step.status === "current"
|
||||
? "detail-timeline-item detail-timeline-item--current"
|
||||
: step.status === "completed"
|
||||
? "detail-timeline-item detail-timeline-item--done"
|
||||
: "detail-timeline-item"
|
||||
}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3 text-sm">
|
||||
<span className="font-medium text-zinc-900">
|
||||
第 {index + 1} 步 · {step.name}
|
||||
</span>
|
||||
<span className="text-xs text-zinc-500">
|
||||
{step.status === "completed" ? "已完成" : step.status === "current" ? "当前节点" : "待处理"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{detail.approvalHistory.length ? (
|
||||
<div className="detail-command-panel">
|
||||
<h3 className="text-base font-semibold text-zinc-900">审批记录</h3>
|
||||
<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">{formatDisplayDateTime(item.createdAt)}</span>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{item.stepName ?? "-"} · {item.actorName || "系统"}
|
||||
</div>
|
||||
{item.comment ? <div className="mt-2 text-zinc-600">{item.comment}</div> : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SectionPanel>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-5">
|
||||
{detail.status === "approved" && canPay ? (
|
||||
<DetailSideMeta title="付款">
|
||||
<Button asChild variant="secondary" size="sm">
|
||||
<Link to="/payment-batches">进入付款批次</Link>
|
||||
</Button>
|
||||
</DetailSideMeta>
|
||||
) : null}
|
||||
|
||||
<ReimbursementActions
|
||||
status={detail.status}
|
||||
showSubmitAction={false}
|
||||
|
||||
在新工单中引用
屏蔽一个用户