R19/R20: 项目主域进 OA 并打通报销差旅周报待办
这个提交包含在:
@@ -0,0 +1,61 @@
|
|||||||
|
import { apiRequestData } from "./http";
|
||||||
|
import type { ConfidentialityLevel, EmployeeBasic } from "./projects";
|
||||||
|
|
||||||
|
export type ContractRegistryItem = {
|
||||||
|
id: string;
|
||||||
|
contractNo: string;
|
||||||
|
projectId: string | null;
|
||||||
|
counterpartyName: string;
|
||||||
|
summary: string;
|
||||||
|
confidentialityLevel: ConfidentialityLevel;
|
||||||
|
archiveLocation: string;
|
||||||
|
offlineArchiveNo: string | null;
|
||||||
|
ownerEmployeeId: string;
|
||||||
|
status: string;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
owner: EmployeeBasic | null;
|
||||||
|
project: { id: string; projectNo: string; name: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function fetchContractsRegistry(projectId?: string) {
|
||||||
|
const suffix = projectId ? `?projectId=${encodeURIComponent(projectId)}` : "";
|
||||||
|
return apiRequestData<{ items: ContractRegistryItem[] }>(`/api/contracts-registry${suffix}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createContractRegistry(payload: {
|
||||||
|
contractNo: string;
|
||||||
|
projectId?: string | null;
|
||||||
|
counterpartyName: string;
|
||||||
|
summary: string;
|
||||||
|
confidentialityLevel?: ConfidentialityLevel;
|
||||||
|
archiveLocation: string;
|
||||||
|
offlineArchiveNo?: string | null;
|
||||||
|
ownerEmployeeId?: string;
|
||||||
|
status?: string;
|
||||||
|
}) {
|
||||||
|
return apiRequestData<{ item: ContractRegistryItem }>("/api/contracts-registry", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchContractRegistry(
|
||||||
|
id: string,
|
||||||
|
payload: Partial<{
|
||||||
|
contractNo: string;
|
||||||
|
projectId: string | null;
|
||||||
|
counterpartyName: string;
|
||||||
|
summary: string;
|
||||||
|
confidentialityLevel: ConfidentialityLevel;
|
||||||
|
archiveLocation: string;
|
||||||
|
offlineArchiveNo: string | null;
|
||||||
|
ownerEmployeeId: string;
|
||||||
|
status: string;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
return apiRequestData<{ item: ContractRegistryItem }>(`/api/contracts-registry/${id}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,443 @@
|
|||||||
|
import { apiRequestData } from "./http";
|
||||||
|
|
||||||
|
export type ProjectStatus = "draft" | "active" | "paused" | "completed" | "archived";
|
||||||
|
export type ProjectRole = "owner" | "manager" | "member" | "observer";
|
||||||
|
export type ProjectMilestoneStatus = "pending" | "in_progress" | "done" | "delayed" | "cancelled";
|
||||||
|
export type ProjectTaskStatus = "todo" | "doing" | "blocked" | "done" | "cancelled";
|
||||||
|
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 ConfidentialityLevel = "public" | "internal" | "confidential" | "restricted";
|
||||||
|
export type DocumentStorageType = "oa_link" | "nas" | "internal_share" | "offline_archive";
|
||||||
|
|
||||||
|
export type EmployeeBasic = {
|
||||||
|
id: string;
|
||||||
|
employeeNo: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectListItem = {
|
||||||
|
id: string;
|
||||||
|
projectNo: string;
|
||||||
|
name: string;
|
||||||
|
clientName: string;
|
||||||
|
clientAlias: string | null;
|
||||||
|
status: ProjectStatus;
|
||||||
|
startsAt: string | null;
|
||||||
|
targetEndAt: string | null;
|
||||||
|
actualEndAt: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
isConfidential: boolean;
|
||||||
|
owner: EmployeeBasic | null;
|
||||||
|
manager: EmployeeBasic | null;
|
||||||
|
counts: {
|
||||||
|
members: number;
|
||||||
|
milestones: number;
|
||||||
|
tasks: number;
|
||||||
|
risks: number;
|
||||||
|
overdueMilestones: number;
|
||||||
|
highPriorityTasks: number;
|
||||||
|
openRisks: number;
|
||||||
|
};
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectMember = {
|
||||||
|
id: string;
|
||||||
|
employeeId: string;
|
||||||
|
role: ProjectRole;
|
||||||
|
joinedAt: string | null;
|
||||||
|
leftAt: string | null;
|
||||||
|
employee: EmployeeBasic | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectMilestone = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
status: ProjectMilestoneStatus;
|
||||||
|
dueDate: string | null;
|
||||||
|
completedAt: string | null;
|
||||||
|
ownerEmployeeId: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectTask = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
status: ProjectTaskStatus;
|
||||||
|
priority: ProjectTaskPriority;
|
||||||
|
assigneeEmployeeId: string | null;
|
||||||
|
dueDate: string | null;
|
||||||
|
blockedReason: string | null;
|
||||||
|
createdBy: string;
|
||||||
|
assignee: EmployeeBasic | null;
|
||||||
|
creator: EmployeeBasic | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectRisk = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
detail: string;
|
||||||
|
level: ProjectRiskLevel;
|
||||||
|
status: string;
|
||||||
|
ownerEmployeeId: string | null;
|
||||||
|
owner: EmployeeBasic | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectDetail = {
|
||||||
|
id: string;
|
||||||
|
projectNo: string;
|
||||||
|
name: string;
|
||||||
|
clientName: string;
|
||||||
|
clientAlias: string | null;
|
||||||
|
ownerEmployeeId: string;
|
||||||
|
managerEmployeeId: string | null;
|
||||||
|
status: ProjectStatus;
|
||||||
|
startsAt: string | null;
|
||||||
|
targetEndAt: string | null;
|
||||||
|
actualEndAt: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
isConfidential: boolean;
|
||||||
|
createdAt: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
owner: EmployeeBasic | null;
|
||||||
|
manager: EmployeeBasic | null;
|
||||||
|
counts: {
|
||||||
|
members: number;
|
||||||
|
milestones: number;
|
||||||
|
tasks: number;
|
||||||
|
risks: number;
|
||||||
|
reimbursements: number;
|
||||||
|
travelRequests: number;
|
||||||
|
todoItems: number;
|
||||||
|
weeklyReports: number;
|
||||||
|
contracts: number;
|
||||||
|
deliveries: number;
|
||||||
|
documentRefs: number;
|
||||||
|
};
|
||||||
|
members: ProjectMember[];
|
||||||
|
milestones: ProjectMilestone[];
|
||||||
|
tasks: ProjectTask[];
|
||||||
|
risks: ProjectRisk[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectExpenseSummary = {
|
||||||
|
totalAmount: string;
|
||||||
|
items: Array<{
|
||||||
|
id: string;
|
||||||
|
formNumber: string;
|
||||||
|
status: string;
|
||||||
|
totalAmount: string;
|
||||||
|
reimbursementDate: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectTravelSummary = {
|
||||||
|
totalAmount: string;
|
||||||
|
items: Array<{
|
||||||
|
id: string;
|
||||||
|
requestNo: string;
|
||||||
|
status: string;
|
||||||
|
estimatedAmount: string;
|
||||||
|
startDate: string | null;
|
||||||
|
endDate: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectWeeklyReportLink = {
|
||||||
|
id: string;
|
||||||
|
weeklyReportId: string;
|
||||||
|
weekStart: string | null;
|
||||||
|
status: "draft" | "submitted";
|
||||||
|
employee: EmployeeBasic | null;
|
||||||
|
progressText: string | null;
|
||||||
|
riskText: string | null;
|
||||||
|
nextStepText: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProjectDeliveryRecord = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
recordType: ProjectDeliveryRecordType;
|
||||||
|
summary: string;
|
||||||
|
occurredAt: string | null;
|
||||||
|
owner: EmployeeBasic | null;
|
||||||
|
createdAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ConfidentialDocumentRef = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
documentNo: string | null;
|
||||||
|
confidentialityLevel: ConfidentialityLevel;
|
||||||
|
storageType: DocumentStorageType;
|
||||||
|
storagePath: string;
|
||||||
|
offlineArchiveNo: string | null;
|
||||||
|
updatedAt: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function toQueryString(query: Record<string, string | number | boolean | undefined>) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
for (const [key, value] of Object.entries(query)) {
|
||||||
|
if (value == null || value === "") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
params.set(key, typeof value === "boolean" ? (value ? "1" : "0") : String(value));
|
||||||
|
}
|
||||||
|
const encoded = params.toString();
|
||||||
|
return encoded ? `?${encoded}` : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProjects(params: {
|
||||||
|
status?: ProjectStatus;
|
||||||
|
mine?: boolean;
|
||||||
|
q?: string;
|
||||||
|
} = {}) {
|
||||||
|
return apiRequestData<{ items: ProjectListItem[] }>(`/api/projects${toQueryString(params)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProject(payload: {
|
||||||
|
projectNo?: string;
|
||||||
|
name: string;
|
||||||
|
clientName: string;
|
||||||
|
clientAlias?: string | null;
|
||||||
|
ownerEmployeeId?: string;
|
||||||
|
managerEmployeeId?: string | null;
|
||||||
|
status?: ProjectStatus;
|
||||||
|
startsAt?: string | null;
|
||||||
|
targetEndAt?: string | null;
|
||||||
|
actualEndAt?: string | null;
|
||||||
|
summary?: string | null;
|
||||||
|
isConfidential?: boolean;
|
||||||
|
}) {
|
||||||
|
return apiRequestData<{ item: ProjectDetail }>("/api/projects", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProject(projectId: string) {
|
||||||
|
return apiRequestData<{ item: ProjectDetail }>(`/api/projects/${projectId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchProject(
|
||||||
|
projectId: string,
|
||||||
|
payload: Partial<{
|
||||||
|
projectNo: string;
|
||||||
|
name: string;
|
||||||
|
clientName: string;
|
||||||
|
clientAlias: string | null;
|
||||||
|
ownerEmployeeId: string;
|
||||||
|
managerEmployeeId: string | null;
|
||||||
|
status: ProjectStatus;
|
||||||
|
startsAt: string | null;
|
||||||
|
targetEndAt: string | null;
|
||||||
|
actualEndAt: string | null;
|
||||||
|
summary: string | null;
|
||||||
|
isConfidential: boolean;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
return apiRequestData<{ item: ProjectDetail }>(`/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`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProjectMembers(projectId: string) {
|
||||||
|
return apiRequestData<{ items: ProjectMember[] }>(`/api/projects/${projectId}/members`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProjectMember(projectId: string, payload: { employeeId: string; role?: ProjectRole }) {
|
||||||
|
return apiRequestData<{ item: ProjectMember }>(`/api/projects/${projectId}/members`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchProjectMember(
|
||||||
|
projectId: string,
|
||||||
|
memberId: string,
|
||||||
|
payload: Partial<{ role: ProjectRole; leftAt: string | null }>,
|
||||||
|
) {
|
||||||
|
return apiRequestData<{ item: ProjectMember }>(`/api/projects/${projectId}/members/${memberId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProjectMilestones(projectId: string) {
|
||||||
|
return apiRequestData<{ items: ProjectMilestone[] }>(`/api/projects/${projectId}/milestones`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProjectMilestone(
|
||||||
|
projectId: string,
|
||||||
|
payload: {
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
status?: ProjectMilestoneStatus;
|
||||||
|
dueDate?: string | null;
|
||||||
|
completedAt?: string | null;
|
||||||
|
ownerEmployeeId?: string | null;
|
||||||
|
sortOrder?: number;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return apiRequestData<{ item: ProjectMilestone }>(`/api/projects/${projectId}/milestones`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchProjectMilestone(
|
||||||
|
projectId: string,
|
||||||
|
milestoneId: string,
|
||||||
|
payload: Partial<{
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
status: ProjectMilestoneStatus;
|
||||||
|
dueDate: string | null;
|
||||||
|
completedAt: string | null;
|
||||||
|
ownerEmployeeId: string | null;
|
||||||
|
sortOrder: number;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
return apiRequestData<{ item: ProjectMilestone }>(`/api/projects/${projectId}/milestones/${milestoneId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProjectTasks(projectId: string) {
|
||||||
|
return apiRequestData<{ items: ProjectTask[] }>(`/api/projects/${projectId}/tasks`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProjectTask(
|
||||||
|
projectId: string,
|
||||||
|
payload: {
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
status?: ProjectTaskStatus;
|
||||||
|
priority?: ProjectTaskPriority;
|
||||||
|
assigneeEmployeeId?: string | null;
|
||||||
|
dueDate?: string | null;
|
||||||
|
blockedReason?: string | null;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return apiRequestData<{ item: ProjectTask }>(`/api/projects/${projectId}/tasks`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchProjectTask(
|
||||||
|
projectId: string,
|
||||||
|
taskId: string,
|
||||||
|
payload: Partial<{
|
||||||
|
title: string;
|
||||||
|
description: string | null;
|
||||||
|
status: ProjectTaskStatus;
|
||||||
|
priority: ProjectTaskPriority;
|
||||||
|
assigneeEmployeeId: string | null;
|
||||||
|
dueDate: string | null;
|
||||||
|
blockedReason: string | null;
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
|
return apiRequestData<{ item: ProjectTask }>(`/api/projects/${projectId}/tasks/${taskId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProjectRisks(projectId: string) {
|
||||||
|
return apiRequestData<{ items: ProjectRisk[] }>(`/api/projects/${projectId}/risks`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProjectRisk(
|
||||||
|
projectId: string,
|
||||||
|
payload: { title: string; detail: string; level?: ProjectRiskLevel; status?: string; ownerEmployeeId?: string | null },
|
||||||
|
) {
|
||||||
|
return apiRequestData<{ item: ProjectRisk }>(`/api/projects/${projectId}/risks`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function patchProjectRisk(
|
||||||
|
projectId: string,
|
||||||
|
riskId: string,
|
||||||
|
payload: Partial<{ title: string; detail: string; level: ProjectRiskLevel; status: string; ownerEmployeeId: string | null }>,
|
||||||
|
) {
|
||||||
|
return apiRequestData<{ item: ProjectRisk }>(`/api/projects/${projectId}/risks/${riskId}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProjectExpensesSummary(projectId: string) {
|
||||||
|
return apiRequestData<ProjectExpenseSummary>(`/api/projects/${projectId}/expenses/summary`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProjectTravelSummary(projectId: string) {
|
||||||
|
return apiRequestData<ProjectTravelSummary>(`/api/projects/${projectId}/travel/summary`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProjectWeeklyReports(projectId: string) {
|
||||||
|
return apiRequestData<{ items: ProjectWeeklyReportLink[] }>(`/api/projects/${projectId}/weekly-reports`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProjectDeliveries(projectId: string) {
|
||||||
|
return apiRequestData<{ items: ProjectDeliveryRecord[] }>(`/api/projects/${projectId}/deliveries`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createProjectDelivery(
|
||||||
|
projectId: string,
|
||||||
|
payload: {
|
||||||
|
title: string;
|
||||||
|
recordType: ProjectDeliveryRecordType;
|
||||||
|
summary: string;
|
||||||
|
occurredAt: string;
|
||||||
|
ownerEmployeeId?: string;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return apiRequestData<{ item: ProjectDeliveryRecord }>(`/api/projects/${projectId}/deliveries`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchProjectDocumentRefs(projectId: string) {
|
||||||
|
return apiRequestData<{ items: ConfidentialDocumentRef[] }>(`/api/projects/${projectId}/document-refs`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createConfidentialDocumentRef(payload: {
|
||||||
|
projectId?: string | null;
|
||||||
|
contractRegistryId?: string | null;
|
||||||
|
title: string;
|
||||||
|
documentNo?: string | null;
|
||||||
|
confidentialityLevel?: ConfidentialityLevel;
|
||||||
|
storageType: DocumentStorageType;
|
||||||
|
storagePath: string;
|
||||||
|
offlineArchiveNo?: string | null;
|
||||||
|
ownerEmployeeId?: string;
|
||||||
|
accessRuleJson?: Record<string, unknown> | null;
|
||||||
|
}) {
|
||||||
|
return apiRequestData<{ item: ConfidentialDocumentRef & { owner?: EmployeeBasic | null; project?: { id: string; projectNo: string; name: string } | null; contractRegistry?: { id: string; contractNo: string } | null } }>("/api/confidential-doc-refs", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -48,6 +48,11 @@ export type ReimbursementListItem = {
|
|||||||
employeeNo: string;
|
employeeNo: string;
|
||||||
name: string;
|
name: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
project: {
|
||||||
|
id: string;
|
||||||
|
projectNo: string;
|
||||||
|
name: string;
|
||||||
|
} | null;
|
||||||
createdAt: string | null;
|
createdAt: string | null;
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
};
|
};
|
||||||
@@ -86,6 +91,12 @@ export type ReimbursementDetail = {
|
|||||||
reimbursementDate: string | null;
|
reimbursementDate: string | null;
|
||||||
totalAmount: string | null;
|
totalAmount: string | null;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
|
projectId: string | null;
|
||||||
|
project: {
|
||||||
|
id: string;
|
||||||
|
projectNo: string;
|
||||||
|
name: string;
|
||||||
|
} | null;
|
||||||
owner: {
|
owner: {
|
||||||
id: string;
|
id: string;
|
||||||
employee_no: string;
|
employee_no: string;
|
||||||
@@ -270,3 +281,11 @@ export async function deleteReimbursementItem(reimbursementId: string, itemId: s
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function linkReimbursementProject(reimbursementId: string, projectId: string | null) {
|
||||||
|
return apiRequestData<{ projectId: string | null; project: { id: string; projectNo: string; name: string } | null }>(`/api/reimbursements/${reimbursementId}/link-project`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ projectId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ export type TodoItem = {
|
|||||||
priority: TodoPriority;
|
priority: TodoPriority;
|
||||||
status: TodoStatus;
|
status: TodoStatus;
|
||||||
source: TodoSource;
|
source: TodoSource;
|
||||||
|
projectId: string | null;
|
||||||
|
project: { id: string; projectNo: string; name: string } | null;
|
||||||
createdAt: string | null;
|
createdAt: string | null;
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
completedAt: string | null;
|
completedAt: string | null;
|
||||||
@@ -43,6 +45,7 @@ export async function createTodo(payload: {
|
|||||||
dueDate?: string | null;
|
dueDate?: string | null;
|
||||||
priority?: TodoPriority;
|
priority?: TodoPriority;
|
||||||
source?: TodoSource;
|
source?: TodoSource;
|
||||||
|
projectId?: string | null;
|
||||||
}) {
|
}) {
|
||||||
return apiRequestData<{ item: TodoItem }>("/api/todos", {
|
return apiRequestData<{ item: TodoItem }>("/api/todos", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -57,6 +60,7 @@ export async function patchTodo(id: string, payload: Partial<{
|
|||||||
dueDate: string | null;
|
dueDate: string | null;
|
||||||
priority: TodoPriority;
|
priority: TodoPriority;
|
||||||
status: TodoStatus;
|
status: TodoStatus;
|
||||||
|
projectId: string | null;
|
||||||
}>) {
|
}>) {
|
||||||
return apiRequestData<{ item: TodoItem }>(`/api/todos/${id}`, {
|
return apiRequestData<{ item: TodoItem }>(`/api/todos/${id}`, {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ export type TravelRequestSummary = {
|
|||||||
startDate: string | null;
|
startDate: string | null;
|
||||||
endDate: string | null;
|
endDate: string | null;
|
||||||
estimatedAmount: string;
|
estimatedAmount: string;
|
||||||
|
projectId: string | null;
|
||||||
|
project: {
|
||||||
|
id: string;
|
||||||
|
projectNo: string;
|
||||||
|
name: string;
|
||||||
|
} | null;
|
||||||
status: TravelRequestStatus;
|
status: TravelRequestStatus;
|
||||||
createdAt: string | null;
|
createdAt: string | null;
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
@@ -233,3 +239,11 @@ export async function withdrawTravelRequest(travelRequestId: string) {
|
|||||||
body: JSON.stringify({}),
|
body: JSON.stringify({}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export async function linkTravelRequestProject(travelRequestId: string, projectId: string | null) {
|
||||||
|
return apiRequestData<{ projectId: string | null; project: { id: string; projectNo: string; name: string } | null }>(`/api/travel-requests/${travelRequestId}/link-project`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ projectId }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,10 @@ export type MeResponse = {
|
|||||||
directManagerId: string | null;
|
directManagerId: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
roleCodes: string[];
|
roleCodes: string[];
|
||||||
|
wecomBinding?: {
|
||||||
|
externalUserId: string;
|
||||||
|
createdAt: string | null;
|
||||||
|
} | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AuthState = {
|
export type AuthState = {
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
import { apiRequestData } from "./http";
|
import { apiRequestData } from "./http";
|
||||||
|
|
||||||
|
export type WeeklyReportProjectLink = {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
progressText: string | null;
|
||||||
|
riskText: string | null;
|
||||||
|
nextStepText: string | null;
|
||||||
|
project: { id: string; projectNo: string; name: string } | null;
|
||||||
|
};
|
||||||
|
|
||||||
export type WeeklyReport = {
|
export type WeeklyReport = {
|
||||||
id: string;
|
id: string;
|
||||||
employeeId: string;
|
employeeId: string;
|
||||||
@@ -12,6 +21,7 @@ export type WeeklyReport = {
|
|||||||
submittedAt: string | null;
|
submittedAt: string | null;
|
||||||
createdAt: string | null;
|
createdAt: string | null;
|
||||||
updatedAt: string | null;
|
updatedAt: string | null;
|
||||||
|
projectLinks: WeeklyReportProjectLink[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type WeeklyReportTeamItem = {
|
export type WeeklyReportTeamItem = {
|
||||||
@@ -44,6 +54,7 @@ export async function updateMyWeeklyReport(payload: {
|
|||||||
blockersText?: string;
|
blockersText?: string;
|
||||||
nextWeekText?: string;
|
nextWeekText?: string;
|
||||||
needsHelp?: boolean;
|
needsHelp?: boolean;
|
||||||
|
projectLinks?: Array<{ projectId: string; progressText?: string; riskText?: string; nextStepText?: string }>;
|
||||||
}) {
|
}) {
|
||||||
return apiRequestData<{ item: WeeklyReport }>("/api/weekly-reports/me", {
|
return apiRequestData<{ item: WeeklyReport }>("/api/weekly-reports/me", {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const TRAVEL_SELF_SERVICE_PERMISSIONS = [
|
|||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const ASSET_SELF_SERVICE_PERMISSIONS = ["asset.view_own"] as const;
|
const ASSET_SELF_SERVICE_PERMISSIONS = ["asset.view_own"] as const;
|
||||||
|
const PROJECT_SELF_SERVICE_PERMISSIONS = ["project.view_own"] as const;
|
||||||
|
|
||||||
const MASTERDATA_VIEW_PERMISSIONS = ["masterdata.view"] as const;
|
const MASTERDATA_VIEW_PERMISSIONS = ["masterdata.view"] as const;
|
||||||
const MASTERDATA_MANAGE_PERMISSIONS = ["masterdata.manage"] as const;
|
const MASTERDATA_MANAGE_PERMISSIONS = ["masterdata.manage"] as const;
|
||||||
@@ -37,6 +38,7 @@ const EMPLOYEE_BASE_PERMISSIONS = [
|
|||||||
...EXPENSE_SELF_SERVICE_PERMISSIONS,
|
...EXPENSE_SELF_SERVICE_PERMISSIONS,
|
||||||
...LEAVE_SELF_SERVICE_PERMISSIONS,
|
...LEAVE_SELF_SERVICE_PERMISSIONS,
|
||||||
...TRAVEL_SELF_SERVICE_PERMISSIONS,
|
...TRAVEL_SELF_SERVICE_PERMISSIONS,
|
||||||
|
...PROJECT_SELF_SERVICE_PERMISSIONS,
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
||||||
@@ -58,8 +60,13 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
|||||||
"travel.view_all",
|
"travel.view_all",
|
||||||
"asset.view_all",
|
"asset.view_all",
|
||||||
"report.dashboard.view",
|
"report.dashboard.view",
|
||||||
|
"project.view_all",
|
||||||
"budget.view",
|
"budget.view",
|
||||||
|
"project.view_all",
|
||||||
|
"project.manage",
|
||||||
"budget.manage",
|
"budget.manage",
|
||||||
|
"project.view_all",
|
||||||
|
"project.manage",
|
||||||
],
|
],
|
||||||
ceo: [
|
ceo: [
|
||||||
...EMPLOYEE_BASE_PERMISSIONS,
|
...EMPLOYEE_BASE_PERMISSIONS,
|
||||||
@@ -122,6 +129,7 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
|||||||
"system.audit.view",
|
"system.audit.view",
|
||||||
"payment.batch.manage",
|
"payment.batch.manage",
|
||||||
"risk.review.manage",
|
"risk.review.manage",
|
||||||
|
"project.view_all",
|
||||||
],
|
],
|
||||||
administration: [
|
administration: [
|
||||||
"expense.proxy_submit",
|
"expense.proxy_submit",
|
||||||
@@ -134,6 +142,8 @@ const ROLE_PERMISSIONS: Record<string, readonly string[]> = {
|
|||||||
"asset.view_all",
|
"asset.view_all",
|
||||||
"asset.manage",
|
"asset.manage",
|
||||||
"asset.transfer",
|
"asset.transfer",
|
||||||
|
"project.view_all",
|
||||||
|
"project.manage",
|
||||||
],
|
],
|
||||||
hr: [
|
hr: [
|
||||||
...LEAVE_SELF_SERVICE_PERMISSIONS,
|
...LEAVE_SELF_SERVICE_PERMISSIONS,
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ export const NAV_ITEMS: NavItem[] = [
|
|||||||
{ to: "/weekly-reports", label: "周报", icon: "article" },
|
{ to: "/weekly-reports", label: "周报", icon: "article" },
|
||||||
{ to: "/todos", label: "待办", icon: "check_circle" },
|
{ to: "/todos", label: "待办", icon: "check_circle" },
|
||||||
{ to: "/announcements", label: "公告", icon: "campaign" },
|
{ to: "/announcements", label: "公告", icon: "campaign" },
|
||||||
|
{ to: "/projects", label: "项目", icon: "workspaces", anyOf: ["project.view_own", "project.view_all", "project.manage"] },
|
||||||
|
{ to: "/contracts-registry", label: "合同台账", icon: "description", anyOf: ["project.view_own", "project.view_all", "project.manage", "system.settings"] },
|
||||||
{ to: "/system/workspace", label: "工作区设置", icon: "tune", anyOf: ["system.settings"] },
|
{ to: "/system/workspace", label: "工作区设置", icon: "tune", anyOf: ["system.settings"] },
|
||||||
{ to: "/people", label: "人员管理", icon: "group", anyOf: ["system.users", "report.dashboard.view"] },
|
{ to: "/people", label: "人员管理", icon: "group", anyOf: ["system.users", "report.dashboard.view"] },
|
||||||
{ to: "/system/workflow-definitions", label: "流程定义", icon: "schema", anyOf: ["system.settings"] },
|
{ to: "/system/workflow-definitions", label: "流程定义", icon: "schema", anyOf: ["system.settings"] },
|
||||||
@@ -90,8 +92,8 @@ function buildFallbackItems(permissions: string[]) {
|
|||||||
.map((item) => ({ key: item.to, href: item.to, label: item.label, icon: item.icon }));
|
.map((item) => ({ key: item.to, href: item.to, label: item.label, icon: item.icon }));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
main: mapItems(["/portal", "/team", "/leave-requests", "/reimbursements", "/assets", "/weekly-reports", "/todos", "/announcements"]),
|
main: mapItems(["/portal", "/team", "/leave-requests", "/reimbursements", "/assets", "/weekly-reports", "/todos", "/announcements", "/projects"]),
|
||||||
admin: mapItems(["/system/workspace", "/people", "/system/workflow-definitions", "/system/settings", "/system/feature-flags", "/audit-logs"]),
|
admin: mapItems(["/system/workspace", "/people", "/contracts-registry", "/system/workflow-definitions", "/system/settings", "/system/feature-flags", "/audit-logs"]),
|
||||||
backup: mapItems(["/workflow/tasks", "/purchase-requests", "/travel-requests", "/budgets", "/payment-batches", "/ap-operations", "/invoice-pool", "/inventory-checks", "/system/cutover", "/system/integrations/erp", "/dashboard"]),
|
backup: mapItems(["/workflow/tasks", "/purchase-requests", "/travel-requests", "/budgets", "/payment-batches", "/ap-operations", "/invoice-pool", "/inventory-checks", "/system/cutover", "/system/integrations/erp", "/dashboard"]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,234 @@
|
|||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Link, useSearchParams } from "react-router-dom";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { fetchEmployees } from "../api/employees";
|
||||||
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
|
import { createContractRegistry, fetchContractsRegistry, patchContractRegistry, type ContractRegistryItem } from "../api/contractsRegistry";
|
||||||
|
import { fetchProjects, type ConfidentialityLevel } from "../api/projects";
|
||||||
|
import { isApiError } from "../api/types";
|
||||||
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
|
import { StatusBadge } from "../components/common/StatusBadge";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import { Input } from "../components/ui/input";
|
||||||
|
import { Label } from "../components/ui/label";
|
||||||
|
import { Textarea } from "../components/ui/textarea";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const CONFIDENTIALITY_OPTIONS: ConfidentialityLevel[] = ["public", "internal", "confidential", "restricted"];
|
||||||
|
|
||||||
|
function resolveError(error: unknown, fallback = "操作失败") {
|
||||||
|
if (isApiError(error)) {
|
||||||
|
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||||
|
}
|
||||||
|
return error instanceof Error ? error.message : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confidentialityLabel(value: ConfidentialityLevel) {
|
||||||
|
if (value === "public") return "公开";
|
||||||
|
if (value === "internal") return "内部";
|
||||||
|
if (value === "confidential") return "机密";
|
||||||
|
return "限制";
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextStatus(item: ContractRegistryItem) {
|
||||||
|
return item.status === "archived" ? "active" : "archived";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ContractsRegistryPage() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const initialProjectId = searchParams.get("projectId") ?? "";
|
||||||
|
|
||||||
|
const [projectId, setProjectId] = useState(initialProjectId);
|
||||||
|
const [contractNo, setContractNo] = useState("");
|
||||||
|
const [counterpartyName, setCounterpartyName] = useState("");
|
||||||
|
const [summary, setSummary] = useState("");
|
||||||
|
const [archiveLocation, setArchiveLocation] = useState("");
|
||||||
|
const [offlineArchiveNo, setOfflineArchiveNo] = useState("");
|
||||||
|
const [ownerEmployeeId, setOwnerEmployeeId] = useState("");
|
||||||
|
const [confidentialityLevel, setConfidentialityLevel] = useState<ConfidentialityLevel>("internal");
|
||||||
|
const [status, setStatus] = useState("active");
|
||||||
|
|
||||||
|
const contractsQuery = useQuery({
|
||||||
|
queryKey: ["contracts-registry", projectId],
|
||||||
|
queryFn: () => fetchContractsRegistry(projectId || undefined),
|
||||||
|
});
|
||||||
|
const projectsQuery = useQuery({ queryKey: ["projects", "contracts-page"], queryFn: () => fetchProjects({}) });
|
||||||
|
const employeesQuery = useQuery({ queryKey: ["employees", "contracts-page"], queryFn: () => fetchEmployees({ page: 1, pageSize: 100, status: "active" }) });
|
||||||
|
|
||||||
|
const projectOptions = projectsQuery.data?.items ?? [];
|
||||||
|
const employeeOptions = employeesQuery.data?.items ?? [];
|
||||||
|
|
||||||
|
const groupedContracts = useMemo(() => contractsQuery.data?.items ?? [], [contractsQuery.data?.items]);
|
||||||
|
|
||||||
|
const refreshAll = async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["contracts-registry"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects"] }),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
createContractRegistry({
|
||||||
|
contractNo: contractNo.trim(),
|
||||||
|
projectId: projectId || null,
|
||||||
|
counterpartyName: counterpartyName.trim(),
|
||||||
|
summary: summary.trim(),
|
||||||
|
archiveLocation: archiveLocation.trim(),
|
||||||
|
offlineArchiveNo: offlineArchiveNo.trim() || null,
|
||||||
|
ownerEmployeeId: ownerEmployeeId || undefined,
|
||||||
|
confidentialityLevel,
|
||||||
|
status,
|
||||||
|
}),
|
||||||
|
onSuccess: async () => {
|
||||||
|
setContractNo("");
|
||||||
|
setCounterpartyName("");
|
||||||
|
setSummary("");
|
||||||
|
setArchiveLocation("");
|
||||||
|
setOfflineArchiveNo("");
|
||||||
|
setOwnerEmployeeId("");
|
||||||
|
setConfidentialityLevel("internal");
|
||||||
|
setStatus("active");
|
||||||
|
await refreshAll();
|
||||||
|
toast.success("合同台账已创建");
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(resolveError(error, "创建合同台账失败")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleStatusMutation = useMutation({
|
||||||
|
mutationFn: (item: ContractRegistryItem) => patchContractRegistry(item.id, { status: nextStatus(item) }),
|
||||||
|
onSuccess: refreshAll,
|
||||||
|
onError: (error) => toast.error(resolveError(error, "更新合同状态失败")),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="合同台账"
|
||||||
|
subtitle="只维护合同元数据、归档位置和项目关联,不在 OA 存放涉密正文附件。"
|
||||||
|
actions={
|
||||||
|
<Button asChild variant="secondary" type="button">
|
||||||
|
<Link to="/projects">返回项目</Link>
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[360px,minmax(0,1fr)]">
|
||||||
|
<div className="card-std space-y-4 p-6">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">新建合同台账</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>合同编号</Label>
|
||||||
|
<Input value={contractNo} onChange={(event) => setContractNo(event.target.value)} placeholder="例如 HT-2026-001" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>关联项目</Label>
|
||||||
|
<select className="select-std" value={projectId} onChange={(event) => setProjectId(event.target.value)}>
|
||||||
|
<option value="">未关联</option>
|
||||||
|
{projectOptions.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.projectNo} · {item.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>相对方</Label>
|
||||||
|
<Input value={counterpartyName} onChange={(event) => setCounterpartyName(event.target.value)} placeholder="合同对方名称" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>摘要</Label>
|
||||||
|
<Textarea value={summary} onChange={(event) => setSummary(event.target.value)} placeholder="只写最小必要摘要,不录入涉密正文" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>归档位置</Label>
|
||||||
|
<Input value={archiveLocation} onChange={(event) => setArchiveLocation(event.target.value)} placeholder="NAS/文件柜/共享盘路径" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>离线档案号</Label>
|
||||||
|
<Input value={offlineArchiveNo} onChange={(event) => setOfflineArchiveNo(event.target.value)} placeholder="可选" />
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>密级</Label>
|
||||||
|
<select className="select-std" value={confidentialityLevel} onChange={(event) => setConfidentialityLevel(event.target.value as ConfidentialityLevel)}>
|
||||||
|
{CONFIDENTIALITY_OPTIONS.map((item) => (
|
||||||
|
<option key={item} value={item}>{confidentialityLabel(item)}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>状态</Label>
|
||||||
|
<select className="select-std" value={status} onChange={(event) => setStatus(event.target.value)}>
|
||||||
|
<option value="active">active</option>
|
||||||
|
<option value="draft">draft</option>
|
||||||
|
<option value="archived">archived</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>责任人</Label>
|
||||||
|
<select className="select-std" value={ownerEmployeeId} onChange={(event) => setOwnerEmployeeId(event.target.value)}>
|
||||||
|
<option value="">默认当前用户</option>
|
||||||
|
{employeeOptions.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>{item.name} · {item.employeeNo}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={createMutation.isPending || !contractNo.trim() || !counterpartyName.trim() || !summary.trim() || !archiveLocation.trim()}
|
||||||
|
onClick={() => createMutation.mutate()}
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? "创建中..." : "创建合同台账"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-std space-y-4 p-6">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">台账列表</h3>
|
||||||
|
<div className="text-sm text-zinc-500">{projectId ? "已按项目过滤" : "显示全部可见合同"}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{contractsQuery.isLoading ? <div className="text-sm text-zinc-500">加载中...</div> : null}
|
||||||
|
{contractsQuery.isError ? <div className="text-sm text-red-600">{resolveError(contractsQuery.error, "加载合同台账失败")}</div> : null}
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
{groupedContracts.map((item) => (
|
||||||
|
<div key={item.id} className="rounded-lg border border-zinc-200 p-4">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="font-semibold text-zinc-900">{item.contractNo}</div>
|
||||||
|
<StatusBadge status={item.status} />
|
||||||
|
<span className="rounded-full bg-zinc-100 px-2 py-1 text-xs text-zinc-600">{confidentialityLabel(item.confidentialityLevel)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-zinc-900">{item.counterpartyName}</div>
|
||||||
|
<div className="mt-1 text-sm text-zinc-600">{item.summary}</div>
|
||||||
|
<div className="mt-2 text-xs text-zinc-500">
|
||||||
|
项目:{item.project ? `${item.project.projectNo} · ${item.project.name}` : "未关联"} · 责任人:{item.owner?.name ?? "-"}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">归档:{item.archiveLocation}{item.offlineArchiveNo ? ` · 离线档案号 ${item.offlineArchiveNo}` : ""}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{item.project ? (
|
||||||
|
<Button asChild size="sm" variant="secondary" type="button">
|
||||||
|
<Link to={`/projects/${item.project.id}`}>查看项目</Link>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button size="sm" variant="secondary" type="button" disabled={toggleStatusMutation.isPending} onClick={() => toggleStatusMutation.mutate(item)}>
|
||||||
|
{item.status === "archived" ? "恢复为 active" : "归档"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{!contractsQuery.isLoading && !groupedContracts.length ? <div className="text-sm text-zinc-500">暂无合同台账。</div> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ContractsRegistryPage;
|
||||||
@@ -0,0 +1,760 @@
|
|||||||
|
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 { fetchContractsRegistry } from "../api/contractsRegistry";
|
||||||
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
|
import { isApiError } from "../api/types";
|
||||||
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
|
import { StatusBadge } from "../components/common/StatusBadge";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import { Input } from "../components/ui/input";
|
||||||
|
import { Label } from "../components/ui/label";
|
||||||
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||||
|
import { Textarea } from "../components/ui/textarea";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const PROJECT_STATUS_OPTIONS: ProjectStatus[] = ["draft", "active", "paused", "completed", "archived"];
|
||||||
|
const MEMBER_ROLE_OPTIONS = ["owner", "manager", "member", "observer"] as const;
|
||||||
|
const MILESTONE_STATUS_OPTIONS: ProjectMilestoneStatus[] = ["pending", "in_progress", "done", "delayed", "cancelled"];
|
||||||
|
const TASK_STATUS_OPTIONS: ProjectTaskStatus[] = ["todo", "doing", "blocked", "done", "cancelled"];
|
||||||
|
const TASK_PRIORITY_OPTIONS: ProjectTaskPriority[] = ["low", "normal", "high", "urgent"];
|
||||||
|
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 CONFIDENTIALITY_OPTIONS: ConfidentialityLevel[] = ["public", "internal", "confidential", "restricted"];
|
||||||
|
const STORAGE_TYPE_OPTIONS: DocumentStorageType[] = ["oa_link", "nas", "internal_share", "offline_archive"];
|
||||||
|
|
||||||
|
function resolveError(error: unknown, fallback = "操作失败") {
|
||||||
|
if (isApiError(error)) {
|
||||||
|
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||||
|
}
|
||||||
|
return error instanceof Error ? error.message : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function confidentialityLabel(value: ConfidentialityLevel) {
|
||||||
|
if (value === "public") return "公开";
|
||||||
|
if (value === "internal") return "内部";
|
||||||
|
if (value === "confidential") return "机密";
|
||||||
|
return "限制";
|
||||||
|
}
|
||||||
|
|
||||||
|
function storageTypeLabel(value: DocumentStorageType) {
|
||||||
|
if (value === "oa_link") return "OA 链接";
|
||||||
|
if (value === "nas") return "NAS";
|
||||||
|
if (value === "internal_share") return "内网共享";
|
||||||
|
return "离线档案";
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordTypeLabel(value: ProjectDeliveryRecordType) {
|
||||||
|
if (value === "kickoff") return "启动";
|
||||||
|
if (value === "onsite") return "现场";
|
||||||
|
if (value === "stage_acceptance") return "阶段验收";
|
||||||
|
if (value === "final_acceptance") return "最终验收";
|
||||||
|
return "交接";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectDetailPage() {
|
||||||
|
const { projectId = "" } = useParams();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const detailQuery = useQuery({
|
||||||
|
enabled: Boolean(projectId),
|
||||||
|
queryKey: ["projects", "detail", projectId],
|
||||||
|
queryFn: () => fetchProject(projectId),
|
||||||
|
});
|
||||||
|
const employeesQuery = useQuery({
|
||||||
|
enabled: Boolean(projectId),
|
||||||
|
queryKey: ["employees", "project-detail"],
|
||||||
|
queryFn: () => fetchEmployees({ page: 1, pageSize: 100, status: "active" }),
|
||||||
|
});
|
||||||
|
const expensesQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", projectId, "expenses"], queryFn: () => fetchProjectExpensesSummary(projectId) });
|
||||||
|
const travelQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", projectId, "travel"], queryFn: () => fetchProjectTravelSummary(projectId) });
|
||||||
|
const weeklyQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", projectId, "weekly"], queryFn: () => fetchProjectWeeklyReports(projectId) });
|
||||||
|
const deliveriesQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", projectId, "deliveries"], queryFn: () => fetchProjectDeliveries(projectId) });
|
||||||
|
const docsQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", projectId, "document-refs"], queryFn: () => fetchProjectDocumentRefs(projectId) });
|
||||||
|
const contractsQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["contracts-registry", projectId], queryFn: () => fetchContractsRegistry(projectId) });
|
||||||
|
const membersQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", projectId, "members"], queryFn: () => fetchProjectMembers(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 risksQuery = useQuery({ enabled: Boolean(projectId), queryKey: ["projects", projectId, "risks"], queryFn: () => fetchProjectRisks(projectId) });
|
||||||
|
|
||||||
|
const employeeOptions = employeesQuery.data?.items ?? [];
|
||||||
|
const detail = detailQuery.data?.item ?? null;
|
||||||
|
|
||||||
|
const [projectForm, setProjectForm] = useState({
|
||||||
|
name: "",
|
||||||
|
clientName: "",
|
||||||
|
clientAlias: "",
|
||||||
|
status: "draft" as ProjectStatus,
|
||||||
|
startsAt: "",
|
||||||
|
targetEndAt: "",
|
||||||
|
actualEndAt: "",
|
||||||
|
managerEmployeeId: "",
|
||||||
|
summary: "",
|
||||||
|
isConfidential: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [memberEmployeeId, setMemberEmployeeId] = useState("");
|
||||||
|
const [memberRole, setMemberRole] = useState<(typeof MEMBER_ROLE_OPTIONS)[number]>("member");
|
||||||
|
|
||||||
|
const [milestoneTitle, setMilestoneTitle] = useState("");
|
||||||
|
const [milestoneDueDate, setMilestoneDueDate] = useState("");
|
||||||
|
const [milestoneStatus, setMilestoneStatus] = useState<ProjectMilestoneStatus>("pending");
|
||||||
|
|
||||||
|
const [taskTitle, setTaskTitle] = useState("");
|
||||||
|
const [taskDueDate, setTaskDueDate] = useState("");
|
||||||
|
const [taskAssigneeId, setTaskAssigneeId] = useState("");
|
||||||
|
const [taskPriority, setTaskPriority] = useState<ProjectTaskPriority>("normal");
|
||||||
|
|
||||||
|
const [riskTitle, setRiskTitle] = useState("");
|
||||||
|
const [riskDetail, setRiskDetail] = useState("");
|
||||||
|
const [riskLevel, setRiskLevel] = useState<ProjectRiskLevel>("medium");
|
||||||
|
const [riskOwnerId, setRiskOwnerId] = useState("");
|
||||||
|
|
||||||
|
const [deliveryTitle, setDeliveryTitle] = useState("");
|
||||||
|
const [deliverySummary, setDeliverySummary] = useState("");
|
||||||
|
const [deliveryType, setDeliveryType] = useState<ProjectDeliveryRecordType>("onsite");
|
||||||
|
const [deliveryOccurredAt, setDeliveryOccurredAt] = useState("");
|
||||||
|
|
||||||
|
const [docTitle, setDocTitle] = useState("");
|
||||||
|
const [docNo, setDocNo] = useState("");
|
||||||
|
const [docStoragePath, setDocStoragePath] = useState("");
|
||||||
|
const [docOfflineArchiveNo, setDocOfflineArchiveNo] = useState("");
|
||||||
|
const [docConfidentiality, setDocConfidentiality] = useState<ConfidentialityLevel>("internal");
|
||||||
|
const [docStorageType, setDocStorageType] = useState<DocumentStorageType>("nas");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!detail) return;
|
||||||
|
setProjectForm({
|
||||||
|
name: detail.name,
|
||||||
|
clientName: detail.clientName,
|
||||||
|
clientAlias: detail.clientAlias ?? "",
|
||||||
|
status: detail.status,
|
||||||
|
startsAt: detail.startsAt ?? "",
|
||||||
|
targetEndAt: detail.targetEndAt ?? "",
|
||||||
|
actualEndAt: detail.actualEndAt ?? "",
|
||||||
|
managerEmployeeId: detail.managerEmployeeId ?? "",
|
||||||
|
summary: detail.summary ?? "",
|
||||||
|
isConfidential: detail.isConfidential,
|
||||||
|
});
|
||||||
|
}, [detail]);
|
||||||
|
|
||||||
|
const refreshProject = async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects", "detail", projectId] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects", projectId] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["global-search"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["contracts-registry", projectId] }),
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveProjectMutation = useMutation({
|
||||||
|
mutationFn: () => patchProject(projectId, {
|
||||||
|
name: projectForm.name.trim(),
|
||||||
|
clientName: projectForm.clientName.trim(),
|
||||||
|
clientAlias: projectForm.clientAlias.trim() || null,
|
||||||
|
status: projectForm.status,
|
||||||
|
startsAt: projectForm.startsAt || null,
|
||||||
|
targetEndAt: projectForm.targetEndAt || null,
|
||||||
|
actualEndAt: projectForm.actualEndAt || null,
|
||||||
|
managerEmployeeId: projectForm.managerEmployeeId || null,
|
||||||
|
summary: projectForm.summary.trim() || null,
|
||||||
|
isConfidential: projectForm.isConfidential,
|
||||||
|
}),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await refreshProject();
|
||||||
|
toast.success("项目主档已更新");
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(resolveError(error, "保存项目失败")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const archiveProjectMutation = useMutation({
|
||||||
|
mutationFn: () => archiveProject(projectId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await refreshProject();
|
||||||
|
toast.success("项目已归档");
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(resolveError(error, "归档项目失败")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addMemberMutation = useMutation({
|
||||||
|
mutationFn: () => createProjectMember(projectId, { employeeId: memberEmployeeId, role: memberRole }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
setMemberEmployeeId("");
|
||||||
|
setMemberRole("member");
|
||||||
|
await Promise.all([
|
||||||
|
refreshProject(),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects", projectId, "members"] }),
|
||||||
|
]);
|
||||||
|
toast.success("项目成员已添加");
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(resolveError(error, "添加成员失败")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const patchMemberMutation = useMutation({
|
||||||
|
mutationFn: ({ memberId, role, leftAt }: { memberId: string; role?: (typeof MEMBER_ROLE_OPTIONS)[number]; leftAt?: string | null }) =>
|
||||||
|
patchProjectMember(projectId, memberId, { ...(role ? { role } : {}), ...(leftAt !== undefined ? { leftAt } : {}) }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
refreshProject(),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects", projectId, "members"] }),
|
||||||
|
]);
|
||||||
|
toast.success("成员已更新");
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(resolveError(error, "更新成员失败")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addMilestoneMutation = useMutation({
|
||||||
|
mutationFn: () => createProjectMilestone(projectId, { title: milestoneTitle.trim(), dueDate: milestoneDueDate || null, status: milestoneStatus }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
setMilestoneTitle("");
|
||||||
|
setMilestoneDueDate("");
|
||||||
|
setMilestoneStatus("pending");
|
||||||
|
await Promise.all([
|
||||||
|
refreshProject(),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects", projectId, "milestones"] }),
|
||||||
|
]);
|
||||||
|
toast.success("里程碑已创建");
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(resolveError(error, "创建里程碑失败")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const patchMilestoneMutation = useMutation({
|
||||||
|
mutationFn: ({ milestoneId, status }: { milestoneId: string; status: ProjectMilestoneStatus }) => patchProjectMilestone(projectId, milestoneId, { status }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
refreshProject(),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects", projectId, "milestones"] }),
|
||||||
|
]);
|
||||||
|
toast.success("里程碑状态已更新");
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(resolveError(error, "更新里程碑失败")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addTaskMutation = useMutation({
|
||||||
|
mutationFn: () => createProjectTask(projectId, { title: taskTitle.trim(), dueDate: taskDueDate || null, assigneeEmployeeId: taskAssigneeId || null, priority: taskPriority, status: "todo" }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
setTaskTitle("");
|
||||||
|
setTaskDueDate("");
|
||||||
|
setTaskAssigneeId("");
|
||||||
|
setTaskPriority("normal");
|
||||||
|
await Promise.all([
|
||||||
|
refreshProject(),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects", projectId, "tasks"] }),
|
||||||
|
]);
|
||||||
|
toast.success("项目任务已创建");
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(resolveError(error, "创建任务失败")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const patchTaskMutation = useMutation({
|
||||||
|
mutationFn: ({ taskId, payload }: { taskId: string; payload: Partial<{ status: ProjectTaskStatus; priority: ProjectTaskPriority; assigneeEmployeeId: string | null }> }) =>
|
||||||
|
patchProjectTask(projectId, taskId, payload),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
refreshProject(),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects", projectId, "tasks"] }),
|
||||||
|
]);
|
||||||
|
toast.success("任务已更新");
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(resolveError(error, "更新任务失败")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addRiskMutation = useMutation({
|
||||||
|
mutationFn: () => createProjectRisk(projectId, { title: riskTitle.trim(), detail: riskDetail.trim(), level: riskLevel, ownerEmployeeId: riskOwnerId || null, status: "open" }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
setRiskTitle("");
|
||||||
|
setRiskDetail("");
|
||||||
|
setRiskLevel("medium");
|
||||||
|
setRiskOwnerId("");
|
||||||
|
await Promise.all([
|
||||||
|
refreshProject(),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects", projectId, "risks"] }),
|
||||||
|
]);
|
||||||
|
toast.success("项目风险已登记");
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(resolveError(error, "创建风险失败")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const patchRiskMutation = useMutation({
|
||||||
|
mutationFn: ({ riskId, status }: { riskId: string; status: string }) => patchProjectRisk(projectId, riskId, { status }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
refreshProject(),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects", projectId, "risks"] }),
|
||||||
|
]);
|
||||||
|
toast.success("风险状态已更新");
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(resolveError(error, "更新风险失败")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addDeliveryMutation = useMutation({
|
||||||
|
mutationFn: () => createProjectDelivery(projectId, { title: deliveryTitle.trim(), summary: deliverySummary.trim(), recordType: deliveryType, occurredAt: `${deliveryOccurredAt || new Date().toISOString().slice(0, 10)}T00:00:00.000Z` }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
setDeliveryTitle("");
|
||||||
|
setDeliverySummary("");
|
||||||
|
setDeliveryType("onsite");
|
||||||
|
setDeliveryOccurredAt("");
|
||||||
|
await Promise.all([
|
||||||
|
refreshProject(),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects", projectId, "deliveries"] }),
|
||||||
|
]);
|
||||||
|
toast.success("交付记录已创建");
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(resolveError(error, "创建交付记录失败")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const addDocRefMutation = useMutation({
|
||||||
|
mutationFn: () => createConfidentialDocumentRef({ projectId, title: docTitle.trim(), documentNo: docNo.trim() || null, confidentialityLevel: docConfidentiality, storageType: docStorageType, storagePath: docStoragePath.trim(), offlineArchiveNo: docOfflineArchiveNo.trim() || null }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
setDocTitle("");
|
||||||
|
setDocNo("");
|
||||||
|
setDocStoragePath("");
|
||||||
|
setDocOfflineArchiveNo("");
|
||||||
|
setDocConfidentiality("internal");
|
||||||
|
setDocStorageType("nas");
|
||||||
|
await Promise.all([
|
||||||
|
refreshProject(),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects", projectId, "document-refs"] }),
|
||||||
|
]);
|
||||||
|
toast.success("涉密资料引用已登记");
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(resolveError(error, "登记资料引用失败")),
|
||||||
|
});
|
||||||
|
|
||||||
|
const counts = detail?.counts;
|
||||||
|
|
||||||
|
if (!projectId) {
|
||||||
|
return <div className="card-std p-6 text-sm text-red-600">缺少 projectId 参数。</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailQuery.isLoading) {
|
||||||
|
return <div className="card-std p-6 text-sm text-zinc-500">加载项目详情中...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (detailQuery.isError || !detail) {
|
||||||
|
return (
|
||||||
|
<div className="card-std space-y-3 p-6">
|
||||||
|
<div className="text-sm text-red-600">{resolveError(detailQuery.error, "加载项目详情失败")}</div>
|
||||||
|
<Button asChild variant="secondary" type="button">
|
||||||
|
<Link to="/projects">返回项目列表</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const memberItems = membersQuery.data?.items ?? detail.members;
|
||||||
|
const milestoneItems = milestonesQuery.data?.items ?? detail.milestones;
|
||||||
|
const taskItems = tasksQuery.data?.items ?? detail.tasks;
|
||||||
|
const riskItems = risksQuery.data?.items ?? detail.risks;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title={`${detail.projectNo} · ${detail.name}`}
|
||||||
|
subtitle="项目主档、成员、里程碑、任务、风险、费用、周报与资料引用统一查看。"
|
||||||
|
actions={
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button asChild variant="secondary" type="button">
|
||||||
|
<Link to="/projects">返回项目</Link>
|
||||||
|
</Button>
|
||||||
|
<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()}>
|
||||||
|
{archiveProjectMutation.isPending ? "归档中..." : "归档项目"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-6">
|
||||||
|
<div className="card-std p-4"><div className="text-sm text-zinc-500">成员</div><div className="mt-2 text-2xl font-bold">{counts?.members ?? 0}</div></div>
|
||||||
|
<div className="card-std p-4"><div className="text-sm text-zinc-500">里程碑</div><div className="mt-2 text-2xl font-bold">{counts?.milestones ?? 0}</div></div>
|
||||||
|
<div className="card-std p-4"><div className="text-sm text-zinc-500">任务</div><div className="mt-2 text-2xl font-bold">{counts?.tasks ?? 0}</div></div>
|
||||||
|
<div className="card-std p-4"><div className="text-sm text-zinc-500">风险</div><div className="mt-2 text-2xl font-bold">{counts?.risks ?? 0}</div></div>
|
||||||
|
<div className="card-std p-4"><div className="text-sm text-zinc-500">报销合计</div><div className="mt-2 text-2xl font-bold">{expensesQuery.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 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="flex items-center gap-3">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">项目主档</h3>
|
||||||
|
<StatusBadge status={detail.status} />
|
||||||
|
{detail.isConfidential ? <span className="rounded-full bg-amber-100 px-2 py-1 text-xs text-amber-700">涉密项目</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>项目名称</Label>
|
||||||
|
<Input value={projectForm.name} onChange={(event) => setProjectForm((prev) => ({ ...prev, name: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>客户名称</Label>
|
||||||
|
<Input value={projectForm.clientName} onChange={(event) => setProjectForm((prev) => ({ ...prev, clientName: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>客户简称</Label>
|
||||||
|
<Input value={projectForm.clientAlias} onChange={(event) => setProjectForm((prev) => ({ ...prev, clientAlias: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>状态</Label>
|
||||||
|
<select className="select-std" value={projectForm.status} onChange={(event) => setProjectForm((prev) => ({ ...prev, status: event.target.value as ProjectStatus }))}>
|
||||||
|
{PROJECT_STATUS_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>开始日期</Label>
|
||||||
|
<Input type="date" value={projectForm.startsAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, startsAt: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>目标完成</Label>
|
||||||
|
<Input type="date" value={projectForm.targetEndAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, targetEndAt: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>实际完成</Label>
|
||||||
|
<Input type="date" value={projectForm.actualEndAt} onChange={(event) => setProjectForm((prev) => ({ ...prev, actualEndAt: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>项目经理</Label>
|
||||||
|
<select className="select-std" value={projectForm.managerEmployeeId} onChange={(event) => setProjectForm((prev) => ({ ...prev, managerEmployeeId: event.target.value }))}>
|
||||||
|
<option value="">未指定</option>
|
||||||
|
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.employeeNo}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>项目摘要</Label>
|
||||||
|
<Textarea value={projectForm.summary} onChange={(event) => setProjectForm((prev) => ({ ...prev, summary: event.target.value }))} />
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-zinc-700">
|
||||||
|
<input type="checkbox" checked={projectForm.isConfidential} onChange={(event) => setProjectForm((prev) => ({ ...prev, isConfidential: event.target.checked }))} />
|
||||||
|
项目包含涉密资料,正文与附件保持离线管理
|
||||||
|
</label>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button type="button" disabled={saveProjectMutation.isPending} onClick={() => saveProjectMutation.mutate()}>
|
||||||
|
{saveProjectMutation.isPending ? "保存中..." : "保存主档"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-std space-y-3 p-6 text-sm">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">项目摘要</h3>
|
||||||
|
<div>负责人:{detail.owner?.name ?? "-"}</div>
|
||||||
|
<div>项目经理:{detail.manager?.name ?? "-"}</div>
|
||||||
|
<div>周报关联:{counts?.weeklyReports ?? 0}</div>
|
||||||
|
<div>合同台账:{counts?.contracts ?? 0}</div>
|
||||||
|
<div>交付记录:{counts?.deliveries ?? 0}</div>
|
||||||
|
<div>资料引用:{counts?.documentRefs ?? 0}</div>
|
||||||
|
<div className="rounded-lg border border-zinc-200 p-3 text-zinc-600">
|
||||||
|
项目相关的出差、报销、待办、周报都可以在当前项目下汇总查看;涉密正文与附件仍不进 OA。
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
|
<div className="card-std space-y-4 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">成员</h3>
|
||||||
|
<div className="text-sm text-zinc-500">当前 {memberItems.length} 人</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),160px,120px]">
|
||||||
|
<select className="select-std" value={memberEmployeeId} onChange={(event) => setMemberEmployeeId(event.target.value)}>
|
||||||
|
<option value="">选择成员</option>
|
||||||
|
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name} · {item.employeeNo}</option>)}
|
||||||
|
</select>
|
||||||
|
<select className="select-std" value={memberRole} onChange={(event) => setMemberRole(event.target.value as (typeof MEMBER_ROLE_OPTIONS)[number])}>
|
||||||
|
{MEMBER_ROLE_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||||
|
</select>
|
||||||
|
<Button type="button" disabled={addMemberMutation.isPending || !memberEmployeeId} onClick={() => addMemberMutation.mutate()}>
|
||||||
|
添加成员
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader><TableRow><TableHead>成员</TableHead><TableHead>角色</TableHead><TableHead>加入时间</TableHead><TableHead>操作</TableHead></TableRow></TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{memberItems.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>{item.employee?.name ?? item.employeeId}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<select className="select-std h-9" value={item.role} onChange={(event) => patchMemberMutation.mutate({ memberId: item.id, role: event.target.value as (typeof MEMBER_ROLE_OPTIONS)[number] })}>
|
||||||
|
{MEMBER_ROLE_OPTIONS.map((role) => <option key={role} value={role}>{role}</option>)}
|
||||||
|
</select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{item.joinedAt ? item.joinedAt.slice(0, 10) : "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{item.leftAt ? "已移出" : (
|
||||||
|
<Button size="sm" variant="secondary" type="button" onClick={() => patchMemberMutation.mutate({ memberId: item.id, leftAt: new Date().toISOString().slice(0, 10) })}>移出</Button>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-std space-y-4 p-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">里程碑</h3>
|
||||||
|
<div className="text-sm text-zinc-500">逾期请及时调整状态</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),160px,160px,120px]">
|
||||||
|
<Input value={milestoneTitle} onChange={(event) => setMilestoneTitle(event.target.value)} placeholder="里程碑标题" />
|
||||||
|
<Input type="date" value={milestoneDueDate} onChange={(event) => setMilestoneDueDate(event.target.value)} />
|
||||||
|
<select className="select-std" value={milestoneStatus} onChange={(event) => setMilestoneStatus(event.target.value as ProjectMilestoneStatus)}>
|
||||||
|
{MILESTONE_STATUS_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||||
|
</select>
|
||||||
|
<Button type="button" disabled={addMilestoneMutation.isPending || !milestoneTitle.trim()} onClick={() => addMilestoneMutation.mutate()}>新增</Button>
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader><TableRow><TableHead>标题</TableHead><TableHead>截止日期</TableHead><TableHead>状态</TableHead></TableRow></TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{milestoneItems.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>{item.title}</TableCell>
|
||||||
|
<TableCell>{item.dueDate ?? "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<select className="select-std h-9" value={item.status} onChange={(event) => patchMilestoneMutation.mutate({ milestoneId: item.id, status: event.target.value as ProjectMilestoneStatus })}>
|
||||||
|
{MILESTONE_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||||
|
</select>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
|
<div className="card-std space-y-4 p-6">
|
||||||
|
<div className="flex items-center justify-between"><h3 className="text-base font-semibold text-zinc-900">任务</h3><div className="text-sm text-zinc-500">支持高优先级与阻塞状态</div></div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),160px,160px,160px,120px]">
|
||||||
|
<Input value={taskTitle} onChange={(event) => setTaskTitle(event.target.value)} placeholder="任务标题" />
|
||||||
|
<select className="select-std" value={taskAssigneeId} onChange={(event) => setTaskAssigneeId(event.target.value)}>
|
||||||
|
<option value="">未指派</option>
|
||||||
|
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<select className="select-std" value={taskPriority} onChange={(event) => setTaskPriority(event.target.value as ProjectTaskPriority)}>
|
||||||
|
{TASK_PRIORITY_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||||
|
</select>
|
||||||
|
<Input type="date" value={taskDueDate} onChange={(event) => setTaskDueDate(event.target.value)} />
|
||||||
|
<Button type="button" disabled={addTaskMutation.isPending || !taskTitle.trim()} onClick={() => addTaskMutation.mutate()}>新增</Button>
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader><TableRow><TableHead>任务</TableHead><TableHead>负责人</TableHead><TableHead>优先级</TableHead><TableHead>状态</TableHead></TableRow></TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{taskItems.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||||
|
<div className="text-xs text-zinc-500">截止:{item.dueDate ?? "-"}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<select className="select-std h-9" value={item.assigneeEmployeeId ?? ""} onChange={(event) => patchTaskMutation.mutate({ taskId: item.id, payload: { assigneeEmployeeId: event.target.value || null } })}>
|
||||||
|
<option value="">未指派</option>
|
||||||
|
{employeeOptions.map((employee) => <option key={employee.id} value={employee.id}>{employee.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<select className="select-std h-9" value={item.priority} onChange={(event) => patchTaskMutation.mutate({ taskId: item.id, payload: { priority: event.target.value as ProjectTaskPriority } })}>
|
||||||
|
{TASK_PRIORITY_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||||
|
</select>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<select className="select-std h-9" value={item.status} onChange={(event) => patchTaskMutation.mutate({ taskId: item.id, payload: { status: event.target.value as ProjectTaskStatus } })}>
|
||||||
|
{TASK_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||||
|
</select>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-std space-y-4 p-6">
|
||||||
|
<div className="flex items-center justify-between"><h3 className="text-base font-semibold text-zinc-900">风险</h3><div className="text-sm text-zinc-500">问题与阻塞统一留痕</div></div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input value={riskTitle} onChange={(event) => setRiskTitle(event.target.value)} placeholder="风险标题" />
|
||||||
|
<Textarea value={riskDetail} onChange={(event) => setRiskDetail(event.target.value)} placeholder="风险详情、影响与当前处理动作" />
|
||||||
|
<div className="grid gap-3 md:grid-cols-[160px,160px,minmax(0,1fr),120px]">
|
||||||
|
<select className="select-std" value={riskLevel} onChange={(event) => setRiskLevel(event.target.value as ProjectRiskLevel)}>
|
||||||
|
{RISK_LEVEL_OPTIONS.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||||
|
</select>
|
||||||
|
<select className="select-std" value={riskOwnerId} onChange={(event) => setRiskOwnerId(event.target.value)}>
|
||||||
|
<option value="">未指定责任人</option>
|
||||||
|
{employeeOptions.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}
|
||||||
|
</select>
|
||||||
|
<div className="text-sm text-zinc-500 flex items-center">高优项目建议每个风险都指定责任人。</div>
|
||||||
|
<Button type="button" disabled={addRiskMutation.isPending || !riskTitle.trim() || !riskDetail.trim()} onClick={() => addRiskMutation.mutate()}>登记</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader><TableRow><TableHead>风险</TableHead><TableHead>等级</TableHead><TableHead>责任人</TableHead><TableHead>状态</TableHead></TableRow></TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{riskItems.map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||||
|
<div className="text-xs text-zinc-500">{item.detail}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{item.level}</TableCell>
|
||||||
|
<TableCell>{item.owner?.name ?? "-"}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<select className="select-std h-9" value={item.status} onChange={(event) => patchRiskMutation.mutate({ riskId: item.id, status: event.target.value })}>
|
||||||
|
{RISK_STATUS_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
|
||||||
|
</select>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-2">
|
||||||
|
<div className="card-std space-y-4 p-6">
|
||||||
|
<div className="flex items-center justify-between"><h3 className="text-base font-semibold text-zinc-900">执行面板</h3><div className="text-sm text-zinc-500">人、事、费、周报统一查看</div></div>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
|
<div className="rounded-lg border border-zinc-200 p-4">
|
||||||
|
<div className="text-sm text-zinc-500">报销</div>
|
||||||
|
<div className="mt-1 text-xl font-semibold text-zinc-900">{expensesQuery.data?.totalAmount ?? "0.00"}</div>
|
||||||
|
<div className="mt-2 space-y-2 text-sm">
|
||||||
|
{(expensesQuery.data?.items ?? []).slice(0, 5).map((item) => (
|
||||||
|
<Link key={item.id} to={`/reimbursements/${item.id}`} className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
|
||||||
|
{item.formNumber} · {item.totalAmount}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-zinc-200 p-4">
|
||||||
|
<div className="text-sm text-zinc-500">差旅</div>
|
||||||
|
<div className="mt-1 text-xl font-semibold text-zinc-900">{travelQuery.data?.totalAmount ?? "0.00"}</div>
|
||||||
|
<div className="mt-2 space-y-2 text-sm">
|
||||||
|
{(travelQuery.data?.items ?? []).slice(0, 5).map((item) => (
|
||||||
|
<Link key={item.id} to={`/travel-requests?tab=requests&id=${item.id}`} className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
|
||||||
|
{item.requestNo} · {item.estimatedAmount}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-zinc-200 p-4">
|
||||||
|
<div className="text-sm text-zinc-500">项目周报</div>
|
||||||
|
<div className="mt-1 text-xl font-semibold text-zinc-900">{weeklyQuery.data?.items.length ?? 0}</div>
|
||||||
|
<div className="mt-2 space-y-2 text-sm">
|
||||||
|
{(weeklyQuery.data?.items ?? []).slice(0, 5).map((item) => (
|
||||||
|
<Link key={item.id} to="/weekly-reports" className="block rounded-lg border border-zinc-100 px-3 py-2 hover:bg-zinc-50">
|
||||||
|
{item.employee?.name ?? "成员"} · {item.weekStart ?? "-"}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-zinc-200 p-4">
|
||||||
|
<div className="text-sm text-zinc-500">合同台账</div>
|
||||||
|
<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">
|
||||||
|
{item.contractNo} · {item.counterpartyName}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-std space-y-4 p-6">
|
||||||
|
<div className="flex items-center justify-between"><h3 className="text-base font-semibold text-zinc-900">交付记录</h3><div className="text-sm text-zinc-500">现场、验收、交接留痕</div></div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input value={deliveryTitle} onChange={(event) => setDeliveryTitle(event.target.value)} placeholder="记录标题" />
|
||||||
|
<Textarea value={deliverySummary} onChange={(event) => setDeliverySummary(event.target.value)} placeholder="记录摘要" />
|
||||||
|
<div className="grid gap-3 md:grid-cols-[180px,180px,120px]">
|
||||||
|
<select className="select-std" value={deliveryType} onChange={(event) => setDeliveryType(event.target.value as ProjectDeliveryRecordType)}>
|
||||||
|
{DELIVERY_TYPE_OPTIONS.map((item) => <option key={item} value={item}>{recordTypeLabel(item)}</option>)}
|
||||||
|
</select>
|
||||||
|
<Input type="date" value={deliveryOccurredAt} onChange={(event) => setDeliveryOccurredAt(event.target.value)} />
|
||||||
|
<Button type="button" disabled={addDeliveryMutation.isPending || !deliveryTitle.trim() || !deliverySummary.trim()} onClick={() => addDeliveryMutation.mutate()}>新增</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader><TableRow><TableHead>标题</TableHead><TableHead>类型</TableHead><TableHead>时间</TableHead><TableHead>责任人</TableHead></TableRow></TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(deliveriesQuery.data?.items ?? []).map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||||
|
<div className="text-xs text-zinc-500">{item.summary}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{recordTypeLabel(item.recordType)}</TableCell>
|
||||||
|
<TableCell>{item.occurredAt ? item.occurredAt.slice(0, 10) : "-"}</TableCell>
|
||||||
|
<TableCell>{item.owner?.name ?? "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),minmax(0,1fr)]">
|
||||||
|
<div className="card-std space-y-4 p-6">
|
||||||
|
<div className="flex items-center justify-between"><h3 className="text-base font-semibold text-zinc-900">涉密资料引用</h3><div className="text-sm text-zinc-500">只登记目录 / 链接 / 归档位置</div></div>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Input value={docTitle} onChange={(event) => setDocTitle(event.target.value)} placeholder="资料标题" />
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<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 / 文件盘路径或引用地址" />
|
||||||
|
<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>)}
|
||||||
|
</select>
|
||||||
|
<select className="select-std" value={docStorageType} onChange={(event) => setDocStorageType(event.target.value as DocumentStorageType)}>
|
||||||
|
{STORAGE_TYPE_OPTIONS.map((item) => <option key={item} value={item}>{storageTypeLabel(item)}</option>)}
|
||||||
|
</select>
|
||||||
|
<Button type="button" disabled={addDocRefMutation.isPending || !docTitle.trim() || !docStoragePath.trim()} onClick={() => addDocRefMutation.mutate()}>登记</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader><TableRow><TableHead>资料</TableHead><TableHead>密级</TableHead><TableHead>存储</TableHead><TableHead>路径</TableHead></TableRow></TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(docsQuery.data?.items ?? []).map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>
|
||||||
|
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||||
|
<div className="text-xs text-zinc-500">{item.documentNo ?? item.offlineArchiveNo ?? "无编号"}</div>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{confidentialityLabel(item.confidentialityLevel)}</TableCell>
|
||||||
|
<TableCell>{storageTypeLabel(item.storageType)}</TableCell>
|
||||||
|
<TableCell className="max-w-[280px] truncate">{item.storagePath}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-std space-y-4 p-6">
|
||||||
|
<div className="flex items-center justify-between"><h3 className="text-base font-semibold text-zinc-900">项目周报关联</h3><div className="text-sm text-zinc-500">按项目查看每周进展</div></div>
|
||||||
|
<Table>
|
||||||
|
<TableHeader><TableRow><TableHead>成员</TableHead><TableHead>周起始</TableHead><TableHead>进展</TableHead><TableHead>下一步</TableHead></TableRow></TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{(weeklyQuery.data?.items ?? []).map((item) => (
|
||||||
|
<TableRow key={item.id}>
|
||||||
|
<TableCell>{item.employee?.name ?? "-"}</TableCell>
|
||||||
|
<TableCell>{item.weekStart ?? "-"}</TableCell>
|
||||||
|
<TableCell>{item.progressText || item.riskText || "-"}</TableCell>
|
||||||
|
<TableCell>{item.nextStepText || "-"}</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectDetailPage;
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
import { Link, useNavigate, useSearchParams } from "react-router-dom";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { fetchEmployees } from "../api/employees";
|
||||||
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
|
import {
|
||||||
|
createProject,
|
||||||
|
fetchProjects,
|
||||||
|
type ProjectListItem,
|
||||||
|
type ProjectStatus,
|
||||||
|
} from "../api/projects";
|
||||||
|
import { isApiError } from "../api/types";
|
||||||
|
import { useAuth } from "../auth/AuthContext";
|
||||||
|
import { hasAnyPermission } from "../auth/permissions";
|
||||||
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
|
import { StatusBadge } from "../components/common/StatusBadge";
|
||||||
|
import { Button } from "../components/ui/button";
|
||||||
|
import { Input } from "../components/ui/input";
|
||||||
|
import { Label } from "../components/ui/label";
|
||||||
|
import { Textarea } from "../components/ui/textarea";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
const STATUS_OPTIONS: Array<{ value: "all" | ProjectStatus; label: string }> = [
|
||||||
|
{ value: "all", label: "全部状态" },
|
||||||
|
{ value: "draft", label: "草稿" },
|
||||||
|
{ value: "active", label: "进行中" },
|
||||||
|
{ value: "paused", label: "暂停" },
|
||||||
|
{ value: "completed", label: "完成" },
|
||||||
|
{ value: "archived", label: "归档" },
|
||||||
|
];
|
||||||
|
|
||||||
|
function resolveError(error: unknown, fallback = "操作失败") {
|
||||||
|
if (isApiError(error)) {
|
||||||
|
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||||
|
}
|
||||||
|
return error instanceof Error ? error.message : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function summaryLine(item: ProjectListItem) {
|
||||||
|
const parts = [
|
||||||
|
`成员 ${item.counts.members}`,
|
||||||
|
`里程碑 ${item.counts.milestones}`,
|
||||||
|
`任务 ${item.counts.tasks}`,
|
||||||
|
`风险 ${item.counts.risks}`,
|
||||||
|
];
|
||||||
|
if (item.counts.overdueMilestones > 0) {
|
||||||
|
parts.push(`逾期 ${item.counts.overdueMilestones}`);
|
||||||
|
}
|
||||||
|
if (item.counts.highPriorityTasks > 0) {
|
||||||
|
parts.push(`高优任务 ${item.counts.highPriorityTasks}`);
|
||||||
|
}
|
||||||
|
return parts.join(" · ");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProjectsPage() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { permissions, me } = useAuth();
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
|
||||||
|
const q = searchParams.get("q") ?? "";
|
||||||
|
const status = (searchParams.get("status") as "all" | ProjectStatus | null) ?? "all";
|
||||||
|
const mine = searchParams.get("mine") === "1";
|
||||||
|
|
||||||
|
const canManageProjects = hasAnyPermission(permissions, ["project.manage", "system.settings", "report.dashboard.view"]);
|
||||||
|
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [clientName, setClientName] = useState("");
|
||||||
|
const [clientAlias, setClientAlias] = useState("");
|
||||||
|
const [summary, setSummary] = useState("");
|
||||||
|
const [managerEmployeeId, setManagerEmployeeId] = useState("");
|
||||||
|
const [startsAt, setStartsAt] = useState("");
|
||||||
|
const [targetEndAt, setTargetEndAt] = useState("");
|
||||||
|
const [isConfidential, setIsConfidential] = useState(false);
|
||||||
|
|
||||||
|
const listQuery = useQuery({
|
||||||
|
queryKey: ["projects", "list", status, mine, q],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchProjects({
|
||||||
|
q: q || undefined,
|
||||||
|
mine,
|
||||||
|
status: status === "all" ? undefined : status,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const employeesQuery = useQuery({
|
||||||
|
enabled: canManageProjects,
|
||||||
|
queryKey: ["employees", "projects-managers"],
|
||||||
|
queryFn: () => fetchEmployees({ page: 1, pageSize: 100, status: "active" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const managerCandidates = useMemo(
|
||||||
|
() => (employeesQuery.data?.items ?? []).filter((item) => item.id !== me?.id),
|
||||||
|
[employeesQuery.data?.items, me?.id],
|
||||||
|
);
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
createProject({
|
||||||
|
name: name.trim(),
|
||||||
|
clientName: clientName.trim(),
|
||||||
|
clientAlias: clientAlias.trim() || null,
|
||||||
|
summary: summary.trim() || null,
|
||||||
|
managerEmployeeId: managerEmployeeId || null,
|
||||||
|
startsAt: startsAt || null,
|
||||||
|
targetEndAt: targetEndAt || null,
|
||||||
|
isConfidential,
|
||||||
|
status: "draft",
|
||||||
|
}),
|
||||||
|
onSuccess: async (payload) => {
|
||||||
|
setName("");
|
||||||
|
setClientName("");
|
||||||
|
setClientAlias("");
|
||||||
|
setSummary("");
|
||||||
|
setManagerEmployeeId("");
|
||||||
|
setStartsAt("");
|
||||||
|
setTargetEndAt("");
|
||||||
|
setIsConfidential(false);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||||
|
toast.success("项目已创建");
|
||||||
|
navigate(`/projects/${payload.item.id}`);
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(resolveError(error, "创建项目失败")),
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateSearch(next: Partial<{ q: string; status: string; mine: boolean }>) {
|
||||||
|
const params = new URLSearchParams(searchParams);
|
||||||
|
if (next.q !== undefined) {
|
||||||
|
if (next.q) params.set("q", next.q);
|
||||||
|
else params.delete("q");
|
||||||
|
}
|
||||||
|
if (next.status !== undefined) {
|
||||||
|
if (next.status && next.status !== "all") params.set("status", next.status);
|
||||||
|
else params.delete("status");
|
||||||
|
}
|
||||||
|
if (next.mine !== undefined) {
|
||||||
|
if (next.mine) params.set("mine", "1");
|
||||||
|
else params.delete("mine");
|
||||||
|
}
|
||||||
|
setSearchParams(params, { replace: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<PageHeader
|
||||||
|
title="项目"
|
||||||
|
subtitle="轻项目主档、成员、里程碑、任务与风险统一在 OA 内跟进。"
|
||||||
|
actions={
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button asChild variant="secondary" type="button">
|
||||||
|
<Link to="/contracts-registry">合同台账</Link>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid gap-6 xl:grid-cols-[minmax(0,1fr),360px]">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="card-std grid gap-3 p-4 md:grid-cols-[minmax(0,1fr),180px,140px]">
|
||||||
|
<Input placeholder="搜索项目编号、项目名、客户名" value={q} onChange={(event) => updateSearch({ q: event.target.value })} />
|
||||||
|
<select className="select-std" value={status} onChange={(event) => updateSearch({ status: event.target.value })}>
|
||||||
|
{STATUS_OPTIONS.map((item) => (
|
||||||
|
<option key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button type="button" variant={mine ? "default" : "secondary"} onClick={() => updateSearch({ mine: !mine })}>
|
||||||
|
{mine ? "只看我的" : "查看全部"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{listQuery.isLoading ? <div className="card-std p-6 text-sm text-zinc-500">项目加载中...</div> : null}
|
||||||
|
{listQuery.isError ? <div className="card-std p-6 text-sm text-red-600">{resolveError(listQuery.error, "加载项目失败")}</div> : null}
|
||||||
|
|
||||||
|
<div className="grid gap-4">
|
||||||
|
{(listQuery.data?.items ?? []).map((item) => (
|
||||||
|
<Link key={item.id} to={`/projects/${item.id}`} className="card-std block space-y-3 p-5 hover:border-primary/40">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="text-lg font-semibold text-zinc-900">{item.projectNo}</div>
|
||||||
|
<StatusBadge status={item.status} />
|
||||||
|
{item.isConfidential ? <span className="rounded-full bg-amber-100 px-2 py-1 text-xs text-amber-700">涉密</span> : null}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-base text-zinc-900">{item.name}</div>
|
||||||
|
<div className="mt-1 text-sm text-zinc-500">客户:{item.clientAlias || item.clientName}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-zinc-500">
|
||||||
|
<div>负责人:{item.owner?.name ?? "-"}</div>
|
||||||
|
<div>项目经理:{item.manager?.name ?? "-"}</div>
|
||||||
|
<div>目标完成:{item.targetEndAt ?? "-"}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-zinc-600">{item.summary || "暂无项目摘要"}</div>
|
||||||
|
<div className="text-sm text-zinc-500">{summaryLine(item)}</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{!listQuery.isLoading && !(listQuery.data?.items ?? []).length ? (
|
||||||
|
<div className="card-std p-6 text-sm text-zinc-500">当前没有符合筛选条件的项目。</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-std space-y-4 p-6">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">新建项目</h3>
|
||||||
|
{!canManageProjects ? (
|
||||||
|
<div className="text-sm text-zinc-500">你当前只有查看权限,项目创建入口仅对老板 / 管理员 / 项目管理角色开放。</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>项目名称</Label>
|
||||||
|
<Input value={name} onChange={(event) => setName(event.target.value)} placeholder="例如:深圳某客户现场实施" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>客户名称</Label>
|
||||||
|
<Input value={clientName} onChange={(event) => setClientName(event.target.value)} placeholder="客户全称" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>客户简称</Label>
|
||||||
|
<Input value={clientAlias} onChange={(event) => setClientAlias(event.target.value)} placeholder="可选" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>项目经理</Label>
|
||||||
|
<select className="select-std" value={managerEmployeeId} onChange={(event) => setManagerEmployeeId(event.target.value)}>
|
||||||
|
<option value="">未指定</option>
|
||||||
|
{managerCandidates.map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.name} · {item.employeeNo}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>开始日期</Label>
|
||||||
|
<Input type="date" value={startsAt} onChange={(event) => setStartsAt(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>目标完成</Label>
|
||||||
|
<Input type="date" value={targetEndAt} onChange={(event) => setTargetEndAt(event.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label>项目摘要</Label>
|
||||||
|
<Textarea value={summary} onChange={(event) => setSummary(event.target.value)} placeholder="填项目目标、交付范围、关键约束" />
|
||||||
|
</div>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-zinc-700">
|
||||||
|
<input type="checkbox" checked={isConfidential} onChange={(event) => setIsConfidential(event.target.checked)} />
|
||||||
|
项目含涉密内容(正文与附件继续离线保存)
|
||||||
|
</label>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={createMutation.isPending || !name.trim() || !clientName.trim()}
|
||||||
|
onClick={() => createMutation.mutate()}
|
||||||
|
>
|
||||||
|
{createMutation.isPending ? "创建中..." : "创建项目"}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProjectsPage;
|
||||||
@@ -5,6 +5,7 @@ import { isApiError } from "../api/types";
|
|||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
import { fetchExpenseTypes } from "../api/expenseTypes";
|
import { fetchExpenseTypes } from "../api/expenseTypes";
|
||||||
import { exportReimbursementJournal, previewReimbursementJournal } from "../api/integrations";
|
import { exportReimbursementJournal, previewReimbursementJournal } from "../api/integrations";
|
||||||
|
import { fetchProjects } from "../api/projects";
|
||||||
import {
|
import {
|
||||||
createReimbursementItem,
|
createReimbursementItem,
|
||||||
deleteReimbursementItem,
|
deleteReimbursementItem,
|
||||||
@@ -14,6 +15,7 @@ import {
|
|||||||
submitReimbursement,
|
submitReimbursement,
|
||||||
updateReimbursementItem,
|
updateReimbursementItem,
|
||||||
withdrawReimbursement,
|
withdrawReimbursement,
|
||||||
|
linkReimbursementProject,
|
||||||
type ReimbursementItem,
|
type ReimbursementItem,
|
||||||
} from "../api/reimbursements";
|
} from "../api/reimbursements";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@@ -69,6 +71,10 @@ export function ReimbursementDetailPage() {
|
|||||||
queryFn: () => fetchExpenseTypes({ enabled: true }),
|
queryFn: () => fetchExpenseTypes({ enabled: true }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const projectsQuery = useQuery({
|
||||||
|
queryKey: ["projects", "reimbursement-link"],
|
||||||
|
queryFn: () => fetchProjects({ mine: true }),
|
||||||
|
});
|
||||||
|
|
||||||
const canManageIntegration = hasPermission(permissions, "integration.manage");
|
const canManageIntegration = hasPermission(permissions, "integration.manage");
|
||||||
|
|
||||||
@@ -211,6 +217,27 @@ export function ReimbursementDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const projectLinkMutation = useMutation({
|
||||||
|
mutationFn: (projectId: string | null) => linkReimbursementProject(reimbursementId, projectId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["reimbursements"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["reimbursements", reimbursementId] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||||
|
]);
|
||||||
|
toast.success("已更新关联项目");
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const message = isApiError(error)
|
||||||
|
? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "关联项目失败";
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const erpExportMutation = useMutation({
|
const erpExportMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (!reimbursementId) {
|
if (!reimbursementId) {
|
||||||
@@ -383,6 +410,29 @@ export function ReimbursementDetailPage() {
|
|||||||
<div>说明:{detail.description || "-"}</div>
|
<div>说明:{detail.description || "-"}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="card-std space-y-3 p-6 text-sm">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">关联项目</h3>
|
||||||
|
<p className="text-xs text-zinc-500">报销可选挂到项目,用于项目执行面板汇总。</p>
|
||||||
|
</div>
|
||||||
|
{detail.project ? <Link to={`/projects/${detail.project.id}`} className="text-sm text-primary hover:underline">查看项目</Link> : null}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="select-std max-w-xl"
|
||||||
|
value={detail.projectId ?? ""}
|
||||||
|
onChange={(event) => projectLinkMutation.mutate(event.target.value || null)}
|
||||||
|
disabled={projectLinkMutation.isPending}
|
||||||
|
>
|
||||||
|
<option value="">不关联项目</option>
|
||||||
|
{(projectsQuery.data?.items ?? []).map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.projectNo} · {item.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
{budgetHint ? (
|
{budgetHint ? (
|
||||||
<div className="card-std space-y-2 p-6 text-sm" data-testid="reimbursement-budget-hint">
|
<div className="card-std space-y-2 p-6 text-sm" data-testid="reimbursement-budget-hint">
|
||||||
<h3 className="text-base font-semibold text-zinc-900">预算提示</h3>
|
<h3 className="text-base font-semibold text-zinc-900">预算提示</h3>
|
||||||
|
|||||||
@@ -284,6 +284,7 @@ export function ReimbursementsPage() {
|
|||||||
{item.formNumber && item.formNumber !== item.id && (
|
{item.formNumber && item.formNumber !== item.id && (
|
||||||
<div className="text-xs text-zinc-500">{item.id}</div>
|
<div className="text-xs text-zinc-500">{item.id}</div>
|
||||||
)}
|
)}
|
||||||
|
{item.project ? <div className="text-xs text-zinc-500">项目:{item.project.projectNo} · {item.project.name}</div> : null}
|
||||||
</td>
|
</td>
|
||||||
<td className="table-td"><StatusBadge status={item.status} /></td>
|
<td className="table-td"><StatusBadge status={item.status} /></td>
|
||||||
<td className="table-td-strong">
|
<td className="table-td-strong">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { cancelTodo, completeTodo, createTodo, fetchTodos } from "../api/todos";
|
import { cancelTodo, completeTodo, createTodo, fetchTodos } from "../api/todos";
|
||||||
|
import { fetchProjects } from "../api/projects";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
import { Button } from "../components/ui/button";
|
import { Button } from "../components/ui/button";
|
||||||
import { Input } from "../components/ui/input";
|
import { Input } from "../components/ui/input";
|
||||||
@@ -20,17 +21,23 @@ export function TodosPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [title, setTitle] = useState("");
|
const [title, setTitle] = useState("");
|
||||||
const [description, setDescription] = useState("");
|
const [description, setDescription] = useState("");
|
||||||
|
const [projectId, setProjectId] = useState("");
|
||||||
const [statusFilter, setStatusFilter] = useState<"todo" | "doing" | "done" | "cancelled" | "">("");
|
const [statusFilter, setStatusFilter] = useState<"todo" | "doing" | "done" | "cancelled" | "">("");
|
||||||
const todosQuery = useQuery({
|
const todosQuery = useQuery({
|
||||||
queryKey: ["todos", statusFilter],
|
queryKey: ["todos", statusFilter],
|
||||||
queryFn: () => fetchTodos({ status: statusFilter || undefined }),
|
queryFn: () => fetchTodos({ status: statusFilter || undefined }),
|
||||||
});
|
});
|
||||||
|
const projectsQuery = useQuery({
|
||||||
|
queryKey: ["projects", "todo-options"],
|
||||||
|
queryFn: () => fetchProjects({}),
|
||||||
|
});
|
||||||
|
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: () => createTodo({ title, description: description || undefined }),
|
mutationFn: () => createTodo({ title, description: description || undefined, projectId: projectId || null }),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
setTitle("");
|
setTitle("");
|
||||||
setDescription("");
|
setDescription("");
|
||||||
|
setProjectId("");
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ["todos"] }),
|
queryClient.invalidateQueries({ queryKey: ["todos"] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||||
@@ -66,12 +73,20 @@ export function TodosPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader title="待办" subtitle="员工维护个人待办,老板看高优先级和未完成项。" />
|
<PageHeader title="待办" subtitle="员工维护个人待办,老板看高优先级和未完成项。支持直接挂到项目。" />
|
||||||
|
|
||||||
<div className="card-std p-6 space-y-4">
|
<div className="card-std p-6 space-y-4">
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
<Input placeholder="待办标题" value={title} onChange={(event) => setTitle(event.target.value)} />
|
<Input placeholder="待办标题" value={title} onChange={(event) => setTitle(event.target.value)} />
|
||||||
<Input placeholder="补充说明(可选)" value={description} onChange={(event) => setDescription(event.target.value)} />
|
<Input placeholder="补充说明(可选)" value={description} onChange={(event) => setDescription(event.target.value)} />
|
||||||
|
<select className="select-std" value={projectId} onChange={(event) => setProjectId(event.target.value)}>
|
||||||
|
<option value="">不关联项目</option>
|
||||||
|
{(projectsQuery.data?.items ?? []).map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.projectNo} · {item.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<select className="select-std" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as typeof statusFilter)}>
|
<select className="select-std" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as typeof statusFilter)}>
|
||||||
<option value="">全部状态</option>
|
<option value="">全部状态</option>
|
||||||
@@ -90,6 +105,7 @@ export function TodosPage() {
|
|||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableHead>标题</TableHead>
|
<TableHead>标题</TableHead>
|
||||||
|
<TableHead>项目</TableHead>
|
||||||
<TableHead>负责人</TableHead>
|
<TableHead>负责人</TableHead>
|
||||||
<TableHead>优先级</TableHead>
|
<TableHead>优先级</TableHead>
|
||||||
<TableHead>状态</TableHead>
|
<TableHead>状态</TableHead>
|
||||||
@@ -103,6 +119,7 @@ export function TodosPage() {
|
|||||||
<div className="font-medium text-zinc-900">{item.title}</div>
|
<div className="font-medium text-zinc-900">{item.title}</div>
|
||||||
<div className="text-xs text-zinc-500">{item.description ?? "-"}</div>
|
<div className="text-xs text-zinc-500">{item.description ?? "-"}</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>{item.project ? `${item.project.projectNo} · ${item.project.name}` : "-"}</TableCell>
|
||||||
<TableCell>{item.assignee?.name ?? "-"}</TableCell>
|
<TableCell>{item.assignee?.name ?? "-"}</TableCell>
|
||||||
<TableCell>{item.priority}</TableCell>
|
<TableCell>{item.priority}</TableCell>
|
||||||
<TableCell>{item.status}</TableCell>
|
<TableCell>{item.status}</TableCell>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useSearchParams } from "react-router-dom";
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
|
import { fetchProjects } from "../api/projects";
|
||||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||||
import { isApiError } from "../api/types";
|
import { isApiError } from "../api/types";
|
||||||
import {
|
import {
|
||||||
@@ -12,6 +13,7 @@ import {
|
|||||||
submitTravelRequest,
|
submitTravelRequest,
|
||||||
updateTravelRequest,
|
updateTravelRequest,
|
||||||
withdrawTravelRequest,
|
withdrawTravelRequest,
|
||||||
|
linkTravelRequestProject,
|
||||||
type TravelRequestDetail,
|
type TravelRequestDetail,
|
||||||
type TravelRequestPayload,
|
type TravelRequestPayload,
|
||||||
type TravelRequestStatus,
|
type TravelRequestStatus,
|
||||||
@@ -250,6 +252,12 @@ export function TravelRequestsPage() {
|
|||||||
queryFn: () => fetchTravelRequestDetail(selectedId),
|
queryFn: () => fetchTravelRequestDetail(selectedId),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const projectsQuery = useQuery({
|
||||||
|
enabled: canAccessRequestsTab && activeTab === "requests",
|
||||||
|
queryKey: ["projects", "travel-link"],
|
||||||
|
queryFn: () => fetchProjects({ mine: true }),
|
||||||
|
});
|
||||||
|
|
||||||
const selectableCostCenters = useMemo(() => {
|
const selectableCostCenters = useMemo(() => {
|
||||||
return (costCentersQuery.data?.items ?? []).filter((item) => {
|
return (costCentersQuery.data?.items ?? []).filter((item) => {
|
||||||
if (!legalEntityId) {
|
if (!legalEntityId) {
|
||||||
@@ -413,6 +421,20 @@ export function TravelRequestsPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const projectLinkMutation = useMutation({
|
||||||
|
mutationFn: (projectId: string | null) => linkTravelRequestProject(selectedId, projectId),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await Promise.all([
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["travel-requests"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["travel-requests", "detail", selectedId] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||||
|
]);
|
||||||
|
toast.success("已更新关联项目");
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(resolveError(error)),
|
||||||
|
});
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
validateForm();
|
validateForm();
|
||||||
@@ -650,6 +672,7 @@ export function TravelRequestsPage() {
|
|||||||
<div>{tripTypeLabel(item.tripType)} · {item.startDate ?? "-"} → {item.endDate ?? "-"}</div>
|
<div>{tripTypeLabel(item.tripType)} · {item.startDate ?? "-"} → {item.endDate ?? "-"}</div>
|
||||||
<div>预估金额:{item.estimatedAmount}</div>
|
<div>预估金额:{item.estimatedAmount}</div>
|
||||||
<div>申请人:{item.applicant?.name ?? "-"}</div>
|
<div>申请人:{item.applicant?.name ?? "-"}</div>
|
||||||
|
{item.project ? <div>项目:{item.project.projectNo} · {item.project.name}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
))
|
))
|
||||||
@@ -756,6 +779,29 @@ export function TravelRequestsPage() {
|
|||||||
|
|
||||||
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
|
<RiskFlagsCard items={detail.riskFlags} title="待处理风险项" />
|
||||||
|
|
||||||
|
<div className="card-std border border-zinc-200 p-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium text-zinc-900">关联项目</div>
|
||||||
|
<div className="text-xs text-zinc-500">差旅申请可选挂到项目,用于费用和出差统一汇总。</div>
|
||||||
|
</div>
|
||||||
|
{detail.project ? <a href={`/projects/${detail.project.id}`} className="text-sm text-primary hover:underline">查看项目</a> : null}
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
className="select-std mt-3 max-w-xl"
|
||||||
|
value={detail.projectId ?? ""}
|
||||||
|
onChange={(event) => projectLinkMutation.mutate(event.target.value || null)}
|
||||||
|
disabled={projectLinkMutation.isPending}
|
||||||
|
>
|
||||||
|
<option value="">不关联项目</option>
|
||||||
|
{(projectsQuery.data?.items ?? []).map((item) => (
|
||||||
|
<option key={item.id} value={item.id}>
|
||||||
|
{item.projectNo} · {item.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4 text-sm">
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4 text-sm">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-zinc-500">申请人</p>
|
<p className="text-zinc-500">申请人</p>
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { createTodo } from "../api/todos";
|
import { createTodo } from "../api/todos";
|
||||||
import { fetchTeamWeeklyReports, fetchMyWeeklyReport, submitMyWeeklyReport, updateMyWeeklyReport } from "../api/weeklyReports";
|
import { fetchProjects } from "../api/projects";
|
||||||
|
import { fetchTeamWeeklyReports, fetchMyWeeklyReport, submitMyWeeklyReport, updateMyWeeklyReport, type WeeklyReportProjectLink } from "../api/weeklyReports";
|
||||||
import { useAuth } from "../auth/AuthContext";
|
import { useAuth } from "../auth/AuthContext";
|
||||||
import { hasAnyPermission } from "../auth/permissions";
|
import { hasAnyPermission } from "../auth/permissions";
|
||||||
import { PageHeader } from "../components/common/PageHeader";
|
import { PageHeader } from "../components/common/PageHeader";
|
||||||
@@ -19,6 +20,26 @@ function errorMessage(error: unknown, fallback: string) {
|
|||||||
return error instanceof Error ? error.message : fallback;
|
return error instanceof Error ? error.message : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DraftProjectLink = {
|
||||||
|
projectId: string;
|
||||||
|
progressText: string;
|
||||||
|
riskText: string;
|
||||||
|
nextStepText: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function emptyProjectLink(): DraftProjectLink {
|
||||||
|
return { projectId: "", progressText: "", riskText: "", nextStepText: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapProjectLink(item: WeeklyReportProjectLink): DraftProjectLink {
|
||||||
|
return {
|
||||||
|
projectId: item.projectId,
|
||||||
|
progressText: item.progressText ?? "",
|
||||||
|
riskText: item.riskText ?? "",
|
||||||
|
nextStepText: item.nextStepText ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function WeeklyReportsPage() {
|
export function WeeklyReportsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { permissions } = useAuth();
|
const { permissions } = useAuth();
|
||||||
@@ -26,9 +47,11 @@ export function WeeklyReportsPage() {
|
|||||||
const [weekStart, setWeekStart] = useState("");
|
const [weekStart, setWeekStart] = useState("");
|
||||||
const reportQuery = useQuery({ queryKey: ["weekly-report", "me", weekStart], queryFn: () => fetchMyWeeklyReport(weekStart || undefined) });
|
const reportQuery = useQuery({ queryKey: ["weekly-report", "me", weekStart], queryFn: () => fetchMyWeeklyReport(weekStart || undefined) });
|
||||||
const teamQuery = useQuery({ enabled: canViewTeam, queryKey: ["weekly-report", "team", weekStart], queryFn: () => fetchTeamWeeklyReports(weekStart || undefined) });
|
const teamQuery = useQuery({ enabled: canViewTeam, queryKey: ["weekly-report", "team", weekStart], queryFn: () => fetchTeamWeeklyReports(weekStart || undefined) });
|
||||||
|
const projectsQuery = useQuery({ queryKey: ["projects", "weekly-links"], queryFn: () => fetchProjects({ mine: true }) });
|
||||||
|
|
||||||
const report = reportQuery.data?.item;
|
const report = reportQuery.data?.item;
|
||||||
const [draft, setDraft] = useState({ highlightsText: "", blockersText: "", nextWeekText: "", needsHelp: false });
|
const [draft, setDraft] = useState({ highlightsText: "", blockersText: "", nextWeekText: "", needsHelp: false });
|
||||||
|
const [projectLinks, setProjectLinks] = useState<DraftProjectLink[]>([emptyProjectLink()]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!report) return;
|
if (!report) return;
|
||||||
@@ -38,15 +61,26 @@ export function WeeklyReportsPage() {
|
|||||||
nextWeekText: report.nextWeekText,
|
nextWeekText: report.nextWeekText,
|
||||||
needsHelp: report.needsHelp,
|
needsHelp: report.needsHelp,
|
||||||
});
|
});
|
||||||
|
setProjectLinks(report.projectLinks.length ? report.projectLinks.map(mapProjectLink) : [emptyProjectLink()]);
|
||||||
}, [report]);
|
}, [report]);
|
||||||
|
|
||||||
|
const normalizedProjectLinks = projectLinks
|
||||||
|
.filter((item) => item.projectId)
|
||||||
|
.map((item) => ({
|
||||||
|
projectId: item.projectId,
|
||||||
|
progressText: item.progressText.trim() || undefined,
|
||||||
|
riskText: item.riskText.trim() || undefined,
|
||||||
|
nextStepText: item.nextStepText.trim() || undefined,
|
||||||
|
}));
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
mutationFn: () => updateMyWeeklyReport({ weekStart: weekStart || undefined, ...draft }),
|
mutationFn: () => updateMyWeeklyReport({ weekStart: weekStart || undefined, ...draft, projectLinks: normalizedProjectLinks }),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
queryClient.invalidateQueries({ queryKey: ["weekly-report", "me"] }),
|
queryClient.invalidateQueries({ queryKey: ["weekly-report", "me"] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ["weekly-report", "team"] }),
|
queryClient.invalidateQueries({ queryKey: ["weekly-report", "team"] }),
|
||||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["projects"] }),
|
||||||
]);
|
]);
|
||||||
toast.success("周报已保存");
|
toast.success("周报已保存");
|
||||||
},
|
},
|
||||||
@@ -72,6 +106,7 @@ export function WeeklyReportsPage() {
|
|||||||
description: draft.nextWeekText || "来自周报的下周计划",
|
description: draft.nextWeekText || "来自周报的下周计划",
|
||||||
source: "weekly_report",
|
source: "weekly_report",
|
||||||
priority: draft.needsHelp ? "high" : "normal",
|
priority: draft.needsHelp ? "high" : "normal",
|
||||||
|
projectId: normalizedProjectLinks[0]?.projectId ?? null,
|
||||||
}),
|
}),
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -85,7 +120,7 @@ export function WeeklyReportsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<PageHeader title="周报" subtitle="固定三段结构:本周完成、当前阻塞、下周计划。" />
|
<PageHeader title="周报" subtitle="固定三段结构:本周完成、当前阻塞、下周计划,可按项目拆分进展。" />
|
||||||
|
|
||||||
<div className="card-std p-6 space-y-4">
|
<div className="card-std p-6 space-y-4">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
@@ -111,7 +146,31 @@ export function WeeklyReportsPage() {
|
|||||||
需要老板/管理员协助
|
需要老板/管理员协助
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-std p-6 space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-base font-semibold text-zinc-900">项目关联</h3>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setProjectLinks((prev) => [...prev, emptyProjectLink()])}>新增项目进展</Button>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{projectLinks.map((item, index) => (
|
||||||
|
<div key={`${item.projectId}-${index}`} className="rounded-lg border border-zinc-200 p-4 space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<select className="select-std" value={item.projectId} onChange={(event) => setProjectLinks((prev) => prev.map((entry, entryIndex) => entryIndex === index ? { ...entry, projectId: event.target.value } : entry))}>
|
||||||
|
<option value="">选择项目</option>
|
||||||
|
{(projectsQuery.data?.items ?? []).map((project) => (
|
||||||
|
<option key={project.id} value={project.id}>{project.projectNo} · {project.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => setProjectLinks((prev) => prev.length === 1 ? [emptyProjectLink()] : prev.filter((_, entryIndex) => entryIndex !== index))}>移除</Button>
|
||||||
|
</div>
|
||||||
|
<Textarea placeholder="本周项目进展" value={item.progressText} onChange={(event) => setProjectLinks((prev) => prev.map((entry, entryIndex) => entryIndex === index ? { ...entry, progressText: event.target.value } : entry))} />
|
||||||
|
<Textarea placeholder="项目风险 / 问题" value={item.riskText} onChange={(event) => setProjectLinks((prev) => prev.map((entry, entryIndex) => entryIndex === index ? { ...entry, riskText: event.target.value } : entry))} />
|
||||||
|
<Textarea placeholder="下一步动作" value={item.nextStepText} onChange={(event) => setProjectLinks((prev) => prev.map((entry, entryIndex) => entryIndex === index ? { ...entry, nextStepText: event.target.value } : entry))} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<div className="flex flex-wrap justify-end gap-2">
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
<Button type="button" variant="secondary" onClick={() => createTodoMutation.mutate()} disabled={createTodoMutation.isPending}>转成待办</Button>
|
<Button type="button" variant="secondary" onClick={() => createTodoMutation.mutate()} disabled={createTodoMutation.isPending}>转成待办</Button>
|
||||||
<Button type="button" variant="secondary" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>保存草稿</Button>
|
<Button type="button" variant="secondary" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>保存草稿</Button>
|
||||||
@@ -133,6 +192,7 @@ export function WeeklyReportsPage() {
|
|||||||
<TableHead>成员</TableHead>
|
<TableHead>成员</TableHead>
|
||||||
<TableHead>状态</TableHead>
|
<TableHead>状态</TableHead>
|
||||||
<TableHead>需协助</TableHead>
|
<TableHead>需协助</TableHead>
|
||||||
|
<TableHead>关联项目</TableHead>
|
||||||
<TableHead>摘要</TableHead>
|
<TableHead>摘要</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -142,6 +202,7 @@ export function WeeklyReportsPage() {
|
|||||||
<TableCell>{item.employee.name}</TableCell>
|
<TableCell>{item.employee.name}</TableCell>
|
||||||
<TableCell>{item.report?.status ?? "missing"}</TableCell>
|
<TableCell>{item.report?.status ?? "missing"}</TableCell>
|
||||||
<TableCell>{item.report?.needsHelp ? "是" : "否"}</TableCell>
|
<TableCell>{item.report?.needsHelp ? "是" : "否"}</TableCell>
|
||||||
|
<TableCell>{item.report?.projectLinks?.map((link) => link.project?.name ?? link.projectId).join(" / ") || "-"}</TableCell>
|
||||||
<TableCell>{item.report?.highlightsText || item.report?.nextWeekText || item.report?.blockersText || "-"}</TableCell>
|
<TableCell>{item.report?.highlightsText || item.report?.nextWeekText || item.report?.blockersText || "-"}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ContractsRegistryPage as default } from "../../pages/ContractsRegistryPage";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ProjectDetailPage as default } from "../../pages/ProjectDetailPage";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { ProjectsPage as default } from "../../pages/ProjectsPage";
|
||||||
@@ -29,6 +29,9 @@ export const WorkspaceSetupPage = lazy(() => import("./WorkspaceSetupPage"));
|
|||||||
export const SystemSettingsPage = lazy(() => import("./SystemSettingsPage"));
|
export const SystemSettingsPage = lazy(() => import("./SystemSettingsPage"));
|
||||||
export const PortalAnnouncementsPage = lazy(() => import("./PortalAnnouncementsPage"));
|
export const PortalAnnouncementsPage = lazy(() => import("./PortalAnnouncementsPage"));
|
||||||
export const PortalHomePage = lazy(() => import("./PortalHomePage"));
|
export const PortalHomePage = lazy(() => import("./PortalHomePage"));
|
||||||
|
export const ProjectsPage = lazy(() => import("./ProjectsPage"));
|
||||||
|
export const ProjectDetailPage = lazy(() => import("./ProjectDetailPage"));
|
||||||
|
export const ContractsRegistryPage = lazy(() => import("./ContractsRegistryPage"));
|
||||||
export const PurchaseRequestsPage = lazy(() => import("./PurchaseRequestsPage"));
|
export const PurchaseRequestsPage = lazy(() => import("./PurchaseRequestsPage"));
|
||||||
export const ReimbursementDetailPage = lazy(() => import("./ReimbursementDetailPage"));
|
export const ReimbursementDetailPage = lazy(() => import("./ReimbursementDetailPage"));
|
||||||
export const ReimbursementsPage = lazy(() => import("./ReimbursementsPage"));
|
export const ReimbursementsPage = lazy(() => import("./ReimbursementsPage"));
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
const { z } = require("zod");
|
||||||
|
|
||||||
|
const { AppError } = require("../utils/AppError");
|
||||||
|
const { sendData } = require("../utils/reply");
|
||||||
|
const { buildAuditLogData } = require("../utils/audit");
|
||||||
|
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||||
|
|
||||||
|
const UUID_SCHEMA = z.string().uuid();
|
||||||
|
const CONFIDENTIALITY_SCHEMA = z.enum(["public", "internal", "confidential", "restricted"]);
|
||||||
|
const STORAGE_TYPE_SCHEMA = z.enum(["oa_link", "nas", "internal_share", "offline_archive"]);
|
||||||
|
const QUERY_SCHEMA = z.object({ projectId: UUID_SCHEMA.optional(), contractRegistryId: UUID_SCHEMA.optional() });
|
||||||
|
const WRITE_SCHEMA = z.object({
|
||||||
|
projectId: UUID_SCHEMA.nullable().optional(),
|
||||||
|
contractRegistryId: UUID_SCHEMA.nullable().optional(),
|
||||||
|
title: z.string().trim().min(1).max(200),
|
||||||
|
documentNo: z.string().trim().max(80).nullable().optional(),
|
||||||
|
confidentialityLevel: CONFIDENTIALITY_SCHEMA.optional(),
|
||||||
|
storageType: STORAGE_TYPE_SCHEMA,
|
||||||
|
storagePath: z.string().trim().min(1).max(500),
|
||||||
|
offlineArchiveNo: z.string().trim().max(120).nullable().optional(),
|
||||||
|
ownerEmployeeId: UUID_SCHEMA.optional(),
|
||||||
|
accessRuleJson: z.record(z.string(), z.unknown()).nullable().optional(),
|
||||||
|
}).strict();
|
||||||
|
const PATCH_SCHEMA = WRITE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||||
|
|
||||||
|
function buildValidationError(details, statusCode = 400) {
|
||||||
|
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBody(schema, body) {
|
||||||
|
const parsed = schema.safeParse(body ?? {});
|
||||||
|
if (!parsed.success) throw buildValidationError(parsed.error.flatten());
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureFeatureEnabled(fastify, request) {
|
||||||
|
const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.confidentialDocRefs, departmentId: request.employee.department_id ?? null, strictDepartment: false });
|
||||||
|
if (!gate.enabled) throw buildValidationError({ featureFlag: "confidential_doc_refs_v1_disabled", reason: gate.reason }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canManage(fastify, employee) {
|
||||||
|
return fastify.hasPermission(employee, "project.manage") || fastify.hasPermission(employee, "project.view_all") || fastify.hasPermission(employee, "system.settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeAudit(fastify, request, action, targetId, extra = {}) {
|
||||||
|
try {
|
||||||
|
await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action, targetType: "confidential_document_ref", targetId, extra }) });
|
||||||
|
} catch (error) {
|
||||||
|
request.log.error({ err: error, action, targetId }, "confidential doc ref audit failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRow(item) {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
projectId: item.project_id ?? null,
|
||||||
|
contractRegistryId: item.contract_registry_id ?? null,
|
||||||
|
title: item.title,
|
||||||
|
documentNo: item.document_no ?? null,
|
||||||
|
confidentialityLevel: item.confidentiality_level,
|
||||||
|
storageType: item.storage_type,
|
||||||
|
storagePath: item.storage_path,
|
||||||
|
offlineArchiveNo: item.offline_archive_no ?? null,
|
||||||
|
ownerEmployeeId: item.owner_employee_id,
|
||||||
|
accessRuleJson: item.access_rule_json ?? null,
|
||||||
|
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||||
|
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||||
|
owner: item.owner ? { id: item.owner.id, employeeNo: item.owner.employee_no, name: item.owner.name } : null,
|
||||||
|
project: item.project ? { id: item.project.id, projectNo: item.project.project_no, name: item.project.name } : null,
|
||||||
|
contractRegistry: item.contract_registry ? { id: item.contract_registry.id, contractNo: item.contract_registry.contract_no } : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confidentialDocRefsRoutes(fastify) {
|
||||||
|
fastify.get("/confidential-doc-refs", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request);
|
||||||
|
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
|
||||||
|
if (!parsed.success) throw buildValidationError(parsed.error.flatten());
|
||||||
|
const rows = await fastify.prisma.confidentialDocumentRef.findMany({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
parsed.data.projectId ? { project_id: parsed.data.projectId } : {},
|
||||||
|
parsed.data.contractRegistryId ? { contract_registry_id: parsed.data.contractRegistryId } : {},
|
||||||
|
canManage(fastify, request.employee)
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
OR: [
|
||||||
|
{ owner_employee_id: request.employee.id },
|
||||||
|
{ project: { owner_employee_id: request.employee.id } },
|
||||||
|
{ project: { manager_employee_id: request.employee.id } },
|
||||||
|
{ project: { members: { some: { employee_id: request.employee.id, left_at: null } } } },
|
||||||
|
{ contract_registry: { owner_employee_id: request.employee.id } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { updated_at: "desc" },
|
||||||
|
include: {
|
||||||
|
owner: { select: { id: true, employee_no: true, name: true } },
|
||||||
|
project: { select: { id: true, project_no: true, name: true } },
|
||||||
|
contract_registry: { select: { id: true, contract_no: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (rows.some((item) => ["confidential", "restricted"].includes(item.confidentiality_level))) {
|
||||||
|
await writeAudit(fastify, request, "CONFIDENTIAL_DOC_REF_VIEW", rows[0]?.id ?? request.employee.id, { count: rows.length, query: parsed.data });
|
||||||
|
}
|
||||||
|
return sendData(reply, request, { items: rows.map(mapRow) });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post("/confidential-doc-refs", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request);
|
||||||
|
if (!canManage(fastify, request.employee)) {
|
||||||
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "project.manage" } });
|
||||||
|
}
|
||||||
|
const body = parseBody(WRITE_SCHEMA, request.body);
|
||||||
|
const created = await fastify.prisma.confidentialDocumentRef.create({
|
||||||
|
data: {
|
||||||
|
project_id: body.projectId ?? null,
|
||||||
|
contract_registry_id: body.contractRegistryId ?? null,
|
||||||
|
title: body.title,
|
||||||
|
document_no: body.documentNo ?? null,
|
||||||
|
confidentiality_level: body.confidentialityLevel ?? "internal",
|
||||||
|
storage_type: body.storageType,
|
||||||
|
storage_path: body.storagePath,
|
||||||
|
offline_archive_no: body.offlineArchiveNo ?? null,
|
||||||
|
owner_employee_id: body.ownerEmployeeId ?? request.employee.id,
|
||||||
|
access_rule_json: body.accessRuleJson ?? null,
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
owner: { select: { id: true, employee_no: true, name: true } },
|
||||||
|
project: { select: { id: true, project_no: true, name: true } },
|
||||||
|
contract_registry: { select: { id: true, contract_no: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await writeAudit(fastify, request, "CONFIDENTIAL_DOC_REF_CREATED", created.id, { confidentialityLevel: created.confidentiality_level });
|
||||||
|
return reply.code(201).send({ requestId: request.id, data: { item: mapRow(created) } });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.patch("/confidential-doc-refs/:id", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request);
|
||||||
|
if (!canManage(fastify, request.employee)) {
|
||||||
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "project.manage" } });
|
||||||
|
}
|
||||||
|
const refId = UUID_SCHEMA.parse(request.params?.id);
|
||||||
|
const body = parseBody(PATCH_SCHEMA, request.body);
|
||||||
|
const updated = await fastify.prisma.confidentialDocumentRef.update({
|
||||||
|
where: { id: refId },
|
||||||
|
data: {
|
||||||
|
...(body.projectId !== undefined ? { project_id: body.projectId ?? null } : {}),
|
||||||
|
...(body.contractRegistryId !== undefined ? { contract_registry_id: body.contractRegistryId ?? null } : {}),
|
||||||
|
...(body.title !== undefined ? { title: body.title } : {}),
|
||||||
|
...(body.documentNo !== undefined ? { document_no: body.documentNo ?? null } : {}),
|
||||||
|
...(body.confidentialityLevel !== undefined ? { confidentiality_level: body.confidentialityLevel } : {}),
|
||||||
|
...(body.storageType !== undefined ? { storage_type: body.storageType } : {}),
|
||||||
|
...(body.storagePath !== undefined ? { storage_path: body.storagePath } : {}),
|
||||||
|
...(body.offlineArchiveNo !== undefined ? { offline_archive_no: body.offlineArchiveNo ?? null } : {}),
|
||||||
|
...(body.ownerEmployeeId !== undefined ? { owner_employee_id: body.ownerEmployeeId } : {}),
|
||||||
|
...(body.accessRuleJson !== undefined ? { access_rule_json: body.accessRuleJson ?? null } : {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
owner: { select: { id: true, employee_no: true, name: true } },
|
||||||
|
project: { select: { id: true, project_no: true, name: true } },
|
||||||
|
contract_registry: { select: { id: true, contract_no: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await writeAudit(fastify, request, "CONFIDENTIAL_DOC_REF_UPDATED", updated.id, { fields: Object.keys(body) });
|
||||||
|
return sendData(reply, request, { item: mapRow(updated) });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = confidentialDocRefsRoutes;
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
const { z } = require("zod");
|
||||||
|
|
||||||
|
const { AppError } = require("../utils/AppError");
|
||||||
|
const { sendData } = require("../utils/reply");
|
||||||
|
const { buildAuditLogData } = require("../utils/audit");
|
||||||
|
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||||
|
|
||||||
|
const UUID_SCHEMA = z.string().uuid();
|
||||||
|
const CONFIDENTIALITY_SCHEMA = z.enum(["public", "internal", "confidential", "restricted"]);
|
||||||
|
const LIST_QUERY_SCHEMA = z.object({ projectId: UUID_SCHEMA.optional() });
|
||||||
|
const WRITE_SCHEMA = z.object({
|
||||||
|
contractNo: z.string().trim().min(1).max(80),
|
||||||
|
projectId: UUID_SCHEMA.nullable().optional(),
|
||||||
|
counterpartyName: z.string().trim().min(1).max(200),
|
||||||
|
summary: z.string().trim().min(1).max(4000),
|
||||||
|
confidentialityLevel: CONFIDENTIALITY_SCHEMA.optional(),
|
||||||
|
archiveLocation: z.string().trim().min(1).max(500),
|
||||||
|
offlineArchiveNo: z.string().trim().max(120).nullable().optional(),
|
||||||
|
ownerEmployeeId: UUID_SCHEMA.optional(),
|
||||||
|
status: z.string().trim().min(1).max(32).optional(),
|
||||||
|
}).strict();
|
||||||
|
const PATCH_SCHEMA = WRITE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||||
|
|
||||||
|
function buildValidationError(details, statusCode = 400) {
|
||||||
|
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBody(schema, body) {
|
||||||
|
const parsed = schema.safeParse(body ?? {});
|
||||||
|
if (!parsed.success) throw buildValidationError(parsed.error.flatten());
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureFeatureEnabled(fastify, request) {
|
||||||
|
const gate = await evaluateFeatureFlag({ prisma: fastify.prisma, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.projectLinking, departmentId: request.employee.department_id ?? null, strictDepartment: false });
|
||||||
|
if (!gate.enabled) throw buildValidationError({ featureFlag: "project_linking_v1_disabled", reason: gate.reason }, 409);
|
||||||
|
}
|
||||||
|
|
||||||
|
function canManage(fastify, employee) {
|
||||||
|
return fastify.hasPermission(employee, "project.manage") || fastify.hasPermission(employee, "project.view_all") || fastify.hasPermission(employee, "system.settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeAudit(fastify, request, action, targetId, extra = {}) {
|
||||||
|
try {
|
||||||
|
await fastify.prisma.auditLog.create({ data: buildAuditLogData(request, { actorId: request.employee.id, action, targetType: "contract_registry", targetId, extra }) });
|
||||||
|
} catch (error) {
|
||||||
|
request.log.error({ err: error, action, targetId }, "contract registry audit failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRow(item) {
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
contractNo: item.contract_no,
|
||||||
|
projectId: item.project_id ?? null,
|
||||||
|
counterpartyName: item.counterparty_name,
|
||||||
|
summary: item.summary,
|
||||||
|
confidentialityLevel: item.confidentiality_level,
|
||||||
|
archiveLocation: item.archive_location,
|
||||||
|
offlineArchiveNo: item.offline_archive_no ?? null,
|
||||||
|
ownerEmployeeId: item.owner_employee_id,
|
||||||
|
status: item.status,
|
||||||
|
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||||
|
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||||
|
owner: item.owner ? { id: item.owner.id, employeeNo: item.owner.employee_no, name: item.owner.name } : null,
|
||||||
|
project: item.project ? { id: item.project.id, projectNo: item.project.project_no, name: item.project.name } : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function contractsRegistryRoutes(fastify) {
|
||||||
|
fastify.get("/contracts-registry", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request);
|
||||||
|
const parsed = LIST_QUERY_SCHEMA.safeParse(request.query ?? {});
|
||||||
|
if (!parsed.success) throw buildValidationError(parsed.error.flatten());
|
||||||
|
const rows = await fastify.prisma.contractRegistry.findMany({
|
||||||
|
where: {
|
||||||
|
AND: [
|
||||||
|
parsed.data.projectId ? { project_id: parsed.data.projectId } : {},
|
||||||
|
canManage(fastify, request.employee)
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
OR: [
|
||||||
|
{ owner_employee_id: request.employee.id },
|
||||||
|
{ project: { owner_employee_id: request.employee.id } },
|
||||||
|
{ project: { manager_employee_id: request.employee.id } },
|
||||||
|
{ project: { members: { some: { employee_id: request.employee.id, left_at: null } } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
orderBy: { updated_at: "desc" },
|
||||||
|
include: { owner: { select: { id: true, employee_no: true, name: true } }, project: { select: { id: true, project_no: true, name: true } } },
|
||||||
|
});
|
||||||
|
return sendData(reply, request, { items: rows.map(mapRow) });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post("/contracts-registry", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request);
|
||||||
|
if (!canManage(fastify, request.employee)) {
|
||||||
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "project.manage" } });
|
||||||
|
}
|
||||||
|
const body = parseBody(WRITE_SCHEMA, request.body);
|
||||||
|
const created = await fastify.prisma.contractRegistry.create({
|
||||||
|
data: {
|
||||||
|
contract_no: body.contractNo,
|
||||||
|
project_id: body.projectId ?? null,
|
||||||
|
counterparty_name: body.counterpartyName,
|
||||||
|
summary: body.summary,
|
||||||
|
confidentiality_level: body.confidentialityLevel ?? "internal",
|
||||||
|
archive_location: body.archiveLocation,
|
||||||
|
offline_archive_no: body.offlineArchiveNo ?? null,
|
||||||
|
owner_employee_id: body.ownerEmployeeId ?? request.employee.id,
|
||||||
|
status: body.status ?? "active",
|
||||||
|
},
|
||||||
|
include: { owner: { select: { id: true, employee_no: true, name: true } }, project: { select: { id: true, project_no: true, name: true } } },
|
||||||
|
});
|
||||||
|
await writeAudit(fastify, request, "CONTRACT_REGISTRY_CREATED", created.id, { contractNo: created.contract_no });
|
||||||
|
return reply.code(201).send({ requestId: request.id, data: { item: mapRow(created) } });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.patch("/contracts-registry/:id", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request);
|
||||||
|
if (!canManage(fastify, request.employee)) {
|
||||||
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "project.manage" } });
|
||||||
|
}
|
||||||
|
const contractId = UUID_SCHEMA.parse(request.params?.id);
|
||||||
|
const body = parseBody(PATCH_SCHEMA, request.body);
|
||||||
|
const updated = await fastify.prisma.contractRegistry.update({
|
||||||
|
where: { id: contractId },
|
||||||
|
data: {
|
||||||
|
...(body.contractNo !== undefined ? { contract_no: body.contractNo } : {}),
|
||||||
|
...(body.projectId !== undefined ? { project_id: body.projectId ?? null } : {}),
|
||||||
|
...(body.counterpartyName !== undefined ? { counterparty_name: body.counterpartyName } : {}),
|
||||||
|
...(body.summary !== undefined ? { summary: body.summary } : {}),
|
||||||
|
...(body.confidentialityLevel !== undefined ? { confidentiality_level: body.confidentialityLevel } : {}),
|
||||||
|
...(body.archiveLocation !== undefined ? { archive_location: body.archiveLocation } : {}),
|
||||||
|
...(body.offlineArchiveNo !== undefined ? { offline_archive_no: body.offlineArchiveNo ?? null } : {}),
|
||||||
|
...(body.ownerEmployeeId !== undefined ? { owner_employee_id: body.ownerEmployeeId } : {}),
|
||||||
|
...(body.status !== undefined ? { status: body.status } : {}),
|
||||||
|
},
|
||||||
|
include: { owner: { select: { id: true, employee_no: true, name: true } }, project: { select: { id: true, project_no: true, name: true } } },
|
||||||
|
});
|
||||||
|
await writeAudit(fastify, request, "CONTRACT_REGISTRY_UPDATED", updated.id, { fields: Object.keys(body) });
|
||||||
|
return sendData(reply, request, { item: mapRow(updated) });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = contractsRegistryRoutes;
|
||||||
@@ -0,0 +1,735 @@
|
|||||||
|
const { z } = require("zod");
|
||||||
|
|
||||||
|
const { AppError } = require("../utils/AppError");
|
||||||
|
const { sendData } = require("../utils/reply");
|
||||||
|
const { buildAuditLogData } = require("../utils/audit");
|
||||||
|
const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureFlags");
|
||||||
|
|
||||||
|
const UUID_SCHEMA = z.string().uuid();
|
||||||
|
const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
const PROJECT_STATUS_SCHEMA = z.enum(["draft", "active", "paused", "completed", "archived"]);
|
||||||
|
const PROJECT_ROLE_SCHEMA = z.enum(["owner", "manager", "member", "observer"]);
|
||||||
|
const MILESTONE_STATUS_SCHEMA = z.enum(["pending", "in_progress", "done", "delayed", "cancelled"]);
|
||||||
|
const TASK_STATUS_SCHEMA = z.enum(["todo", "doing", "blocked", "done", "cancelled"]);
|
||||||
|
const TASK_PRIORITY_SCHEMA = z.enum(["low", "normal", "high", "urgent"]);
|
||||||
|
const RISK_LEVEL_SCHEMA = z.enum(["low", "medium", "high"]);
|
||||||
|
const DELIVERY_RECORD_TYPE_SCHEMA = z.enum(["kickoff", "onsite", "stage_acceptance", "final_acceptance", "handover"]);
|
||||||
|
|
||||||
|
const LIST_QUERY_SCHEMA = z.object({
|
||||||
|
status: PROJECT_STATUS_SCHEMA.optional(),
|
||||||
|
mine: z.union([z.literal("1"), z.literal("0")]).optional(),
|
||||||
|
q: z.string().trim().max(100).optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const PROJECT_CREATE_SCHEMA = z.object({
|
||||||
|
projectNo: z.string().trim().max(40).optional(),
|
||||||
|
name: z.string().trim().min(1).max(200),
|
||||||
|
clientName: z.string().trim().min(1).max(200),
|
||||||
|
clientAlias: z.string().trim().max(200).nullable().optional(),
|
||||||
|
ownerEmployeeId: UUID_SCHEMA.optional(),
|
||||||
|
managerEmployeeId: UUID_SCHEMA.nullable().optional(),
|
||||||
|
status: PROJECT_STATUS_SCHEMA.optional(),
|
||||||
|
startsAt: DATE_SCHEMA.nullable().optional(),
|
||||||
|
targetEndAt: DATE_SCHEMA.nullable().optional(),
|
||||||
|
actualEndAt: DATE_SCHEMA.nullable().optional(),
|
||||||
|
summary: z.string().trim().max(4000).nullable().optional(),
|
||||||
|
isConfidential: z.boolean().optional(),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
const PROJECT_PATCH_SCHEMA = PROJECT_CREATE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||||
|
|
||||||
|
const MEMBER_CREATE_SCHEMA = z.object({
|
||||||
|
employeeId: UUID_SCHEMA,
|
||||||
|
role: PROJECT_ROLE_SCHEMA.optional(),
|
||||||
|
}).strict();
|
||||||
|
const MEMBER_PATCH_SCHEMA = z.object({
|
||||||
|
role: PROJECT_ROLE_SCHEMA.optional(),
|
||||||
|
leftAt: DATE_SCHEMA.nullable().optional(),
|
||||||
|
}).strict().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||||
|
|
||||||
|
const MILESTONE_CREATE_SCHEMA = z.object({
|
||||||
|
title: z.string().trim().min(1).max(200),
|
||||||
|
description: z.string().trim().max(4000).nullable().optional(),
|
||||||
|
status: MILESTONE_STATUS_SCHEMA.optional(),
|
||||||
|
dueDate: DATE_SCHEMA.nullable().optional(),
|
||||||
|
completedAt: z.string().datetime().nullable().optional(),
|
||||||
|
ownerEmployeeId: UUID_SCHEMA.nullable().optional(),
|
||||||
|
sortOrder: z.number().int().min(0).max(9999).optional(),
|
||||||
|
}).strict();
|
||||||
|
const MILESTONE_PATCH_SCHEMA = MILESTONE_CREATE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||||
|
|
||||||
|
const TASK_CREATE_SCHEMA = z.object({
|
||||||
|
title: z.string().trim().min(1).max(200),
|
||||||
|
description: z.string().trim().max(4000).nullable().optional(),
|
||||||
|
status: TASK_STATUS_SCHEMA.optional(),
|
||||||
|
priority: TASK_PRIORITY_SCHEMA.optional(),
|
||||||
|
assigneeEmployeeId: UUID_SCHEMA.nullable().optional(),
|
||||||
|
dueDate: DATE_SCHEMA.nullable().optional(),
|
||||||
|
blockedReason: z.string().trim().max(1000).nullable().optional(),
|
||||||
|
}).strict();
|
||||||
|
const TASK_PATCH_SCHEMA = TASK_CREATE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||||
|
|
||||||
|
const RISK_CREATE_SCHEMA = z.object({
|
||||||
|
title: z.string().trim().min(1).max(200),
|
||||||
|
detail: z.string().trim().min(1).max(4000),
|
||||||
|
level: RISK_LEVEL_SCHEMA.optional(),
|
||||||
|
status: z.string().trim().min(1).max(32).optional(),
|
||||||
|
ownerEmployeeId: UUID_SCHEMA.nullable().optional(),
|
||||||
|
}).strict();
|
||||||
|
const RISK_PATCH_SCHEMA = RISK_CREATE_SCHEMA.partial().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||||
|
|
||||||
|
const DELIVERY_CREATE_SCHEMA = z.object({
|
||||||
|
title: z.string().trim().min(1).max(200),
|
||||||
|
recordType: DELIVERY_RECORD_TYPE_SCHEMA,
|
||||||
|
summary: z.string().trim().min(1).max(4000),
|
||||||
|
occurredAt: z.string().datetime(),
|
||||||
|
ownerEmployeeId: UUID_SCHEMA.optional(),
|
||||||
|
}).strict();
|
||||||
|
|
||||||
|
function buildValidationError(details, statusCode = 400) {
|
||||||
|
return new AppError({ code: "VALID_001", message: "参数校验失败", statusCode, details });
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseBody(schema, body) {
|
||||||
|
const parsed = schema.safeParse(body ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw buildValidationError(parsed.error.flatten());
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseId(value, key = "id") {
|
||||||
|
const parsed = UUID_SCHEMA.safeParse(value);
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw buildValidationError({ [key]: "invalid_uuid" });
|
||||||
|
}
|
||||||
|
return parsed.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateOnly(value) {
|
||||||
|
return value ? new Date(`${value}T00:00:00.000Z`) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureFeatureEnabled(fastify, request, flagKey) {
|
||||||
|
const gate = await evaluateFeatureFlag({
|
||||||
|
prisma: fastify.prisma,
|
||||||
|
config: fastify.config,
|
||||||
|
flagKey,
|
||||||
|
departmentId: request.employee.department_id ?? null,
|
||||||
|
strictDepartment: false,
|
||||||
|
});
|
||||||
|
if (!gate.enabled) {
|
||||||
|
throw buildValidationError({ featureFlag: `${flagKey}_disabled`, reason: gate.reason }, 409);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasGlobalProjectRead(fastify, employee) {
|
||||||
|
return fastify.hasPermission(employee, "project.view_all") || fastify.hasPermission(employee, "project.manage") || fastify.hasPermission(employee, "system.settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasGlobalProjectManage(fastify, employee) {
|
||||||
|
return fastify.hasPermission(employee, "project.manage") || fastify.hasPermission(employee, "system.settings");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProjectContext(prisma, projectId, employeeId) {
|
||||||
|
return prisma.project.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
include: {
|
||||||
|
owner: { select: { id: true, employee_no: true, name: true } },
|
||||||
|
manager: { select: { id: true, employee_no: true, name: true } },
|
||||||
|
members: {
|
||||||
|
where: { left_at: null },
|
||||||
|
include: { employee: { select: { id: true, employee_no: true, name: true } } },
|
||||||
|
orderBy: [{ role: "asc" }, { joined_at: "asc" }],
|
||||||
|
},
|
||||||
|
milestones: { orderBy: [{ sort_order: "asc" }, { due_date: "asc" }] },
|
||||||
|
tasks: { include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } } }, orderBy: [{ priority: "desc" }, { due_date: "asc" }, { created_at: "desc" }] },
|
||||||
|
risks: { include: { owner: { select: { id: true, employee_no: true, name: true } } }, orderBy: [{ level: "desc" }, { created_at: "desc" }] },
|
||||||
|
_count: {
|
||||||
|
select: {
|
||||||
|
members: true,
|
||||||
|
milestones: true,
|
||||||
|
tasks: true,
|
||||||
|
risks: true,
|
||||||
|
reimbursements: true,
|
||||||
|
travel_requests: true,
|
||||||
|
todo_items: true,
|
||||||
|
weekly_reports: true,
|
||||||
|
contracts: true,
|
||||||
|
deliveries: true,
|
||||||
|
document_refs: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function canManageProjectRecord(fastify, requestEmployeeId, project) {
|
||||||
|
if (!project) return false;
|
||||||
|
if (project.owner_employee_id === requestEmployeeId) return true;
|
||||||
|
if (project.manager_employee_id === requestEmployeeId) return true;
|
||||||
|
return (project.members ?? []).some((item) => item.employee_id === requestEmployeeId && item.left_at == null && ["owner", "manager"].includes(item.role));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureReadableProject(fastify, request, projectId) {
|
||||||
|
const project = await loadProjectContext(fastify.prisma, projectId, request.employee.id);
|
||||||
|
if (!project) {
|
||||||
|
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||||
|
}
|
||||||
|
if (hasGlobalProjectRead(fastify, request.employee)) {
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
const isMember = project.owner_employee_id === request.employee.id || project.manager_employee_id === request.employee.id || project.members.some((item) => item.employee_id === request.employee.id && item.left_at == null);
|
||||||
|
if (!isMember) {
|
||||||
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "project.view_own" } });
|
||||||
|
}
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureManageableProject(fastify, request, projectId) {
|
||||||
|
const project = await ensureReadableProject(fastify, request, projectId);
|
||||||
|
if (hasGlobalProjectManage(fastify, request.employee) || canManageProjectRecord(fastify, request.employee.id, project)) {
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "project.manage" } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeAudit(fastify, request, action, targetId, extra = {}) {
|
||||||
|
try {
|
||||||
|
await fastify.prisma.auditLog.create({
|
||||||
|
data: buildAuditLogData(request, {
|
||||||
|
actorId: request.employee.id,
|
||||||
|
action,
|
||||||
|
targetType: "project",
|
||||||
|
targetId,
|
||||||
|
extra,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
request.log.error({ err: error, action, targetId }, "project audit failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function buildProjectNo(prisma) {
|
||||||
|
const now = new Date();
|
||||||
|
const day = now.toISOString().slice(0, 10).replace(/-/g, "");
|
||||||
|
const start = new Date(`${now.toISOString().slice(0, 10)}T00:00:00.000Z`);
|
||||||
|
const end = new Date(`${now.toISOString().slice(0, 10)}T23:59:59.999Z`);
|
||||||
|
const count = await prisma.project.count({ where: { created_at: { gte: start, lte: end } } });
|
||||||
|
return `PRJ-${day}-${String(count + 1).padStart(3, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapEmployeeBasic(employee) {
|
||||||
|
if (!employee) return null;
|
||||||
|
return { id: employee.id, employeeNo: employee.employee_no, name: employee.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapProjectDetail(project) {
|
||||||
|
return {
|
||||||
|
id: project.id,
|
||||||
|
projectNo: project.project_no,
|
||||||
|
name: project.name,
|
||||||
|
clientName: project.client_name,
|
||||||
|
clientAlias: project.client_alias,
|
||||||
|
ownerEmployeeId: project.owner_employee_id,
|
||||||
|
managerEmployeeId: project.manager_employee_id,
|
||||||
|
status: project.status,
|
||||||
|
startsAt: project.starts_at?.toISOString?.().slice(0, 10) ?? null,
|
||||||
|
targetEndAt: project.target_end_at?.toISOString?.().slice(0, 10) ?? null,
|
||||||
|
actualEndAt: project.actual_end_at?.toISOString?.().slice(0, 10) ?? null,
|
||||||
|
summary: project.summary ?? null,
|
||||||
|
isConfidential: Boolean(project.is_confidential),
|
||||||
|
createdAt: project.created_at?.toISOString?.() ?? null,
|
||||||
|
updatedAt: project.updated_at?.toISOString?.() ?? null,
|
||||||
|
owner: mapEmployeeBasic(project.owner),
|
||||||
|
manager: mapEmployeeBasic(project.manager),
|
||||||
|
counts: {
|
||||||
|
members: project._count?.members ?? 0,
|
||||||
|
milestones: project._count?.milestones ?? 0,
|
||||||
|
tasks: project._count?.tasks ?? 0,
|
||||||
|
risks: project._count?.risks ?? 0,
|
||||||
|
reimbursements: project._count?.reimbursements ?? 0,
|
||||||
|
travelRequests: project._count?.travel_requests ?? 0,
|
||||||
|
todoItems: project._count?.todo_items ?? 0,
|
||||||
|
weeklyReports: project._count?.weekly_reports ?? 0,
|
||||||
|
contracts: project._count?.contracts ?? 0,
|
||||||
|
deliveries: project._count?.deliveries ?? 0,
|
||||||
|
documentRefs: project._count?.document_refs ?? 0,
|
||||||
|
},
|
||||||
|
members: (project.members ?? []).map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
employeeId: item.employee_id,
|
||||||
|
role: item.role,
|
||||||
|
joinedAt: item.joined_at?.toISOString?.() ?? null,
|
||||||
|
leftAt: item.left_at?.toISOString?.() ?? null,
|
||||||
|
employee: mapEmployeeBasic(item.employee),
|
||||||
|
})),
|
||||||
|
milestones: (project.milestones ?? []).map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
description: item.description ?? null,
|
||||||
|
status: item.status,
|
||||||
|
dueDate: item.due_date?.toISOString?.().slice(0, 10) ?? null,
|
||||||
|
completedAt: item.completed_at?.toISOString?.() ?? null,
|
||||||
|
ownerEmployeeId: item.owner_employee_id ?? null,
|
||||||
|
sortOrder: item.sort_order,
|
||||||
|
})),
|
||||||
|
tasks: (project.tasks ?? []).map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
description: item.description ?? null,
|
||||||
|
status: item.status,
|
||||||
|
priority: item.priority,
|
||||||
|
assigneeEmployeeId: item.assignee_employee_id ?? null,
|
||||||
|
dueDate: item.due_date?.toISOString?.().slice(0, 10) ?? null,
|
||||||
|
blockedReason: item.blocked_reason ?? null,
|
||||||
|
createdBy: item.created_by,
|
||||||
|
assignee: mapEmployeeBasic(item.assignee),
|
||||||
|
creator: mapEmployeeBasic(item.creator),
|
||||||
|
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||||
|
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||||
|
})),
|
||||||
|
risks: (project.risks ?? []).map((item) => ({
|
||||||
|
id: item.id,
|
||||||
|
title: item.title,
|
||||||
|
detail: item.detail,
|
||||||
|
level: item.level,
|
||||||
|
status: item.status,
|
||||||
|
ownerEmployeeId: item.owner_employee_id ?? null,
|
||||||
|
owner: mapEmployeeBasic(item.owner),
|
||||||
|
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||||
|
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function projectsRoutes(fastify) {
|
||||||
|
fastify.get("/projects", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||||
|
const parsed = LIST_QUERY_SCHEMA.safeParse(request.query ?? {});
|
||||||
|
if (!parsed.success) {
|
||||||
|
throw buildValidationError(parsed.error.flatten());
|
||||||
|
}
|
||||||
|
const mine = parsed.data.mine === "1";
|
||||||
|
const where = {
|
||||||
|
AND: [
|
||||||
|
parsed.data.status ? { status: parsed.data.status } : {},
|
||||||
|
parsed.data.q
|
||||||
|
? {
|
||||||
|
OR: [
|
||||||
|
{ project_no: { contains: parsed.data.q, mode: "insensitive" } },
|
||||||
|
{ name: { contains: parsed.data.q, mode: "insensitive" } },
|
||||||
|
{ client_name: { contains: parsed.data.q, mode: "insensitive" } },
|
||||||
|
{ client_alias: { contains: parsed.data.q, mode: "insensitive" } },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
: {},
|
||||||
|
hasGlobalProjectRead(fastify, request.employee) && !mine
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
OR: [
|
||||||
|
{ owner_employee_id: request.employee.id },
|
||||||
|
{ manager_employee_id: request.employee.id },
|
||||||
|
{ members: { some: { employee_id: request.employee.id, left_at: null } } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const rows = await fastify.prisma.project.findMany({
|
||||||
|
where,
|
||||||
|
orderBy: [{ target_end_at: "asc" }, { updated_at: "desc" }],
|
||||||
|
include: {
|
||||||
|
owner: { select: { id: true, employee_no: true, name: true } },
|
||||||
|
manager: { select: { id: true, employee_no: true, name: true } },
|
||||||
|
_count: { select: { members: true, milestones: true, tasks: true, risks: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const today = new Date();
|
||||||
|
const items = await Promise.all(rows.map(async (item) => {
|
||||||
|
const overdueMilestones = await fastify.prisma.projectMilestone.count({ where: { project_id: item.id, status: { in: ["pending", "in_progress", "delayed"] }, due_date: { lt: today } } });
|
||||||
|
const highPriorityTasks = await fastify.prisma.projectTask.count({ where: { project_id: item.id, status: { in: ["todo", "doing", "blocked"] }, priority: { in: ["high", "urgent"] } } });
|
||||||
|
const openRisks = await fastify.prisma.projectRisk.count({ where: { project_id: item.id, status: { not: "closed" } } });
|
||||||
|
return {
|
||||||
|
id: item.id,
|
||||||
|
projectNo: item.project_no,
|
||||||
|
name: item.name,
|
||||||
|
clientName: item.client_name,
|
||||||
|
clientAlias: item.client_alias,
|
||||||
|
status: item.status,
|
||||||
|
startsAt: item.starts_at?.toISOString?.().slice(0, 10) ?? null,
|
||||||
|
targetEndAt: item.target_end_at?.toISOString?.().slice(0, 10) ?? null,
|
||||||
|
actualEndAt: item.actual_end_at?.toISOString?.().slice(0, 10) ?? null,
|
||||||
|
summary: item.summary ?? null,
|
||||||
|
isConfidential: Boolean(item.is_confidential),
|
||||||
|
owner: mapEmployeeBasic(item.owner),
|
||||||
|
manager: mapEmployeeBasic(item.manager),
|
||||||
|
counts: {
|
||||||
|
members: item._count.members,
|
||||||
|
milestones: item._count.milestones,
|
||||||
|
tasks: item._count.tasks,
|
||||||
|
risks: item._count.risks,
|
||||||
|
overdueMilestones,
|
||||||
|
highPriorityTasks,
|
||||||
|
openRisks,
|
||||||
|
},
|
||||||
|
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
return sendData(reply, request, { items });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post("/projects", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||||
|
if (!fastify.hasPermission(request.employee, "project.manage") && !fastify.hasPermission(request.employee, "system.settings") && !fastify.hasPermission(request.employee, "report.dashboard.view")) {
|
||||||
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "project.manage" } });
|
||||||
|
}
|
||||||
|
const body = parseBody(PROJECT_CREATE_SCHEMA, request.body);
|
||||||
|
const created = await fastify.prisma.project.create({
|
||||||
|
data: {
|
||||||
|
project_no: body.projectNo || await buildProjectNo(fastify.prisma),
|
||||||
|
name: body.name,
|
||||||
|
client_name: body.clientName,
|
||||||
|
client_alias: body.clientAlias ?? null,
|
||||||
|
owner_employee_id: body.ownerEmployeeId ?? request.employee.id,
|
||||||
|
manager_employee_id: body.managerEmployeeId ?? null,
|
||||||
|
status: body.status ?? "draft",
|
||||||
|
starts_at: parseDateOnly(body.startsAt),
|
||||||
|
target_end_at: parseDateOnly(body.targetEndAt),
|
||||||
|
actual_end_at: parseDateOnly(body.actualEndAt),
|
||||||
|
summary: body.summary ?? null,
|
||||||
|
is_confidential: body.isConfidential ?? false,
|
||||||
|
members: {
|
||||||
|
create: {
|
||||||
|
employee_id: body.ownerEmployeeId ?? request.employee.id,
|
||||||
|
role: "owner",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
owner: { select: { id: true, employee_no: true, name: true } },
|
||||||
|
manager: { select: { id: true, employee_no: true, name: true } },
|
||||||
|
members: { where: { left_at: null }, include: { employee: { select: { id: true, employee_no: true, name: true } } } },
|
||||||
|
milestones: true,
|
||||||
|
tasks: { include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } } } },
|
||||||
|
risks: { include: { owner: { select: { id: true, employee_no: true, name: true } } } },
|
||||||
|
_count: { select: { members: true, milestones: true, tasks: true, risks: true, reimbursements: true, travel_requests: true, todo_items: true, weekly_reports: true, contracts: true, deliveries: true, document_refs: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await writeAudit(fastify, request, "PROJECT_CREATED", created.id, { projectNo: created.project_no });
|
||||||
|
return reply.code(201).send({ requestId: request.id, data: { item: mapProjectDetail(created) } });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get("/projects/:id", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||||
|
const projectId = parseId(request.params?.id);
|
||||||
|
const project = await ensureReadableProject(fastify, request, projectId);
|
||||||
|
return sendData(reply, request, { item: mapProjectDetail(project) });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.patch("/projects/:id", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||||
|
const projectId = parseId(request.params?.id);
|
||||||
|
await ensureManageableProject(fastify, request, projectId);
|
||||||
|
const body = parseBody(PROJECT_PATCH_SCHEMA, request.body);
|
||||||
|
const updated = await fastify.prisma.project.update({
|
||||||
|
where: { id: projectId },
|
||||||
|
data: {
|
||||||
|
...(body.projectNo !== undefined ? { project_no: body.projectNo } : {}),
|
||||||
|
...(body.name !== undefined ? { name: body.name } : {}),
|
||||||
|
...(body.clientName !== undefined ? { client_name: body.clientName } : {}),
|
||||||
|
...(body.clientAlias !== undefined ? { client_alias: body.clientAlias ?? null } : {}),
|
||||||
|
...(body.ownerEmployeeId !== undefined ? { owner_employee_id: body.ownerEmployeeId } : {}),
|
||||||
|
...(body.managerEmployeeId !== undefined ? { manager_employee_id: body.managerEmployeeId ?? null } : {}),
|
||||||
|
...(body.status !== undefined ? { status: body.status } : {}),
|
||||||
|
...(body.startsAt !== undefined ? { starts_at: parseDateOnly(body.startsAt) } : {}),
|
||||||
|
...(body.targetEndAt !== undefined ? { target_end_at: parseDateOnly(body.targetEndAt) } : {}),
|
||||||
|
...(body.actualEndAt !== undefined ? { actual_end_at: parseDateOnly(body.actualEndAt) } : {}),
|
||||||
|
...(body.summary !== undefined ? { summary: body.summary ?? null } : {}),
|
||||||
|
...(body.isConfidential !== undefined ? { is_confidential: body.isConfidential } : {}),
|
||||||
|
},
|
||||||
|
include: {
|
||||||
|
owner: { select: { id: true, employee_no: true, name: true } },
|
||||||
|
manager: { select: { id: true, employee_no: true, name: true } },
|
||||||
|
members: { where: { left_at: null }, include: { employee: { select: { id: true, employee_no: true, name: true } } } },
|
||||||
|
milestones: true,
|
||||||
|
tasks: { include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } } } },
|
||||||
|
risks: { include: { owner: { select: { id: true, employee_no: true, name: true } } } },
|
||||||
|
_count: { select: { members: true, milestones: true, tasks: true, risks: true, reimbursements: true, travel_requests: true, todo_items: true, weekly_reports: true, contracts: true, deliveries: true, document_refs: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await writeAudit(fastify, request, "PROJECT_UPDATED", projectId, { fields: Object.keys(body) });
|
||||||
|
return sendData(reply, request, { item: mapProjectDetail(updated) });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post("/projects/:id/archive", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||||
|
const projectId = parseId(request.params?.id);
|
||||||
|
await ensureManageableProject(fastify, request, projectId);
|
||||||
|
const updated = await fastify.prisma.project.update({ where: { id: projectId }, data: { status: "archived", actual_end_at: new Date() } });
|
||||||
|
await writeAudit(fastify, request, "PROJECT_ARCHIVED", projectId, {});
|
||||||
|
return sendData(reply, request, { item: { id: updated.id, status: updated.status } });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get("/projects/:id/members", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||||
|
const projectId = parseId(request.params?.id);
|
||||||
|
const project = await ensureReadableProject(fastify, request, projectId);
|
||||||
|
return sendData(reply, request, { items: mapProjectDetail(project).members });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post("/projects/:id/members", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||||
|
const projectId = parseId(request.params?.id);
|
||||||
|
await ensureManageableProject(fastify, request, projectId);
|
||||||
|
const body = parseBody(MEMBER_CREATE_SCHEMA, request.body);
|
||||||
|
const item = await fastify.prisma.projectMember.upsert({
|
||||||
|
where: { project_id_employee_id: { project_id: projectId, employee_id: body.employeeId } },
|
||||||
|
update: { role: body.role ?? "member", left_at: null },
|
||||||
|
create: { project_id: projectId, employee_id: body.employeeId, role: body.role ?? "member" },
|
||||||
|
include: { employee: { select: { id: true, employee_no: true, name: true } } },
|
||||||
|
});
|
||||||
|
await writeAudit(fastify, request, "PROJECT_MEMBER_UPSERTED", projectId, { memberId: item.id, employeeId: item.employee_id });
|
||||||
|
return reply.code(201).send({ requestId: request.id, data: { item: { id: item.id, employeeId: item.employee_id, role: item.role, joinedAt: item.joined_at?.toISOString?.() ?? null, leftAt: item.left_at?.toISOString?.() ?? null, employee: mapEmployeeBasic(item.employee) } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.patch("/projects/:id/members/:memberId", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||||
|
const projectId = parseId(request.params?.id);
|
||||||
|
await ensureManageableProject(fastify, request, projectId);
|
||||||
|
const memberId = parseId(request.params?.memberId, "memberId");
|
||||||
|
const body = parseBody(MEMBER_PATCH_SCHEMA, request.body);
|
||||||
|
const updated = await fastify.prisma.projectMember.update({
|
||||||
|
where: { id: memberId },
|
||||||
|
data: {
|
||||||
|
...(body.role !== undefined ? { role: body.role } : {}),
|
||||||
|
...(body.leftAt !== undefined ? { left_at: parseDateOnly(body.leftAt) } : {}),
|
||||||
|
},
|
||||||
|
include: { employee: { select: { id: true, employee_no: true, name: true } } },
|
||||||
|
});
|
||||||
|
return sendData(reply, request, { item: { id: updated.id, employeeId: updated.employee_id, role: updated.role, joinedAt: updated.joined_at?.toISOString?.() ?? null, leftAt: updated.left_at?.toISOString?.() ?? null, employee: mapEmployeeBasic(updated.employee) } });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get("/projects/:id/milestones", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||||
|
const projectId = parseId(request.params?.id);
|
||||||
|
const project = await ensureReadableProject(fastify, request, projectId);
|
||||||
|
return sendData(reply, request, { items: mapProjectDetail(project).milestones });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post("/projects/:id/milestones", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||||
|
const projectId = parseId(request.params?.id);
|
||||||
|
await ensureManageableProject(fastify, request, projectId);
|
||||||
|
const body = parseBody(MILESTONE_CREATE_SCHEMA, request.body);
|
||||||
|
const created = await fastify.prisma.projectMilestone.create({
|
||||||
|
data: {
|
||||||
|
project_id: projectId,
|
||||||
|
title: body.title,
|
||||||
|
description: body.description ?? null,
|
||||||
|
status: body.status ?? "pending",
|
||||||
|
due_date: parseDateOnly(body.dueDate),
|
||||||
|
completed_at: body.completedAt ? new Date(body.completedAt) : null,
|
||||||
|
owner_employee_id: body.ownerEmployeeId ?? null,
|
||||||
|
sort_order: body.sortOrder ?? 100,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return reply.code(201).send({ requestId: request.id, data: { item: { id: created.id, title: created.title, description: created.description ?? null, status: created.status, dueDate: created.due_date?.toISOString?.().slice(0, 10) ?? null, completedAt: created.completed_at?.toISOString?.() ?? null, ownerEmployeeId: created.owner_employee_id ?? null, sortOrder: created.sort_order } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.patch("/projects/:id/milestones/:milestoneId", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||||
|
const projectId = parseId(request.params?.id);
|
||||||
|
await ensureManageableProject(fastify, request, projectId);
|
||||||
|
const milestoneId = parseId(request.params?.milestoneId, "milestoneId");
|
||||||
|
const body = parseBody(MILESTONE_PATCH_SCHEMA, request.body);
|
||||||
|
const updated = await fastify.prisma.projectMilestone.update({
|
||||||
|
where: { id: milestoneId },
|
||||||
|
data: {
|
||||||
|
...(body.title !== undefined ? { title: body.title } : {}),
|
||||||
|
...(body.description !== undefined ? { description: body.description ?? null } : {}),
|
||||||
|
...(body.status !== undefined ? { status: body.status } : {}),
|
||||||
|
...(body.dueDate !== undefined ? { due_date: parseDateOnly(body.dueDate) } : {}),
|
||||||
|
...(body.completedAt !== undefined ? { completed_at: body.completedAt ? new Date(body.completedAt) : null } : {}),
|
||||||
|
...(body.ownerEmployeeId !== undefined ? { owner_employee_id: body.ownerEmployeeId ?? null } : {}),
|
||||||
|
...(body.sortOrder !== undefined ? { sort_order: body.sortOrder } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return sendData(reply, request, { item: { id: updated.id, title: updated.title, description: updated.description ?? null, status: updated.status, dueDate: updated.due_date?.toISOString?.().slice(0, 10) ?? null, completedAt: updated.completed_at?.toISOString?.() ?? null, ownerEmployeeId: updated.owner_employee_id ?? null, sortOrder: updated.sort_order } });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get("/projects/:id/tasks", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||||
|
const projectId = parseId(request.params?.id);
|
||||||
|
const project = await ensureReadableProject(fastify, request, projectId);
|
||||||
|
return sendData(reply, request, { items: mapProjectDetail(project).tasks });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post("/projects/:id/tasks", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||||
|
const projectId = parseId(request.params?.id);
|
||||||
|
await ensureManageableProject(fastify, request, projectId);
|
||||||
|
const body = parseBody(TASK_CREATE_SCHEMA, request.body);
|
||||||
|
const created = await fastify.prisma.projectTask.create({
|
||||||
|
data: {
|
||||||
|
project_id: projectId,
|
||||||
|
title: body.title,
|
||||||
|
description: body.description ?? null,
|
||||||
|
status: body.status ?? "todo",
|
||||||
|
priority: body.priority ?? "normal",
|
||||||
|
assignee_employee_id: body.assigneeEmployeeId ?? null,
|
||||||
|
due_date: parseDateOnly(body.dueDate),
|
||||||
|
blocked_reason: body.blockedReason ?? null,
|
||||||
|
created_by: request.employee.id,
|
||||||
|
},
|
||||||
|
include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } } },
|
||||||
|
});
|
||||||
|
return reply.code(201).send({ requestId: request.id, data: { item: { id: created.id, title: created.title, description: created.description ?? null, status: created.status, priority: created.priority, assigneeEmployeeId: created.assignee_employee_id ?? null, dueDate: created.due_date?.toISOString?.().slice(0, 10) ?? null, blockedReason: created.blocked_reason ?? null, createdBy: created.created_by, assignee: mapEmployeeBasic(created.assignee), creator: mapEmployeeBasic(created.creator), createdAt: created.created_at?.toISOString?.() ?? null, updatedAt: created.updated_at?.toISOString?.() ?? null } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.patch("/projects/:id/tasks/:taskId", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||||
|
const projectId = parseId(request.params?.id);
|
||||||
|
await ensureManageableProject(fastify, request, projectId);
|
||||||
|
const taskId = parseId(request.params?.taskId, "taskId");
|
||||||
|
const body = parseBody(TASK_PATCH_SCHEMA, request.body);
|
||||||
|
const updated = await fastify.prisma.projectTask.update({
|
||||||
|
where: { id: taskId },
|
||||||
|
data: {
|
||||||
|
...(body.title !== undefined ? { title: body.title } : {}),
|
||||||
|
...(body.description !== undefined ? { description: body.description ?? null } : {}),
|
||||||
|
...(body.status !== undefined ? { status: body.status } : {}),
|
||||||
|
...(body.priority !== undefined ? { priority: body.priority } : {}),
|
||||||
|
...(body.assigneeEmployeeId !== undefined ? { assignee_employee_id: body.assigneeEmployeeId ?? null } : {}),
|
||||||
|
...(body.dueDate !== undefined ? { due_date: parseDateOnly(body.dueDate) } : {}),
|
||||||
|
...(body.blockedReason !== undefined ? { blocked_reason: body.blockedReason ?? null } : {}),
|
||||||
|
},
|
||||||
|
include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } } },
|
||||||
|
});
|
||||||
|
return sendData(reply, request, { item: { id: updated.id, title: updated.title, description: updated.description ?? null, status: updated.status, priority: updated.priority, assigneeEmployeeId: updated.assignee_employee_id ?? null, dueDate: updated.due_date?.toISOString?.().slice(0, 10) ?? null, blockedReason: updated.blocked_reason ?? null, createdBy: updated.created_by, assignee: mapEmployeeBasic(updated.assignee), creator: mapEmployeeBasic(updated.creator), createdAt: updated.created_at?.toISOString?.() ?? null, updatedAt: updated.updated_at?.toISOString?.() ?? null } });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get("/projects/:id/risks", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||||
|
const projectId = parseId(request.params?.id);
|
||||||
|
const project = await ensureReadableProject(fastify, request, projectId);
|
||||||
|
return sendData(reply, request, { items: mapProjectDetail(project).risks });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post("/projects/:id/risks", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||||
|
const projectId = parseId(request.params?.id);
|
||||||
|
await ensureManageableProject(fastify, request, projectId);
|
||||||
|
const body = parseBody(RISK_CREATE_SCHEMA, request.body);
|
||||||
|
const created = await fastify.prisma.projectRisk.create({
|
||||||
|
data: {
|
||||||
|
project_id: projectId,
|
||||||
|
title: body.title,
|
||||||
|
detail: body.detail,
|
||||||
|
level: body.level ?? "medium",
|
||||||
|
status: body.status ?? "open",
|
||||||
|
owner_employee_id: body.ownerEmployeeId ?? null,
|
||||||
|
},
|
||||||
|
include: { owner: { select: { id: true, employee_no: true, name: true } } },
|
||||||
|
});
|
||||||
|
return reply.code(201).send({ requestId: request.id, data: { item: { id: created.id, title: created.title, detail: created.detail, level: created.level, status: created.status, ownerEmployeeId: created.owner_employee_id ?? null, owner: mapEmployeeBasic(created.owner), createdAt: created.created_at?.toISOString?.() ?? null, updatedAt: created.updated_at?.toISOString?.() ?? null } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.patch("/projects/:id/risks/:riskId", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectDomain);
|
||||||
|
const projectId = parseId(request.params?.id);
|
||||||
|
await ensureManageableProject(fastify, request, projectId);
|
||||||
|
const riskId = parseId(request.params?.riskId, "riskId");
|
||||||
|
const body = parseBody(RISK_PATCH_SCHEMA, request.body);
|
||||||
|
const updated = await fastify.prisma.projectRisk.update({
|
||||||
|
where: { id: riskId },
|
||||||
|
data: {
|
||||||
|
...(body.title !== undefined ? { title: body.title } : {}),
|
||||||
|
...(body.detail !== undefined ? { detail: body.detail } : {}),
|
||||||
|
...(body.level !== undefined ? { level: body.level } : {}),
|
||||||
|
...(body.status !== undefined ? { status: body.status } : {}),
|
||||||
|
...(body.ownerEmployeeId !== undefined ? { owner_employee_id: body.ownerEmployeeId ?? null } : {}),
|
||||||
|
},
|
||||||
|
include: { owner: { select: { id: true, employee_no: true, name: true } } },
|
||||||
|
});
|
||||||
|
return sendData(reply, request, { item: { id: updated.id, title: updated.title, detail: updated.detail, level: updated.level, status: updated.status, ownerEmployeeId: updated.owner_employee_id ?? null, owner: mapEmployeeBasic(updated.owner), createdAt: updated.created_at?.toISOString?.() ?? null, updatedAt: updated.updated_at?.toISOString?.() ?? null } });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get("/projects/:id/expenses/summary", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking);
|
||||||
|
const projectId = parseId(request.params?.id);
|
||||||
|
await ensureReadableProject(fastify, request, projectId);
|
||||||
|
const items = await fastify.prisma.reimbursementForm.findMany({
|
||||||
|
where: { project_id: projectId },
|
||||||
|
orderBy: { updated_at: "desc" },
|
||||||
|
select: { id: true, form_number: true, status: true, total_amount: true, reimbursement_date: true, updated_at: true },
|
||||||
|
});
|
||||||
|
const totalAmount = items.reduce((sum, item) => sum + Number(item.total_amount ?? 0), 0);
|
||||||
|
return sendData(reply, request, { totalAmount: totalAmount.toFixed(2), items: items.map((item) => ({ id: item.id, formNumber: item.form_number ?? item.id, status: item.status, totalAmount: String(item.total_amount ?? 0), reimbursementDate: item.reimbursement_date?.toISOString?.().slice(0, 10) ?? null, updatedAt: item.updated_at?.toISOString?.() ?? null })) });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get("/projects/:id/travel/summary", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking);
|
||||||
|
const projectId = parseId(request.params?.id);
|
||||||
|
await ensureReadableProject(fastify, request, projectId);
|
||||||
|
const items = await fastify.prisma.travelRequest.findMany({
|
||||||
|
where: { project_id: projectId },
|
||||||
|
orderBy: { updated_at: "desc" },
|
||||||
|
select: { id: true, request_no: true, status: true, estimated_amount: true, start_date: true, end_date: true, updated_at: true },
|
||||||
|
});
|
||||||
|
const totalAmount = items.reduce((sum, item) => sum + Number(item.estimated_amount ?? 0), 0);
|
||||||
|
return sendData(reply, request, { totalAmount: totalAmount.toFixed(2), items: items.map((item) => ({ id: item.id, requestNo: item.request_no ?? item.id, status: item.status, estimatedAmount: String(item.estimated_amount ?? 0), startDate: item.start_date?.toISOString?.().slice(0, 10) ?? null, endDate: item.end_date?.toISOString?.().slice(0, 10) ?? null, updatedAt: item.updated_at?.toISOString?.() ?? null })) });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get("/projects/:id/weekly-reports", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking);
|
||||||
|
const projectId = parseId(request.params?.id);
|
||||||
|
await ensureReadableProject(fastify, request, projectId);
|
||||||
|
const items = await fastify.prisma.projectLinkOnWeeklyReport.findMany({
|
||||||
|
where: { project_id: projectId },
|
||||||
|
orderBy: { updated_at: "desc" },
|
||||||
|
include: { weekly_report: { include: { employee: { select: { id: true, employee_no: true, name: true } } } } },
|
||||||
|
});
|
||||||
|
return sendData(reply, request, { items: items.map((item) => ({ id: item.id, weeklyReportId: item.weekly_report_id, weekStart: item.weekly_report.week_start?.toISOString?.().slice(0, 10) ?? null, status: item.weekly_report.status, employee: mapEmployeeBasic(item.weekly_report.employee), progressText: item.progress_text, riskText: item.risk_text, nextStepText: item.next_step_text, updatedAt: item.updated_at?.toISOString?.() ?? null })) });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get("/projects/:id/deliveries", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking);
|
||||||
|
const projectId = parseId(request.params?.id);
|
||||||
|
await ensureReadableProject(fastify, request, projectId);
|
||||||
|
const items = await fastify.prisma.projectDeliveryRecord.findMany({
|
||||||
|
where: { project_id: projectId },
|
||||||
|
orderBy: { occurred_at: "desc" },
|
||||||
|
include: { owner: { select: { id: true, employee_no: true, name: true } } },
|
||||||
|
});
|
||||||
|
return sendData(reply, request, { items: items.map((item) => ({ id: item.id, title: item.title, recordType: item.record_type, summary: item.summary, occurredAt: item.occurred_at?.toISOString?.() ?? null, owner: mapEmployeeBasic(item.owner), createdAt: item.created_at?.toISOString?.() ?? null })) });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post("/projects/:id/deliveries", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.projectLinking);
|
||||||
|
const projectId = parseId(request.params?.id);
|
||||||
|
await ensureManageableProject(fastify, request, projectId);
|
||||||
|
const body = parseBody(DELIVERY_CREATE_SCHEMA, request.body);
|
||||||
|
const created = await fastify.prisma.projectDeliveryRecord.create({
|
||||||
|
data: {
|
||||||
|
project_id: projectId,
|
||||||
|
title: body.title,
|
||||||
|
record_type: body.recordType,
|
||||||
|
summary: body.summary,
|
||||||
|
occurred_at: new Date(body.occurredAt),
|
||||||
|
owner_employee_id: body.ownerEmployeeId ?? request.employee.id,
|
||||||
|
},
|
||||||
|
include: { owner: { select: { id: true, employee_no: true, name: true } } },
|
||||||
|
});
|
||||||
|
return reply.code(201).send({ requestId: request.id, data: { item: { id: created.id, title: created.title, recordType: created.record_type, summary: created.summary, occurredAt: created.occurred_at?.toISOString?.() ?? null, owner: mapEmployeeBasic(created.owner), createdAt: created.created_at?.toISOString?.() ?? null } } });
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get("/projects/:id/document-refs", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
await ensureFeatureEnabled(fastify, request, FEATURE_FLAG_KEYS.confidentialDocRefs);
|
||||||
|
const projectId = parseId(request.params?.id);
|
||||||
|
await ensureReadableProject(fastify, request, projectId);
|
||||||
|
const items = await fastify.prisma.confidentialDocumentRef.findMany({ where: { project_id: projectId }, orderBy: { updated_at: "desc" } });
|
||||||
|
return sendData(reply, request, { items: items.map((item) => ({ id: item.id, title: item.title, documentNo: item.document_no ?? null, confidentialityLevel: item.confidentiality_level, storageType: item.storage_type, storagePath: item.storage_path, offlineArchiveNo: item.offline_archive_no ?? null, updatedAt: item.updated_at?.toISOString?.() ?? null })) });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = projectsRoutes;
|
||||||
@@ -268,6 +268,13 @@ function mapReimbursementSummary(item) {
|
|||||||
: null,
|
: null,
|
||||||
createdAt: item.created_at?.toISOString?.() ?? null,
|
createdAt: item.created_at?.toISOString?.() ?? null,
|
||||||
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
updatedAt: item.updated_at?.toISOString?.() ?? null,
|
||||||
|
project: item.project
|
||||||
|
? {
|
||||||
|
id: item.project.id,
|
||||||
|
projectNo: item.project.project_no,
|
||||||
|
name: item.project.name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -549,6 +556,8 @@ async function reimbursementRoutes(fastify) {
|
|||||||
reimbursementDate,
|
reimbursementDate,
|
||||||
status: reimbursement.status,
|
status: reimbursement.status,
|
||||||
description: reimbursement.description,
|
description: reimbursement.description,
|
||||||
|
projectId: reimbursement.project_id ?? null,
|
||||||
|
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null,
|
||||||
totalAmount: toMoneyString(reimbursement.total_amount),
|
totalAmount: toMoneyString(reimbursement.total_amount),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
@@ -610,6 +619,7 @@ async function reimbursementRoutes(fastify) {
|
|||||||
paid_by: true,
|
paid_by: true,
|
||||||
created_at: true,
|
created_at: true,
|
||||||
updated_at: true,
|
updated_at: true,
|
||||||
|
project: { select: { id: true, project_no: true, name: true } },
|
||||||
owner: {
|
owner: {
|
||||||
select: { id: true, employee_no: true, name: true },
|
select: { id: true, employee_no: true, name: true },
|
||||||
},
|
},
|
||||||
@@ -652,6 +662,8 @@ async function reimbursementRoutes(fastify) {
|
|||||||
total_amount: true,
|
total_amount: true,
|
||||||
status: true,
|
status: true,
|
||||||
description: true,
|
description: true,
|
||||||
|
project_id: true,
|
||||||
|
project: { select: { id: true, project_no: true, name: true } },
|
||||||
owner: {
|
owner: {
|
||||||
select: { id: true, employee_no: true, name: true },
|
select: { id: true, employee_no: true, name: true },
|
||||||
},
|
},
|
||||||
@@ -745,6 +757,8 @@ async function reimbursementRoutes(fastify) {
|
|||||||
totalAmount: toMoneyString(reimbursement.total_amount),
|
totalAmount: toMoneyString(reimbursement.total_amount),
|
||||||
status: reimbursement.status,
|
status: reimbursement.status,
|
||||||
description: reimbursement.description,
|
description: reimbursement.description,
|
||||||
|
projectId: reimbursement.project_id ?? null,
|
||||||
|
project: reimbursement.project ? { id: reimbursement.project.id, projectNo: reimbursement.project.project_no, name: reimbursement.project.name } : null,
|
||||||
owner: reimbursement.owner,
|
owner: reimbursement.owner,
|
||||||
submitter: reimbursement.submitter,
|
submitter: reimbursement.submitter,
|
||||||
items: reimbursement.items.map((item) => ({
|
items: reimbursement.items.map((item) => ({
|
||||||
@@ -1787,6 +1801,47 @@ async function reimbursementRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
fastify.post(
|
||||||
|
"/reimbursements/:id/link-project",
|
||||||
|
{
|
||||||
|
preHandler: fastify.authenticate,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const gate = await evaluateFeatureFlag({
|
||||||
|
prisma: fastify.prisma,
|
||||||
|
config: fastify.config,
|
||||||
|
flagKey: FEATURE_FLAG_KEYS.projectLinking,
|
||||||
|
departmentId: request.employee.department_id ?? null,
|
||||||
|
strictDepartment: false,
|
||||||
|
});
|
||||||
|
if (!gate.enabled) {
|
||||||
|
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 409, details: { featureFlag: "project_linking_v1_disabled", reason: gate.reason } });
|
||||||
|
}
|
||||||
|
const reimbursementId = parseReimbursementId(request.params?.id);
|
||||||
|
const body = parseBody(z.object({ projectId: z.string().uuid().nullable() }).strict(), request.body);
|
||||||
|
const current = await fastify.prisma.reimbursementForm.findUnique({
|
||||||
|
where: { id: reimbursementId },
|
||||||
|
select: { id: true, owner_id: true, submitter_id: true, project_id: true, status: true },
|
||||||
|
});
|
||||||
|
if (!current) {
|
||||||
|
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||||
|
}
|
||||||
|
ensureOwnOrCanViewAll(fastify, request, current);
|
||||||
|
const updated = await fastify.prisma.reimbursementForm.update({
|
||||||
|
where: { id: reimbursementId },
|
||||||
|
data: { project_id: body.projectId ?? null, updated_by: request.employee.id },
|
||||||
|
select: { id: true, project_id: true, project: { select: { id: true, project_no: true, name: true } } },
|
||||||
|
});
|
||||||
|
return sendData(reply, request, {
|
||||||
|
item: {
|
||||||
|
reimbursementId: updated.id,
|
||||||
|
projectId: updated.project_id ?? null,
|
||||||
|
project: updated.project ? { id: updated.project.id, projectNo: updated.project.project_no, name: updated.project.name } : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
fastify.post(
|
fastify.post(
|
||||||
"/reimbursements/:id/pay",
|
"/reimbursements/:id/pay",
|
||||||
{
|
{
|
||||||
|
|||||||
+11
-2
@@ -19,6 +19,7 @@ const CREATE_SCHEMA = z.object({
|
|||||||
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
|
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
|
||||||
priority: z.enum(["low", "normal", "high"]).optional(),
|
priority: z.enum(["low", "normal", "high"]).optional(),
|
||||||
source: z.enum(["manual", "weekly_report"]).optional(),
|
source: z.enum(["manual", "weekly_report"]).optional(),
|
||||||
|
projectId: UUID_SCHEMA.nullable().optional(),
|
||||||
}).strict();
|
}).strict();
|
||||||
const PATCH_SCHEMA = z.object({
|
const PATCH_SCHEMA = z.object({
|
||||||
title: z.string().trim().min(1).max(200).optional(),
|
title: z.string().trim().min(1).max(200).optional(),
|
||||||
@@ -27,6 +28,7 @@ const PATCH_SCHEMA = z.object({
|
|||||||
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
|
dueDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional(),
|
||||||
priority: z.enum(["low", "normal", "high"]).optional(),
|
priority: z.enum(["low", "normal", "high"]).optional(),
|
||||||
status: z.enum(["todo", "doing", "done", "cancelled"]).optional(),
|
status: z.enum(["todo", "doing", "done", "cancelled"]).optional(),
|
||||||
|
projectId: UUID_SCHEMA.nullable().optional(),
|
||||||
}).strict().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
}).strict().refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||||
|
|
||||||
function buildValidationError(details, statusCode = 400) {
|
function buildValidationError(details, statusCode = 400) {
|
||||||
@@ -60,11 +62,13 @@ function mapTodo(row) {
|
|||||||
priority: row.priority,
|
priority: row.priority,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
source: row.source,
|
source: row.source,
|
||||||
|
projectId: row.project_id ?? null,
|
||||||
createdAt: row.created_at?.toISOString?.() ?? null,
|
createdAt: row.created_at?.toISOString?.() ?? null,
|
||||||
updatedAt: row.updated_at?.toISOString?.() ?? null,
|
updatedAt: row.updated_at?.toISOString?.() ?? null,
|
||||||
completedAt: row.completed_at?.toISOString?.() ?? null,
|
completedAt: row.completed_at?.toISOString?.() ?? null,
|
||||||
assignee: row.assignee ? { id: row.assignee.id, employeeNo: row.assignee.employee_no, name: row.assignee.name } : null,
|
assignee: row.assignee ? { id: row.assignee.id, employeeNo: row.assignee.employee_no, name: row.assignee.name } : null,
|
||||||
creator: row.creator ? { id: row.creator.id, employeeNo: row.creator.employee_no, name: row.creator.name } : null,
|
creator: row.creator ? { id: row.creator.id, employeeNo: row.creator.employee_no, name: row.creator.name } : null,
|
||||||
|
project: row.project ? { id: row.project.id, projectNo: row.project.project_no, name: row.project.name } : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +126,7 @@ async function todosRoutes(fastify) {
|
|||||||
include: {
|
include: {
|
||||||
assignee: { select: { id: true, employee_no: true, name: true } },
|
assignee: { select: { id: true, employee_no: true, name: true } },
|
||||||
creator: { select: { id: true, employee_no: true, name: true } },
|
creator: { select: { id: true, employee_no: true, name: true } },
|
||||||
|
project: { select: { id: true, project_no: true, name: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return sendData(reply, request, { items: rows.map(mapTodo) });
|
return sendData(reply, request, { items: rows.map(mapTodo) });
|
||||||
@@ -144,10 +149,12 @@ async function todosRoutes(fastify) {
|
|||||||
priority: body.priority ?? "normal",
|
priority: body.priority ?? "normal",
|
||||||
status: "todo",
|
status: "todo",
|
||||||
source: body.source ?? "manual",
|
source: body.source ?? "manual",
|
||||||
|
project_id: body.projectId ?? null,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
assignee: { select: { id: true, employee_no: true, name: true } },
|
assignee: { select: { id: true, employee_no: true, name: true } },
|
||||||
creator: { select: { id: true, employee_no: true, name: true } },
|
creator: { select: { id: true, employee_no: true, name: true } },
|
||||||
|
project: { select: { id: true, project_no: true, name: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -188,11 +195,13 @@ async function todosRoutes(fastify) {
|
|||||||
...(body.assigneeEmployeeId !== undefined ? { assignee_employee_id: body.assigneeEmployeeId } : {}),
|
...(body.assigneeEmployeeId !== undefined ? { assignee_employee_id: body.assigneeEmployeeId } : {}),
|
||||||
...(body.dueDate !== undefined ? { due_date: body.dueDate ? new Date(`${body.dueDate}T00:00:00.000Z`) : null } : {}),
|
...(body.dueDate !== undefined ? { due_date: body.dueDate ? new Date(`${body.dueDate}T00:00:00.000Z`) : null } : {}),
|
||||||
...(body.priority !== undefined ? { priority: body.priority } : {}),
|
...(body.priority !== undefined ? { priority: body.priority } : {}),
|
||||||
|
...(body.projectId !== undefined ? { project_id: body.projectId ?? null } : {}),
|
||||||
...(body.status !== undefined ? { status: body.status, completed_at: body.status === "done" ? new Date() : body.status === "cancelled" ? current.completed_at : null } : {}),
|
...(body.status !== undefined ? { status: body.status, completed_at: body.status === "done" ? new Date() : body.status === "cancelled" ? current.completed_at : null } : {}),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
assignee: { select: { id: true, employee_no: true, name: true } },
|
assignee: { select: { id: true, employee_no: true, name: true } },
|
||||||
creator: { select: { id: true, employee_no: true, name: true } },
|
creator: { select: { id: true, employee_no: true, name: true } },
|
||||||
|
project: { select: { id: true, project_no: true, name: true } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return sendData(reply, request, { item: mapTodo(updated) });
|
return sendData(reply, request, { item: mapTodo(updated) });
|
||||||
@@ -211,7 +220,7 @@ async function todosRoutes(fastify) {
|
|||||||
const updated = await fastify.prisma.todoItem.update({
|
const updated = await fastify.prisma.todoItem.update({
|
||||||
where: { id: todoId },
|
where: { id: todoId },
|
||||||
data: { status: "done", completed_at: new Date() },
|
data: { status: "done", completed_at: new Date() },
|
||||||
include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } } },
|
include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } }, project: { select: { id: true, project_no: true, name: true } } },
|
||||||
});
|
});
|
||||||
return sendData(reply, request, { item: mapTodo(updated) });
|
return sendData(reply, request, { item: mapTodo(updated) });
|
||||||
});
|
});
|
||||||
@@ -229,7 +238,7 @@ async function todosRoutes(fastify) {
|
|||||||
const updated = await fastify.prisma.todoItem.update({
|
const updated = await fastify.prisma.todoItem.update({
|
||||||
where: { id: todoId },
|
where: { id: todoId },
|
||||||
data: { status: "cancelled", completed_at: null },
|
data: { status: "cancelled", completed_at: null },
|
||||||
include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } } },
|
include: { assignee: { select: { id: true, employee_no: true, name: true } }, creator: { select: { id: true, employee_no: true, name: true } }, project: { select: { id: true, project_no: true, name: true } } },
|
||||||
});
|
});
|
||||||
return sendData(reply, request, { item: mapTodo(updated) });
|
return sendData(reply, request, { item: mapTodo(updated) });
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ const TRAVEL_SUMMARY_SELECT = {
|
|||||||
legal_entity_id: true,
|
legal_entity_id: true,
|
||||||
cost_center_id: true,
|
cost_center_id: true,
|
||||||
budget_pool_id: true,
|
budget_pool_id: true,
|
||||||
|
project_id: true,
|
||||||
purpose: true,
|
purpose: true,
|
||||||
trip_type: true,
|
trip_type: true,
|
||||||
start_date: true,
|
start_date: true,
|
||||||
@@ -126,6 +127,13 @@ const TRAVEL_SUMMARY_SELECT = {
|
|||||||
name: true,
|
name: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
project: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
project_no: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const TRAVEL_DETAIL_SELECT = {
|
const TRAVEL_DETAIL_SELECT = {
|
||||||
@@ -418,6 +426,7 @@ function mapTravelRequestSummary(item) {
|
|||||||
legalEntityId: item.legal_entity_id,
|
legalEntityId: item.legal_entity_id,
|
||||||
costCenterId: item.cost_center_id,
|
costCenterId: item.cost_center_id,
|
||||||
budgetPoolId: item.budget_pool_id ?? null,
|
budgetPoolId: item.budget_pool_id ?? null,
|
||||||
|
projectId: item.project_id ?? null,
|
||||||
purpose: item.purpose,
|
purpose: item.purpose,
|
||||||
tripType: item.trip_type,
|
tripType: item.trip_type,
|
||||||
startDate: toIsoDate(item.start_date),
|
startDate: toIsoDate(item.start_date),
|
||||||
@@ -456,6 +465,13 @@ function mapTravelRequestSummary(item) {
|
|||||||
name: item.budget_pool.name,
|
name: item.budget_pool.name,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
|
project: item.project
|
||||||
|
? {
|
||||||
|
id: item.project.id,
|
||||||
|
projectNo: item.project.project_no,
|
||||||
|
name: item.project.name,
|
||||||
|
}
|
||||||
|
: null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1201,6 +1217,49 @@ async function createTravelRequestsRoutes(fastify) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
fastify.post(
|
||||||
|
"/travel-requests/:id/link-project",
|
||||||
|
{
|
||||||
|
preHandler: fastify.authenticate,
|
||||||
|
},
|
||||||
|
async (request, reply) => {
|
||||||
|
const gate = await evaluateFeatureFlag({
|
||||||
|
prisma: fastify.prisma,
|
||||||
|
config: fastify.config,
|
||||||
|
flagKey: FEATURE_FLAG_KEYS.projectLinking,
|
||||||
|
departmentId: request.employee.department_id ?? null,
|
||||||
|
strictDepartment: false,
|
||||||
|
});
|
||||||
|
if (!gate.enabled) {
|
||||||
|
throw buildValidationError({ featureFlag: "project_linking_v1_disabled", reason: gate.reason }, 409);
|
||||||
|
}
|
||||||
|
const travelRequestId = parseUuid(request.params?.id, "id");
|
||||||
|
const body = parseBody(z.object({ projectId: UUID_SCHEMA.nullable() }).strict(), request.body);
|
||||||
|
const current = await fastify.prisma.travelRequest.findUnique({
|
||||||
|
where: { id: travelRequestId },
|
||||||
|
select: { id: true, applicant_employee_id: true, submitter_id: true, status: true },
|
||||||
|
});
|
||||||
|
if (!current) {
|
||||||
|
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||||
|
}
|
||||||
|
if (!canReadTravelRequest(request, current)) {
|
||||||
|
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: "travel.view_own" } });
|
||||||
|
}
|
||||||
|
const updated = await fastify.prisma.travelRequest.update({
|
||||||
|
where: { id: travelRequestId },
|
||||||
|
data: { project_id: body.projectId ?? null, updated_by: request.employee.id },
|
||||||
|
select: { id: true, project_id: true, project: { select: { id: true, project_no: true, name: true } } },
|
||||||
|
});
|
||||||
|
return sendData(reply, request, {
|
||||||
|
item: {
|
||||||
|
travelRequestId: updated.id,
|
||||||
|
projectId: updated.project_id ?? null,
|
||||||
|
project: updated.project ? { id: updated.project.id, projectNo: updated.project.project_no, name: updated.project.name } : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
fastify.post(
|
fastify.post(
|
||||||
"/travel-requests/:id/withdraw",
|
"/travel-requests/:id/withdraw",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const { FEATURE_FLAG_KEYS, evaluateFeatureFlag } = require("../services/featureF
|
|||||||
const { currentDateString, parseDateOnlyString } = require("../utils/date");
|
const { currentDateString, parseDateOnlyString } = require("../utils/date");
|
||||||
|
|
||||||
const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
const DATE_SCHEMA = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
|
||||||
|
const UUID_SCHEMA = z.string().uuid();
|
||||||
const QUERY_SCHEMA = z.object({ weekStart: DATE_SCHEMA.optional() });
|
const QUERY_SCHEMA = z.object({ weekStart: DATE_SCHEMA.optional() });
|
||||||
const WRITE_SCHEMA = z
|
const WRITE_SCHEMA = z
|
||||||
.object({
|
.object({
|
||||||
@@ -14,6 +15,7 @@ const WRITE_SCHEMA = z
|
|||||||
blockersText: z.string().max(5000).optional(),
|
blockersText: z.string().max(5000).optional(),
|
||||||
nextWeekText: z.string().max(5000).optional(),
|
nextWeekText: z.string().max(5000).optional(),
|
||||||
needsHelp: z.boolean().optional(),
|
needsHelp: z.boolean().optional(),
|
||||||
|
projectLinks: z.array(z.object({ projectId: UUID_SCHEMA, progressText: z.string().max(4000).optional(), riskText: z.string().max(4000).optional(), nextStepText: z.string().max(4000).optional() }).strict()).max(20).optional(),
|
||||||
})
|
})
|
||||||
.strict()
|
.strict()
|
||||||
.refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
.refine((value) => Object.keys(value).length > 0, { message: "empty_patch" });
|
||||||
@@ -68,9 +70,14 @@ function mapReport(row) {
|
|||||||
submittedAt: row.submitted_at?.toISOString?.() ?? null,
|
submittedAt: row.submitted_at?.toISOString?.() ?? null,
|
||||||
createdAt: row.created_at?.toISOString?.() ?? null,
|
createdAt: row.created_at?.toISOString?.() ?? null,
|
||||||
updatedAt: row.updated_at?.toISOString?.() ?? null,
|
updatedAt: row.updated_at?.toISOString?.() ?? null,
|
||||||
|
projectLinks: Array.isArray(row.project_links) ? row.project_links.map((item) => ({ id: item.id, projectId: item.project_id, progressText: item.progress_text, riskText: item.risk_text, nextStepText: item.next_step_text, project: item.project ? { id: item.project.id, projectNo: item.project.project_no, name: item.project.name } : null })) : [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadReportWithLinks(prisma, reportId) {
|
||||||
|
return prisma.weeklyReport.findUnique({ where: { id: reportId }, include: { project_links: { include: { project: { select: { id: true, project_no: true, name: true } } }, orderBy: { updated_at: "desc" } } } });
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureReportDraft(prisma, employeeId, weekStart) {
|
async function ensureReportDraft(prisma, employeeId, weekStart) {
|
||||||
const existing = await prisma.weeklyReport.findUnique({
|
const existing = await prisma.weeklyReport.findUnique({
|
||||||
where: {
|
where: {
|
||||||
@@ -105,7 +112,7 @@ async function weeklyReportsRoutes(fastify) {
|
|||||||
}
|
}
|
||||||
const weekStart = resolveWeekStart(parsed.data.weekStart, fastify.config.appTimezone);
|
const weekStart = resolveWeekStart(parsed.data.weekStart, fastify.config.appTimezone);
|
||||||
const report = await ensureReportDraft(fastify.prisma, request.employee.id, weekStart);
|
const report = await ensureReportDraft(fastify.prisma, request.employee.id, weekStart);
|
||||||
return sendData(reply, request, { item: mapReport(report) });
|
return sendData(reply, request, { item: mapReport(await loadReportWithLinks(fastify.prisma, report.id)) });
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.put("/weekly-reports/me", { preHandler: fastify.authenticate }, async (request, reply) => {
|
fastify.put("/weekly-reports/me", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
@@ -116,16 +123,37 @@ async function weeklyReportsRoutes(fastify) {
|
|||||||
if (current.status === "submitted") {
|
if (current.status === "submitted") {
|
||||||
throw buildValidationError({ status: "submitted_readonly" }, 409);
|
throw buildValidationError({ status: "submitted_readonly" }, 409);
|
||||||
}
|
}
|
||||||
const updated = await fastify.prisma.weeklyReport.update({
|
const updated = await fastify.prisma.$transaction(async (tx) => {
|
||||||
where: { id: current.id },
|
const saved = await tx.weeklyReport.update({
|
||||||
data: {
|
where: { id: current.id },
|
||||||
...(body.highlightsText !== undefined ? { highlights_text: body.highlightsText } : {}),
|
data: {
|
||||||
...(body.blockersText !== undefined ? { blockers_text: body.blockersText } : {}),
|
...(body.highlightsText !== undefined ? { highlights_text: body.highlightsText } : {}),
|
||||||
...(body.nextWeekText !== undefined ? { next_week_text: body.nextWeekText } : {}),
|
...(body.blockersText !== undefined ? { blockers_text: body.blockersText } : {}),
|
||||||
...(body.needsHelp !== undefined ? { needs_help: body.needsHelp } : {}),
|
...(body.nextWeekText !== undefined ? { next_week_text: body.nextWeekText } : {}),
|
||||||
},
|
...(body.needsHelp !== undefined ? { needs_help: body.needsHelp } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (body.projectLinks !== undefined) {
|
||||||
|
const linkGate = await evaluateFeatureFlag({ prisma: tx, config: fastify.config, flagKey: FEATURE_FLAG_KEYS.projectLinking, departmentId: request.employee.department_id ?? null, strictDepartment: false });
|
||||||
|
if (!linkGate.enabled) {
|
||||||
|
throw buildValidationError({ featureFlag: "project_linking_v1_disabled", reason: linkGate.reason }, 409);
|
||||||
|
}
|
||||||
|
await tx.projectLinkOnWeeklyReport.deleteMany({ where: { weekly_report_id: current.id } });
|
||||||
|
if (body.projectLinks.length) {
|
||||||
|
await tx.projectLinkOnWeeklyReport.createMany({
|
||||||
|
data: body.projectLinks.map((item) => ({
|
||||||
|
weekly_report_id: current.id,
|
||||||
|
project_id: item.projectId,
|
||||||
|
progress_text: item.progressText ?? "",
|
||||||
|
risk_text: item.riskText ?? "",
|
||||||
|
next_step_text: item.nextStepText ?? "",
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return saved;
|
||||||
});
|
});
|
||||||
return sendData(reply, request, { item: mapReport(updated) });
|
return sendData(reply, request, { item: mapReport(await loadReportWithLinks(fastify.prisma, updated.id)) });
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.post("/weekly-reports/me/submit", { preHandler: fastify.authenticate }, async (request, reply) => {
|
fastify.post("/weekly-reports/me/submit", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
@@ -140,7 +168,7 @@ async function weeklyReportsRoutes(fastify) {
|
|||||||
submitted_at: new Date(),
|
submitted_at: new Date(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
return sendData(reply, request, { item: mapReport(updated) });
|
return sendData(reply, request, { item: mapReport(await loadReportWithLinks(fastify.prisma, updated.id)) });
|
||||||
});
|
});
|
||||||
|
|
||||||
fastify.get("/weekly-reports/team", { preHandler: fastify.authenticate }, async (request, reply) => {
|
fastify.get("/weekly-reports/team", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||||
|
|||||||
在新工单中引用
屏蔽一个用户