239 行
9.0 KiB
TypeScript
239 行
9.0 KiB
TypeScript
import { useMemo, useState } from "react";
|
|
import { Link, useLocation, useSearchParams } from "react-router-dom";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import {
|
|
fetchPurchaseRequests,
|
|
type PurchaseRequestStatus,
|
|
type PurchaseRequestSummary,
|
|
} from "../api/purchaseRequests";
|
|
import { PageHeader } from "../components/common/PageHeader";
|
|
import { StatusBadge } from "../components/common/StatusBadge";
|
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
|
import { Input } from "../components/ui/input";
|
|
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
|
|
import { resolvePurchaseDisplayCode } from "../lib/approvalDisplay";
|
|
import { joinBusinessMeta } from "../lib/businessDisplay";
|
|
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
|
|
|
const STATUS_TABS: Array<{ value: PurchaseRequestStatus; label: string }> = [
|
|
{ value: "draft", label: "草稿" },
|
|
{ value: "reviewing", label: "审批中" },
|
|
{ value: "approved", label: "已通过" },
|
|
{ value: "rejected", label: "已驳回" },
|
|
{ value: "cancelled", label: "已取消" },
|
|
];
|
|
|
|
function isPurchaseStatus(value: string | null): value is PurchaseRequestStatus {
|
|
return ["draft", "reviewing", "approved", "rejected", "cancelled"].includes(value ?? "");
|
|
}
|
|
|
|
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 resolvePurchaseListNextStep(status: PurchaseRequestStatus) {
|
|
if (status === "draft") return "继续完善采购材料";
|
|
if (status === "reviewing") return "查看审批进度";
|
|
if (status === "rejected") return "按意见补材料";
|
|
if (status === "approved") return "核对到货 / 归档";
|
|
return "查看取消记录";
|
|
}
|
|
|
|
function resolvePurchaseListEvidence(item: PurchaseRequestSummary) {
|
|
if (!item.supplierId) return "待补供应商";
|
|
if (!item.legalEntityId || !item.costCenterId) return "待补公司 / 成本中心";
|
|
if (!item.documentCategoryId) return "待补单据分类";
|
|
if (!item.budgetPoolId) return "预算池未匹配";
|
|
if (!item.projectId) return "未关联项目";
|
|
return "基础依据已齐";
|
|
}
|
|
|
|
function resolveMobileEmptyState(status: PurchaseRequestStatus) {
|
|
if (status === "draft") return "暂无采购草稿";
|
|
if (status === "reviewing") return "暂无审批中的采购";
|
|
if (status === "rejected") return "暂无需要补材料的采购";
|
|
if (status === "approved") return "暂无已通过采购";
|
|
return "暂无已取消采购";
|
|
}
|
|
|
|
function normalizeSearchValue(value: string | null | undefined) {
|
|
return String(value ?? "").trim().toLowerCase();
|
|
}
|
|
|
|
function matchesPurchaseSearch(item: PurchaseRequestSummary, keyword: string) {
|
|
const searchValue = normalizeSearchValue(keyword);
|
|
if (!searchValue) {
|
|
return true;
|
|
}
|
|
|
|
const haystack = [
|
|
resolvePurchaseDisplayCode(item),
|
|
item.title,
|
|
item.reason,
|
|
item.totalAmount,
|
|
item.applicant?.name,
|
|
item.supplier?.name,
|
|
item.project?.name,
|
|
].map(normalizeSearchValue).join(" ");
|
|
|
|
return haystack.includes(searchValue);
|
|
}
|
|
|
|
function PurchaseRequestCard({
|
|
item,
|
|
currentMobileSource,
|
|
}: {
|
|
item: PurchaseRequestSummary;
|
|
currentMobileSource: string;
|
|
}) {
|
|
const displayCode = resolvePurchaseDisplayCode(item);
|
|
|
|
return (
|
|
<Link
|
|
className="mobile-list-card block"
|
|
data-testid="mobile-purchase-request-card"
|
|
state={{ from: currentMobileSource }}
|
|
to={`/mobile/purchase-requests/${encodeURIComponent(item.id)}`}
|
|
>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<div className="truncate text-sm font-semibold text-zinc-900">{displayCode}</div>
|
|
<div className="mt-1 truncate text-xs text-zinc-500">
|
|
{joinBusinessMeta([
|
|
item.title || null,
|
|
item.applicant?.name ? `申请人:${item.applicant.name}` : null,
|
|
item.updatedAt ? `更新:${formatDate(item.updatedAt)}` : null,
|
|
])}
|
|
</div>
|
|
</div>
|
|
<StatusBadge className="shrink-0" status={item.status} />
|
|
</div>
|
|
<div className="mt-3 text-sm text-zinc-600">
|
|
{joinBusinessMeta([
|
|
`金额:${formatCurrency(item.totalAmount, item.currencyCode)}`,
|
|
item.supplier?.name ? `供应商:${item.supplier.name}` : null,
|
|
item.expectedDeliveryDate ? `到货:${formatDate(item.expectedDeliveryDate)}` : null,
|
|
])}
|
|
</div>
|
|
<div className="mt-3 rounded-lg bg-slate-50 px-3 py-2 text-xs leading-5 text-zinc-600">
|
|
<div>材料:{resolvePurchaseListEvidence(item)}</div>
|
|
<div>下一步:{resolvePurchaseListNextStep(item.status)}</div>
|
|
</div>
|
|
</Link>
|
|
);
|
|
}
|
|
|
|
export function MobilePurchaseRequestsPage() {
|
|
const location = useLocation();
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const [status, setStatus] = useState<PurchaseRequestStatus>(() => {
|
|
const queryStatus = searchParams.get("status");
|
|
return isPurchaseStatus(queryStatus) ? queryStatus : "draft";
|
|
});
|
|
const [searchInput, setSearchInput] = useState("");
|
|
|
|
const currentMobileSource = useMemo(() => {
|
|
const params = new URLSearchParams(location.search);
|
|
params.set("status", status);
|
|
const query = params.toString();
|
|
return `${location.pathname}${query ? `?${query}` : ""}`;
|
|
}, [location.pathname, location.search, status]);
|
|
|
|
const listQuery = useQuery({
|
|
queryKey: ["mobile", "purchase-requests", status],
|
|
queryFn: () => fetchPurchaseRequests({ mine: true, status, page: 1, pageSize: 20 }),
|
|
});
|
|
|
|
function handleStatusChange(value: string) {
|
|
if (!isPurchaseStatus(value)) {
|
|
return;
|
|
}
|
|
setStatus(value);
|
|
const nextParams = new URLSearchParams(searchParams);
|
|
nextParams.set("status", value);
|
|
setSearchParams(nextParams, { replace: true });
|
|
}
|
|
|
|
const items = listQuery.data?.items ?? [];
|
|
const visibleItems = useMemo(
|
|
() => items.filter((item) => matchesPurchaseSearch(item, searchInput)),
|
|
[items, searchInput],
|
|
);
|
|
const totalAmount = useMemo(
|
|
() => visibleItems.reduce((sum, item) => sum + Number(item.totalAmount ?? 0), 0),
|
|
[visibleItems],
|
|
);
|
|
|
|
return (
|
|
<PageScaffold className="space-y-4">
|
|
<PageHeader title="我的采购" />
|
|
|
|
<section className="mobile-card">
|
|
<div className="text-sm font-semibold text-zinc-900">当前采购任务</div>
|
|
<div className="mt-1 text-xs leading-5 text-zinc-500">先看材料是否齐,再进详情核对采购依据。</div>
|
|
<div className="mobile-info-grid">
|
|
<div>
|
|
<div className="text-xs text-zinc-500">当前列表</div>
|
|
<div className="mt-1 text-2xl font-semibold text-zinc-900">{visibleItems.length}</div>
|
|
</div>
|
|
<div>
|
|
<div className="text-xs text-zinc-500">当前金额</div>
|
|
<div className="mt-1 text-2xl font-semibold text-zinc-900">{formatCurrency(String(totalAmount))}</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<section className="mobile-card" data-testid="mobile-purchase-requests-list">
|
|
<div className="mobile-command-surface mb-4">
|
|
<label className="text-xs font-medium text-zinc-500" htmlFor="mobile-purchase-current-search">
|
|
当前列表搜索
|
|
</label>
|
|
<Input
|
|
id="mobile-purchase-current-search"
|
|
aria-label="当前列表搜索"
|
|
value={searchInput}
|
|
onChange={(event) => setSearchInput(event.target.value)}
|
|
placeholder="搜索编号、标题、供应商"
|
|
/>
|
|
</div>
|
|
<Tabs value={status} onValueChange={handleStatusChange}>
|
|
<TabsList className="grid h-auto w-full grid-cols-3 gap-1 min-[390px]:grid-cols-5">
|
|
{STATUS_TABS.map((item) => (
|
|
<TabsTrigger key={item.value} value={item.value} data-testid={`mobile-purchase-tab-${item.value}`}>
|
|
{item.label}
|
|
</TabsTrigger>
|
|
))}
|
|
</TabsList>
|
|
</Tabs>
|
|
|
|
<div className="mt-4 space-y-3">
|
|
{listQuery.isLoading ? <div className="mobile-empty-state">加载中...</div> : null}
|
|
{listQuery.isError ? (
|
|
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-600">
|
|
{resolveErrorMessage(listQuery.error)}
|
|
</div>
|
|
) : null}
|
|
{!listQuery.isLoading && !listQuery.isError && !visibleItems.length ? (
|
|
<div className="mobile-empty-state">
|
|
{searchInput.trim() ? "没有符合搜索的采购记录" : resolveMobileEmptyState(status)}
|
|
</div>
|
|
) : null}
|
|
{visibleItems.map((item) => (
|
|
<PurchaseRequestCard currentMobileSource={currentMobileSource} item={item} key={item.id} />
|
|
))}
|
|
</div>
|
|
</section>
|
|
</PageScaffold>
|
|
);
|
|
}
|
|
|
|
export default MobilePurchaseRequestsPage;
|