R19/R20: 项目主域进 OA 并打通报销差旅周报待办

这个提交包含在:
2026-03-22 08:34:18 +08:00
父节点 0235ccb968
当前提交 3f9738ac9c
共修改 28 个文件,包含 3236 行新增和 21 行删除
+234
查看文件
@@ -0,0 +1,234 @@
import { useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { fetchEmployees } from "../api/employees";
import { mapApiErrorMessage } from "../api/errorMessages";
import { createContractRegistry, fetchContractsRegistry, patchContractRegistry, type ContractRegistryItem } from "../api/contractsRegistry";
import { fetchProjects, type ConfidentialityLevel } from "../api/projects";
import { isApiError } from "../api/types";
import { PageHeader } from "../components/common/PageHeader";
import { StatusBadge } from "../components/common/StatusBadge";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Textarea } from "../components/ui/textarea";
import { toast } from "sonner";
const CONFIDENTIALITY_OPTIONS: ConfidentialityLevel[] = ["public", "internal", "confidential", "restricted"];
function resolveError(error: unknown, fallback = "操作失败") {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
return error instanceof Error ? error.message : fallback;
}
function confidentialityLabel(value: ConfidentialityLevel) {
if (value === "public") return "公开";
if (value === "internal") return "内部";
if (value === "confidential") return "机密";
return "限制";
}
function nextStatus(item: ContractRegistryItem) {
return item.status === "archived" ? "active" : "archived";
}
export function ContractsRegistryPage() {
const queryClient = useQueryClient();
const [searchParams] = useSearchParams();
const initialProjectId = searchParams.get("projectId") ?? "";
const [projectId, setProjectId] = useState(initialProjectId);
const [contractNo, setContractNo] = useState("");
const [counterpartyName, setCounterpartyName] = useState("");
const [summary, setSummary] = useState("");
const [archiveLocation, setArchiveLocation] = useState("");
const [offlineArchiveNo, setOfflineArchiveNo] = useState("");
const [ownerEmployeeId, setOwnerEmployeeId] = useState("");
const [confidentialityLevel, setConfidentialityLevel] = useState<ConfidentialityLevel>("internal");
const [status, setStatus] = useState("active");
const contractsQuery = useQuery({
queryKey: ["contracts-registry", projectId],
queryFn: () => fetchContractsRegistry(projectId || undefined),
});
const projectsQuery = useQuery({ queryKey: ["projects", "contracts-page"], queryFn: () => fetchProjects({}) });
const employeesQuery = useQuery({ queryKey: ["employees", "contracts-page"], queryFn: () => fetchEmployees({ page: 1, pageSize: 100, status: "active" }) });
const projectOptions = projectsQuery.data?.items ?? [];
const employeeOptions = employeesQuery.data?.items ?? [];
const groupedContracts = useMemo(() => contractsQuery.data?.items ?? [], [contractsQuery.data?.items]);
const refreshAll = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["contracts-registry"] }),
queryClient.invalidateQueries({ queryKey: ["projects"] }),
]);
};
const createMutation = useMutation({
mutationFn: () =>
createContractRegistry({
contractNo: contractNo.trim(),
projectId: projectId || null,
counterpartyName: counterpartyName.trim(),
summary: summary.trim(),
archiveLocation: archiveLocation.trim(),
offlineArchiveNo: offlineArchiveNo.trim() || null,
ownerEmployeeId: ownerEmployeeId || undefined,
confidentialityLevel,
status,
}),
onSuccess: async () => {
setContractNo("");
setCounterpartyName("");
setSummary("");
setArchiveLocation("");
setOfflineArchiveNo("");
setOwnerEmployeeId("");
setConfidentialityLevel("internal");
setStatus("active");
await refreshAll();
toast.success("合同台账已创建");
},
onError: (error) => toast.error(resolveError(error, "创建合同台账失败")),
});
const toggleStatusMutation = useMutation({
mutationFn: (item: ContractRegistryItem) => patchContractRegistry(item.id, { status: nextStatus(item) }),
onSuccess: refreshAll,
onError: (error) => toast.error(resolveError(error, "更新合同状态失败")),
});
return (
<div className="space-y-6">
<PageHeader
title="合同台账"
subtitle="只维护合同元数据、归档位置和项目关联,不在 OA 存放涉密正文附件。"
actions={
<Button asChild variant="secondary" type="button">
<Link to="/projects">返回项目</Link>
</Button>
}
/>
<div className="grid gap-6 xl:grid-cols-[360px,minmax(0,1fr)]">
<div className="card-std space-y-4 p-6">
<h3 className="text-base font-semibold text-zinc-900">新建合同台账</h3>
<div className="space-y-2">
<Label>合同编号</Label>
<Input value={contractNo} onChange={(event) => setContractNo(event.target.value)} placeholder="例如 HT-2026-001" />
</div>
<div className="space-y-2">
<Label>关联项目</Label>
<select className="select-std" value={projectId} onChange={(event) => setProjectId(event.target.value)}>
<option value="">未关联</option>
{projectOptions.map((item) => (
<option key={item.id} value={item.id}>
{item.projectNo} · {item.name}
</option>
))}
</select>
</div>
<div className="space-y-2">
<Label>相对方</Label>
<Input value={counterpartyName} onChange={(event) => setCounterpartyName(event.target.value)} placeholder="合同对方名称" />
</div>
<div className="space-y-2">
<Label>摘要</Label>
<Textarea value={summary} onChange={(event) => setSummary(event.target.value)} placeholder="只写最小必要摘要,不录入涉密正文" />
</div>
<div className="space-y-2">
<Label>归档位置</Label>
<Input value={archiveLocation} onChange={(event) => setArchiveLocation(event.target.value)} placeholder="NAS/文件柜/共享盘路径" />
</div>
<div className="space-y-2">
<Label>离线档案号</Label>
<Input value={offlineArchiveNo} onChange={(event) => setOfflineArchiveNo(event.target.value)} placeholder="可选" />
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="space-y-2">
<Label>密级</Label>
<select className="select-std" value={confidentialityLevel} onChange={(event) => setConfidentialityLevel(event.target.value as ConfidentialityLevel)}>
{CONFIDENTIALITY_OPTIONS.map((item) => (
<option key={item} value={item}>{confidentialityLabel(item)}</option>
))}
</select>
</div>
<div className="space-y-2">
<Label>状态</Label>
<select className="select-std" value={status} onChange={(event) => setStatus(event.target.value)}>
<option value="active">active</option>
<option value="draft">draft</option>
<option value="archived">archived</option>
</select>
</div>
</div>
<div className="space-y-2">
<Label>责任人</Label>
<select className="select-std" value={ownerEmployeeId} onChange={(event) => setOwnerEmployeeId(event.target.value)}>
<option value="">默认当前用户</option>
{employeeOptions.map((item) => (
<option key={item.id} value={item.id}>{item.name} · {item.employeeNo}</option>
))}
</select>
</div>
<Button
type="button"
disabled={createMutation.isPending || !contractNo.trim() || !counterpartyName.trim() || !summary.trim() || !archiveLocation.trim()}
onClick={() => createMutation.mutate()}
>
{createMutation.isPending ? "创建中..." : "创建合同台账"}
</Button>
</div>
<div className="card-std space-y-4 p-6">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-base font-semibold text-zinc-900">台账列表</h3>
<div className="text-sm text-zinc-500">{projectId ? "已按项目过滤" : "显示全部可见合同"}</div>
</div>
{contractsQuery.isLoading ? <div className="text-sm text-zinc-500">加载中...</div> : null}
{contractsQuery.isError ? <div className="text-sm text-red-600">{resolveError(contractsQuery.error, "加载合同台账失败")}</div> : null}
<div className="space-y-3">
{groupedContracts.map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-200 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<div className="flex items-center gap-3">
<div className="font-semibold text-zinc-900">{item.contractNo}</div>
<StatusBadge status={item.status} />
<span className="rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600">{confidentialityLabel(item.confidentialityLevel)}</span>
</div>
<div className="mt-1 text-sm text-zinc-900">{item.counterpartyName}</div>
<div className="mt-1 text-sm text-zinc-600">{item.summary}</div>
<div className="mt-2 text-xs text-zinc-500">
项目:{item.project ? `${item.project.projectNo} · ${item.project.name}` : "未关联"} · 责任人:{item.owner?.name ?? "-"}
</div>
<div className="mt-1 text-xs text-zinc-500">归档:{item.archiveLocation}{item.offlineArchiveNo ? ` · 离线档案号 ${item.offlineArchiveNo}` : ""}</div>
</div>
<div className="flex gap-2">
{item.project ? (
<Button asChild size="sm" variant="secondary" type="button">
<Link to={`/projects/${item.project.id}`}>查看项目</Link>
</Button>
) : null}
<Button size="sm" variant="secondary" type="button" disabled={toggleStatusMutation.isPending} onClick={() => toggleStatusMutation.mutate(item)}>
{item.status === "archived" ? "恢复为 active" : "归档"}
</Button>
</div>
</div>
</div>
))}
{!contractsQuery.isLoading && !groupedContracts.length ? <div className="text-sm text-zinc-500">暂无合同台账。</div> : null}
</div>
</div>
</div>
</div>
);
}
export default ContractsRegistryPage;