R19/R20: 项目主域进 OA 并打通报销差旅周报待办
这个提交包含在:
@@ -1,7 +1,8 @@
|
||||
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 { 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";
|
||||
@@ -19,6 +20,26 @@ function errorMessage(error: unknown, fallback: string) {
|
||||
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();
|
||||
@@ -26,9 +47,11 @@ export function WeeklyReportsPage() {
|
||||
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;
|
||||
@@ -38,15 +61,26 @@ export function WeeklyReportsPage() {
|
||||
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 saveMutation = useMutation({
|
||||
mutationFn: () => updateMyWeeklyReport({ weekStart: weekStart || undefined, ...draft }),
|
||||
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("周报已保存");
|
||||
},
|
||||
@@ -72,6 +106,7 @@ export function WeeklyReportsPage() {
|
||||
description: draft.nextWeekText || "来自周报的下周计划",
|
||||
source: "weekly_report",
|
||||
priority: draft.needsHelp ? "high" : "normal",
|
||||
projectId: normalizedProjectLinks[0]?.projectId ?? null,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await Promise.all([
|
||||
@@ -85,7 +120,7 @@ export function WeeklyReportsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader title="周报" subtitle="固定三段结构:本周完成、当前阻塞、下周计划。" />
|
||||
<PageHeader title="周报" subtitle="固定三段结构:本周完成、当前阻塞、下周计划,可按项目拆分进展。" />
|
||||
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
@@ -111,7 +146,31 @@ export function WeeklyReportsPage() {
|
||||
需要老板/管理员协助
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
@@ -133,6 +192,7 @@ export function WeeklyReportsPage() {
|
||||
<TableHead>成员</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>需协助</TableHead>
|
||||
<TableHead>关联项目</TableHead>
|
||||
<TableHead>摘要</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
@@ -142,6 +202,7 @@ export function WeeklyReportsPage() {
|
||||
<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>
|
||||
))}
|
||||
|
||||
在新工单中引用
屏蔽一个用户