文件
hyb-oa/client/src/pages/MobilePurchaseRequestsPage.tsx
T

165 行
6.1 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 { 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 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>
</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 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 totalAmount = useMemo(
() => items.reduce((sum, item) => sum + Number(item.totalAmount ?? 0), 0),
[items],
);
return (
<PageScaffold className="space-y-4">
<PageHeader title="采购" />
<section className="mobile-card">
<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">{listQuery.data?.total ?? 0}</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">
<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 && !items.length ? (
<div className="mobile-empty-state">暂无采购申请</div>
) : null}
{items.map((item) => (
<PurchaseRequestCard currentMobileSource={currentMobileSource} item={item} key={item.id} />
))}
</div>
</section>
</PageScaffold>
);
}
export default MobilePurchaseRequestsPage;