415 lines
17 KiB
TypeScript
415 lines
17 KiB
TypeScript
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>
|
||
);
|
||
}
|