R19/R20: 项目主域进 OA 并打通报销差旅周报待办
这个提交包含在:
@@ -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;
|
||||
在新工单中引用
屏蔽一个用户