diff --git a/client/src/api/contractsRegistry.ts b/client/src/api/contractsRegistry.ts new file mode 100644 index 00000000..7bfbc6b7 --- /dev/null +++ b/client/src/api/contractsRegistry.ts @@ -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), + }); +} diff --git a/client/src/api/projects.ts b/client/src/api/projects.ts new file mode 100644 index 00000000..9733557b --- /dev/null +++ b/client/src/api/projects.ts @@ -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) { + 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(`/api/projects/${projectId}/expenses/summary`); +} + +export async function fetchProjectTravelSummary(projectId: string) { + return apiRequestData(`/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 | 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), + }); +} diff --git a/client/src/api/reimbursements.ts b/client/src/api/reimbursements.ts index 0c350962..e7d92c7a 100644 --- a/client/src/api/reimbursements.ts +++ b/client/src/api/reimbursements.ts @@ -48,6 +48,11 @@ export type ReimbursementListItem = { employeeNo: string; name: string; } | null; + project: { + id: string; + projectNo: string; + name: string; + } | null; createdAt: string | null; updatedAt: string | null; }; @@ -86,6 +91,12 @@ export type ReimbursementDetail = { reimbursementDate: string | null; totalAmount: string | null; description: string | null; + projectId: string | null; + project: { + id: string; + projectNo: string; + name: string; + } | null; owner: { id: 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 }), + }); +} diff --git a/client/src/api/todos.ts b/client/src/api/todos.ts index d0791261..3379470d 100644 --- a/client/src/api/todos.ts +++ b/client/src/api/todos.ts @@ -14,6 +14,8 @@ export type TodoItem = { priority: TodoPriority; status: TodoStatus; source: TodoSource; + projectId: string | null; + project: { id: string; projectNo: string; name: string } | null; createdAt: string | null; updatedAt: string | null; completedAt: string | null; @@ -43,6 +45,7 @@ export async function createTodo(payload: { dueDate?: string | null; priority?: TodoPriority; source?: TodoSource; + projectId?: string | null; }) { return apiRequestData<{ item: TodoItem }>("/api/todos", { method: "POST", @@ -57,6 +60,7 @@ export async function patchTodo(id: string, payload: Partial<{ dueDate: string | null; priority: TodoPriority; status: TodoStatus; + projectId: string | null; }>) { return apiRequestData<{ item: TodoItem }>(`/api/todos/${id}`, { method: "PATCH", diff --git a/client/src/api/travelRequests.ts b/client/src/api/travelRequests.ts index 2a5765f4..73635709 100644 --- a/client/src/api/travelRequests.ts +++ b/client/src/api/travelRequests.ts @@ -20,6 +20,12 @@ export type TravelRequestSummary = { startDate: string | null; endDate: string | null; estimatedAmount: string; + projectId: string | null; + project: { + id: string; + projectNo: string; + name: string; + } | null; status: TravelRequestStatus; createdAt: string | null; updatedAt: string | null; @@ -233,3 +239,11 @@ export async function withdrawTravelRequest(travelRequestId: string) { 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 }), + }); +} diff --git a/client/src/api/types.ts b/client/src/api/types.ts index b03af272..0f0305c0 100644 --- a/client/src/api/types.ts +++ b/client/src/api/types.ts @@ -32,6 +32,10 @@ export type MeResponse = { directManagerId: string | null; status: string; roleCodes: string[]; + wecomBinding?: { + externalUserId: string; + createdAt: string | null; + } | null; }; export type AuthState = { diff --git a/client/src/api/weeklyReports.ts b/client/src/api/weeklyReports.ts index a6ecff5e..f67f1e07 100644 --- a/client/src/api/weeklyReports.ts +++ b/client/src/api/weeklyReports.ts @@ -1,5 +1,14 @@ 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 = { id: string; employeeId: string; @@ -12,6 +21,7 @@ export type WeeklyReport = { submittedAt: string | null; createdAt: string | null; updatedAt: string | null; + projectLinks: WeeklyReportProjectLink[]; }; export type WeeklyReportTeamItem = { @@ -44,6 +54,7 @@ export async function updateMyWeeklyReport(payload: { blockersText?: string; nextWeekText?: string; needsHelp?: boolean; + projectLinks?: Array<{ projectId: string; progressText?: string; riskText?: string; nextStepText?: string }>; }) { return apiRequestData<{ item: WeeklyReport }>("/api/weekly-reports/me", { method: "PUT", diff --git a/client/src/auth/permissions.ts b/client/src/auth/permissions.ts index 58fdb17d..96bd448a 100644 --- a/client/src/auth/permissions.ts +++ b/client/src/auth/permissions.ts @@ -29,6 +29,7 @@ const TRAVEL_SELF_SERVICE_PERMISSIONS = [ ] 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_MANAGE_PERMISSIONS = ["masterdata.manage"] as const; @@ -37,6 +38,7 @@ const EMPLOYEE_BASE_PERMISSIONS = [ ...EXPENSE_SELF_SERVICE_PERMISSIONS, ...LEAVE_SELF_SERVICE_PERMISSIONS, ...TRAVEL_SELF_SERVICE_PERMISSIONS, + ...PROJECT_SELF_SERVICE_PERMISSIONS, ] as const; const ROLE_PERMISSIONS: Record = { @@ -58,8 +60,13 @@ const ROLE_PERMISSIONS: Record = { "travel.view_all", "asset.view_all", "report.dashboard.view", + "project.view_all", "budget.view", + "project.view_all", + "project.manage", "budget.manage", + "project.view_all", + "project.manage", ], ceo: [ ...EMPLOYEE_BASE_PERMISSIONS, @@ -122,6 +129,7 @@ const ROLE_PERMISSIONS: Record = { "system.audit.view", "payment.batch.manage", "risk.review.manage", + "project.view_all", ], administration: [ "expense.proxy_submit", @@ -134,6 +142,8 @@ const ROLE_PERMISSIONS: Record = { "asset.view_all", "asset.manage", "asset.transfer", + "project.view_all", + "project.manage", ], hr: [ ...LEAVE_SELF_SERVICE_PERMISSIONS, diff --git a/client/src/components/navigation/NavLinks.tsx b/client/src/components/navigation/NavLinks.tsx index f3704aa2..513fa4f3 100644 --- a/client/src/components/navigation/NavLinks.tsx +++ b/client/src/components/navigation/NavLinks.tsx @@ -21,6 +21,8 @@ export const NAV_ITEMS: NavItem[] = [ { to: "/weekly-reports", label: "周报", icon: "article" }, { to: "/todos", label: "待办", icon: "check_circle" }, { 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: "/people", label: "人员管理", icon: "group", anyOf: ["system.users", "report.dashboard.view"] }, { 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 })); return { - main: mapItems(["/portal", "/team", "/leave-requests", "/reimbursements", "/assets", "/weekly-reports", "/todos", "/announcements"]), - admin: mapItems(["/system/workspace", "/people", "/system/workflow-definitions", "/system/settings", "/system/feature-flags", "/audit-logs"]), + main: mapItems(["/portal", "/team", "/leave-requests", "/reimbursements", "/assets", "/weekly-reports", "/todos", "/announcements", "/projects"]), + 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"]), }; } diff --git a/client/src/pages/ContractsRegistryPage.tsx b/client/src/pages/ContractsRegistryPage.tsx new file mode 100644 index 00000000..55e6a637 --- /dev/null +++ b/client/src/pages/ContractsRegistryPage.tsx @@ -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("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 ( +
+ + 返回项目 + + } + /> + +
+
+

新建合同台账

+
+ + setContractNo(event.target.value)} placeholder="例如 HT-2026-001" /> +
+
+ + +
+
+ + setCounterpartyName(event.target.value)} placeholder="合同对方名称" /> +
+
+ +