R29-1 R29-6: 重构海音达工作台壳层与高频页面

这个提交包含在:
2026-03-23 09:59:35 +08:00
父节点 f9198a781a
当前提交 7e183c0cdc
共修改 33 个文件,包含 2193 行新增和 1564 行删除
+97 -64
查看文件
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { createTodo } from "../api/todos";
import { fetchProjects } from "../api/projects";
@@ -6,6 +6,9 @@ import { fetchTeamWeeklyReports, fetchMyWeeklyReport, submitMyWeeklyReport, upda
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";
@@ -73,6 +76,11 @@ export function WeeklyReportsPage() {
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 () => {
@@ -119,40 +127,54 @@ export function WeeklyReportsPage() {
});
return (
<div className="space-y-6">
<ListWorkspace>
<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)} />
<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 className="input-std w-[220px]" type="date" value={weekStart} onChange={(event) => setWeekStart(event.target.value)} />
</FilterBar>
<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>
<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>
<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>
<Button type="button" variant="secondary" onClick={() => setProjectLinks((prev) => [...prev, emptyProjectLink()])}>新增项目进展</Button>
<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">本周完成</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>
</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">
@@ -170,47 +192,58 @@ export function WeeklyReportsPage() {
<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 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>
<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>
</SectionPanel>
{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>
<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>
<Table>
<TableHeader>
<TableRow>
<TableHead>成员</TableHead>
<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?.projectLinks?.map((link) => link.project?.name ?? link.projectId).join(" / ") || "-"}</TableCell>
<TableCell>{item.report?.highlightsText || item.report?.nextWeekText || item.report?.blockersText || "-"}</TableCell>
{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>
))}
</TableBody>
</Table>
</div>
</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}
</div>
</ListWorkspace>
);
}