重构项目合同轻台账与详情命令面板(05-30-project-contract-ledger-detail-shell)

这个提交包含在:
2026-05-30 12:50:03 +08:00
父节点 dd1b655122
当前提交 fed797d19b
共修改 6 个文件,包含 253 行新增和 128 行删除
+26 -7
查看文件
@@ -5,6 +5,7 @@ import { fetchEmployees } from "../api/employees";
import { archiveProject, createConfidentialDocumentRef, createProjectCommunicationLog, createProjectDelivery, createProjectMember, createProjectMilestone, createProjectRisk, createProjectTask, fetchProject, fetchProjectClosureChecklist, fetchProjectCommunicationLogs, fetchProjectDeliveries, fetchProjectDocumentRefs, fetchProjectExpensesSummary, fetchProjectMembers, fetchProjectMilestones, fetchProjectPurchaseSummary, fetchProjectRisks, fetchProjectTasks, fetchProjectTravelSummary, fetchProjectWeeklyReports, patchProject, patchProjectClosureChecklistItem, patchProjectCommunicationLog, patchProjectMember, patchProjectMilestone, patchProjectRisk, patchProjectTask, type ConfidentialityLevel, type DocumentStorageType, type ProjectCommunicationChannel, type ProjectClosureChecklistStatus, type ProjectDeliveryRecordType, type ProjectDetail, type ProjectMilestoneStatus, type ProjectRiskLevel, type ProjectStatus, type ProjectTaskPriority, type ProjectTaskStatus } from "../api/projects";
import { fetchContractsRegistry } from "../api/contractsRegistry";
import { mapApiErrorMessage } from "../api/errorMessages";
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
import { DetailSideMeta } from "../components/patterns/DetailSideMeta";
import { EntityOverviewHero } from "../components/patterns/EntityOverviewHero";
import { MetricCard } from "../components/patterns/MetricCard";
@@ -160,6 +161,14 @@ function resolveProjectEvidenceText(detail: ProjectDetail) {
return `交付 ${facts.deliveryRecordCount} · 合同 ${facts.contractCount} · 资料引用 ${facts.confidentialDocRefCount}`;
}
function documentLocationSummary(item: { confidentialityLevel: ConfidentialityLevel; storageType: DocumentStorageType; offlineArchiveNo: string | null }) {
const archiveNo = item.offlineArchiveNo ? ` · ${item.offlineArchiveNo}` : "";
if (item.confidentialityLevel === "confidential" || item.confidentialityLevel === "restricted") {
return `${storageTypeLabel(item.storageType)} · 已登记受控索引${archiveNo}`;
}
return `${storageTypeLabel(item.storageType)} · 归档索引${archiveNo || " · 已登记"}`;
}
export function ProjectDetailPage() {
const { projectId = "" } = useParams();
const queryClient = useQueryClient();
@@ -253,6 +262,7 @@ export function ProjectDetailPage() {
const [editingCommunicationLogId, setEditingCommunicationLogId] = useState<string | null>(null);
const [waiveChecklistTarget, setWaiveChecklistTarget] = useState<{ itemId: string; currentNote: string | null } | null>(null);
const [waiveChecklistNote, setWaiveChecklistNote] = useState("");
const [confirmArchiveProject, setConfirmArchiveProject] = useState(false);
useEffect(() => {
if (!detail) return;
@@ -588,7 +598,7 @@ export function ProjectDetailPage() {
<Button asChild variant="secondary" type="button">
<Link to={`/contracts-registry?projectId=${detail.id}`}>合同台账</Link>
</Button>
<Button type="button" variant="secondary" disabled={archiveProjectMutation.isPending || detail.status === "archived"} onClick={() => archiveProjectMutation.mutate()}>
<Button type="button" variant="secondary" disabled={archiveProjectMutation.isPending || detail.status === "archived"} onClick={() => setConfirmArchiveProject(true)}>
{archiveProjectMutation.isPending ? "归档中..." : "归档项目"}
</Button>
</div>
@@ -1019,7 +1029,7 @@ export function ProjectDetailPage() {
</div>
</SectionPanel>
<SectionPanel title="归档与合规 · 涉密资料引用" description="只登记目录、链接和归档位置,正文与附件仍离线管理。">
<SectionPanel title="归档与合规 · 资料索引" description="只登记最小必要索引,正文、源码、密钥、完整路径和附件仍走受控归档。">
<div className="space-y-4">
<div className="space-y-3">
<Input value={docTitle} onChange={(event) => setDocTitle(event.target.value)} placeholder="资料标题" />
@@ -1027,7 +1037,7 @@ export function ProjectDetailPage() {
<Input value={docNo} onChange={(event) => setDocNo(event.target.value)} placeholder="文档编号(可选)" />
<Input value={docOfflineArchiveNo} onChange={(event) => setDocOfflineArchiveNo(event.target.value)} placeholder="离线档案号(可选)" />
</div>
<Input value={docStoragePath} onChange={(event) => setDocStoragePath(event.target.value)} placeholder="NAS / Git / 文件盘路径或引用地址" />
<Input value={docStoragePath} onChange={(event) => setDocStoragePath(event.target.value)} placeholder="归档位置摘要或受控索引,不填写完整路径、密钥或源码地址" />
<div className="grid gap-3 md:grid-cols-[180px,180px,120px]">
<select className="select-std" value={docConfidentiality} onChange={(event) => setDocConfidentiality(event.target.value as ConfidentialityLevel)}>
{CONFIDENTIALITY_OPTIONS.map((item) => <option key={item} value={item}>{confidentialityLabel(item)}</option>)}
@@ -1039,7 +1049,7 @@ export function ProjectDetailPage() {
</div>
</div>
<Table>
<TableHeader><TableRow><TableHead>资料</TableHead><TableHead>密级</TableHead><TableHead>存储</TableHead><TableHead>路径</TableHead></TableRow></TableHeader>
<TableHeader><TableRow><TableHead>资料</TableHead><TableHead>密级</TableHead><TableHead>存储</TableHead><TableHead>归档索引</TableHead></TableRow></TableHeader>
<TableBody>
{(docsQuery.data?.items ?? []).map((item) => (
<TableRow key={item.id}>
@@ -1049,7 +1059,7 @@ export function ProjectDetailPage() {
</TableCell>
<TableCell>{confidentialityLabel(item.confidentialityLevel)}</TableCell>
<TableCell>{storageTypeLabel(item.storageType)}</TableCell>
<TableCell className="max-w-[280px] truncate">{item.storagePath}</TableCell>
<TableCell className="max-w-[280px] truncate">{documentLocationSummary(item)}</TableCell>
</TableRow>
))}
</TableBody>
@@ -1150,9 +1160,9 @@ export function ProjectDetailPage() {
<div className="mt-1 text-xl font-semibold text-zinc-900">{contractsQuery.data?.items.length ?? 0}</div>
<div className="mt-2 space-y-2 text-sm">
{(contractsQuery.data?.items ?? []).slice(0, 5).map((item) => (
<div key={item.id} className="rounded-lg border border-zinc-100 px-3 py-2">
<Link key={item.id} to={`/contracts-registry?projectId=${detail.id}`} className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
{item.contractNo} · {item.counterpartyName}
</div>
</Link>
))}
</div>
</div>
@@ -1224,6 +1234,15 @@ export function ProjectDetailPage() {
</DialogFooter>
</DialogContent>
</Dialog>
<ActionConfirmDialog
open={confirmArchiveProject}
onOpenChange={setConfirmArchiveProject}
kind="archive"
targetName={detail.projectNo}
description="归档后该项目不再作为新的执行项目推进,历史成员、交付、合同、费用和资料索引仍保留。请确认当前收口项、风险和关联业务已经核对。"
confirmLabel="确认归档"
onConfirm={() => archiveProjectMutation.mutate()}
/>
</>
);
}