feat(P25-2/P25-3/P26-2/P28-1/P24-2/P29-3): 落地内部OA够用版前端入口与运营页面

关联 Issue: P25-2, P25-3, P25-4, P26-2, P26-4, P28-1, P28-2, P28-3, P28-4, P24-2, P29-3
这个提交包含在:
2026-03-21 18:03:48 +08:00
父节点 e39e41287e
当前提交 9905c1c79d
共修改 44 个文件,包含 2029 行新增和 169 行删除
+51 -9
查看文件
@@ -12,6 +12,7 @@ import {
fetchPurchaseRequestErpTrack,
fetchPurchaseRequests,
issuePurchaseAttachmentSignedUrl,
precheckPurchaseRequest,
requeuePurchaseRequestErpSync,
submitPurchaseRequest,
updatePurchaseRequest,
@@ -32,6 +33,7 @@ import {
import { useAuth } from "../auth/AuthContext";
import { hasPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
import { Pagination } from "../components/common/Pagination";
import { StatusBadge } from "../components/common/StatusBadge";
@@ -40,6 +42,7 @@ import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
import { Textarea } from "../components/ui/textarea";
import { buildPrecheckDialogMessage } from "../lib/precheck";
const PAGE_SIZE = 20;
@@ -211,6 +214,7 @@ export function PurchaseRequestsPage() {
const [costCenterFilter, setCostCenterFilter] = useState("");
const [page, setPage] = useState(1);
const [selectedId, setSelectedId] = useState("");
const [hydratedDetailId, setHydratedDetailId] = useState("");
const [formError, setFormError] = useState("");
const [title, setTitle] = useState("");
@@ -296,7 +300,7 @@ export function PurchaseRequestsPage() {
}, [detailQuery.isError, detailQuery.isLoading, listQuery.data?.items, selectedId]);
useEffect(() => {
if (!detailQuery.data) {
if (!detailQuery.data || detailQuery.data.id !== selectedId || hydratedDetailId === detailQuery.data.id) {
return;
}
setTitle(detailQuery.data.title || "");
@@ -320,7 +324,8 @@ export function PurchaseRequestsPage() {
setArchivePoNo(detailQuery.data.archiveOrder?.poNo || "");
setArchiveContractNo(detailQuery.data.archiveOrder?.contractNo || "");
setFormError("");
}, [detailQuery.data]);
setHydratedDetailId(detailQuery.data.id);
}, [detailQuery.data, hydratedDetailId, selectedId]);
useEffect(() => {
if (!archiveOrderQuery.data) {
@@ -355,6 +360,7 @@ export function PurchaseRequestsPage() {
mutationFn: () => createPurchaseRequest(),
onSuccess: async (data) => {
setSelectedId(data.purchaseRequest.id);
setHydratedDetailId("");
await queryClient.invalidateQueries({ queryKey: ["purchase-requests", "list"] });
await queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail"] });
toast.success("采购草稿已创建");
@@ -444,21 +450,22 @@ export function PurchaseRequestsPage() {
if (!selectedId) {
throw new Error("请先选择采购单");
}
validateBeforeSubmit();
await updatePurchaseRequest(selectedId, buildPayload());
return submitPurchaseRequest(selectedId);
},
onSuccess: async (data) => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["purchase-requests"] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "detail", selectedId] }),
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
queryClient.invalidateQueries({ queryKey: ["purchase-requests", "erp-track", selectedId] }),
]);
setFormError("");
toast.success(
data.warnings.includes("missing_budget_pool")
? "采购申请已提交(未匹配预算池)"
: "采购申请已提交",
data.precheckDecision === "manual_review"
? `采购申请已提交,已生成 ${data.openedRiskFlagCount} 条待复核风险`
: data.warnings.includes("missing_budget_pool")
? "采购申请已提交(未匹配预算池)"
: "采购申请已提交",
);
},
onError: (error) => {
@@ -606,6 +613,36 @@ export function PurchaseRequestsPage() {
const detail = detailQuery.data;
const archiveOrder = archiveOrderQuery.data?.archiveOrder ?? detail?.archiveOrder ?? null;
const handleSubmitAction = async () => {
if (!selectedId) {
return;
}
try {
setFormError("");
validateBeforeSubmit();
await updatePurchaseRequest(selectedId, buildPayload());
const precheck = await precheckPurchaseRequest(selectedId);
const message = buildPrecheckDialogMessage(precheck);
if (precheck.decision === "block") {
setFormError(message);
toast.error("提交前校验未通过");
return;
}
if ((precheck.decision === "warn" || precheck.decision === "manual_review") && !window.confirm(message)) {
return;
}
submitMutation.mutate();
} catch (error) {
const message = resolveError(error);
setFormError(message);
toast.error(message);
}
};
const erpTrack = erpTrackQuery.data ?? null;
const canRetryErp = Boolean(canIntegrationManage && erpTrack?.permissions.canRetry);
const showArchiveSection = Boolean(detail && (detail.status === "approved" || archiveOrder));
@@ -767,7 +804,10 @@ export function PurchaseRequestsPage() {
<TableRow
key={item.id}
className={item.id === selectedId ? "bg-zinc-50" : undefined}
onClick={() => setSelectedId(item.id)}
onClick={() => {
setSelectedId(item.id);
setHydratedDetailId("");
}}
>
<TableCell className="cursor-pointer">
<div className="font-medium text-zinc-900">{item.requestNo || item.title || item.id}</div>
@@ -835,7 +875,7 @@ export function PurchaseRequestsPage() {
data-testid="purchase-submit"
type="button"
disabled={!canSubmit || submitMutation.isPending}
onClick={() => submitMutation.mutate()}
onClick={() => void handleSubmitAction()}
>
{submitMutation.isPending ? "提交中..." : "提交审批"}
</Button>
@@ -851,6 +891,8 @@ export function PurchaseRequestsPage() {
</div>
</div>
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<label className="text-sm font-medium text-zinc-700">标题</label>