PMFlow/frontend/src/pages/WorkloadsPage.tsx

415 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMemo, useState } from "react";
import { Link } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, Download } from "lucide-react";
import {
applyAIOptimizePlan,
getAIOptimizePreview,
getTeamWorkloads,
type AIOptimizeProposal,
} from "@/api/workloads";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { RoleBadge } from "@/components/ui/RoleBadge";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { useAuthStore } from "@/stores/auth";
import { canManageTeamWorkload } from "@/lib/rbac";
import { useToast } from "@/components/ui/toast";
function ratioClass(value: number): string {
if (value > 100) return "text-red-500";
if (value >= 60) return "text-yellow-500";
return "text-green-500";
}
function progressClass(value: number): string {
if (value > 100) return "bg-red-500";
if (value >= 60) return "bg-yellow-400";
return "bg-green-400";
}
export default function WorkloadsPage() {
const queryClient = useQueryClient();
const user = useAuthStore((s) => s.user);
const canOptimize = canManageTeamWorkload(user?.role);
const { toast } = useToast();
const [projectId, setProjectId] = useState("");
const [range, setRange] = useState<"week" | "month" | "custom">("week");
const [customFrom, setCustomFrom] = useState("");
const [customTo, setCustomTo] = useState("");
const [optimizeOpen, setOptimizeOpen] = useState(false);
const [optimizeProposals, setOptimizeProposals] = useState<AIOptimizeProposal[]>([]);
const { data, isLoading, error } = useQuery({
queryKey: ["workloads", "team", projectId],
queryFn: () => getTeamWorkloads({ project_id: projectId ? Number(projectId) : undefined }),
refetchInterval: 15000,
});
const items = data?.items ?? [];
const summary = data?.summary;
const hasOverloaded = (summary?.overloaded_count ?? 0) > 0;
const hasQueryError = !!error;
const exportRows = useMemo(
() =>
items.map((r) => ({
member: r.user_name || r.username,
role: String(r.role || ""),
assigned_hours: r.current_assigned_hours,
max_weekly_hours: r.max_weekly_hours,
workload_ratio: r.workload_ratio,
remaining_hours: r.remaining_hours,
})),
[items]
);
function exportExcelLike() {
const headers = ["成员", "角色", "已分配工时", "每周上限", "负载比例%", "剩余工时"];
const lines = [
headers.join("\t"),
...exportRows.map((row) =>
[
row.member,
row.role,
row.assigned_hours.toFixed(1),
row.max_weekly_hours.toFixed(1),
row.workload_ratio.toFixed(1),
row.remaining_hours.toFixed(1),
].join("\t")
),
];
const blob = new Blob([`\ufeff${lines.join("\n")}`], { type: "application/vnd.ms-excel;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `团队负载报告-${new Date().toISOString().slice(0, 10)}.xls`;
a.click();
URL.revokeObjectURL(url);
}
function exportPdfLike() {
const htmlRows = exportRows
.map(
(r) => `<tr><td>${r.member}</td><td>${r.role}</td><td>${r.assigned_hours.toFixed(1)}</td><td>${r.max_weekly_hours.toFixed(1)}</td><td>${r.workload_ratio.toFixed(1)}%</td><td>${r.remaining_hours.toFixed(1)}</td></tr>`
)
.join("");
const win = window.open("", "_blank");
if (!win) return;
win.document.write(`
<html>
<head><title>团队负载报告</title></head>
<body>
<h2>团队负载报告</h2>
<table border="1" cellspacing="0" cellpadding="6">
<thead><tr><th>成员</th><th>角色</th><th>已分配工时</th><th>每周上限</th><th>负载比例%</th><th>剩余工时</th></tr></thead>
<tbody>${htmlRows}</tbody>
</table>
</body>
</html>
`);
win.document.close();
win.focus();
win.print();
}
const optimizePreviewMutation = useMutation({
mutationFn: () =>
getAIOptimizePreview({
project_id: projectId ? Number(projectId) : undefined,
max_moves: 30,
}),
onSuccess: (res) => {
setOptimizeProposals(res.proposals ?? []);
setOptimizeOpen(true);
toast({ title: `已生成 ${res.total ?? 0} 条优化建议`, variant: "success" });
},
onError: () => {
toast({ title: "生成优化预览失败", variant: "error" });
},
});
const optimizeApplyMutation = useMutation({
mutationFn: (proposals: AIOptimizeProposal[]) => applyAIOptimizePlan(proposals),
onSuccess: (res) => {
setOptimizeOpen(false);
queryClient.invalidateQueries({ queryKey: ["workloads"] });
toast({ title: `已应用 ${res.applied_count} 条优化`, variant: "success" });
},
onError: () => {
toast({ title: "应用优化失败", variant: "error" });
},
});
return (
<div>
<div className="mb-6 flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">团队工作负载</h1>
<p className="text-muted-foreground">查看成员负载比例、剩余工时与过载告警</p>
</div>
<Button
disabled={!canOptimize || optimizePreviewMutation.isPending}
onClick={() => optimizePreviewMutation.mutate()}
className="w-full md:w-auto"
>
AI 优化任务分配
</Button>
</div>
<div className="mb-4 rounded-xl bg-gray-50 p-4">
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
<div>
<p className="text-sm text-gray-500">团队均值</p>
<p className="text-2xl font-bold">
{isLoading || hasQueryError ? "--" : `${(summary?.avg_ratio ?? 0).toFixed(1)}%`}
</p>
</div>
<div>
<p className="text-sm text-gray-500">闲置</p>
<p className="text-2xl font-bold">{isLoading || hasQueryError ? "--" : `${summary?.idle_count ?? 0}人`}</p>
</div>
<div>
<p className="text-sm text-gray-500">正常</p>
<p className="text-2xl font-bold">{isLoading || hasQueryError ? "--" : `${summary?.normal_count ?? 0}人`}</p>
</div>
<div>
<p className="text-sm text-gray-500">过载</p>
<p className="text-2xl font-bold text-red-500">
{isLoading || hasQueryError ? "--" : `${summary?.overloaded_count ?? 0}人`}
</p>
</div>
</div>
</div>
{hasOverloaded && (
<div className="mb-4 flex h-9 items-center rounded-md border-l-4 border-orange-400 bg-orange-50 px-3 text-sm text-orange-700">
<AlertTriangle className="mr-2 h-4 w-4 shrink-0" />
<span>{summary?.overloaded_count ?? 0}名成员负载超过100%,建议立即执行任务迁移</span>
</div>
)}
{hasQueryError && (
<div className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
负载数据请求失败,请稍后重试。
</div>
)}
<div className="mb-6 flex flex-col gap-3 rounded-lg border border-slate-200 bg-white p-3 sm:flex-row sm:items-center sm:justify-between">
<div className="w-full sm:w-auto">
<Input
className="w-full sm:w-40"
placeholder="项目编号搜索"
value={projectId}
onChange={(e) => setProjectId(e.target.value)}
/>
<div className="mt-2 overflow-x-auto [-webkit-overflow-scrolling:touch]">
<div className="flex w-max items-center gap-2 whitespace-nowrap pr-1">
<select
className="h-10 min-w-[96px] rounded-md border border-input bg-background px-3 py-2 text-sm"
value={range}
onChange={(e) => setRange(e.target.value as "week" | "month" | "custom")}
>
<option value="week">本周</option>
<option value="month">本月</option>
<option value="custom">自定义</option>
</select>
{range === "custom" && (
<>
<Input
type="date"
className="w-[168px]"
value={customFrom}
onChange={(e) => setCustomFrom(e.target.value)}
/>
<Input
type="date"
className="w-[168px]"
value={customTo}
onChange={(e) => setCustomTo(e.target.value)}
/>
</>
)}
</div>
</div>
</div>
<div className="overflow-x-auto [-webkit-overflow-scrolling:touch]">
<div className="flex w-max items-center gap-2 whitespace-nowrap pr-1">
<Button variant="outline" onClick={exportExcelLike}>
<Download className="mr-2 h-4 w-4" />
导出 Excel
</Button>
<Button variant="outline" onClick={exportPdfLike}>
<Download className="mr-2 h-4 w-4" />
导出 PDF
</Button>
</div>
</div>
</div>
<Card>
<CardHeader>
<CardTitle>团队负载明细</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<p className="py-8 text-center text-muted-foreground">加载中…</p>
) : error ? (
<p className="py-8 text-center text-destructive">加载失败:{String(error)}</p>
) : items.length === 0 ? (
<p className="py-8 text-center text-muted-foreground">暂无数据</p>
) : (
<>
<div className="hidden overflow-x-auto [-webkit-overflow-scrolling:touch] md:block">
<Table className="min-w-[720px]">
<TableHeader>
<TableRow>
<TableHead className="min-w-[140px] whitespace-nowrap">成员</TableHead>
<TableHead className="min-w-[160px] whitespace-nowrap">主要角色</TableHead>
<TableHead className="min-w-[200px] whitespace-nowrap">负载进度条</TableHead>
<TableHead className="min-w-[90px] whitespace-nowrap text-right">比例</TableHead>
<TableHead className="min-w-[80px] whitespace-nowrap">操作</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((row) => (
<TableRow key={row.user_id} className={row.workload_ratio > 100 ? "bg-red-50/40 dark:bg-red-900/10" : ""}>
<TableCell className="min-w-[140px]">{row.user_name || row.username}</TableCell>
<TableCell className="min-w-[160px]">
<RoleBadge roles={[row.role]} />
</TableCell>
<TableCell className="min-w-[200px]">
<div className="flex items-center gap-2">
<div className="h-[6px] w-[120px] overflow-hidden rounded-full bg-slate-200">
<div
className={`h-[6px] rounded-full ${progressClass(row.workload_ratio)}`}
style={{ width: `${Math.min(Math.max(row.workload_ratio, 0), 100)}%` }}
/>
</div>
<span className="text-sm text-slate-600">{row.workload_ratio.toFixed(1)}%</span>
</div>
</TableCell>
<TableCell className={`min-w-[90px] whitespace-nowrap text-right font-semibold ${ratioClass(row.workload_ratio)}`}>
{row.workload_ratio.toFixed(1)}%
</TableCell>
<TableCell className="min-w-[80px] whitespace-nowrap">
<Link to={`/workloads/${row.user_id}`} className="text-primary hover:underline">
查看详情
</Link>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
<div className="space-y-3 md:hidden">
{items.map((row) => (
<article
key={row.user_id}
className={`rounded-lg border bg-white p-4 ${row.workload_ratio > 100 ? "border-red-200 bg-red-50/30" : ""}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="text-base font-semibold">{row.user_name || row.username}</p>
<div className="mt-2">
<RoleBadge roles={[row.role]} className="max-w-full" />
</div>
</div>
<span className={`text-sm font-semibold ${ratioClass(row.workload_ratio)}`}>
{row.workload_ratio.toFixed(1)}%
</span>
</div>
<div className="mt-3">
<div className="h-[8px] w-full overflow-hidden rounded-full bg-slate-200">
<div
className={`h-[8px] rounded-full ${progressClass(row.workload_ratio)}`}
style={{ width: `${Math.min(Math.max(row.workload_ratio, 0), 100)}%` }}
/>
</div>
</div>
<div className="mt-3 flex items-center justify-between text-sm">
<span className="text-slate-500">剩余工时 {(row.remaining_hours ?? 0).toFixed(1)}h</span>
<Link to={`/workloads/${row.user_id}`} className="text-primary hover:underline">
查看详情
</Link>
</div>
</article>
))}
</div>
</>
)}
</CardContent>
</Card>
<Dialog open={optimizeOpen} onOpenChange={setOptimizeOpen}>
<DialogContent className="max-w-3xl">
<DialogHeader>
<DialogTitle>任务分配优化预览</DialogTitle>
<DialogDescription>预览 旧负责人 → 新负责人,确认后批量应用。</DialogDescription>
</DialogHeader>
<div className="max-h-[420px] overflow-auto rounded-md border">
<Table className="min-w-[900px]">
<TableHeader>
<TableRow>
<TableHead>任务</TableHead>
<TableHead>旧负责人</TableHead>
<TableHead>新负责人</TableHead>
<TableHead>旧负载%</TableHead>
<TableHead>新负载%</TableHead>
<TableHead>原因</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{optimizeProposals.map((row) => (
<TableRow key={`${row.task_id}-${row.new_assignee_id}`}>
<TableCell>{row.task_title}</TableCell>
<TableCell>{row.old_assignee_id}</TableCell>
<TableCell>{row.new_assignee_id}</TableCell>
<TableCell>{row.old_ratio.toFixed(1)}</TableCell>
<TableCell>{row.new_ratio.toFixed(1)}</TableCell>
<TableCell>{row.reason}</TableCell>
</TableRow>
))}
{optimizeProposals.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="py-8 text-center text-muted-foreground">
当前无可优化分配项
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOptimizeOpen(false)}>
取消
</Button>
<Button
disabled={!optimizeProposals.length || optimizeApplyMutation.isPending}
onClick={() => optimizeApplyMutation.mutate(optimizeProposals)}
>
{optimizeApplyMutation.isPending ? "应用中…" : "确认并应用"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}