feat: 完成 R13/R14/R15/R16 小团队管理核心能力
这个提交包含在:
@@ -0,0 +1,156 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { createTodo } from "../api/todos";
|
||||
import { fetchTeamWeeklyReports, fetchMyWeeklyReport, submitMyWeeklyReport, updateMyWeeklyReport } from "../api/weeklyReports";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission } from "../auth/permissions";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
import { toast } from "sonner";
|
||||
import { isApiError } from "../api/types";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
|
||||
function errorMessage(error: unknown, fallback: string) {
|
||||
if (isApiError(error)) {
|
||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
}
|
||||
return error instanceof Error ? error.message : fallback;
|
||||
}
|
||||
|
||||
export function WeeklyReportsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions } = useAuth();
|
||||
const canViewTeam = hasAnyPermission(permissions, ["report.dashboard.view", "system.users"]);
|
||||
const [weekStart, setWeekStart] = useState("");
|
||||
const reportQuery = useQuery({ queryKey: ["weekly-report", "me", weekStart], queryFn: () => fetchMyWeeklyReport(weekStart || undefined) });
|
||||
const teamQuery = useQuery({ enabled: canViewTeam, queryKey: ["weekly-report", "team", weekStart], queryFn: () => fetchTeamWeeklyReports(weekStart || undefined) });
|
||||
|
||||
const report = reportQuery.data?.item;
|
||||
const [draft, setDraft] = useState({ highlightsText: "", blockersText: "", nextWeekText: "", needsHelp: false });
|
||||
|
||||
useEffect(() => {
|
||||
if (!report) return;
|
||||
setDraft({
|
||||
highlightsText: report.highlightsText,
|
||||
blockersText: report.blockersText,
|
||||
nextWeekText: report.nextWeekText,
|
||||
needsHelp: report.needsHelp,
|
||||
});
|
||||
}, [report]);
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: () => updateMyWeeklyReport({ weekStart: weekStart || undefined, ...draft }),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["weekly-report", "me"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["weekly-report", "team"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
]);
|
||||
toast.success("周报已保存");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "保存失败")),
|
||||
});
|
||||
|
||||
const submitMutation = useMutation({
|
||||
mutationFn: () => submitMyWeeklyReport({ weekStart: weekStart || undefined }),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["weekly-report", "me"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["weekly-report", "team"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
]);
|
||||
toast.success("周报已提交");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "提交失败")),
|
||||
});
|
||||
|
||||
const createTodoMutation = useMutation({
|
||||
mutationFn: () => createTodo({
|
||||
title: `${report?.weekStart ?? "本周"} 周计划`,
|
||||
description: draft.nextWeekText || "来自周报的下周计划",
|
||||
source: "weekly_report",
|
||||
priority: draft.needsHelp ? "high" : "normal",
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["todos"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
||||
]);
|
||||
toast.success("已从周报生成待办");
|
||||
},
|
||||
onError: (error) => toast.error(errorMessage(error, "生成待办失败")),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="周报" subtitle="固定三段结构:本周完成、当前阻塞、下周计划。" />
|
||||
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="text-sm text-zinc-500">选择周起始日(默认自动按周一归档)</div>
|
||||
<input className="input-std" type="date" value={weekStart} onChange={(event) => setWeekStart(event.target.value)} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4">
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">本周完成</label>
|
||||
<Textarea value={draft.highlightsText} onChange={(event) => setDraft((prev) => ({ ...prev, highlightsText: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">当前阻塞</label>
|
||||
<Textarea value={draft.blockersText} onChange={(event) => setDraft((prev) => ({ ...prev, blockersText: event.target.value }))} />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<label className="text-sm text-zinc-500">下周计划</label>
|
||||
<Textarea value={draft.nextWeekText} onChange={(event) => setDraft((prev) => ({ ...prev, nextWeekText: event.target.value }))} />
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-700">
|
||||
<input type="checkbox" checked={draft.needsHelp} onChange={(event) => setDraft((prev) => ({ ...prev, needsHelp: event.target.checked }))} />
|
||||
需要老板/管理员协助
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button type="button" variant="secondary" onClick={() => createTodoMutation.mutate()} disabled={createTodoMutation.isPending}>转成待办</Button>
|
||||
<Button type="button" variant="secondary" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>保存草稿</Button>
|
||||
<Button type="button" onClick={() => submitMutation.mutate()} disabled={submitMutation.isPending}>提交周报</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canViewTeam ? (
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-zinc-900">团队汇总</h3>
|
||||
<div className="text-sm text-zinc-500">
|
||||
未提交 {teamQuery.data?.summary.missingCount ?? 0} · 需协助 {teamQuery.data?.summary.needsHelpCount ?? 0}
|
||||
</div>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>成员</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>需协助</TableHead>
|
||||
<TableHead>摘要</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(teamQuery.data?.items ?? []).map((item) => (
|
||||
<TableRow key={item.employee.id}>
|
||||
<TableCell>{item.employee.name}</TableCell>
|
||||
<TableCell>{item.report?.status ?? "missing"}</TableCell>
|
||||
<TableCell>{item.report?.needsHelp ? "是" : "否"}</TableCell>
|
||||
<TableCell>{item.report?.highlightsText || item.report?.nextWeekText || item.report?.blockersText || "-"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WeeklyReportsPage;
|
||||
在新工单中引用
屏蔽一个用户