258 行
14 KiB
TypeScript
258 行
14 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { createTodo } from "../api/todos";
|
|
import { fetchProjects } from "../api/projects";
|
|
import { fetchTeamWeeklyReports, fetchMyWeeklyReport, submitMyWeeklyReport, updateMyWeeklyReport, type WeeklyReportProjectLink } from "../api/weeklyReports";
|
|
import { useAuth } from "../auth/AuthContext";
|
|
import { hasAnyPermission } from "../auth/permissions";
|
|
import { PageHeader } from "../components/common/PageHeader";
|
|
import { FilterBar, ListWorkspace, PrimaryTable } from "../components/patterns/ListWorkspace";
|
|
import { MetricCard } from "../components/patterns/MetricCard";
|
|
import { SectionPanel } from "../components/patterns/SectionPanel";
|
|
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";
|
|
import { TEST_IDS } from "../lib/testIds";
|
|
|
|
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;
|
|
}
|
|
|
|
type DraftProjectLink = {
|
|
projectId: string;
|
|
progressText: string;
|
|
riskText: string;
|
|
nextStepText: string;
|
|
};
|
|
|
|
function emptyProjectLink(): DraftProjectLink {
|
|
return { projectId: "", progressText: "", riskText: "", nextStepText: "" };
|
|
}
|
|
|
|
function mapProjectLink(item: WeeklyReportProjectLink): DraftProjectLink {
|
|
return {
|
|
projectId: item.projectId,
|
|
progressText: item.progressText ?? "",
|
|
riskText: item.riskText ?? "",
|
|
nextStepText: item.nextStepText ?? "",
|
|
};
|
|
}
|
|
|
|
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 projectsQuery = useQuery({ queryKey: ["projects", "weekly-links"], queryFn: () => fetchProjects({ mine: true }) });
|
|
|
|
const report = reportQuery.data?.item;
|
|
const [draft, setDraft] = useState({ highlightsText: "", blockersText: "", nextWeekText: "", needsHelp: false });
|
|
const [projectLinks, setProjectLinks] = useState<DraftProjectLink[]>([emptyProjectLink()]);
|
|
|
|
useEffect(() => {
|
|
if (!report) return;
|
|
setDraft({
|
|
highlightsText: report.highlightsText,
|
|
blockersText: report.blockersText,
|
|
nextWeekText: report.nextWeekText,
|
|
needsHelp: report.needsHelp,
|
|
});
|
|
setProjectLinks(report.projectLinks.length ? report.projectLinks.map(mapProjectLink) : [emptyProjectLink()]);
|
|
}, [report]);
|
|
|
|
const normalizedProjectLinks = projectLinks
|
|
.filter((item) => item.projectId)
|
|
.map((item) => ({
|
|
projectId: item.projectId,
|
|
progressText: item.progressText.trim() || undefined,
|
|
riskText: item.riskText.trim() || undefined,
|
|
nextStepText: item.nextStepText.trim() || undefined,
|
|
}));
|
|
|
|
const linkedProjectCount = useMemo(
|
|
() => projectLinks.filter((item) => item.projectId).length,
|
|
[projectLinks],
|
|
);
|
|
|
|
const saveMutation = useMutation({
|
|
mutationFn: () => updateMyWeeklyReport({ weekStart: weekStart || undefined, ...draft, projectLinks: normalizedProjectLinks }),
|
|
onSuccess: async () => {
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ["weekly-report", "me"] }),
|
|
queryClient.invalidateQueries({ queryKey: ["weekly-report", "team"] }),
|
|
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
|
queryClient.invalidateQueries({ queryKey: ["projects"] }),
|
|
]);
|
|
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",
|
|
projectId: normalizedProjectLinks[0]?.projectId ?? null,
|
|
}),
|
|
onSuccess: async () => {
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ["todos"] }),
|
|
queryClient.invalidateQueries({ queryKey: ["portal-home"] }),
|
|
]);
|
|
toast.success("已从周报生成待办");
|
|
},
|
|
onError: (error) => toast.error(errorMessage(error, "生成待办失败")),
|
|
});
|
|
|
|
return (
|
|
<ListWorkspace>
|
|
<PageHeader title="周报" subtitle="固定三段结构:本周完成、当前阻塞、下周计划,可按项目拆分进展。" />
|
|
|
|
<FilterBar className="flex flex-wrap items-center justify-between gap-3">
|
|
<div className="space-y-1">
|
|
<div className="text-sm font-medium text-zinc-900">按周归档</div>
|
|
<div className="text-sm text-zinc-500">默认按周一归档;提交后会同步更新 Portal 与项目摘要。</div>
|
|
</div>
|
|
<input
|
|
aria-label="周报归档周"
|
|
className="input-std w-[220px]"
|
|
type="date"
|
|
value={weekStart}
|
|
onChange={(event) => setWeekStart(event.target.value)}
|
|
/>
|
|
</FilterBar>
|
|
|
|
<section className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
<MetricCard title="当前状态" value={report?.status === "submitted" ? "已提交" : "待完善"} note={report?.submittedAt ? `提交于 ${new Date(report.submittedAt).toLocaleString()}` : "保存后可继续补充"} tone={report?.status === "submitted" ? "neutral" : "warning"} />
|
|
<MetricCard title="关联项目" value={linkedProjectCount} note={`共 ${projectsQuery.data?.items.length ?? 0} 个我的项目可选`} />
|
|
<MetricCard title="需协助" value={draft.needsHelp ? "是" : "否"} note="勾选后会在管理首页高亮" tone={draft.needsHelp ? "warning" : "neutral"} />
|
|
<MetricCard title="团队缺口" value={canViewTeam ? (teamQuery.data?.summary.missingCount ?? 0) : "-"} note={canViewTeam ? `待协助 ${teamQuery.data?.summary.needsHelpCount ?? 0}` : "仅管理员可见"} tone={canViewTeam && (teamQuery.data?.summary.missingCount ?? 0) > 0 ? "warning" : "neutral"} />
|
|
</section>
|
|
|
|
<SectionPanel title="本周周报" description="先写本周完成,再补当前阻塞和下周计划。">
|
|
<div className="space-y-4">
|
|
{reportQuery.isLoading ? <div className="text-sm text-zinc-500">周报加载中...</div> : null}
|
|
{reportQuery.isError ? <div className="text-sm text-red-600">{errorMessage(reportQuery.error, "加载周报失败")}</div> : null}
|
|
<div className="grid gap-4">
|
|
<div className="grid gap-2">
|
|
<label className="text-sm text-zinc-500" htmlFor="weekly-report-highlights">本周完成</label>
|
|
<Textarea id="weekly-report-highlights" 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" htmlFor="weekly-report-blockers">当前阻塞</label>
|
|
<Textarea id="weekly-report-blockers" 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" htmlFor="weekly-report-next-week">下周计划</label>
|
|
<Textarea id="weekly-report-next-week" 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>
|
|
</SectionPanel>
|
|
|
|
<SectionPanel
|
|
title="项目关联"
|
|
description="项目进展、风险与下一步动作建议按项目拆开写。"
|
|
actions={<Button type="button" variant="secondary" onClick={() => setProjectLinks((prev) => [...prev, emptyProjectLink()])}>新增项目进展</Button>}
|
|
>
|
|
<div className="space-y-4">
|
|
{projectLinks.map((item, index) => (
|
|
<div key={`${item.projectId}-${index}`} className="rounded-lg border border-zinc-200 p-4 space-y-3">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<select className="select-std" value={item.projectId} onChange={(event) => setProjectLinks((prev) => prev.map((entry, entryIndex) => entryIndex === index ? { ...entry, projectId: event.target.value } : entry))}>
|
|
<option value="">选择项目</option>
|
|
{(projectsQuery.data?.items ?? []).map((project) => (
|
|
<option key={project.id} value={project.id}>{project.projectNo} · {project.name}</option>
|
|
))}
|
|
</select>
|
|
<Button type="button" variant="secondary" onClick={() => setProjectLinks((prev) => prev.length === 1 ? [emptyProjectLink()] : prev.filter((_, entryIndex) => entryIndex !== index))}>移除</Button>
|
|
</div>
|
|
<Textarea placeholder="本周项目进展" value={item.progressText} onChange={(event) => setProjectLinks((prev) => prev.map((entry, entryIndex) => entryIndex === index ? { ...entry, progressText: event.target.value } : entry))} />
|
|
<Textarea placeholder="项目风险 / 问题" value={item.riskText} onChange={(event) => setProjectLinks((prev) => prev.map((entry, entryIndex) => entryIndex === index ? { ...entry, riskText: event.target.value } : entry))} />
|
|
<Textarea placeholder="下一步动作" value={item.nextStepText} onChange={(event) => setProjectLinks((prev) => prev.map((entry, entryIndex) => entryIndex === index ? { ...entry, nextStepText: event.target.value } : entry))} />
|
|
</div>
|
|
))}
|
|
<div className="flex flex-wrap justify-end gap-2">
|
|
<Button data-testid={TEST_IDS.weeklyReport.createTodo} type="button" variant="secondary" onClick={() => createTodoMutation.mutate()} disabled={createTodoMutation.isPending}>转成待办</Button>
|
|
<Button data-testid={TEST_IDS.weeklyReport.save} type="button" variant="secondary" onClick={() => saveMutation.mutate()} disabled={saveMutation.isPending}>保存草稿</Button>
|
|
<Button data-testid={TEST_IDS.weeklyReport.submit} type="button" onClick={() => submitMutation.mutate()} disabled={submitMutation.isPending}>提交周报</Button>
|
|
</div>
|
|
</div>
|
|
</SectionPanel>
|
|
|
|
{canViewTeam ? (
|
|
<SectionPanel title="团队汇总" description="帮助老板和管理员先看到未提交与需协助人员。">
|
|
<div className="mb-4 grid gap-3 md:grid-cols-4">
|
|
<MetricCard title="团队人数" value={teamQuery.data?.summary.totalEmployees ?? 0} />
|
|
<MetricCard title="已提交" value={teamQuery.data?.summary.submittedCount ?? 0} />
|
|
<MetricCard title="未提交" value={teamQuery.data?.summary.missingCount ?? 0} tone={(teamQuery.data?.summary.missingCount ?? 0) > 0 ? "warning" : "neutral"} />
|
|
<MetricCard title="需协助" value={teamQuery.data?.summary.needsHelpCount ?? 0} tone={(teamQuery.data?.summary.needsHelpCount ?? 0) > 0 ? "warning" : "neutral"} />
|
|
</div>
|
|
{teamQuery.isError ? <div className="mb-4 text-sm text-red-600">{errorMessage(teamQuery.error, "加载团队周报失败")}</div> : null}
|
|
<PrimaryTable>
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>成员</TableHead>
|
|
<TableHead>状态</TableHead>
|
|
<TableHead>需协助</TableHead>
|
|
<TableHead>关联项目</TableHead>
|
|
<TableHead>摘要</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{teamQuery.isLoading ? (
|
|
<TableRow>
|
|
<TableCell className="py-8 text-center text-sm text-zinc-500" colSpan={5}>
|
|
团队周报加载中...
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
(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?.projectLinks?.map((link) => link.project?.name ?? link.projectId).join(" / ") || "-"}</TableCell>
|
|
<TableCell>{item.report?.highlightsText || item.report?.nextWeekText || item.report?.blockersText || "-"}</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</PrimaryTable>
|
|
</SectionPanel>
|
|
) : null}
|
|
</ListWorkspace>
|
|
);
|
|
}
|
|
|
|
export default WeeklyReportsPage;
|