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 { DateTextInput } from "../components/common/DateTextInput"; import { StatusBadge } from "../components/common/StatusBadge"; import { HeaderActionGroup, ListWorkspace, WorkspaceHeader, WorkspaceNotice } from "../components/patterns/ListWorkspace"; import { BusinessListToolbar, BusinessMobileList, BusinessTable, } from "../components/patterns/BusinessListTemplate"; import { SectionPanel } from "../components/patterns/SectionPanel"; import { Button } from "../components/ui/button"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "../components/ui/dropdown-menu"; import { Input } from "../components/ui/input"; import { Label } from "../components/ui/label"; import { Textarea } from "../components/ui/textarea"; import { formatDisplayDate } from "../lib/displayFormat"; import { hasTrialNoise } from "../lib/trialNoise"; 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") !== "0"; 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 [showTrialData, setShowTrialData] = useState(false); const [showCreatePanel, setShowCreatePanel] = 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 projectItems = listQuery.data?.items ?? []; const visibleProjectItems = useMemo(() => { const formalItems = showTrialData ? projectItems : projectItems.filter( (item) => !hasTrialNoise(item.projectNo, item.name, item.clientName, item.clientAlias, item.summary), ); return [...formalItems].sort((a, b) => { const score = (item: ProjectListItem) => { let value = 0; if (item.status === "active") value += 40; if (item.counts.openRisks > 0) value += 30; if (item.counts.overdueMilestones > 0) value += 20; if (item.counts.highPriorityTasks > 0) value += 10; return value; }; const scoreDelta = score(b) - score(a); if (scoreDelta !== 0) return scoreDelta; return (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""); }); }, [projectItems, showTrialData]); const hiddenTrialProjectCount = projectItems.length - visibleProjectItems.length; 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.delete("mine"); else params.set("mine", "0"); } setSearchParams(params, { replace: true }); } return ( {canManageProjects ? ( ) : null} } /> {showCreatePanel ? (
setName(event.target.value)} placeholder="例如:深圳某客户现场实施" />
setClientName(event.target.value)} placeholder="客户全称" />
setClientAlias(event.target.value)} placeholder="可选" />
setStartsAt(event.target.value)} />
setTargetEndAt(event.target.value)} />