|
|
|
@@ -0,0 +1,634 @@
|
|
|
|
|
import { useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
|
|
|
|
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
|
|
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
|
|
|
import {
|
|
|
|
|
appendContractRegistryCorrection,
|
|
|
|
|
createContractRegistry,
|
|
|
|
|
fetchContractRegistryDetail,
|
|
|
|
|
patchContractRegistry,
|
|
|
|
|
renewContractRegistry,
|
|
|
|
|
terminateContractRegistry,
|
|
|
|
|
type ContractRegistryDetail,
|
|
|
|
|
type ContractRegistryItem,
|
|
|
|
|
type ContractRegistryType,
|
|
|
|
|
type ContractRegistryWritableStatus,
|
|
|
|
|
} from "../api/contractsRegistry";
|
|
|
|
|
import { fetchEmployees } from "../api/employees";
|
|
|
|
|
import { fetchProjects, type ConfidentialityLevel } from "../api/projects";
|
|
|
|
|
import { useAuth } from "../auth/AuthContext";
|
|
|
|
|
import { hasAnyPermission } from "../auth/permissions";
|
|
|
|
|
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
|
|
|
|
|
import { PageHeader } from "../components/common/PageHeader";
|
|
|
|
|
import { StatusBadge } from "../components/common/StatusBadge";
|
|
|
|
|
import {
|
|
|
|
|
usePostSaveNavigation,
|
|
|
|
|
useUnsavedChangesGuard,
|
|
|
|
|
} from "../components/common/UnsavedChangesGuard";
|
|
|
|
|
import {
|
|
|
|
|
BusinessDetailActionPanel,
|
|
|
|
|
BusinessDetailAside,
|
|
|
|
|
BusinessDetailFact,
|
|
|
|
|
BusinessDetailFactGrid,
|
|
|
|
|
BusinessDetailMain,
|
|
|
|
|
BusinessDetailWorkspace,
|
|
|
|
|
} from "../components/patterns/BusinessDetailTemplate";
|
|
|
|
|
import {
|
|
|
|
|
MOBILE_STICKY_ACTION_PAGE_CLASS,
|
|
|
|
|
MobileStickyActionBar,
|
|
|
|
|
} from "../components/patterns/MobileStickyActionBar";
|
|
|
|
|
import { PageScaffold } from "../components/patterns/PageScaffold";
|
|
|
|
|
import { SectionPanel } from "../components/patterns/SectionPanel";
|
|
|
|
|
import { Button } from "../components/ui/button";
|
|
|
|
|
import { Input } from "../components/ui/input";
|
|
|
|
|
import { Textarea } from "../components/ui/textarea";
|
|
|
|
|
import {
|
|
|
|
|
CONTRACT_TYPE_OPTIONS,
|
|
|
|
|
confidentialityLabel,
|
|
|
|
|
contractStatusLabel,
|
|
|
|
|
contractTypeLabel,
|
|
|
|
|
documentStorageLabel,
|
|
|
|
|
} from "../features/contractsRegistry/presentation";
|
|
|
|
|
import { formatDisplayDate, formatDisplayDateTime, formatMoney } from "../lib/displayFormat";
|
|
|
|
|
import {
|
|
|
|
|
buildPcDetailSource,
|
|
|
|
|
resolvePcDetailSource,
|
|
|
|
|
type PcDetailNavigationState,
|
|
|
|
|
} from "../lib/pcDetailNavigation";
|
|
|
|
|
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
|
|
|
|
import { toast } from "sonner";
|
|
|
|
|
|
|
|
|
|
const CONFIDENTIALITY_OPTIONS: ConfidentialityLevel[] = ["public", "internal", "confidential", "restricted"];
|
|
|
|
|
const AMOUNT_PATTERN = /^\d{1,12}(\.\d{1,2})?$/;
|
|
|
|
|
|
|
|
|
|
type ContractEditorState = {
|
|
|
|
|
contractNo: string;
|
|
|
|
|
projectId: string;
|
|
|
|
|
contractType: ContractRegistryType;
|
|
|
|
|
counterpartyName: string;
|
|
|
|
|
summary: string;
|
|
|
|
|
amount: string;
|
|
|
|
|
currency: string;
|
|
|
|
|
signedDate: string;
|
|
|
|
|
effectiveDate: string;
|
|
|
|
|
expiryDate: string;
|
|
|
|
|
confidentialityLevel: ConfidentialityLevel;
|
|
|
|
|
archiveLocation: string;
|
|
|
|
|
offlineArchiveNo: string;
|
|
|
|
|
ownerEmployeeId: string;
|
|
|
|
|
status: ContractRegistryWritableStatus;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function emptyEditor(ownerEmployeeId = ""): ContractEditorState {
|
|
|
|
|
return {
|
|
|
|
|
contractNo: "",
|
|
|
|
|
projectId: "",
|
|
|
|
|
contractType: "other",
|
|
|
|
|
counterpartyName: "",
|
|
|
|
|
summary: "",
|
|
|
|
|
amount: "",
|
|
|
|
|
currency: "CNY",
|
|
|
|
|
signedDate: "",
|
|
|
|
|
effectiveDate: "",
|
|
|
|
|
expiryDate: "",
|
|
|
|
|
confidentialityLevel: "internal",
|
|
|
|
|
archiveLocation: "",
|
|
|
|
|
offlineArchiveNo: "",
|
|
|
|
|
ownerEmployeeId,
|
|
|
|
|
status: "active",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function editorFromDetail(item: ContractRegistryDetail): ContractEditorState {
|
|
|
|
|
return {
|
|
|
|
|
contractNo: item.contractNo,
|
|
|
|
|
projectId: item.projectId ?? "",
|
|
|
|
|
contractType: item.contractType,
|
|
|
|
|
counterpartyName: item.counterpartyName,
|
|
|
|
|
summary: item.summary,
|
|
|
|
|
amount: item.amount ?? "",
|
|
|
|
|
currency: item.currency,
|
|
|
|
|
signedDate: item.signedDate ?? "",
|
|
|
|
|
effectiveDate: item.effectiveDate ?? "",
|
|
|
|
|
expiryDate: item.expiryDate ?? "",
|
|
|
|
|
confidentialityLevel: item.confidentialityLevel,
|
|
|
|
|
archiveLocation: item.archiveLocation,
|
|
|
|
|
offlineArchiveNo: item.offlineArchiveNo ?? "",
|
|
|
|
|
ownerEmployeeId: item.ownerEmployeeId,
|
|
|
|
|
status: item.persistedStatus === "draft" ? "draft" : "active",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function nextStepText(item: ContractRegistryDetail) {
|
|
|
|
|
if (item.persistedStatus === "terminated") return "合同已终止,仅保留事实与审计记录。";
|
|
|
|
|
if (item.persistedStatus === "archived") return "普通字段已锁定;如需纠错,请追加修正记录。";
|
|
|
|
|
if (item.status === "expired") return "合同已过期,可评估续约、归档或终止。";
|
|
|
|
|
if (item.persistedStatus === "draft") return "补齐合同信息后,可转为生效合同或归档。";
|
|
|
|
|
return "合同处于生效状态,请关注到期和归档安排。";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildPayload(form: ContractEditorState) {
|
|
|
|
|
return {
|
|
|
|
|
contractNo: form.contractNo.trim(),
|
|
|
|
|
projectId: form.projectId || null,
|
|
|
|
|
contractType: form.contractType,
|
|
|
|
|
counterpartyName: form.counterpartyName.trim(),
|
|
|
|
|
summary: form.summary.trim(),
|
|
|
|
|
amount: form.amount.trim() || null,
|
|
|
|
|
currency: form.currency.trim().toUpperCase() || "CNY",
|
|
|
|
|
signedDate: form.signedDate || null,
|
|
|
|
|
effectiveDate: form.effectiveDate || null,
|
|
|
|
|
expiryDate: form.expiryDate || null,
|
|
|
|
|
confidentialityLevel: form.confidentialityLevel,
|
|
|
|
|
archiveLocation: form.archiveLocation.trim(),
|
|
|
|
|
offlineArchiveNo: form.offlineArchiveNo.trim() || null,
|
|
|
|
|
ownerEmployeeId: form.ownerEmployeeId,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function validateForm(form: ContractEditorState) {
|
|
|
|
|
if (!form.contractNo.trim()) throw new Error("请填写合同编号");
|
|
|
|
|
if (!form.counterpartyName.trim()) throw new Error("请填写相对方");
|
|
|
|
|
if (!form.summary.trim()) throw new Error("请填写合同摘要");
|
|
|
|
|
if (!form.archiveLocation.trim()) throw new Error("请填写归档位置");
|
|
|
|
|
if (!form.ownerEmployeeId) throw new Error("请选择负责人");
|
|
|
|
|
if (form.amount.trim() && !AMOUNT_PATTERN.test(form.amount.trim())) {
|
|
|
|
|
throw new Error("合同金额最多 12 位整数和 2 位小数");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ContractEditorFields({
|
|
|
|
|
form,
|
|
|
|
|
setForm,
|
|
|
|
|
projects,
|
|
|
|
|
employees,
|
|
|
|
|
creating,
|
|
|
|
|
}: {
|
|
|
|
|
form: ContractEditorState;
|
|
|
|
|
setForm: Dispatch<SetStateAction<ContractEditorState>>;
|
|
|
|
|
projects: Array<{ id: string; projectNo: string; name: string }>;
|
|
|
|
|
employees: Array<{ id: string; employeeNo: string; name: string }>;
|
|
|
|
|
creating: boolean;
|
|
|
|
|
}) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="space-y-5" data-testid="contract-editor">
|
|
|
|
|
<SectionPanel title="基本信息">
|
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
|
|
|
<label className="space-y-2 text-sm font-medium text-zinc-700">
|
|
|
|
|
合同编号
|
|
|
|
|
<Input data-testid="contract-no" value={form.contractNo} onChange={(event) => setForm((current) => ({ ...current, contractNo: event.target.value }))} />
|
|
|
|
|
</label>
|
|
|
|
|
<label className="space-y-2 text-sm font-medium text-zinc-700">
|
|
|
|
|
合同类型
|
|
|
|
|
<select className="select-std" value={form.contractType} onChange={(event) => setForm((current) => ({ ...current, contractType: event.target.value as ContractRegistryType }))}>
|
|
|
|
|
{CONTRACT_TYPE_OPTIONS.map((item) => <option key={item} value={item}>{contractTypeLabel(item)}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="space-y-2 text-sm font-medium text-zinc-700">
|
|
|
|
|
相对方
|
|
|
|
|
<Input data-testid="contract-counterparty" value={form.counterpartyName} onChange={(event) => setForm((current) => ({ ...current, counterpartyName: event.target.value }))} />
|
|
|
|
|
</label>
|
|
|
|
|
<label className="space-y-2 text-sm font-medium text-zinc-700">
|
|
|
|
|
关联项目
|
|
|
|
|
<select className="select-std" value={form.projectId} onChange={(event) => setForm((current) => ({ ...current, projectId: event.target.value }))}>
|
|
|
|
|
<option value="">不关联项目</option>
|
|
|
|
|
{projects.map((item) => <option key={item.id} value={item.id}>{item.projectNo} · {item.name}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
<label className="space-y-2 text-sm font-medium text-zinc-700">
|
|
|
|
|
负责人
|
|
|
|
|
<select className="select-std" value={form.ownerEmployeeId} onChange={(event) => setForm((current) => ({ ...current, ownerEmployeeId: event.target.value }))}>
|
|
|
|
|
<option value="">请选择负责人</option>
|
|
|
|
|
{employees.map((item) => <option key={item.id} value={item.id}>{item.name} ({item.employeeNo})</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
{creating ? (
|
|
|
|
|
<label className="space-y-2 text-sm font-medium text-zinc-700">
|
|
|
|
|
登记状态
|
|
|
|
|
<select className="select-std" value={form.status} onChange={(event) => setForm((current) => ({ ...current, status: event.target.value as ContractRegistryWritableStatus }))}>
|
|
|
|
|
<option value="active">生效中</option>
|
|
|
|
|
<option value="draft">草稿</option>
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
) : null}
|
|
|
|
|
<label className="space-y-2 text-sm font-medium text-zinc-700 md:col-span-2">
|
|
|
|
|
合同摘要
|
|
|
|
|
<Textarea data-testid="contract-summary" className="min-h-24" value={form.summary} onChange={(event) => setForm((current) => ({ ...current, summary: event.target.value }))} />
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
</SectionPanel>
|
|
|
|
|
|
|
|
|
|
<SectionPanel title="金额与日期">
|
|
|
|
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-3">
|
|
|
|
|
<label className="space-y-2 text-sm font-medium text-zinc-700">合同金额<Input data-testid="contract-amount" inputMode="decimal" value={form.amount} onChange={(event) => setForm((current) => ({ ...current, amount: event.target.value }))} /></label>
|
|
|
|
|
<label className="space-y-2 text-sm font-medium text-zinc-700">币种<Input value={form.currency} maxLength={8} onChange={(event) => setForm((current) => ({ ...current, currency: event.target.value }))} /></label>
|
|
|
|
|
<label className="space-y-2 text-sm font-medium text-zinc-700">签署日期<Input type="date" value={form.signedDate} onChange={(event) => setForm((current) => ({ ...current, signedDate: event.target.value }))} /></label>
|
|
|
|
|
<label className="space-y-2 text-sm font-medium text-zinc-700">生效日期<Input type="date" value={form.effectiveDate} onChange={(event) => setForm((current) => ({ ...current, effectiveDate: event.target.value }))} /></label>
|
|
|
|
|
<label className="space-y-2 text-sm font-medium text-zinc-700">到期日期<Input type="date" value={form.expiryDate} onChange={(event) => setForm((current) => ({ ...current, expiryDate: event.target.value }))} /></label>
|
|
|
|
|
<label className="space-y-2 text-sm font-medium text-zinc-700">
|
|
|
|
|
敏感级别
|
|
|
|
|
<select className="select-std" value={form.confidentialityLevel} onChange={(event) => setForm((current) => ({ ...current, confidentialityLevel: event.target.value as ConfidentialityLevel }))}>
|
|
|
|
|
{CONFIDENTIALITY_OPTIONS.map((item) => <option key={item} value={item}>{confidentialityLabel(item)}</option>)}
|
|
|
|
|
</select>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
</SectionPanel>
|
|
|
|
|
|
|
|
|
|
<SectionPanel title="归档证据">
|
|
|
|
|
<div className="mb-4 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
|
|
|
|
涉密资料请登记线下归档位置
|
|
|
|
|
</div>
|
|
|
|
|
<div className="grid gap-4 md:grid-cols-2">
|
|
|
|
|
<label className="space-y-2 text-sm font-medium text-zinc-700">
|
|
|
|
|
归档位置
|
|
|
|
|
<Input data-testid="contract-archive-location" value={form.archiveLocation} onChange={(event) => setForm((current) => ({ ...current, archiveLocation: event.target.value }))} />
|
|
|
|
|
</label>
|
|
|
|
|
<label className="space-y-2 text-sm font-medium text-zinc-700">
|
|
|
|
|
离线档案号
|
|
|
|
|
<Input data-testid="contract-offline-archive-no" value={form.offlineArchiveNo} onChange={(event) => setForm((current) => ({ ...current, offlineArchiveNo: event.target.value }))} />
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
</SectionPanel>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function ContractRegistryCreatePage() {
|
|
|
|
|
return <ContractRegistryWorkspace mode="create" />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function ContractRegistryDetailPage() {
|
|
|
|
|
const { contractId = "" } = useParams();
|
|
|
|
|
return <ContractRegistryWorkspace mode="detail" contractId={contractId} />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ContractRegistryWorkspace({ mode, contractId = "" }: { mode: "create" | "detail"; contractId?: string }) {
|
|
|
|
|
const navigate = useNavigate();
|
|
|
|
|
const location = useLocation();
|
|
|
|
|
const queryClient = useQueryClient();
|
|
|
|
|
const { me, permissions } = useAuth();
|
|
|
|
|
const creating = mode === "create";
|
|
|
|
|
const canManage = hasAnyPermission(permissions, ["project.manage", "project.view_all", "system.settings"]);
|
|
|
|
|
const currentPath = buildPcDetailSource(location.pathname, location.search);
|
|
|
|
|
const navigationState = location.state as PcDetailNavigationState | null;
|
|
|
|
|
const backSource = resolvePcDetailSource(navigationState?.from, currentPath, "contract") ?? "/contracts-registry";
|
|
|
|
|
const [editing, setEditing] = useState(creating);
|
|
|
|
|
const [form, setForm] = useState<ContractEditorState>(() => emptyEditor(me?.id ?? ""));
|
|
|
|
|
const [savedSignature, setSavedSignature] = useState(() => JSON.stringify(emptyEditor(me?.id ?? "")));
|
|
|
|
|
const [formError, setFormError] = useState("");
|
|
|
|
|
const [terminateReason, setTerminateReason] = useState("");
|
|
|
|
|
const [correctionSummary, setCorrectionSummary] = useState("");
|
|
|
|
|
const [confirmTerminate, setConfirmTerminate] = useState(false);
|
|
|
|
|
const [confirmArchive, setConfirmArchive] = useState(false);
|
|
|
|
|
const [confirmRenew, setConfirmRenew] = useState(false);
|
|
|
|
|
|
|
|
|
|
const detailQuery = useQuery({
|
|
|
|
|
enabled: !creating && Boolean(contractId),
|
|
|
|
|
queryKey: ["contracts-registry", "detail", contractId],
|
|
|
|
|
queryFn: () => fetchContractRegistryDetail(contractId),
|
|
|
|
|
});
|
|
|
|
|
const projectsQuery = useQuery({
|
|
|
|
|
enabled: canManage,
|
|
|
|
|
queryKey: ["projects", "contract-options"],
|
|
|
|
|
queryFn: () => fetchProjects(),
|
|
|
|
|
});
|
|
|
|
|
const employeesQuery = useQuery({
|
|
|
|
|
enabled: canManage,
|
|
|
|
|
queryKey: ["employees", "contract-owner-options"],
|
|
|
|
|
queryFn: () => fetchEmployees({ page: 1, pageSize: 100, status: "active" }),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const detail = detailQuery.data?.item ?? null;
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!detail || editing) return;
|
|
|
|
|
const next = editorFromDetail(detail);
|
|
|
|
|
setForm(next);
|
|
|
|
|
setSavedSignature(JSON.stringify(next));
|
|
|
|
|
setFormError("");
|
|
|
|
|
}, [detail, editing]);
|
|
|
|
|
|
|
|
|
|
const dirty = editing && JSON.stringify(form) !== savedSignature;
|
|
|
|
|
const runGuarded = useUnsavedChangesGuard(dirty, "合同表单");
|
|
|
|
|
const runAfterSuccessfulSave = usePostSaveNavigation();
|
|
|
|
|
const mergeDetailCache = (item: ContractRegistryItem) => {
|
|
|
|
|
queryClient.setQueryData<{ item: ContractRegistryDetail }>(
|
|
|
|
|
["contracts-registry", "detail", contractId],
|
|
|
|
|
(current) => current ? { item: { ...current.item, ...item } } : current,
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const saveMutation = useMutation({
|
|
|
|
|
mutationFn: async () => {
|
|
|
|
|
validateForm(form);
|
|
|
|
|
const payload = buildPayload(form);
|
|
|
|
|
if (creating) return createContractRegistry({ ...payload, status: form.status });
|
|
|
|
|
return patchContractRegistry(contractId, payload);
|
|
|
|
|
},
|
|
|
|
|
onSuccess: async (data) => {
|
|
|
|
|
setFormError("");
|
|
|
|
|
setSavedSignature(JSON.stringify(form));
|
|
|
|
|
if (creating) {
|
|
|
|
|
if (!data.item.id) {
|
|
|
|
|
const message = "合同已创建,但未返回可用标识,请返回列表确认。";
|
|
|
|
|
setFormError(message);
|
|
|
|
|
toast.error(message);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
toast.success("合同已创建");
|
|
|
|
|
runAfterSuccessfulSave(() => {
|
|
|
|
|
navigate(`/contracts-registry/${data.item.id}`, { replace: true, state: { from: backSource } });
|
|
|
|
|
});
|
|
|
|
|
void queryClient.invalidateQueries({ queryKey: ["contracts-registry"] }).catch(() => undefined);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
mergeDetailCache(data.item);
|
|
|
|
|
setEditing(false);
|
|
|
|
|
toast.success("合同信息已更新");
|
|
|
|
|
void Promise.all([
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["contracts-registry", "detail", contractId] }),
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: ["contracts-registry", "list"] }),
|
|
|
|
|
]).catch(() => undefined);
|
|
|
|
|
},
|
|
|
|
|
onError: (error) => {
|
|
|
|
|
const message = resolveErrorMessage(error, "保存合同失败");
|
|
|
|
|
setFormError(message);
|
|
|
|
|
toast.error(message);
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const statusMutation = useMutation({
|
|
|
|
|
mutationFn: () => patchContractRegistry(contractId, { status: detail?.persistedStatus === "archived" ? "active" : "archived" }),
|
|
|
|
|
onSuccess: (data) => {
|
|
|
|
|
mergeDetailCache(data.item);
|
|
|
|
|
toast.success(detail?.persistedStatus === "archived" ? "合同已恢复" : "合同已归档");
|
|
|
|
|
setConfirmArchive(false);
|
|
|
|
|
void queryClient.invalidateQueries({ queryKey: ["contracts-registry"] }).catch(() => undefined);
|
|
|
|
|
},
|
|
|
|
|
onError: (error) => toast.error(resolveErrorMessage(error, "更新合同状态失败")),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const renewMutation = useMutation({
|
|
|
|
|
mutationFn: () => renewContractRegistry(contractId),
|
|
|
|
|
onSuccess: (data) => {
|
|
|
|
|
toast.success("续约草稿已创建,原合同已归档");
|
|
|
|
|
navigate(`/contracts-registry/${data.item.id}`, { replace: true, state: { from: backSource } });
|
|
|
|
|
void queryClient.invalidateQueries({ queryKey: ["contracts-registry"] }).catch(() => undefined);
|
|
|
|
|
},
|
|
|
|
|
onError: (error) => toast.error(resolveErrorMessage(error, "合同续约失败")),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const terminateMutation = useMutation({
|
|
|
|
|
mutationFn: () => terminateContractRegistry(contractId, terminateReason.trim()),
|
|
|
|
|
onSuccess: (data) => {
|
|
|
|
|
setTerminateReason("");
|
|
|
|
|
setConfirmTerminate(false);
|
|
|
|
|
mergeDetailCache(data.item);
|
|
|
|
|
toast.success("合同已终止");
|
|
|
|
|
void queryClient.invalidateQueries({ queryKey: ["contracts-registry"] }).catch(() => undefined);
|
|
|
|
|
},
|
|
|
|
|
onError: (error) => toast.error(resolveErrorMessage(error, "终止合同失败")),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const correctionMutation = useMutation({
|
|
|
|
|
mutationFn: () => appendContractRegistryCorrection(contractId, correctionSummary.trim()),
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
setCorrectionSummary("");
|
|
|
|
|
toast.success("修正记录已追加");
|
|
|
|
|
void queryClient.invalidateQueries({ queryKey: ["contracts-registry", "detail", contractId] }).catch(() => undefined);
|
|
|
|
|
},
|
|
|
|
|
onError: (error) => toast.error(resolveErrorMessage(error, "追加修正记录失败")),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const terminateReasonLength = terminateReason.trim().length;
|
|
|
|
|
const canEdit = Boolean(canManage && detail && ["draft", "active"].includes(detail.persistedStatus));
|
|
|
|
|
const canRenew = Boolean(canManage && detail && ["active", "expired"].includes(detail.status));
|
|
|
|
|
const canArchive = Boolean(canManage && detail && ["draft", "active"].includes(detail.persistedStatus));
|
|
|
|
|
const canRestore = Boolean(canManage && detail?.persistedStatus === "archived");
|
|
|
|
|
const canTerminate = Boolean(canManage && detail && ["active", "expired"].includes(detail.status));
|
|
|
|
|
const mobileActionsVisible = editing || canEdit;
|
|
|
|
|
const headerTitle = creating ? "新增合同" : detail?.contractNo ?? "合同详情";
|
|
|
|
|
|
|
|
|
|
const cancelEditing = () => {
|
|
|
|
|
if (!detail) return;
|
|
|
|
|
const next = editorFromDetail(detail);
|
|
|
|
|
setForm(next);
|
|
|
|
|
setSavedSignature(JSON.stringify(next));
|
|
|
|
|
setEditing(false);
|
|
|
|
|
setFormError("");
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (!creating && detailQuery.isLoading) {
|
|
|
|
|
return <PageScaffold><PageHeader title="合同详情" /><div className="text-sm text-zinc-500">加载中...</div></PageScaffold>;
|
|
|
|
|
}
|
|
|
|
|
if (!creating && detailQuery.isError) {
|
|
|
|
|
return (
|
|
|
|
|
<PageScaffold>
|
|
|
|
|
<PageHeader title="合同详情" actions={<Button asChild variant="secondary"><Link to={backSource}>返回合同列表</Link></Button>} />
|
|
|
|
|
<WorkspaceError message="记录可能不存在或你无权访问" />
|
|
|
|
|
</PageScaffold>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
if (!creating && !detail) return null;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<PageScaffold className={mobileActionsVisible ? `${MOBILE_STICKY_ACTION_PAGE_CLASS} lg:pb-0` : undefined}>
|
|
|
|
|
<PageHeader
|
|
|
|
|
title={headerTitle}
|
|
|
|
|
meta={!creating && detail ? <StatusBadge status={detail.status} label={contractStatusLabel(detail.status)} /> : null}
|
|
|
|
|
actions={(
|
|
|
|
|
<div className="flex flex-wrap gap-2">
|
|
|
|
|
{!creating && canEdit && !editing ? <Button className="hidden lg:inline-flex" type="button" onClick={() => setEditing(true)}>编辑</Button> : null}
|
|
|
|
|
<Button asChild type="button" variant="secondary"><Link to={backSource}>返回合同列表</Link></Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
{formError ? <WorkspaceError message={formError} /> : null}
|
|
|
|
|
|
|
|
|
|
<BusinessDetailWorkspace testId="contract-detail-workspace">
|
|
|
|
|
<BusinessDetailMain>
|
|
|
|
|
{editing ? (
|
|
|
|
|
<ContractEditorFields
|
|
|
|
|
form={form}
|
|
|
|
|
setForm={setForm}
|
|
|
|
|
projects={(projectsQuery.data?.items ?? []).map(({ id, projectNo, name }) => ({ id, projectNo, name }))}
|
|
|
|
|
employees={(employeesQuery.data?.items ?? []).map(({ id, employeeNo, name }) => ({ id, employeeNo, name }))}
|
|
|
|
|
creating={creating}
|
|
|
|
|
/>
|
|
|
|
|
) : detail ? (
|
|
|
|
|
<>
|
|
|
|
|
<SectionPanel title="状态与下一步" actions={<StatusBadge status={detail.status} label={contractStatusLabel(detail.status)} />}>
|
|
|
|
|
<p className="text-sm leading-6 text-zinc-700">{nextStepText(detail)}</p>
|
|
|
|
|
{detail.persistedStatus === "terminated" ? (
|
|
|
|
|
<div className="mt-3 rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-800">
|
|
|
|
|
<div>终止时间:{formatDisplayDateTime(detail.terminatedAt)}</div>
|
|
|
|
|
<div>终止人:{detail.terminator?.name ?? "-"}</div>
|
|
|
|
|
<div>终止原因:{detail.terminatedReason ?? "-"}</div>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</SectionPanel>
|
|
|
|
|
|
|
|
|
|
<SectionPanel title="业务摘要">
|
|
|
|
|
<BusinessDetailFactGrid>
|
|
|
|
|
<BusinessDetailFact label="合同编号">{detail.contractNo}</BusinessDetailFact>
|
|
|
|
|
<BusinessDetailFact label="合同类型">{contractTypeLabel(detail.contractType)}</BusinessDetailFact>
|
|
|
|
|
<BusinessDetailFact label="相对方">{detail.counterpartyName}</BusinessDetailFact>
|
|
|
|
|
<BusinessDetailFact label="金额">{formatMoney(detail.amount)}</BusinessDetailFact>
|
|
|
|
|
<BusinessDetailFact label="签署日期">{formatDisplayDate(detail.signedDate)}</BusinessDetailFact>
|
|
|
|
|
<BusinessDetailFact label="生效日期">{formatDisplayDate(detail.effectiveDate)}</BusinessDetailFact>
|
|
|
|
|
<BusinessDetailFact label="到期日期">{formatDisplayDate(detail.expiryDate)}</BusinessDetailFact>
|
|
|
|
|
<BusinessDetailFact label="负责人">{detail.owner?.name ?? "-"}</BusinessDetailFact>
|
|
|
|
|
<BusinessDetailFact label="敏感级别">{confidentialityLabel(detail.confidentialityLevel)}</BusinessDetailFact>
|
|
|
|
|
<BusinessDetailFact label="关联项目">
|
|
|
|
|
{detail.project ? <Link className="text-primary underline underline-offset-2" to={`/projects/${detail.project.id}`}>{detail.project.projectNo} · {detail.project.name}</Link> : "-"}
|
|
|
|
|
</BusinessDetailFact>
|
|
|
|
|
</BusinessDetailFactGrid>
|
|
|
|
|
<div className="mt-4 rounded-lg bg-zinc-50 px-3 py-2 text-sm leading-6 text-zinc-700">{detail.summary}</div>
|
|
|
|
|
</SectionPanel>
|
|
|
|
|
|
|
|
|
|
<SectionPanel title="证据区">
|
|
|
|
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
|
|
|
|
|
涉密资料请登记线下归档位置
|
|
|
|
|
</div>
|
|
|
|
|
<BusinessDetailFactGrid className="mt-4">
|
|
|
|
|
<BusinessDetailFact label="归档位置">{detail.archiveLocation}</BusinessDetailFact>
|
|
|
|
|
<BusinessDetailFact label="离线档案号">{detail.offlineArchiveNo ?? "-"}</BusinessDetailFact>
|
|
|
|
|
</BusinessDetailFactGrid>
|
|
|
|
|
{detail.documentRefs.length ? (
|
|
|
|
|
<div className="mt-4 space-y-3" data-testid="contract-document-refs">
|
|
|
|
|
<h4 className="text-sm font-semibold text-zinc-900">涉密资料索引</h4>
|
|
|
|
|
{detail.documentRefs.map((item) => (
|
|
|
|
|
<article key={item.id} className="rounded-lg border border-zinc-200 px-3 py-2 text-sm">
|
|
|
|
|
<div className="font-medium text-zinc-900">{item.title}</div>
|
|
|
|
|
<div className="mt-1 text-xs text-zinc-500">
|
|
|
|
|
{item.documentNo ?? "无资料编号"} · {documentStorageLabel(item.storageType)} · {item.offlineArchiveNo ?? "无离线档案号"}
|
|
|
|
|
</div>
|
|
|
|
|
</article>
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</SectionPanel>
|
|
|
|
|
|
|
|
|
|
<SectionPanel title="变更与操作历史">
|
|
|
|
|
<div className="space-y-3" data-testid="contract-history">
|
|
|
|
|
{detail.history.length ? detail.history.map((item) => (
|
|
|
|
|
<article key={item.id} className="border-l-2 border-zinc-200 pl-3 text-sm">
|
|
|
|
|
<div className="font-medium text-zinc-900">{item.summary}</div>
|
|
|
|
|
<div className="mt-1 text-xs text-zinc-500">{item.actor?.name ?? "系统"} · {formatDisplayDateTime(item.createdAt)}</div>
|
|
|
|
|
</article>
|
|
|
|
|
)) : <div className="text-sm text-zinc-500">暂无操作历史</div>}
|
|
|
|
|
</div>
|
|
|
|
|
</SectionPanel>
|
|
|
|
|
</>
|
|
|
|
|
) : null}
|
|
|
|
|
</BusinessDetailMain>
|
|
|
|
|
|
|
|
|
|
<BusinessDetailAside className="hidden lg:block">
|
|
|
|
|
<BusinessDetailActionPanel
|
|
|
|
|
status={!creating && detail ? <StatusBadge status={detail.status} label={contractStatusLabel(detail.status)} /> : null}
|
|
|
|
|
primaryAction={editing ? (
|
|
|
|
|
<Button className="hidden w-full lg:inline-flex" type="button" data-testid="contract-save" disabled={saveMutation.isPending} onClick={() => saveMutation.mutate()}>
|
|
|
|
|
{saveMutation.isPending ? "保存中..." : creating ? "创建合同" : "保存合同"}
|
|
|
|
|
</Button>
|
|
|
|
|
) : canEdit ? (
|
|
|
|
|
<Button className="hidden w-full lg:inline-flex" type="button" onClick={() => setEditing(true)}>编辑</Button>
|
|
|
|
|
) : null}
|
|
|
|
|
secondaryActions={!editing && detail ? (
|
|
|
|
|
<>
|
|
|
|
|
{canRenew ? <Button type="button" size="sm" variant="secondary" onClick={() => setConfirmRenew(true)}>续约</Button> : null}
|
|
|
|
|
{canArchive || canRestore ? <Button type="button" size="sm" variant="secondary" onClick={() => setConfirmArchive(true)}>{canRestore ? "恢复" : "归档"}</Button> : null}
|
|
|
|
|
</>
|
|
|
|
|
) : null}
|
|
|
|
|
summary={!creating && detail ? (
|
|
|
|
|
<>
|
|
|
|
|
<div>合同编号:{detail.contractNo}</div>
|
|
|
|
|
<div>相对方:{detail.counterpartyName}</div>
|
|
|
|
|
<div>金额:{formatMoney(detail.amount)}</div>
|
|
|
|
|
<div>负责人:{detail.owner?.name ?? "-"}</div>
|
|
|
|
|
</>
|
|
|
|
|
) : null}
|
|
|
|
|
testId="contract-detail-action-panel"
|
|
|
|
|
>
|
|
|
|
|
{editing && !creating ? (
|
|
|
|
|
<Button type="button" variant="secondary" className="w-full" onClick={() => runGuarded(cancelEditing)}>取消编辑</Button>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{!editing && canTerminate && detail ? (
|
|
|
|
|
<div className="space-y-3" data-testid="contract-terminate-panel">
|
|
|
|
|
<div className="text-sm font-semibold text-zinc-900">终止合同</div>
|
|
|
|
|
<Textarea
|
|
|
|
|
aria-label="终止原因"
|
|
|
|
|
className="min-h-24"
|
|
|
|
|
value={terminateReason}
|
|
|
|
|
onChange={(event) => setTerminateReason(event.target.value)}
|
|
|
|
|
placeholder="请填写终止原因,至少 10 个字"
|
|
|
|
|
/>
|
|
|
|
|
{terminateReasonLength < 10 ? (
|
|
|
|
|
<p className="text-sm text-red-600" role="alert">请填写终止原因,至少 10 个字(当前 {terminateReasonLength} 个字)</p>
|
|
|
|
|
) : <p className="text-xs text-zinc-500">终止后不可恢复、续约或再归档,合同编号不会释放。</p>}
|
|
|
|
|
<Button type="button" variant="destructive" className="w-full" disabled={terminateReasonLength < 10 || terminateMutation.isPending} onClick={() => setConfirmTerminate(true)}>
|
|
|
|
|
终止合同
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
{!editing && detail?.persistedStatus === "archived" && canManage ? (
|
|
|
|
|
<div className="space-y-3" data-testid="contract-correction-panel">
|
|
|
|
|
<div className="text-sm font-semibold text-zinc-900">追加修正记录</div>
|
|
|
|
|
<Textarea aria-label="修正说明" className="min-h-20" value={correctionSummary} onChange={(event) => setCorrectionSummary(event.target.value)} placeholder="说明需要修正的事实,至少 10 个字;不会覆盖主记录" />
|
|
|
|
|
<Button type="button" className="w-full" variant="secondary" disabled={correctionSummary.trim().length < 10 || correctionMutation.isPending} onClick={() => correctionMutation.mutate()}>
|
|
|
|
|
追加记录
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
) : null}
|
|
|
|
|
</BusinessDetailActionPanel>
|
|
|
|
|
</BusinessDetailAside>
|
|
|
|
|
</BusinessDetailWorkspace>
|
|
|
|
|
|
|
|
|
|
{mobileActionsVisible ? (
|
|
|
|
|
<MobileStickyActionBar className="lg:hidden" testId="contract-mobile-sticky-actions">
|
|
|
|
|
{editing ? (
|
|
|
|
|
<>
|
|
|
|
|
{!creating ? <Button type="button" variant="secondary" onClick={() => runGuarded(cancelEditing)}>取消</Button> : <Button asChild variant="secondary"><Link to={backSource}>取消</Link></Button>}
|
|
|
|
|
<Button type="button" data-testid="contract-mobile-save" disabled={saveMutation.isPending} onClick={() => saveMutation.mutate()}>{saveMutation.isPending ? "保存中..." : creating ? "创建合同" : "保存合同"}</Button>
|
|
|
|
|
</>
|
|
|
|
|
) : canEdit ? <Button type="button" onClick={() => setEditing(true)}>编辑</Button> : null}
|
|
|
|
|
</MobileStickyActionBar>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<ActionConfirmDialog
|
|
|
|
|
open={confirmArchive}
|
|
|
|
|
onOpenChange={setConfirmArchive}
|
|
|
|
|
kind={canRestore ? "enable" : "archive"}
|
|
|
|
|
targetName={detail?.contractNo}
|
|
|
|
|
description={canRestore ? "恢复后合同重新进入生效台账和到期提醒。" : "归档后普通字段锁定,后续修正只能追加记录。"}
|
|
|
|
|
confirmLabel={canRestore ? "确认恢复" : "确认归档"}
|
|
|
|
|
onConfirm={async () => { await statusMutation.mutateAsync(); }}
|
|
|
|
|
/>
|
|
|
|
|
<ActionConfirmDialog
|
|
|
|
|
open={confirmRenew}
|
|
|
|
|
onOpenChange={setConfirmRenew}
|
|
|
|
|
kind="submit"
|
|
|
|
|
title="确认创建续约草稿"
|
|
|
|
|
targetName={detail?.contractNo}
|
|
|
|
|
description="系统将创建下一版草稿并归档当前合同;原合同编号和历史仍保留。"
|
|
|
|
|
confirmLabel="确认续约"
|
|
|
|
|
onConfirm={async () => { await renewMutation.mutateAsync(); }}
|
|
|
|
|
/>
|
|
|
|
|
<ActionConfirmDialog
|
|
|
|
|
open={confirmTerminate}
|
|
|
|
|
onOpenChange={setConfirmTerminate}
|
|
|
|
|
kind="cancel"
|
|
|
|
|
title="确认终止合同"
|
|
|
|
|
targetName={detail?.contractNo}
|
|
|
|
|
description={`合同编号:${detail?.contractNo ?? "-"};相对方:${detail?.counterpartyName ?? "-"};金额:${formatMoney(detail?.amount)}。确认后合同进入不可逆的“已终止”,编号不释放,普通编辑、续约、归档和恢复均不可用。`}
|
|
|
|
|
confirmLabel="确认终止"
|
|
|
|
|
variant="destructive"
|
|
|
|
|
onConfirm={async () => { await terminateMutation.mutateAsync(); }}
|
|
|
|
|
/>
|
|
|
|
|
</PageScaffold>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function WorkspaceError({ message }: { message: string }) {
|
|
|
|
|
return <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700" role="alert">{message}</div>;
|
|
|
|
|
}
|