diff --git a/.trellis/tasks/05-30-project-contract-ledger-detail-shell/prd.md b/.trellis/tasks/05-30-project-contract-ledger-detail-shell/prd.md index 6fcf91b6..c37771db 100644 --- a/.trellis/tasks/05-30-project-contract-ledger-detail-shell/prd.md +++ b/.trellis/tasks/05-30-project-contract-ledger-detail-shell/prd.md @@ -31,10 +31,10 @@ ## Acceptance Criteria -- [ ] 项目页能看清当前项目状态、风险、下一步和关联业务摘要。 -- [ ] 合同页能看清到期、附件完整性、归档和关联上下文。 -- [ ] 项目/合同关联区不突破原业务模块权限。 -- [ ] 高敏合同正文、报价、银行材料不出现在普通列表和自动化截图中。 +- [x] 项目页能看清当前项目状态、风险、下一步和关联业务摘要。 +- [x] 合同页能看清到期、附件完整性、归档和关联上下文。 +- [x] 项目/合同关联区不突破原业务模块权限。 +- [x] 高敏合同正文、报价、银行材料不出现在普通列表和自动化截图中。 ## Test Plan diff --git a/.trellis/tasks/05-30-project-contract-ledger-detail-shell/task.json b/.trellis/tasks/05-30-project-contract-ledger-detail-shell/task.json index 7e1c659c..6cd8dc9f 100644 --- a/.trellis/tasks/05-30-project-contract-ledger-detail-shell/task.json +++ b/.trellis/tasks/05-30-project-contract-ledger-detail-shell/task.json @@ -3,7 +3,7 @@ "name": "project-contract-ledger-detail-shell", "title": "项目合同轻台账与详情命令面板重构", "description": "统一项目和合同轻台账、详情命令面板、跨模块摘要跳转和敏感资料边界。", - "status": "planning", + "status": "in_progress", "dev_type": null, "scope": "frontend-page-redesign", "package": null, diff --git a/client/src/pages/ContractsRegistryPage.tsx b/client/src/pages/ContractsRegistryPage.tsx index 5b466783..638db83f 100644 --- a/client/src/pages/ContractsRegistryPage.tsx +++ b/client/src/pages/ContractsRegistryPage.tsx @@ -21,6 +21,7 @@ import { fetchProjects, type ConfidentialityLevel } from "../api/projects"; import { isApiError } from "../api/types"; 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 { DateTextInput } from "../components/common/DateTextInput"; @@ -205,6 +206,9 @@ export function ContractsRegistryPage() { const [editor, setEditor] = useState(() => buildEmptyEditor(initialProjectId)); const [showExpiring, setShowExpiring] = useState(false); const [showTrialData, setShowTrialData] = useState(false); + const [showLedgerTools, setShowLedgerTools] = useState(false); + const [confirmExportFormat, setConfirmExportFormat] = useState(null); + const [confirmStatusTarget, setConfirmStatusTarget] = useState(null); const filterPayload = buildFiltersPayload(filters); const contractsQuery = useQuery({ @@ -263,6 +267,12 @@ export function ContractsRegistryPage() { setEditor(buildEmptyEditor(filters.projectId)); }; + const openEditor = (item: ContractRegistryItem) => { + setShowLedgerTools(true); + setEditorId(item.id); + setEditor(loadEditorFromItem(item)); + }; + const saveMutation = useMutation({ mutationFn: async () => { const payload = buildSavePayload(editor, { includeOwnerEmployeeId: canBrowseEmployees }); @@ -325,25 +335,12 @@ export function ContractsRegistryPage() { } actions={
- - +
} /> @@ -400,10 +397,7 @@ export function ContractsRegistryPage() { type="button" size="sm" variant="secondary" - onClick={() => { - setEditorId(item.id); - setEditor(loadEditorFromItem(item)); - }} + onClick={() => openEditor(item)} > 编辑 @@ -417,8 +411,47 @@ export function ContractsRegistryPage() { ) : null} -
-
+ {showLedgerTools ? ( +
+
+
+

台账工具

+

+ 新建/编辑、导出和高级到期范围放在这里。导出文件可能包含合同金额、相对方、责任人和归档摘要。 +

+
+
+ + +
+
+
+
+ + setFilters((current) => ({ ...current, expiryDateFrom: event.target.value }))} + /> +
+ +
+ + setFilters((current) => ({ ...current, expiryDateTo: event.target.value }))} + /> +
+
+
+ ) : null} + +
+ {showLedgerTools || editorId ? ( +

{editorId ? "编辑合同台账" : "新建合同台账"}

@@ -662,6 +695,7 @@ export function ContractsRegistryPage() { {saveMutation.isPending ? (editorId ? "保存中..." : "创建中...") : editorId ? "保存修改" : "创建合同台账"}
+ ) : null}
@@ -670,6 +704,11 @@ export function ContractsRegistryPage() {
{filters.projectId ? "已按项目过滤" : "显示全部可见合同"}
+ {!showLedgerTools && !editorId ? ( + + ) : null} - + {showLedgerTools ? ( + + ) : null}
@@ -752,21 +793,6 @@ export function ContractsRegistryPage() { />
-
- - setFilters((current) => ({ ...current, expiryDateFrom: event.target.value }))} - /> -
- -
- - setFilters((current) => ({ ...current, expiryDateTo: event.target.value }))} - /> -
{contractsQuery.isLoading ?
加载中...
: null} @@ -815,8 +841,7 @@ export function ContractsRegistryPage() { size="sm" variant="secondary" onClick={() => { - setEditorId(item.id); - setEditor(loadEditorFromItem(item)); + openEditor(item); }} > 编辑 @@ -840,7 +865,7 @@ export function ContractsRegistryPage() { size="sm" variant="secondary" disabled={toggleStatusMutation.isPending} - onClick={() => toggleStatusMutation.mutate(item)} + onClick={() => setConfirmStatusTarget(item)} > {item.persistedStatus === "archived" ? "恢复生效" : "归档"} @@ -855,6 +880,37 @@ export function ContractsRegistryPage() {
+ { + if (!open) setConfirmExportFormat(null); + }} + kind="sensitiveExport" + targetName="合同台账" + description="导出文件可能包含合同金额、相对方、项目归属、责任人和归档位置摘要。请确认当前筛选范围和用途合规。" + onConfirm={() => { + if (!confirmExportFormat) return; + exportMutation.mutate(confirmExportFormat); + }} + /> + { + if (!open) setConfirmStatusTarget(null); + }} + kind={confirmStatusTarget?.persistedStatus === "archived" ? "enable" : "archive"} + targetName={confirmStatusTarget?.contractNo} + description={ + confirmStatusTarget?.persistedStatus === "archived" + ? "恢复后该合同会重新作为生效台账参与到期提醒和项目关联摘要。" + : "归档后该合同不再作为生效合同推进,台账、项目关联和离线归档索引仍保留。" + } + confirmLabel={confirmStatusTarget?.persistedStatus === "archived" ? "确认恢复" : "确认归档"} + onConfirm={() => { + if (!confirmStatusTarget) return; + toggleStatusMutation.mutate(confirmStatusTarget); + }} + /> ); } diff --git a/client/src/pages/ProjectDetailPage.tsx b/client/src/pages/ProjectDetailPage.tsx index 720aa87c..7e7a82f5 100644 --- a/client/src/pages/ProjectDetailPage.tsx +++ b/client/src/pages/ProjectDetailPage.tsx @@ -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(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() { - @@ -1019,7 +1029,7 @@ export function ProjectDetailPage() { - +
setDocTitle(event.target.value)} placeholder="资料标题" /> @@ -1027,7 +1037,7 @@ export function ProjectDetailPage() { setDocNo(event.target.value)} placeholder="文档编号(可选)" /> setDocOfflineArchiveNo(event.target.value)} placeholder="离线档案号(可选)" />
- setDocStoragePath(event.target.value)} placeholder="NAS / Git / 文件盘路径或引用地址" /> + setDocStoragePath(event.target.value)} placeholder="归档位置摘要或受控索引,不填写完整路径、密钥或源码地址" />
setName(event.target.value)} placeholder="例如:深圳某客户现场实施" /> +
+
+ + setClientName(event.target.value)} placeholder="客户全称" /> +
+
+ + setClientAlias(event.target.value)} placeholder="可选" /> +
+
+ + +
+
+
+
+ + setStartsAt(event.target.value)} /> +
+
+ + setTargetEndAt(event.target.value)} /> +
+
+
+ +