R23-3:完成项目页与Portal收口面板
这个提交包含在:
@@ -1,6 +1,6 @@
|
|||||||
import { apiRequestData } from "./http";
|
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 FeatureFlagOverrideMode = "inherit" | "force_on" | "force_off";
|
||||||
export type FeatureFlagRolloutType = "global" | "department_allowlist";
|
export type FeatureFlagRolloutType = "global" | "department_allowlist";
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ export type PortalHomePayload = {
|
|||||||
overdueMilestonesCount: number;
|
overdueMilestonesCount: number;
|
||||||
highPriorityTaskCount: number;
|
highPriorityTaskCount: number;
|
||||||
openRiskCount: number;
|
openRiskCount: number;
|
||||||
|
followUpDueTodayCount: number;
|
||||||
|
closurePendingProjectCount: number;
|
||||||
href: string;
|
href: string;
|
||||||
}
|
}
|
||||||
| null;
|
| null;
|
||||||
|
|||||||
+120
-2
@@ -7,6 +7,8 @@ export type ProjectTaskStatus = "todo" | "doing" | "blocked" | "done" | "cancell
|
|||||||
export type ProjectTaskPriority = "low" | "normal" | "high" | "urgent";
|
export type ProjectTaskPriority = "low" | "normal" | "high" | "urgent";
|
||||||
export type ProjectRiskLevel = "low" | "medium" | "high";
|
export type ProjectRiskLevel = "low" | "medium" | "high";
|
||||||
export type ProjectDeliveryRecordType = "kickoff" | "onsite" | "stage_acceptance" | "final_acceptance" | "handover";
|
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 ConfidentialityLevel = "public" | "internal" | "confidential" | "restricted";
|
||||||
export type DocumentStorageType = "oa_link" | "nas" | "internal_share" | "offline_archive";
|
export type DocumentStorageType = "oa_link" | "nas" | "internal_share" | "offline_archive";
|
||||||
|
|
||||||
@@ -90,6 +92,35 @@ export type ProjectRisk = {
|
|||||||
updatedAt: string | null;
|
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 = {
|
export type ProjectDetail = {
|
||||||
id: string;
|
id: string;
|
||||||
projectNo: string;
|
projectNo: string;
|
||||||
@@ -117,6 +148,8 @@ export type ProjectDetail = {
|
|||||||
travelRequests: number;
|
travelRequests: number;
|
||||||
todoItems: number;
|
todoItems: number;
|
||||||
weeklyReports: number;
|
weeklyReports: number;
|
||||||
|
communicationLogs: number;
|
||||||
|
closureChecklistItems: number;
|
||||||
contracts: number;
|
contracts: number;
|
||||||
deliveries: number;
|
deliveries: number;
|
||||||
documentRefs: number;
|
documentRefs: number;
|
||||||
@@ -125,6 +158,30 @@ export type ProjectDetail = {
|
|||||||
milestones: ProjectMilestone[];
|
milestones: ProjectMilestone[];
|
||||||
tasks: ProjectTask[];
|
tasks: ProjectTask[];
|
||||||
risks: ProjectRisk[];
|
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 = {
|
export type ProjectExpenseSummary = {
|
||||||
@@ -229,6 +286,11 @@ export async function fetchProject(projectId: string) {
|
|||||||
return apiRequestData<{ item: ProjectDetail }>(`/api/projects/${projectId}`);
|
return apiRequestData<{ item: ProjectDetail }>(`/api/projects/${projectId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ProjectClosureWarning = {
|
||||||
|
pendingCount: number;
|
||||||
|
message: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
export async function patchProject(
|
export async function patchProject(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
payload: Partial<{
|
payload: Partial<{
|
||||||
@@ -246,14 +308,14 @@ export async function patchProject(
|
|||||||
isConfidential: boolean;
|
isConfidential: boolean;
|
||||||
}>,
|
}>,
|
||||||
) {
|
) {
|
||||||
return apiRequestData<{ item: ProjectDetail }>(`/api/projects/${projectId}`, {
|
return apiRequestData<{ item: ProjectDetail; closureWarning: ProjectClosureWarning }>(`/api/projects/${projectId}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function archiveProject(projectId: string) {
|
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",
|
method: "POST",
|
||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
});
|
});
|
||||||
@@ -400,6 +462,62 @@ export async function fetchProjectWeeklyReports(projectId: string) {
|
|||||||
return apiRequestData<{ items: ProjectWeeklyReportLink[] }>(`/api/projects/${projectId}/weekly-reports`);
|
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) {
|
export async function fetchProjectDeliveries(projectId: string) {
|
||||||
return apiRequestData<{ items: ProjectDeliveryRecord[] }>(`/api/projects/${projectId}/deliveries`);
|
return apiRequestData<{ items: ProjectDeliveryRecord[] }>(`/api/projects/${projectId}/deliveries`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,6 +44,12 @@ function toDisplayName(flagKey: string) {
|
|||||||
if (flagKey === "weekly_report_v1") return "周报工作台";
|
if (flagKey === "weekly_report_v1") return "周报工作台";
|
||||||
if (flagKey === "employee_lifecycle_v1") return "人员生命周期";
|
if (flagKey === "employee_lifecycle_v1") return "人员生命周期";
|
||||||
if (flagKey === "todo_board_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;
|
return flagKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export function PortalHomePage() {
|
|||||||
) : null}
|
) : null}
|
||||||
{payload.projectSummary ? (
|
{payload.projectSummary ? (
|
||||||
<Link to={payload.projectSummary.href} className="block rounded-lg border border-zinc-200 px-3 py-3 hover:bg-zinc-50">
|
<Link to={payload.projectSummary.href} className="block rounded-lg border border-zinc-200 px-3 py-3 hover:bg-zinc-50">
|
||||||
项目摘要:我的项目 {payload.projectSummary.myCount},逾期里程碑 {payload.projectSummary.overdueMilestonesCount},风险 {payload.projectSummary.openRiskCount}
|
项目摘要:我的项目 {payload.projectSummary.myCount},逾期里程碑 {payload.projectSummary.overdueMilestonesCount},风险 {payload.projectSummary.openRiskCount},今日待跟进 {payload.projectSummary.followUpDueTodayCount},待收口项目 {payload.projectSummary.closurePendingProjectCount}
|
||||||
</Link>
|
</Link>
|
||||||
) : null}
|
) : null}
|
||||||
{payload.peopleLifecycle ? (
|
{payload.peopleLifecycle ? (
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { useEffect, useState } from "react";
|
|||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { fetchEmployees } from "../api/employees";
|
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 { fetchContractsRegistry } from "../api/contractsRegistry";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
import { isApiError } from "../api/types";
|
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_LEVEL_OPTIONS: ProjectRiskLevel[] = ["low", "medium", "high"];
|
||||||
const RISK_STATUS_OPTIONS = ["open", "monitoring", "mitigating", "closed"];
|
const RISK_STATUS_OPTIONS = ["open", "monitoring", "mitigating", "closed"];
|
||||||
const DELIVERY_TYPE_OPTIONS: ProjectDeliveryRecordType[] = ["kickoff", "onsite", "stage_acceptance", "final_acceptance", "handover"];
|
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 CONFIDENTIALITY_OPTIONS: ConfidentialityLevel[] = ["public", "internal", "confidential", "restricted"];
|
||||||
const STORAGE_TYPE_OPTIONS: DocumentStorageType[] = ["oa_link", "nas", "internal_share", "offline_archive"];
|
const STORAGE_TYPE_OPTIONS: DocumentStorageType[] = ["oa_link", "nas", "internal_share", "offline_archive"];
|
||||||
|
|
||||||
@@ -55,6 +57,27 @@ function recordTypeLabel(value: ProjectDeliveryRecordType) {
|
|||||||
return "交接";
|
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() {
|
export function ProjectDetailPage() {
|
||||||
const { projectId = "" } = useParams();
|
const { projectId = "" } = useParams();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
@@ -79,6 +102,16 @@ export function ProjectDetailPage() {
|
|||||||
const milestonesQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", projectId, "milestones"], queryFn: () => fetchProjectMilestones(projectId) });
|
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 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 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 employeeOptions = employeesQuery.data?.items ?? [];
|
||||||
const detail = detailQuery.data?.item ?? null;
|
const detail = detailQuery.data?.item ?? null;
|
||||||
@@ -125,6 +158,17 @@ export function ProjectDetailPage() {
|
|||||||
const [docConfidentiality, setDocConfidentiality] = useState<ConfidentialityLevel>("internal");
|
const [docConfidentiality, setDocConfidentiality] = useState<ConfidentialityLevel>("internal");
|
||||||
const [docStorageType, setDocStorageType] = useState<DocumentStorageType>("nas");
|
const [docStorageType, setDocStorageType] = useState<DocumentStorageType>("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<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!detail) return;
|
if (!detail) return;
|
||||||
setProjectForm({
|
setProjectForm({
|
||||||
@@ -141,11 +185,26 @@ export function ProjectDetailPage() {
|
|||||||
});
|
});
|
||||||
}, [detail]);
|
}, [detail]);
|
||||||
|
|
||||||
|
const resetCommunicationForm = () => {
|
||||||
|
setCommunicationForm({
|
||||||
|
happenedAt: new Date().toISOString().slice(0, 10),
|
||||||
|
channel: "wechat",
|
||||||
|
externalContactName: "",
|
||||||
|
externalOrgName: "",
|
||||||
|
summary: "",
|
||||||
|
nextAction: "",
|
||||||
|
nextFollowUpDate: "",
|
||||||
|
});
|
||||||
|
setEditingCommunicationLogId(null);
|
||||||
|
};
|
||||||
|
|
||||||
const refreshProject = async () => {
|
const refreshProject = async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ["projects"] }),
|
queryClient.invalidateQueries({ queryKey: ["projects"] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ["projects", "detail", projectId] }),
|
queryClient.invalidateQueries({ queryKey: ["projects", "detail", projectId] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ["projects", 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: ["portal-home"] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ["global-search"] }),
|
queryClient.invalidateQueries({ queryKey: ["global-search"] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ["contracts-registry", projectId] }),
|
queryClient.invalidateQueries({ queryKey: ["contracts-registry", projectId] }),
|
||||||
@@ -165,18 +224,24 @@ export function ProjectDetailPage() {
|
|||||||
summary: projectForm.summary.trim() || null,
|
summary: projectForm.summary.trim() || null,
|
||||||
isConfidential: projectForm.isConfidential,
|
isConfidential: projectForm.isConfidential,
|
||||||
}),
|
}),
|
||||||
onSuccess: async () => {
|
onSuccess: async (payload) => {
|
||||||
await refreshProject();
|
await refreshProject();
|
||||||
toast.success("项目主档已更新");
|
toast.success("项目主档已更新");
|
||||||
|
if (payload.closureWarning) {
|
||||||
|
toast.warning(payload.closureWarning.message);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => toast.error(resolveError(error, "保存项目失败")),
|
onError: (error) => toast.error(resolveError(error, "保存项目失败")),
|
||||||
});
|
});
|
||||||
|
|
||||||
const archiveProjectMutation = useMutation({
|
const archiveProjectMutation = useMutation({
|
||||||
mutationFn: () => archiveProject(projectId),
|
mutationFn: () => archiveProject(projectId),
|
||||||
onSuccess: async () => {
|
onSuccess: async (payload) => {
|
||||||
await refreshProject();
|
await refreshProject();
|
||||||
toast.success("项目已归档");
|
toast.success("项目已归档");
|
||||||
|
if (payload.closureWarning) {
|
||||||
|
toast.warning(payload.closureWarning.message);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onError: (error) => toast.error(resolveError(error, "归档项目失败")),
|
onError: (error) => toast.error(resolveError(error, "归档项目失败")),
|
||||||
});
|
});
|
||||||
@@ -326,7 +391,75 @@ export function ProjectDetailPage() {
|
|||||||
onError: (error) => toast.error(resolveError(error, "登记资料引用失败")),
|
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 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) {
|
if (!projectId) {
|
||||||
return <div className="card-std p-6 text-sm text-red-600">缺少 projectId 参数。</div>;
|
return <div className="card-std p-6 text-sm text-red-600">缺少 projectId 参数。</div>;
|
||||||
@@ -381,6 +514,147 @@ export function ProjectDetailPage() {
|
|||||||
<div className="card-std p-4"><div className="text-sm text-zinc-500">差旅合计</div><div className="mt-2 text-2xl font-bold">{travelQuery.data?.totalAmount ?? "0.00"}</div></div>
|
<div className="card-std p-4"><div className="text-sm text-zinc-500">差旅合计</div><div className="mt-2 text-2xl font-bold">{travelQuery.data?.totalAmount ?? "0.00"}</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{(detail.status === "completed" || detail.status === "archived") && detail.closureSummary.pendingCount > 0 ? (
|
||||||
|
<div className="card-std border-amber-200 bg-amber-50 p-4" data-testid="project-closure-warning">
|
||||||
|
<div className="font-medium text-zinc-900">项目已进入收口状态,但仍有 {detail.closureSummary.pendingCount} 项待处理</div>
|
||||||
|
<div className="mt-1 text-sm text-zinc-600">本轮是软约束:允许完成或归档,但请尽快补齐交付、费用、合同与交接说明。</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{detail.capabilities.communicationLogEnabled || detail.capabilities.closureChecklistEnabled ? (
|
||||||
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
|
{detail.capabilities.communicationLogEnabled ? (
|
||||||
|
<div className="card-std space-y-4 p-6" data-testid="project-communication-panel">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">客户沟通</h3>
|
||||||
|
<div className="text-sm text-zinc-500">只记录结果摘要,不同步聊天记录。</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-xs text-zinc-500">
|
||||||
|
<div>最近沟通:{detail.communicationSummary.latestHappenedAt ? detail.communicationSummary.latestHappenedAt.slice(0, 10) : "-"}</div>
|
||||||
|
<div>今日待跟进 {detail.communicationSummary.followUpDueTodayCount} · 已逾期 {detail.communicationSummary.overdueFollowUpCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm">
|
||||||
|
<div className="text-zinc-500">沟通总数</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-zinc-900">{detail.communicationSummary.totalCount}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-3 text-sm">
|
||||||
|
<div className="text-zinc-500">今日待跟进</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-zinc-900">{detail.communicationSummary.followUpDueTodayCount}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-red-200 bg-red-50 px-3 py-3 text-sm">
|
||||||
|
<div className="text-zinc-500">已逾期</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-zinc-900">{detail.communicationSummary.overdueFollowUpCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 rounded-lg border border-zinc-200 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div className="font-medium text-zinc-900">{editingCommunicationLogId ? "编辑沟通回填" : "新增沟通回填"}</div>
|
||||||
|
{editingCommunicationLogId ? (
|
||||||
|
<Button type="button" variant="secondary" size="sm" onClick={resetCommunicationForm}>取消编辑</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-[160px,160px,minmax(0,1fr)]">
|
||||||
|
<Input type="date" value={communicationForm.happenedAt} onChange={(event) => setCommunicationForm((prev) => ({ ...prev, happenedAt: event.target.value }))} />
|
||||||
|
<select className="select-std" value={communicationForm.channel} onChange={(event) => setCommunicationForm((prev) => ({ ...prev, channel: event.target.value as ProjectCommunicationChannel }))}>
|
||||||
|
{COMMUNICATION_CHANNEL_OPTIONS.map((item) => <option key={item} value={item}>{communicationChannelLabel(item)}</option>)}
|
||||||
|
</select>
|
||||||
|
<Input value={communicationForm.externalContactName} onChange={(event) => setCommunicationForm((prev) => ({ ...prev, externalContactName: event.target.value }))} placeholder="外部联系人(可选)" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),180px]">
|
||||||
|
<Input value={communicationForm.externalOrgName} onChange={(event) => setCommunicationForm((prev) => ({ ...prev, externalOrgName: event.target.value }))} placeholder="外部单位(可选)" />
|
||||||
|
<Input type="date" value={communicationForm.nextFollowUpDate} onChange={(event) => setCommunicationForm((prev) => ({ ...prev, nextFollowUpDate: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<Textarea value={communicationForm.summary} onChange={(event) => setCommunicationForm((prev) => ({ ...prev, summary: event.target.value }))} placeholder="沟通结果摘要(1000字内)" data-testid="project-communication-summary-input" />
|
||||||
|
<Textarea value={communicationForm.nextAction} onChange={(event) => setCommunicationForm((prev) => ({ ...prev, nextAction: event.target.value }))} placeholder="下一步动作(可选)" />
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="button" data-testid="project-communication-submit" disabled={saveCommunicationLogMutation.isPending || !communicationForm.summary.trim()} onClick={() => saveCommunicationLogMutation.mutate()}>
|
||||||
|
{saveCommunicationLogMutation.isPending ? "提交中..." : editingCommunicationLogId ? "保存修改" : "新增回填"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(communicationLogsQuery.isLoading && !communicationItems.length) ? <div className="text-sm text-zinc-500">加载沟通记录中...</div> : null}
|
||||||
|
{!communicationItems.length && !communicationLogsQuery.isLoading ? <div className="text-sm text-zinc-500">暂无沟通回填,建议在 24 小时内补充客户沟通结果。</div> : null}
|
||||||
|
{communicationItems.map((item) => (
|
||||||
|
<div key={item.id} className="rounded-lg border border-zinc-200 p-4" data-testid="project-communication-item">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-zinc-900">{communicationChannelLabel(item.channel)} · {item.externalContactName ?? item.externalOrgName ?? "外部沟通"}</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">{item.happenedAt ? item.happenedAt.slice(0, 10) : "-"} · 创建人 {item.creator?.name ?? item.createdBy}</div>
|
||||||
|
</div>
|
||||||
|
{item.canEdit ? <Button type="button" size="sm" variant="secondary" onClick={() => handleEditCommunicationLog(item)}>编辑</Button> : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 text-sm text-zinc-700 whitespace-pre-wrap">{item.summary}</div>
|
||||||
|
<div className="mt-2 grid gap-2 text-xs text-zinc-500 md:grid-cols-2">
|
||||||
|
<div>下一步:{item.nextAction ?? "-"}</div>
|
||||||
|
<div>下次跟进:{item.nextFollowUpDate ?? "-"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{detail.capabilities.closureChecklistEnabled ? (
|
||||||
|
<div className="card-std space-y-4 p-6" data-testid="project-closure-panel">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">项目收口</h3>
|
||||||
|
<div className="text-sm text-zinc-500">软约束模式:允许完成或归档,但会给出红黄灯提醒。</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-xs text-zinc-500">
|
||||||
|
<div>待处理 {detail.closureSummary.pendingCount} / {detail.closureSummary.totalCount}</div>
|
||||||
|
<div>已豁免 {detail.closureSummary.waivedCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-3 text-sm">
|
||||||
|
<div className="text-zinc-500">待收口项</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-zinc-900">{detail.closureSummary.pendingCount}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm">
|
||||||
|
<div className="text-zinc-500">已豁免</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-zinc-900">{detail.closureSummary.waivedCount}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-zinc-200 px-3 py-3 text-sm">
|
||||||
|
<div className="text-zinc-500">交付记录</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-zinc-900">{detail.closureSummary.facts.deliveryRecordCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3 text-sm">
|
||||||
|
<div className="rounded-lg border border-zinc-200 px-3 py-3">未关闭风险:<span className="font-medium text-zinc-900">{detail.closureSummary.facts.openRiskCount}</span></div>
|
||||||
|
<div className="rounded-lg border border-zinc-200 px-3 py-3">未收口报销:<span className="font-medium text-zinc-900">{detail.closureSummary.facts.openReimbursementCount}</span></div>
|
||||||
|
<div className="rounded-lg border border-zinc-200 px-3 py-3">未收口差旅:<span className="font-medium text-zinc-900">{detail.closureSummary.facts.openTravelCount}</span></div>
|
||||||
|
<div className="rounded-lg border border-zinc-200 px-3 py-3">合同数:<span className="font-medium text-zinc-900">{detail.closureSummary.facts.contractCount}</span></div>
|
||||||
|
<div className="rounded-lg border border-zinc-200 px-3 py-3">资料引用:<span className="font-medium text-zinc-900">{detail.closureSummary.facts.confidentialDocRefCount}</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(closureChecklistQuery.isLoading && !closureChecklistItems.length) ? <div className="text-sm text-zinc-500">加载收口清单中...</div> : null}
|
||||||
|
{closureChecklistItems.map((item) => (
|
||||||
|
<div key={item.id} className="rounded-lg border border-zinc-200 p-4" data-testid="project-closure-item">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">状态:{closureChecklistStatusLabel(item.status)}{item.completedAt ? ` · ${formatDateTime(item.completedAt)}` : ""}</div>
|
||||||
|
</div>
|
||||||
|
{item.canEdit ? (
|
||||||
|
<select className="select-std h-9 w-[120px]" value={item.status} onChange={(event) => handleChecklistStatusChange(item.id, event.target.value as ProjectClosureChecklistStatus, item.note)}>
|
||||||
|
{CLOSURE_CHECKLIST_STATUS_OPTIONS.map((status) => <option key={status} value={status}>{closureChecklistStatusLabel(status)}</option>)}
|
||||||
|
</select>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{item.note ? <div className="mt-2 text-sm text-zinc-600">说明:{item.note}</div> : null}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr),minmax(0,0.8fr)]">
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1.2fr),minmax(0,0.8fr)]">
|
||||||
<div className="card-std space-y-4 p-6">
|
<div className="card-std space-y-4 p-6">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户