From a136000797d70489458ca9d69a75d809025f213a Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 22 Mar 2026 11:31:22 +0800 Subject: [PATCH] =?UTF-8?q?R23-3=EF=BC=9A=E5=AE=8C=E6=88=90=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E9=A1=B5=E4=B8=8EPortal=E6=94=B6=E5=8F=A3=E9=9D=A2?= =?UTF-8?q?=E6=9D=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/featureFlags.ts | 2 +- client/src/api/portal.ts | 2 + client/src/api/projects.ts | 122 ++++++++++- client/src/pages/FeatureFlagsPage.tsx | 6 + client/src/pages/PortalHomePage.tsx | 2 +- client/src/pages/ProjectDetailPage.tsx | 280 ++++++++++++++++++++++++- 6 files changed, 407 insertions(+), 7 deletions(-) diff --git a/client/src/api/featureFlags.ts b/client/src/api/featureFlags.ts index 00ceb521..6538d19a 100644 --- a/client/src/api/featureFlags.ts +++ b/client/src/api/featureFlags.ts @@ -1,6 +1,6 @@ import { apiRequestData } from "./http"; -export type FeatureFlagKey = "erp_sync" | "invoice_verification" | "leave_holiday_calendar" | "budget_precheck_v1" | "payment_batch_v1" | "portal_home_v1" | "global_search_v1" | "announcements_v1" | "workflow_definition_admin_v1" | "cutover_console_v1" | "presence_board_v1" | "weekly_report_v1" | "employee_lifecycle_v1" | "todo_board_v1"; +export type FeatureFlagKey = "erp_sync" | "invoice_verification" | "leave_holiday_calendar" | "budget_precheck_v1" | "payment_batch_v1" | "portal_home_v1" | "global_search_v1" | "announcements_v1" | "workflow_definition_admin_v1" | "cutover_console_v1" | "presence_board_v1" | "weekly_report_v1" | "employee_lifecycle_v1" | "todo_board_v1" | "project_domain_v1" | "project_linking_v1" | "project_communication_log_v1" | "project_closure_checklist_v1" | "confidential_doc_refs_v1" | "public_security_hardening_v1"; export type FeatureFlagOverrideMode = "inherit" | "force_on" | "force_off"; export type FeatureFlagRolloutType = "global" | "department_allowlist"; diff --git a/client/src/api/portal.ts b/client/src/api/portal.ts index d63c2a69..10445f9a 100644 --- a/client/src/api/portal.ts +++ b/client/src/api/portal.ts @@ -34,6 +34,8 @@ export type PortalHomePayload = { overdueMilestonesCount: number; highPriorityTaskCount: number; openRiskCount: number; + followUpDueTodayCount: number; + closurePendingProjectCount: number; href: string; } | null; diff --git a/client/src/api/projects.ts b/client/src/api/projects.ts index 9733557b..0403122b 100644 --- a/client/src/api/projects.ts +++ b/client/src/api/projects.ts @@ -7,6 +7,8 @@ export type ProjectTaskStatus = "todo" | "doing" | "blocked" | "done" | "cancell export type ProjectTaskPriority = "low" | "normal" | "high" | "urgent"; export type ProjectRiskLevel = "low" | "medium" | "high"; export type ProjectDeliveryRecordType = "kickoff" | "onsite" | "stage_acceptance" | "final_acceptance" | "handover"; +export type ProjectCommunicationChannel = "wechat" | "phone" | "meeting" | "wecom" | "email" | "onsite" | "other"; +export type ProjectClosureChecklistStatus = "pending" | "done" | "waived"; export type ConfidentialityLevel = "public" | "internal" | "confidential" | "restricted"; export type DocumentStorageType = "oa_link" | "nas" | "internal_share" | "offline_archive"; @@ -90,6 +92,35 @@ export type ProjectRisk = { updatedAt: string | null; }; +export type ProjectCommunicationLog = { + id: string; + happenedAt: string | null; + channel: ProjectCommunicationChannel; + externalContactName: string | null; + externalOrgName: string | null; + summary: string; + nextAction: string | null; + nextFollowUpDate: string | null; + createdBy: string; + createdAt: string | null; + updatedAt: string | null; + canEdit: boolean; + creator: EmployeeBasic | null; +}; + +export type ProjectClosureChecklistItem = { + id: string; + itemKey: string; + title: string; + status: ProjectClosureChecklistStatus; + note: string | null; + sortOrder: number; + completedAt: string | null; + completedBy: string | null; + canEdit: boolean; + completer: EmployeeBasic | null; +}; + export type ProjectDetail = { id: string; projectNo: string; @@ -117,6 +148,8 @@ export type ProjectDetail = { travelRequests: number; todoItems: number; weeklyReports: number; + communicationLogs: number; + closureChecklistItems: number; contracts: number; deliveries: number; documentRefs: number; @@ -125,6 +158,30 @@ export type ProjectDetail = { milestones: ProjectMilestone[]; tasks: ProjectTask[]; risks: ProjectRisk[]; + communicationSummary: { + totalCount: number; + latestHappenedAt: string | null; + followUpDueTodayCount: number; + overdueFollowUpCount: number; + }; + closureSummary: { + totalCount: number; + pendingCount: number; + waivedCount: number; + warned: boolean; + facts: { + openRiskCount: number; + openReimbursementCount: number; + openTravelCount: number; + deliveryRecordCount: number; + contractCount: number; + confidentialDocRefCount: number; + }; + }; + capabilities: { + communicationLogEnabled: boolean; + closureChecklistEnabled: boolean; + }; }; export type ProjectExpenseSummary = { @@ -229,6 +286,11 @@ export async function fetchProject(projectId: string) { return apiRequestData<{ item: ProjectDetail }>(`/api/projects/${projectId}`); } +export type ProjectClosureWarning = { + pendingCount: number; + message: string; +} | null; + export async function patchProject( projectId: string, payload: Partial<{ @@ -246,14 +308,14 @@ export async function patchProject( isConfidential: boolean; }>, ) { - return apiRequestData<{ item: ProjectDetail }>(`/api/projects/${projectId}`, { + return apiRequestData<{ item: ProjectDetail; closureWarning: ProjectClosureWarning }>(`/api/projects/${projectId}`, { method: "PATCH", body: JSON.stringify(payload), }); } export async function archiveProject(projectId: string) { - return apiRequestData<{ item: { id: string; status: ProjectStatus } }>(`/api/projects/${projectId}/archive`, { + return apiRequestData<{ item: ProjectDetail; closureWarning: ProjectClosureWarning }>(`/api/projects/${projectId}/archive`, { method: "POST", body: JSON.stringify({}), }); @@ -400,6 +462,62 @@ export async function fetchProjectWeeklyReports(projectId: string) { return apiRequestData<{ items: ProjectWeeklyReportLink[] }>(`/api/projects/${projectId}/weekly-reports`); } +export async function fetchProjectCommunicationLogs(projectId: string) { + return apiRequestData<{ items: ProjectCommunicationLog[] }>(`/api/projects/${projectId}/communication-logs`); +} + +export async function createProjectCommunicationLog( + projectId: string, + payload: { + happenedAt: string; + channel: ProjectCommunicationChannel; + externalContactName?: string | null; + externalOrgName?: string | null; + summary: string; + nextAction?: string | null; + nextFollowUpDate?: string | null; + }, +) { + return apiRequestData<{ item: ProjectCommunicationLog }>(`/api/projects/${projectId}/communication-logs`, { + method: "POST", + body: JSON.stringify(payload), + }); +} + +export async function patchProjectCommunicationLog( + projectId: string, + logId: string, + payload: Partial<{ + happenedAt: string; + channel: ProjectCommunicationChannel; + externalContactName: string | null; + externalOrgName: string | null; + summary: string; + nextAction: string | null; + nextFollowUpDate: string | null; + }>, +) { + return apiRequestData<{ item: ProjectCommunicationLog }>(`/api/projects/${projectId}/communication-logs/${logId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + +export async function fetchProjectClosureChecklist(projectId: string) { + return apiRequestData<{ items: ProjectClosureChecklistItem[] }>(`/api/projects/${projectId}/closure-checklist`); +} + +export async function patchProjectClosureChecklistItem( + projectId: string, + itemId: string, + payload: Partial<{ status: ProjectClosureChecklistStatus; note: string | null }>, +) { + return apiRequestData<{ item: ProjectClosureChecklistItem }>(`/api/projects/${projectId}/closure-checklist/${itemId}`, { + method: "PATCH", + body: JSON.stringify(payload), + }); +} + export async function fetchProjectDeliveries(projectId: string) { return apiRequestData<{ items: ProjectDeliveryRecord[] }>(`/api/projects/${projectId}/deliveries`); } diff --git a/client/src/pages/FeatureFlagsPage.tsx b/client/src/pages/FeatureFlagsPage.tsx index 5da8f973..2bc333ed 100644 --- a/client/src/pages/FeatureFlagsPage.tsx +++ b/client/src/pages/FeatureFlagsPage.tsx @@ -44,6 +44,12 @@ function toDisplayName(flagKey: string) { if (flagKey === "weekly_report_v1") return "周报工作台"; if (flagKey === "employee_lifecycle_v1") return "人员生命周期"; if (flagKey === "todo_board_v1") return "待办工作台"; + if (flagKey === "project_domain_v1") return "项目主域"; + if (flagKey === "project_linking_v1") return "项目联动"; + if (flagKey === "project_communication_log_v1") return "项目沟通回填"; + if (flagKey === "project_closure_checklist_v1") return "项目收口清单"; + if (flagKey === "confidential_doc_refs_v1") return "涉密资料引用"; + if (flagKey === "public_security_hardening_v1") return "公网安全强化"; return flagKey; } diff --git a/client/src/pages/PortalHomePage.tsx b/client/src/pages/PortalHomePage.tsx index 681bc5f0..4d4d2bce 100644 --- a/client/src/pages/PortalHomePage.tsx +++ b/client/src/pages/PortalHomePage.tsx @@ -187,7 +187,7 @@ export function PortalHomePage() { ) : null} {payload.projectSummary ? ( - 项目摘要:我的项目 {payload.projectSummary.myCount},逾期里程碑 {payload.projectSummary.overdueMilestonesCount},风险 {payload.projectSummary.openRiskCount} + 项目摘要:我的项目 {payload.projectSummary.myCount},逾期里程碑 {payload.projectSummary.overdueMilestonesCount},风险 {payload.projectSummary.openRiskCount},今日待跟进 {payload.projectSummary.followUpDueTodayCount},待收口项目 {payload.projectSummary.closurePendingProjectCount} ) : null} {payload.peopleLifecycle ? ( diff --git a/client/src/pages/ProjectDetailPage.tsx b/client/src/pages/ProjectDetailPage.tsx index 54745a36..80616cdf 100644 --- a/client/src/pages/ProjectDetailPage.tsx +++ b/client/src/pages/ProjectDetailPage.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { fetchEmployees } from "../api/employees"; -import { createConfidentialDocumentRef, createProjectDelivery, createProjectMember, createProjectMilestone, createProjectRisk, createProjectTask, fetchProject, fetchProjectDeliveries, fetchProjectDocumentRefs, fetchProjectExpensesSummary, fetchProjectMembers, fetchProjectMilestones, fetchProjectRisks, fetchProjectTasks, fetchProjectTravelSummary, fetchProjectWeeklyReports, patchProject, patchProjectMember, patchProjectMilestone, patchProjectRisk, patchProjectTask, archiveProject, type ConfidentialityLevel, type DocumentStorageType, type ProjectDeliveryRecordType, type ProjectMilestoneStatus, type ProjectRiskLevel, type ProjectStatus, type ProjectTaskPriority, type ProjectTaskStatus } from "../api/projects"; +import { archiveProject, createConfidentialDocumentRef, createProjectCommunicationLog, createProjectDelivery, createProjectMember, createProjectMilestone, createProjectRisk, createProjectTask, fetchProject, fetchProjectClosureChecklist, fetchProjectCommunicationLogs, fetchProjectDeliveries, fetchProjectDocumentRefs, fetchProjectExpensesSummary, fetchProjectMembers, fetchProjectMilestones, fetchProjectRisks, fetchProjectTasks, fetchProjectTravelSummary, fetchProjectWeeklyReports, patchProject, patchProjectClosureChecklistItem, patchProjectCommunicationLog, patchProjectMember, patchProjectMilestone, patchProjectRisk, patchProjectTask, type ConfidentialityLevel, type DocumentStorageType, type ProjectCommunicationChannel, type ProjectClosureChecklistStatus, type ProjectDeliveryRecordType, type ProjectMilestoneStatus, type ProjectRiskLevel, type ProjectStatus, type ProjectTaskPriority, type ProjectTaskStatus } from "../api/projects"; import { fetchContractsRegistry } from "../api/contractsRegistry"; import { mapApiErrorMessage } from "../api/errorMessages"; import { isApiError } from "../api/types"; @@ -23,6 +23,8 @@ const TASK_PRIORITY_OPTIONS: ProjectTaskPriority[] = ["low", "normal", "high", " const RISK_LEVEL_OPTIONS: ProjectRiskLevel[] = ["low", "medium", "high"]; const RISK_STATUS_OPTIONS = ["open", "monitoring", "mitigating", "closed"]; const DELIVERY_TYPE_OPTIONS: ProjectDeliveryRecordType[] = ["kickoff", "onsite", "stage_acceptance", "final_acceptance", "handover"]; +const COMMUNICATION_CHANNEL_OPTIONS: ProjectCommunicationChannel[] = ["wechat", "phone", "meeting", "wecom", "email", "onsite", "other"]; +const CLOSURE_CHECKLIST_STATUS_OPTIONS: ProjectClosureChecklistStatus[] = ["pending", "done", "waived"]; const CONFIDENTIALITY_OPTIONS: ConfidentialityLevel[] = ["public", "internal", "confidential", "restricted"]; const STORAGE_TYPE_OPTIONS: DocumentStorageType[] = ["oa_link", "nas", "internal_share", "offline_archive"]; @@ -55,6 +57,27 @@ function recordTypeLabel(value: ProjectDeliveryRecordType) { return "交接"; } +function communicationChannelLabel(value: ProjectCommunicationChannel) { + if (value === "wechat") return "微信"; + if (value === "phone") return "电话"; + if (value === "meeting") return "会议"; + if (value === "wecom") return "企业微信"; + if (value === "email") return "邮件"; + if (value === "onsite") return "现场"; + return "其他"; +} + +function closureChecklistStatusLabel(value: ProjectClosureChecklistStatus) { + if (value === "done") return "已完成"; + if (value === "waived") return "已豁免"; + return "待处理"; +} + +function formatDateTime(value: string | null) { + if (!value) return "-"; + return value.slice(0, 16).replace("T", " "); +} + export function ProjectDetailPage() { const { projectId = "" } = useParams(); const queryClient = useQueryClient(); @@ -79,6 +102,16 @@ export function ProjectDetailPage() { const milestonesQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", projectId, "milestones"], queryFn: () => fetchProjectMilestones(projectId) }); const tasksQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", projectId, "tasks"], queryFn: () => fetchProjectTasks(projectId) }); const risksQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", projectId, "risks"], queryFn: () => fetchProjectRisks(projectId) }); + const communicationLogsQuery = useQuery({ + enabled: Boolean(projectId) && Boolean(detailQuery.data?.item?.capabilities.communicationLogEnabled), + queryKey: ["projects", projectId, "communication-logs"], + queryFn: () => fetchProjectCommunicationLogs(projectId), + }); + const closureChecklistQuery = useQuery({ + enabled: Boolean(projectId) && Boolean(detailQuery.data?.item?.capabilities.closureChecklistEnabled), + queryKey: ["projects", projectId, "closure-checklist"], + queryFn: () => fetchProjectClosureChecklist(projectId), + }); const employeeOptions = employeesQuery.data?.items ?? []; const detail = detailQuery.data?.item ?? null; @@ -125,6 +158,17 @@ export function ProjectDetailPage() { const [docConfidentiality, setDocConfidentiality] = useState("internal"); const [docStorageType, setDocStorageType] = useState("nas"); + const [communicationForm, setCommunicationForm] = useState({ + happenedAt: new Date().toISOString().slice(0, 10), + channel: "wechat" as ProjectCommunicationChannel, + externalContactName: "", + externalOrgName: "", + summary: "", + nextAction: "", + nextFollowUpDate: "", + }); + const [editingCommunicationLogId, setEditingCommunicationLogId] = useState(null); + useEffect(() => { if (!detail) return; setProjectForm({ @@ -141,11 +185,26 @@ export function ProjectDetailPage() { }); }, [detail]); + const resetCommunicationForm = () => { + setCommunicationForm({ + happenedAt: new Date().toISOString().slice(0, 10), + channel: "wechat", + externalContactName: "", + externalOrgName: "", + summary: "", + nextAction: "", + nextFollowUpDate: "", + }); + setEditingCommunicationLogId(null); + }; + const refreshProject = async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: ["projects"] }), queryClient.invalidateQueries({ queryKey: ["projects", "detail", projectId] }), queryClient.invalidateQueries({ queryKey: ["projects", projectId] }), + queryClient.invalidateQueries({ queryKey: ["projects", projectId, "communication-logs"] }), + queryClient.invalidateQueries({ queryKey: ["projects", projectId, "closure-checklist"] }), queryClient.invalidateQueries({ queryKey: ["portal-home"] }), queryClient.invalidateQueries({ queryKey: ["global-search"] }), queryClient.invalidateQueries({ queryKey: ["contracts-registry", projectId] }), @@ -165,18 +224,24 @@ export function ProjectDetailPage() { summary: projectForm.summary.trim() || null, isConfidential: projectForm.isConfidential, }), - onSuccess: async () => { + onSuccess: async (payload) => { await refreshProject(); toast.success("项目主档已更新"); + if (payload.closureWarning) { + toast.warning(payload.closureWarning.message); + } }, onError: (error) => toast.error(resolveError(error, "保存项目失败")), }); const archiveProjectMutation = useMutation({ mutationFn: () => archiveProject(projectId), - onSuccess: async () => { + onSuccess: async (payload) => { await refreshProject(); toast.success("项目已归档"); + if (payload.closureWarning) { + toast.warning(payload.closureWarning.message); + } }, onError: (error) => toast.error(resolveError(error, "归档项目失败")), }); @@ -326,7 +391,75 @@ export function ProjectDetailPage() { onError: (error) => toast.error(resolveError(error, "登记资料引用失败")), }); + const saveCommunicationLogMutation = useMutation({ + mutationFn: async () => { + const payload = { + happenedAt: `${communicationForm.happenedAt || new Date().toISOString().slice(0, 10)}T00:00:00.000Z`, + channel: communicationForm.channel, + externalContactName: communicationForm.externalContactName.trim() || null, + externalOrgName: communicationForm.externalOrgName.trim() || null, + summary: communicationForm.summary.trim(), + nextAction: communicationForm.nextAction.trim() || null, + nextFollowUpDate: communicationForm.nextFollowUpDate || null, + }; + if (editingCommunicationLogId) { + return patchProjectCommunicationLog(projectId, editingCommunicationLogId, payload); + } + return createProjectCommunicationLog(projectId, payload); + }, + onSuccess: async () => { + await refreshProject(); + resetCommunicationForm(); + toast.success(editingCommunicationLogId ? "沟通回填已更新" : "沟通回填已新增"); + }, + onError: (error) => toast.error(resolveError(error, editingCommunicationLogId ? "更新沟通回填失败" : "新增沟通回填失败")), + }); + + const patchClosureChecklistMutation = useMutation({ + mutationFn: ({ itemId, status, note }: { itemId: string; status: ProjectClosureChecklistStatus; note?: string | null }) => + patchProjectClosureChecklistItem(projectId, itemId, { + status, + ...(note !== undefined ? { note } : {}), + }), + onSuccess: async () => { + await refreshProject(); + toast.success("项目收口项已更新"); + }, + onError: (error) => toast.error(resolveError(error, "更新项目收口项失败")), + }); + const counts = detail?.counts; + const communicationItems = (communicationLogsQuery.data?.items ?? []).slice(0, 10); + const closureChecklistItems = closureChecklistQuery.data?.items ?? []; + + const handleEditCommunicationLog = (item: typeof communicationItems[number]) => { + setEditingCommunicationLogId(item.id); + setCommunicationForm({ + happenedAt: item.happenedAt?.slice(0, 10) ?? new Date().toISOString().slice(0, 10), + channel: item.channel, + externalContactName: item.externalContactName ?? "", + externalOrgName: item.externalOrgName ?? "", + summary: item.summary, + nextAction: item.nextAction ?? "", + nextFollowUpDate: item.nextFollowUpDate ?? "", + }); + }; + + const handleChecklistStatusChange = (itemId: string, status: ProjectClosureChecklistStatus, currentNote: string | null) => { + if (status === "waived") { + const note = window.prompt("请填写豁免理由", currentNote ?? ""); + if (note == null) { + return; + } + if (!note.trim()) { + toast.error("豁免必须填写说明"); + return; + } + patchClosureChecklistMutation.mutate({ itemId, status, note: note.trim() }); + return; + } + patchClosureChecklistMutation.mutate({ itemId, status }); + }; if (!projectId) { return
缺少 projectId 参数。
; @@ -381,6 +514,147 @@ export function ProjectDetailPage() {
差旅合计
{travelQuery.data?.totalAmount ?? "0.00"}
+ {(detail.status === "completed" || detail.status === "archived") && detail.closureSummary.pendingCount > 0 ? ( +
+
项目已进入收口状态,但仍有 {detail.closureSummary.pendingCount} 项待处理
+
本轮是软约束:允许完成或归档,但请尽快补齐交付、费用、合同与交接说明。
+
+ ) : null} + + {detail.capabilities.communicationLogEnabled || detail.capabilities.closureChecklistEnabled ? ( +
+ {detail.capabilities.communicationLogEnabled ? ( +
+
+
+

客户沟通

+
只记录结果摘要,不同步聊天记录。
+
+
+
最近沟通:{detail.communicationSummary.latestHappenedAt ? detail.communicationSummary.latestHappenedAt.slice(0, 10) : "-"}
+
今日待跟进 {detail.communicationSummary.followUpDueTodayCount} · 已逾期 {detail.communicationSummary.overdueFollowUpCount}
+
+
+
+
+
沟通总数
+
{detail.communicationSummary.totalCount}
+
+
+
今日待跟进
+
{detail.communicationSummary.followUpDueTodayCount}
+
+
+
已逾期
+
{detail.communicationSummary.overdueFollowUpCount}
+
+
+
+
+
{editingCommunicationLogId ? "编辑沟通回填" : "新增沟通回填"}
+ {editingCommunicationLogId ? ( + + ) : null} +
+
+ setCommunicationForm((prev) => ({ ...prev, happenedAt: event.target.value }))} /> + + setCommunicationForm((prev) => ({ ...prev, externalContactName: event.target.value }))} placeholder="外部联系人(可选)" /> +
+
+ setCommunicationForm((prev) => ({ ...prev, externalOrgName: event.target.value }))} placeholder="外部单位(可选)" /> + setCommunicationForm((prev) => ({ ...prev, nextFollowUpDate: event.target.value }))} /> +
+