R19/R20: 项目主域进 OA 并打通报销差旅周报待办

这个提交包含在:
2026-03-22 08:34:18 +08:00
父节点 0235ccb968
当前提交 3f9738ac9c
共修改 28 个文件,包含 3236 行新增和 21 行删除
+234
查看文件
@@ -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;
+760
查看文件
@@ -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;
+267
查看文件
@@ -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 { fetchExpenseTypes } from "../api/expenseTypes";
import { exportReimbursementJournal, previewReimbursementJournal } from "../api/integrations";
import { fetchProjects } from "../api/projects";
import {
createReimbursementItem,
deleteReimbursementItem,
@@ -14,6 +15,7 @@ import {
submitReimbursement,
updateReimbursementItem,
withdrawReimbursement,
linkReimbursementProject,
type ReimbursementItem,
} from "../api/reimbursements";
import { toast } from "sonner";
@@ -69,6 +71,10 @@ export function ReimbursementDetailPage() {
queryFn: () => fetchExpenseTypes({ enabled: true }),
});
const projectsQuery = useQuery({
queryKey: ["projects", "reimbursement-link"],
queryFn: () => fetchProjects({ mine: true }),
});
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({
mutationFn: async () => {
if (!reimbursementId) {
@@ -383,6 +410,29 @@ export function ReimbursementDetailPage() {
<div>说明:{detail.description || "-"}</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 ? (
<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>
+1
查看文件
@@ -284,6 +284,7 @@ export function ReimbursementsPage() {
{item.formNumber && item.formNumber !== item.id && (
<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 className="table-td"><StatusBadge status={item.status} /></td>
<td className="table-td-strong">
+20 -3
查看文件
@@ -1,6 +1,7 @@
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { cancelTodo, completeTodo, createTodo, fetchTodos } from "../api/todos";
import { fetchProjects } from "../api/projects";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
import { Input } from "../components/ui/input";
@@ -20,17 +21,23 @@ export function TodosPage() {
const queryClient = useQueryClient();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [projectId, setProjectId] = useState("");
const [statusFilter, setStatusFilter] = useState<"todo" | "doing" | "done" | "cancelled" | "">("");
const todosQuery = useQuery({
queryKey: ["todos", statusFilter],
queryFn: () => fetchTodos({ status: statusFilter || undefined }),
});
const projectsQuery = useQuery({
queryKey: ["projects", "todo-options"],
queryFn: () => fetchProjects({}),
});
const createMutation = useMutation({
mutationFn: () => createTodo({ title, description: description || undefined }),
mutationFn: () => createTodo({ title, description: description || undefined, projectId: projectId || null }),
onSuccess: async () => {
setTitle("");
setDescription("");
setProjectId("");
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["todos"] }),
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
@@ -66,12 +73,20 @@ export function TodosPage() {
return (
<div className="space-y-6">
<PageHeader title="待办" subtitle="员工维护个人待办,老板看高优先级和未完成项。" />
<PageHeader title="待办" subtitle="员工维护个人待办,老板看高优先级和未完成项。支持直接挂到项目。" />
<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={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">
<select className="select-std" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as typeof statusFilter)}>
<option value="">全部状态</option>
@@ -90,6 +105,7 @@ export function TodosPage() {
<TableHeader>
<TableRow>
<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="text-xs text-zinc-500">{item.description ?? "-"}</div>
</TableCell>
<TableCell>{item.project ? `${item.project.projectNo} · ${item.project.name}` : "-"}</TableCell>
<TableCell>{item.assignee?.name ?? "-"}</TableCell>
<TableCell>{item.priority}</TableCell>
<TableCell>{item.status}</TableCell>
+46
查看文件
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, type Dispatch, type SetStateAction } from
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { toast } from "sonner";
import { fetchProjects } from "../api/projects";
import { mapApiErrorMessage } from "../api/errorMessages";
import { isApiError } from "../api/types";
import {
@@ -12,6 +13,7 @@ import {
submitTravelRequest,
updateTravelRequest,
withdrawTravelRequest,
linkTravelRequestProject,
type TravelRequestDetail,
type TravelRequestPayload,
type TravelRequestStatus,
@@ -250,6 +252,12 @@ export function TravelRequestsPage() {
queryFn: () => fetchTravelRequestDetail(selectedId),
});
const projectsQuery = useQuery({
enabled: canAccessRequestsTab && activeTab === "requests",
queryKey: ["projects", "travel-link"],
queryFn: () => fetchProjects({ mine: true }),
});
const selectableCostCenters = useMemo(() => {
return (costCentersQuery.data?.items ?? []).filter((item) => {
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({
mutationFn: async () => {
validateForm();
@@ -650,6 +672,7 @@ export function TravelRequestsPage() {
<div>{tripTypeLabel(item.tripType)} · {item.startDate ?? "-"} → {item.endDate ?? "-"}</div>
<div>预估金额:{item.estimatedAmount}</div>
<div>申请人:{item.applicant?.name ?? "-"}</div>
{item.project ? <div>项目:{item.project.projectNo} · {item.project.name}</div> : null}
</div>
</button>
))
@@ -756,6 +779,29 @@ export function TravelRequestsPage() {
<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>
<p className="text-zinc-500">申请人</p>
+64 -3
查看文件
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
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 { hasAnyPermission } from "../auth/permissions";
import { PageHeader } from "../components/common/PageHeader";
@@ -19,6 +20,26 @@ function errorMessage(error: unknown, fallback: string) {
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() {
const queryClient = useQueryClient();
const { permissions } = useAuth();
@@ -26,9 +47,11 @@ export function WeeklyReportsPage() {
const [weekStart, setWeekStart] = useState("");
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 projectsQuery = useQuery({ queryKey: ["projects", "weekly-links"], queryFn: () => fetchProjects({ mine: true }) });
const report = reportQuery.data?.item;
const [draft, setDraft] = useState({ highlightsText: "", blockersText: "", nextWeekText: "", needsHelp: false });
const [projectLinks, setProjectLinks] = useState<DraftProjectLink[]>([emptyProjectLink()]);
useEffect(() => {
if (!report) return;
@@ -38,15 +61,26 @@ export function WeeklyReportsPage() {
nextWeekText: report.nextWeekText,
needsHelp: report.needsHelp,
});
setProjectLinks(report.projectLinks.length ? report.projectLinks.map(mapProjectLink) : [emptyProjectLink()]);
}, [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({
mutationFn: () => updateMyWeeklyReport({ weekStart: weekStart || undefined, ...draft }),
mutationFn: () => updateMyWeeklyReport({ weekStart: weekStart || undefined, ...draft, projectLinks: normalizedProjectLinks }),
onSuccess: async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["weekly-report", "me"] }),
queryClient.invalidateQueries({ queryKey: ["weekly-report", "team"] }),
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
queryClient.invalidateQueries({ queryKey: ["projects"] }),
]);
toast.success("周报已保存");
},
@@ -72,6 +106,7 @@ export function WeeklyReportsPage() {
description: draft.nextWeekText || "来自周报的下周计划",
source: "weekly_report",
priority: draft.needsHelp ? "high" : "normal",
projectId: normalizedProjectLinks[0]?.projectId ?? null,
}),
onSuccess: async () => {
await Promise.all([
@@ -85,7 +120,7 @@ export function WeeklyReportsPage() {
return (
<div className="space-y-6">
<PageHeader title="周报" subtitle="固定三段结构:本周完成、当前阻塞、下周计划。" />
<PageHeader title="周报" subtitle="固定三段结构:本周完成、当前阻塞、下周计划,可按项目拆分进展。" />
<div className="card-std p-6 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
@@ -111,7 +146,31 @@ export function WeeklyReportsPage() {
需要老板/管理员协助
</label>
</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">
<Button type="button" variant="secondary" onClick={() => createTodoMutation.mutate()} disabled={createTodoMutation.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>
</TableRow>
</TableHeader>
@@ -142,6 +202,7 @@ export function WeeklyReportsPage() {
<TableCell>{item.employee.name}</TableCell>
<TableCell>{item.report?.status ?? "missing"}</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>
</TableRow>
))}