203 行
9.7 KiB
TypeScript
203 行
9.7 KiB
TypeScript
import { useParams } from "react-router-dom";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { fetchPurchaseRequestDetail } from "../api/purchaseRequests";
|
|
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
|
|
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
|
|
import { StatusBadge } from "../components/common/StatusBadge";
|
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
|
import {
|
|
formatApprovalHistoryTime,
|
|
resolvePurchaseDisplayCode,
|
|
resolvePurchaseStatusHint,
|
|
workflowActionLabel,
|
|
} from "../lib/approvalDisplay";
|
|
import { joinBusinessMeta } from "../lib/businessDisplay";
|
|
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
|
|
|
function formatCurrency(value: string | null | undefined, currencyCode = "CNY") {
|
|
const amount = Number(value ?? 0);
|
|
if (!Number.isFinite(amount)) {
|
|
return "-";
|
|
}
|
|
return new Intl.NumberFormat("zh-CN", { style: "currency", currency: currencyCode }).format(amount);
|
|
}
|
|
|
|
function formatDate(value: string | null | undefined) {
|
|
return value ? value.slice(0, 10) : "-";
|
|
}
|
|
|
|
function formatBytes(value: number) {
|
|
if (value < 1024) {
|
|
return `${value} B`;
|
|
}
|
|
if (value < 1024 * 1024) {
|
|
return `${(value / 1024).toFixed(1)} KB`;
|
|
}
|
|
return `${(value / 1024 / 1024).toFixed(1)} MB`;
|
|
}
|
|
|
|
function purchaseProgressFallback(status: string | undefined) {
|
|
if (status === "draft") return "/mobile/approvals?progress=draft";
|
|
if (status === "approved") return "/mobile/approvals?progress=approved";
|
|
if (status === "rejected" || status === "cancelled") return "/mobile/approvals?progress=returned";
|
|
return "/mobile/approvals?progress=reviewing";
|
|
}
|
|
|
|
export function MobilePurchaseRequestDetailPage() {
|
|
const { purchaseRequestId = "" } = useParams();
|
|
|
|
const detailQuery = useQuery({
|
|
enabled: Boolean(purchaseRequestId),
|
|
queryKey: ["mobile", "purchase-request", "detail", purchaseRequestId],
|
|
queryFn: () => fetchPurchaseRequestDetail(purchaseRequestId),
|
|
});
|
|
|
|
const detail = detailQuery.data ?? null;
|
|
const displayCode = detail ? resolvePurchaseDisplayCode(detail) : "采购详情";
|
|
const nextStepText = detail
|
|
? detail.pendingWorkflow?.task?.stepName
|
|
? `当前在 ${detail.pendingWorkflow.task.stepName},可从审批详情处理;本页用于核对采购依据。`
|
|
: resolvePurchaseStatusHint(detail)
|
|
: "";
|
|
|
|
return (
|
|
<PageScaffold className="space-y-3 pb-4">
|
|
<MobileDetailHeader
|
|
title={displayCode}
|
|
subtitle="采购详情"
|
|
status={detail ? <StatusBadge status={detail.status} /> : null}
|
|
fallbackTo={purchaseProgressFallback(detail?.status)}
|
|
testId="mobile-purchase-detail-header"
|
|
/>
|
|
|
|
{!purchaseRequestId ? (
|
|
<div className="mobile-card text-sm text-red-600">缺少采购申请 ID。</div>
|
|
) : null}
|
|
|
|
{detailQuery.isLoading ? <div className="mobile-card text-sm text-zinc-500">加载中...</div> : null}
|
|
|
|
{detailQuery.isError ? (
|
|
<div className="mobile-card text-sm text-red-600">{resolveErrorMessage(detailQuery.error)}</div>
|
|
) : null}
|
|
|
|
{detail ? (
|
|
<>
|
|
<section className="mobile-card mobile-focus-card mobile-page-stack" data-testid="mobile-purchase-detail-summary">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<h2 className="truncate text-base font-semibold text-zinc-900">{displayCode}</h2>
|
|
<p className="mt-1 text-sm text-zinc-500">{detail.title || "未命名采购申请"}</p>
|
|
</div>
|
|
<StatusBadge status={detail.status} />
|
|
</div>
|
|
<div className="mobile-info-grid mt-4 text-sm">
|
|
<div>
|
|
<div className="text-xs text-zinc-500">金额</div>
|
|
<div className="mt-1 text-lg font-semibold text-zinc-900">
|
|
{formatCurrency(detail.totalAmount, detail.currencyCode)}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs text-zinc-500">申请人</div>
|
|
<div className="mt-1 truncate font-medium text-zinc-900">{detail.applicant?.name ?? "-"}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs text-zinc-500">当前节点</div>
|
|
<div className="mt-1 truncate font-medium text-zinc-900">{detail.pendingWorkflow?.task?.stepName ?? "-"}</div>
|
|
</div>
|
|
</div>
|
|
<div className="mobile-subcard bg-white text-sm">
|
|
<div className="text-xs font-semibold text-zinc-500">下一步</div>
|
|
<div className="mt-1 leading-6 text-zinc-700">{nextStepText}</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="mobile-card" data-testid="mobile-purchase-detail-fields">
|
|
<h3 className="text-base font-semibold text-zinc-900">业务信息</h3>
|
|
<div className="mobile-field-list mt-3">
|
|
<div className="mobile-field-row">
|
|
<div className="mobile-field-label">供应商</div>
|
|
<div className="mobile-field-value">{detail.supplier?.name ?? "-"}</div>
|
|
</div>
|
|
<div className="mobile-field-row">
|
|
<div className="mobile-field-label">期望到货</div>
|
|
<div className="mobile-field-value">{formatDate(detail.expectedDeliveryDate)}</div>
|
|
</div>
|
|
<div className="mobile-field-row">
|
|
<div className="mobile-field-label">法体</div>
|
|
<div className="mobile-field-value">{detail.legalEntity?.name ?? "-"}</div>
|
|
</div>
|
|
<div className="mobile-field-row">
|
|
<div className="mobile-field-label">成本中心</div>
|
|
<div className="mobile-field-value">{detail.costCenter?.name ?? "-"}</div>
|
|
</div>
|
|
<div className="mobile-field-row">
|
|
<div className="mobile-field-label">预算池</div>
|
|
<div className="mobile-field-value">{detail.budgetPool?.name ?? "未匹配"}</div>
|
|
</div>
|
|
<div className="mobile-field-row">
|
|
<div className="mobile-field-label">当前节点</div>
|
|
<div className="mobile-field-value">{detail.pendingWorkflow?.task?.stepName ?? "-"}</div>
|
|
</div>
|
|
{detail.reason ? (
|
|
<div className="mobile-field-row">
|
|
<div className="mobile-field-label">申请说明</div>
|
|
<div className="mobile-field-value">{detail.reason}</div>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="mobile-card" data-testid="mobile-purchase-detail-items">
|
|
<h3 className="text-base font-semibold text-zinc-900">采购明细</h3>
|
|
<div className="mt-3 space-y-2">
|
|
{detail.items.length ? detail.items.map((item) => (
|
|
<article className="mobile-list-card text-sm" key={item.id}>
|
|
<div className="font-medium text-zinc-900">{item.description}</div>
|
|
<div className="mt-1 text-xs text-zinc-500">
|
|
{joinBusinessMeta([`数量:${item.quantity}`, `单价:${formatCurrency(item.unitPrice, detail.currencyCode)}`, `金额:${formatCurrency(item.lineAmount, detail.currencyCode)}`])}
|
|
</div>
|
|
{item.specification ? <div className="mt-1 text-xs text-zinc-500">{item.specification}</div> : null}
|
|
</article>
|
|
)) : <div className="mobile-empty-state">暂无采购明细</div>}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="mobile-card" data-testid="mobile-purchase-detail-attachments">
|
|
<h3 className="text-base font-semibold text-zinc-900">附件</h3>
|
|
<div className="mt-3 space-y-2">
|
|
{detail.attachments.length ? detail.attachments.map((attachment) => (
|
|
<article className="mobile-list-card text-sm" key={attachment.id}>
|
|
<div className="truncate font-medium text-zinc-900">{attachment.originalName}</div>
|
|
<div className="mt-1 text-xs text-zinc-500">
|
|
{joinBusinessMeta([formatBytes(attachment.fileSize), attachment.uploader?.name, formatDate(attachment.createdAt)])}
|
|
</div>
|
|
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
|
|
</article>
|
|
)) : <div className="mobile-empty-state">暂无附件</div>}
|
|
</div>
|
|
</section>
|
|
|
|
<section className="mobile-card" data-testid="mobile-purchase-detail-approval-history">
|
|
<h3 className="text-base font-semibold text-zinc-900">审批记录</h3>
|
|
<div className="mt-3 space-y-2">
|
|
{detail.approvalHistory.length ? detail.approvalHistory.map((item) => (
|
|
<article className="mobile-list-card text-sm" key={item.id}>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="font-medium text-zinc-900">{workflowActionLabel(item.action)}</div>
|
|
<span className="text-xs text-zinc-500">{formatApprovalHistoryTime(item.createdAt)}</span>
|
|
</div>
|
|
<div className="mt-1 text-xs text-zinc-500">{item.actorName ?? item.actorEmployeeNo ?? "-"}</div>
|
|
{item.comment ? <div className="mt-1 text-xs text-zinc-500">{item.comment}</div> : null}
|
|
</article>
|
|
)) : <div className="mobile-empty-state">暂无审批记录</div>}
|
|
</div>
|
|
</section>
|
|
</>
|
|
) : null}
|
|
</PageScaffold>
|
|
);
|
|
}
|
|
|
|
export default MobilePurchaseRequestDetailPage;
|