文件
hyb-oa/client/src/pages/ProjectsPage.tsx
T

268 行
11 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 { 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;