391 行
17 KiB
TypeScript
391 行
17 KiB
TypeScript
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 (
|
|
<ListWorkspace>
|
|
<WorkspaceHeader
|
|
title="项目"
|
|
actions={
|
|
<HeaderActionGroup>
|
|
{canManageProjects ? (
|
|
<Button type="button" onClick={() => setShowCreatePanel(true)}>
|
|
新建项目
|
|
</Button>
|
|
) : null}
|
|
</HeaderActionGroup>
|
|
}
|
|
/>
|
|
|
|
{showCreatePanel ? (
|
|
<SectionPanel title="新建项目">
|
|
<div className="space-y-4">
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<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>
|
|
<div className="grid gap-3 md:grid-cols-2">
|
|
<div className="space-y-2">
|
|
<Label>开始日期</Label>
|
|
<DateTextInput value={startsAt} onChange={(event) => setStartsAt(event.target.value)} />
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label>目标完成</Label>
|
|
<DateTextInput 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>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Button
|
|
type="button"
|
|
disabled={createMutation.isPending || !name.trim() || !clientName.trim()}
|
|
onClick={() => createMutation.mutate()}
|
|
>
|
|
{createMutation.isPending ? "创建中..." : "创建项目"}
|
|
</Button>
|
|
<Button type="button" variant="secondary" onClick={() => setShowCreatePanel(false)}>
|
|
取消
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</SectionPanel>
|
|
) : null}
|
|
|
|
<div className="space-y-4">
|
|
<BusinessListToolbar
|
|
actions={
|
|
<>
|
|
<Button type="button" size="sm" variant={mine ? "default" : "secondary"} onClick={() => updateSearch({ mine: !mine })}>
|
|
{mine ? "我相关" : "全范围"}
|
|
</Button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button type="button" size="sm" variant={showTrialData ? "default" : "secondary"}>
|
|
更多
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem asChild>
|
|
<Link to="/contracts-registry">查看合同</Link>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onSelect={() => setShowTrialData((value) => !value)}>
|
|
{showTrialData ? "隐藏测试数据" : "显示测试数据"}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</>
|
|
}
|
|
>
|
|
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr),180px]">
|
|
<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>
|
|
</div>
|
|
</BusinessListToolbar>
|
|
|
|
{!showTrialData && hiddenTrialProjectCount > 0 ? (
|
|
<WorkspaceNotice title={`已隐藏 ${hiddenTrialProjectCount} 条 TEST/SMOKE 项目`} tone="neutral">
|
|
试运行默认只展示正式项目;需要排障时可临时显示测试数据。
|
|
</WorkspaceNotice>
|
|
) : null}
|
|
|
|
{listQuery.isLoading ? <div className="text-sm text-zinc-500">项目加载中...</div> : null}
|
|
{listQuery.isError ? <div className="text-sm text-red-600">{resolveError(listQuery.error, "加载项目失败")}</div> : null}
|
|
|
|
<BusinessMobileList>
|
|
{visibleProjectItems.map((item) => (
|
|
<Link key={item.id} to={`/projects/${item.id}`} className="block rounded-lg border border-zinc-200 bg-white p-3 text-sm">
|
|
<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.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>目标完成:{formatDisplayDate(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 && !visibleProjectItems.length ? (
|
|
<div className="rounded-lg border border-zinc-200 bg-white p-4 text-sm text-zinc-500">暂无项目</div>
|
|
) : null}
|
|
</BusinessMobileList>
|
|
|
|
<BusinessTable testId="projects-table">
|
|
<thead className="table-header">
|
|
<tr>
|
|
<th className="table-th">项目</th>
|
|
<th className="table-th">客户</th>
|
|
<th className="table-th">负责人</th>
|
|
<th className="table-th">进度</th>
|
|
<th className="table-th">状态</th>
|
|
<th className="table-th w-28">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-zinc-50">
|
|
{visibleProjectItems.length ? (
|
|
visibleProjectItems.map((item) => (
|
|
<tr key={item.id} className="table-row">
|
|
<td className="table-td">
|
|
<div className="font-semibold text-zinc-900">{item.projectNo}</div>
|
|
<div className="text-xs text-zinc-500">{item.name}</div>
|
|
{item.isConfidential ? <div className="text-xs text-amber-700">涉密</div> : null}
|
|
</td>
|
|
<td className="table-td">
|
|
<div className="font-medium text-zinc-900">{item.clientAlias || item.clientName}</div>
|
|
<div className="line-clamp-2 text-xs text-zinc-500">{item.summary || "-"}</div>
|
|
</td>
|
|
<td className="table-td">
|
|
<div className="font-medium text-zinc-900">{item.owner?.name ?? "-"}</div>
|
|
<div className="text-xs text-zinc-500">项目经理 {item.manager?.name ?? "-"}</div>
|
|
</td>
|
|
<td className="table-td">
|
|
<div className="text-xs text-zinc-500">{summaryLine(item)}</div>
|
|
<div className="mt-1 text-xs text-zinc-500">目标 {formatDisplayDate(item.targetEndAt)}</div>
|
|
</td>
|
|
<td className="table-td">
|
|
<StatusBadge status={item.status} />
|
|
</td>
|
|
<td className="table-td">
|
|
<Button asChild type="button" size="sm" variant="secondary">
|
|
<Link to={`/projects/${item.id}`}>查看详情</Link>
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
))
|
|
) : (
|
|
<tr>
|
|
<td className="py-8 text-center text-sm text-zinc-500" colSpan={6}>
|
|
暂无项目
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</BusinessTable>
|
|
</div>
|
|
</ListWorkspace>
|
|
);
|
|
}
|
|
|
|
export default ProjectsPage;
|