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([]); 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) => `${r.member}${r.role}${r.assigned_hours.toFixed(1)}${r.max_weekly_hours.toFixed(1)}${r.workload_ratio.toFixed(1)}%${r.remaining_hours.toFixed(1)}` ) .join(""); const win = window.open("", "_blank"); if (!win) return; win.document.write(` 团队负载报告

团队负载报告

${htmlRows}
成员角色已分配工时每周上限负载比例%剩余工时
`); 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 (

团队工作负载

查看成员负载比例、剩余工时与过载告警

团队均值

{isLoading || hasQueryError ? "--" : `${(summary?.avg_ratio ?? 0).toFixed(1)}%`}

闲置

{isLoading || hasQueryError ? "--" : `${summary?.idle_count ?? 0}人`}

正常

{isLoading || hasQueryError ? "--" : `${summary?.normal_count ?? 0}人`}

过载

{isLoading || hasQueryError ? "--" : `${summary?.overloaded_count ?? 0}人`}

{hasOverloaded && (
{summary?.overloaded_count ?? 0}名成员负载超过100%,建议立即执行任务迁移
)} {hasQueryError && (
负载数据请求失败,请稍后重试。
)}
setProjectId(e.target.value)} />
{range === "custom" && ( <> setCustomFrom(e.target.value)} /> setCustomTo(e.target.value)} /> )}
团队负载明细 {isLoading ? (

加载中…

) : error ? (

加载失败:{String(error)}

) : items.length === 0 ? (

暂无数据

) : ( <>
成员 主要角色 负载进度条 比例 操作 {items.map((row) => ( 100 ? "bg-red-50/40 dark:bg-red-900/10" : ""}> {row.user_name || row.username}
{row.workload_ratio.toFixed(1)}%
{row.workload_ratio.toFixed(1)}% 查看详情 ))}
{items.map((row) => (
100 ? "border-red-200 bg-red-50/30" : ""}`} >

{row.user_name || row.username}

{row.workload_ratio.toFixed(1)}%
剩余工时 {(row.remaining_hours ?? 0).toFixed(1)}h 查看详情
))}
)}
任务分配优化预览 预览 旧负责人 → 新负责人,确认后批量应用。
任务 旧负责人 新负责人 旧负载% 新负载% 原因 {optimizeProposals.map((row) => ( {row.task_title} {row.old_assignee_id} {row.new_assignee_id} {row.old_ratio.toFixed(1)} {row.new_ratio.toFixed(1)} {row.reason} ))} {optimizeProposals.length === 0 && ( 当前无可优化分配项 )}
); }