diff --git a/client/src/api/presence.ts b/client/src/api/presence.ts index b76026c2..47163e14 100644 --- a/client/src/api/presence.ts +++ b/client/src/api/presence.ts @@ -1,8 +1,9 @@ -import { apiRequestData } from "./http"; +import { apiRequestBlob, apiRequestData, type ApiBlobResponse } from "./http"; export type PresenceStatus = "office" | "remote" | "out" | "travel" | "leave"; export type PresenceTimeRange = "full_day" | "morning" | "afternoon"; -export type PresenceSource = "manual" | "leave_sync"; +export type PresenceSource = "manual" | "leave_sync" | "admin_override"; +export type PresenceHistoryStatusFilter = PresenceStatus | "unspecified"; export type PresenceItem = { id: string; @@ -29,6 +30,17 @@ export type TeamPresenceItem = { presence: PresenceItem | null; }; +export type MyPresenceHistoryItem = { + date: string; + presence: PresenceItem | null; +}; + +export type TeamPresenceHistoryItem = { + date: string; + employee: PresenceEmployee; + presence: PresenceItem | null; +}; + export type PresenceSummary = { office: number; remote: number; @@ -39,6 +51,20 @@ export type PresenceSummary = { total: number; }; +export type MyPresenceHistoryResponse = { + dateFrom: string; + dateTo: string; + summary: PresenceSummary; + items: MyPresenceHistoryItem[]; +}; + +export type TeamPresenceHistoryResponse = { + dateFrom: string; + dateTo: string; + summary: PresenceSummary; + items: TeamPresenceHistoryItem[]; +}; + export async function fetchMyPresence(date?: string) { const suffix = date ? `?date=${encodeURIComponent(date)}` : ""; return apiRequestData<{ item: PresenceItem | null; date: string }>(`/api/presence/me${suffix}`); @@ -61,7 +87,71 @@ export async function fetchTeamPresence(date?: string) { return apiRequestData<{ date: string; summary: PresenceSummary; items: TeamPresenceItem[] }>(`/api/presence/team${suffix}`); } +export async function fetchMyPresenceHistory(params: { + dateFrom?: string; + dateTo?: string; +}) { + const search = new URLSearchParams(); + if (params.dateFrom) search.set("dateFrom", params.dateFrom); + if (params.dateTo) search.set("dateTo", params.dateTo); + + const query = search.toString(); + return apiRequestData(`/api/presence/me/history${query ? `?${query}` : ""}`); +} + +export async function fetchTeamPresenceHistory(params: { + dateFrom?: string; + dateTo?: string; + departmentId?: string; + employeeId?: string; + status?: PresenceHistoryStatusFilter; +}) { + const search = new URLSearchParams(); + if (params.dateFrom) search.set("dateFrom", params.dateFrom); + if (params.dateTo) search.set("dateTo", params.dateTo); + if (params.departmentId) search.set("departmentId", params.departmentId); + if (params.employeeId) search.set("employeeId", params.employeeId); + if (params.status) search.set("status", params.status); + + const query = search.toString(); + return apiRequestData(`/api/presence/team/history${query ? `?${query}` : ""}`); +} + export async function fetchPresenceSummary(date?: string) { const suffix = date ? `?date=${encodeURIComponent(date)}` : ""; return apiRequestData<{ date: string; summary: PresenceSummary }>(`/api/presence/summary${suffix}`); } + +export async function exportPresenceHistory(params: { + format: "csv" | "xlsx"; + dateFrom?: string; + dateTo?: string; + departmentId?: string; + employeeId?: string; + status?: PresenceHistoryStatusFilter; +}): Promise { + const search = new URLSearchParams(); + search.set("format", params.format); + if (params.dateFrom) search.set("dateFrom", params.dateFrom); + if (params.dateTo) search.set("dateTo", params.dateTo); + if (params.departmentId) search.set("departmentId", params.departmentId); + if (params.employeeId) search.set("employeeId", params.employeeId); + if (params.status) search.set("status", params.status); + + return apiRequestBlob(`/api/exports/presence-history?${search.toString()}`); +} + +export async function patchTeamPresence( + employeeId: string, + payload: { + date: string; + status: Extract; + timeRange?: PresenceTimeRange; + note?: string | null; + }, +) { + return apiRequestData<{ item: PresenceItem }>(`/api/presence/team/${employeeId}`, { + method: "PUT", + body: JSON.stringify(payload), + }); +} diff --git a/client/src/pages/TeamPresencePage.tsx b/client/src/pages/TeamPresencePage.tsx index fec2be5e..ff8b2e60 100644 --- a/client/src/pages/TeamPresencePage.tsx +++ b/client/src/pages/TeamPresencePage.tsx @@ -1,13 +1,48 @@ import { useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { fetchMyPresence, fetchTeamPresence, upsertMyPresence } from "../api/presence"; +import { fetchDepartments } from "../api/departments"; +import { + exportPresenceHistory, + fetchMyPresence, + fetchMyPresenceHistory, + fetchTeamPresence, + fetchTeamPresenceHistory, + patchTeamPresence, + type PresenceHistoryStatusFilter, + type PresenceEmployee, + type PresenceStatus, + type PresenceTimeRange, + type TeamPresenceHistoryItem, + upsertMyPresence, +} from "../api/presence"; +import { mapApiErrorMessage } from "../api/errorMessages"; +import { isApiError } from "../api/types"; +import { useAuth } from "../auth/AuthContext"; +import { hasPermission } from "../auth/permissions"; import { PageHeader } from "../components/common/PageHeader"; import { Button } from "../components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "../components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "../components/ui/dropdown-menu"; import { Input } from "../components/ui/input"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table"; +import { Textarea } from "../components/ui/textarea"; +import { downloadBlob } from "../lib/download"; import { toast } from "sonner"; -import { isApiError } from "../api/types"; -import { mapApiErrorMessage } from "../api/errorMessages"; + +type ViewMode = "today" | "history"; +type EditablePresenceStatus = Extract; function statusLabel(status: string | null) { if (status === "office") return "在岗"; @@ -18,15 +53,108 @@ function statusLabel(status: string | null) { return "未更新"; } +function timeRangeLabel(value: string | null | undefined) { + if (value === "morning") return "上午"; + if (value === "afternoon") return "下午"; + if (value === "full_day") return "全天"; + return "-"; +} + +function sourceLabel(value: string | null | undefined) { + if (value === "manual") return "手工"; + if (value === "leave_sync") return "审批同步"; + if (value === "admin_override") return "管理员修正"; + return "-"; +} + +function currentDateString() { + return new Date().toISOString().slice(0, 10); +} + +function offsetDateString(offsetDays: number) { + const next = new Date(); + next.setUTCDate(next.getUTCDate() + offsetDays); + return next.toISOString().slice(0, 10); +} + +function resolveErrorMessage(error: unknown, fallback = "加载失败") { + if (isApiError(error)) { + return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`; + } + + if (error instanceof Error) { + return error.message; + } + + return fallback; +} + +function resolveEditableStatus(item: TeamPresenceHistoryItem | null): EditablePresenceStatus { + if (!item?.presence) { + return "office"; + } + if (item.presence.status === "office" || item.presence.status === "remote" || item.presence.status === "out" || item.presence.status === "travel") { + return item.presence.status; + } + return "office"; +} + export function TeamPresencePage() { + const { permissions } = useAuth(); + const canManagePresence = hasPermission(permissions, "system.users"); const queryClient = useQueryClient(); + const [viewMode, setViewMode] = useState("today"); const [date, setDate] = useState(""); const [note, setNote] = useState(""); - const [status, setStatus] = useState<"office" | "remote" | "out" | "travel">("office"); + const [status, setStatus] = useState("office"); const [filter, setFilter] = useState<"all" | "onsite" | "away">("all"); + const [historyDateFrom, setHistoryDateFrom] = useState(offsetDateString(-6)); + const [historyDateTo, setHistoryDateTo] = useState(currentDateString()); + const [historyDepartmentId, setHistoryDepartmentId] = useState(""); + const [historyEmployeeId, setHistoryEmployeeId] = useState(""); + const [historyStatus, setHistoryStatus] = useState<"" | PresenceHistoryStatusFilter>(""); + const [correctionTarget, setCorrectionTarget] = useState(null); + const [correctionStatus, setCorrectionStatus] = useState("office"); + const [correctionTimeRange, setCorrectionTimeRange] = useState("full_day"); + const [correctionNote, setCorrectionNote] = useState(""); const myPresenceQuery = useQuery({ queryKey: ["presence", "me", date], queryFn: () => fetchMyPresence(date || undefined) }); const teamPresenceQuery = useQuery({ queryKey: ["presence", "team", date], queryFn: () => fetchTeamPresence(date || undefined) }); + const departmentsQuery = useQuery({ + enabled: viewMode === "history", + queryKey: ["departments", "team-presence-history", "active"], + queryFn: () => fetchDepartments({ includeArchived: false }), + }); + const myHistoryQuery = useQuery({ + enabled: viewMode === "history", + queryKey: ["presence", "me", "history", historyDateFrom, historyDateTo], + queryFn: () => + fetchMyPresenceHistory({ + dateFrom: historyDateFrom || undefined, + dateTo: historyDateTo || undefined, + }), + }); + const teamHistoryQuery = useQuery({ + enabled: viewMode === "history", + queryKey: [ + "presence", + "team", + "history", + historyDateFrom, + historyDateTo, + historyDepartmentId, + historyEmployeeId, + historyStatus, + ], + queryFn: () => + fetchTeamPresenceHistory({ + dateFrom: historyDateFrom || undefined, + dateTo: historyDateTo || undefined, + departmentId: historyDepartmentId || undefined, + employeeId: historyEmployeeId || undefined, + status: historyStatus || undefined, + }), + }); const mutation = useMutation({ mutationFn: () => upsertMyPresence({ date: date || undefined, status, note: note || undefined }), @@ -39,12 +167,51 @@ export function TeamPresencePage() { toast.success("今日状态已更新"); }, onError: (error) => { - const message = isApiError(error) - ? `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}` - : error instanceof Error - ? error.message - : "更新失败"; - toast.error(message); + toast.error(resolveErrorMessage(error, "更新失败")); + }, + }); + + const exportMutation = useMutation({ + mutationFn: (format: "csv" | "xlsx") => + exportPresenceHistory({ + format, + dateFrom: historyDateFrom || undefined, + dateTo: historyDateTo || undefined, + departmentId: historyDepartmentId || undefined, + employeeId: historyEmployeeId || undefined, + status: historyStatus || undefined, + }), + onSuccess: (response, format) => { + downloadBlob(response.blob, response.filename ?? `presence-history.${format}`); + toast.success("导出完成"); + }, + onError: (error) => { + toast.error(resolveErrorMessage(error, "导出失败")); + }, + }); + + const correctionMutation = useMutation({ + mutationFn: async () => { + if (!correctionTarget) { + throw new Error("请先选择要修正的历史记录"); + } + return patchTeamPresence(correctionTarget.employee.id, { + date: correctionTarget.date, + status: correctionStatus, + timeRange: correctionTimeRange, + note: correctionNote.trim() ? correctionNote.trim() : null, + }); + }, + onSuccess: async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ["presence"] }), + queryClient.invalidateQueries({ queryKey: ["portal-home"] }), + ]); + handleCorrectionDialogChange(false); + toast.success("管理员修正已保存"); + }, + onError: (error) => { + toast.error(resolveErrorMessage(error, "修正失败")); }, }); @@ -55,74 +222,404 @@ export function TeamPresencePage() { return items.filter((item) => item.presence?.status !== "office"); }, [filter, teamPresenceQuery.data?.items]); + const historyEmployeeOptions = useMemo(() => { + const seen = new Set(); + const items: PresenceEmployee[] = []; + for (const item of teamPresenceQuery.data?.items ?? []) { + if (seen.has(item.employee.id)) { + continue; + } + seen.add(item.employee.id); + items.push(item.employee); + } + return items; + }, [teamPresenceQuery.data?.items]); + + const historyDepartments = departmentsQuery.data?.items ?? []; + const historyMyItems = myHistoryQuery.data?.items ?? []; + const historyTeamItems = teamHistoryQuery.data?.items ?? []; + + function handleCorrectionDialogChange(open: boolean) { + if (!open) { + setCorrectionTarget(null); + setCorrectionStatus("office"); + setCorrectionTimeRange("full_day"); + setCorrectionNote(""); + return; + } + } + + function openCorrectionDialog(item: TeamPresenceHistoryItem) { + setCorrectionTarget(item); + setCorrectionStatus(resolveEditableStatus(item)); + setCorrectionTimeRange(item.presence?.timeRange ?? "full_day"); + setCorrectionNote(item.presence?.note ?? ""); + } + return (
- - -
-
-

更新我的今日状态

- setDate(event.target.value)} /> - - setNote(event.target.value)} placeholder="可选备注,如下午外出办事" /> - -
- 当前:{statusLabel(myPresenceQuery.data?.item?.status ?? null)} -
-
- -
-

今日汇总

-
- {teamPresenceQuery.data?.summary ? Object.entries(teamPresenceQuery.data.summary).map(([key, value]) => ( -
-
{key}
-
{value}
-
- )) : null} -
-
-
- -
-
-

团队列表

+ - - - + {viewMode === "history" && canManagePresence ? ( + + + + + + exportMutation.mutate("csv")}> + 导出 CSV + + exportMutation.mutate("xlsx")}> + 导出 Excel + + + + ) : null} + +
-
+ } + /> - - - - 成员 - 部门 - 状态 - 备注 - - - - {teamItems.map((item) => ( - - -
{item.employee.name}
-
{item.employee.title}
-
- {item.employee.department?.name ?? "-"} - {statusLabel(item.presence?.status ?? null)} - {item.presence?.note ?? "-"} -
- ))} -
-
-
+ {viewMode === "today" ? ( + <> +
+
+

更新我的今日状态

+ setDate(event.target.value)} /> + + setNote(event.target.value)} placeholder="可选备注,如下午外出办事" /> + +
+ 当前:{statusLabel(myPresenceQuery.data?.item?.status ?? null)} +
+
+ +
+

今日汇总

+
+ {teamPresenceQuery.data?.summary ? Object.entries(teamPresenceQuery.data.summary).map(([key, value]) => ( +
+
{key}
+
{value}
+
+ )) : null} +
+
+
+ +
+
+

团队列表

+
+ + + +
+
+ + {teamPresenceQuery.isError ? ( +

+ {resolveErrorMessage(teamPresenceQuery.error)} +

+ ) : null} + + + + + 成员 + 部门 + 状态 + 备注 + + + + {teamItems.length ? ( + teamItems.map((item) => ( + + +
{item.employee.name}
+
{item.employee.title}
+
+ {item.employee.department?.name ?? "-"} + {statusLabel(item.presence?.status ?? null)} + {item.presence?.note ?? "-"} +
+ )) + ) : ( + + + 暂无团队状态数据 + + + )} +
+
+
+ + ) : ( + <> +
+
+
+
开始日期
+ setHistoryDateFrom(event.target.value)} /> +
+
+
结束日期
+ setHistoryDateTo(event.target.value)} /> +
+
+
部门
+ +
+
+
成员
+ +
+
+
状态
+ +
+
+
+ + {myHistoryQuery.isError || teamHistoryQuery.isError ? ( +

+ {resolveErrorMessage(myHistoryQuery.error ?? teamHistoryQuery.error)} +

+ ) : null} + +
+
+
+

我的历史概览

+
+ {myHistoryQuery.data ? `${myHistoryQuery.data.dateFrom} ~ ${myHistoryQuery.data.dateTo}` : "按所选区间查看我的历史状态"} +
+
+ +
+ {myHistoryQuery.data?.summary ? Object.entries(myHistoryQuery.data.summary).map(([key, value]) => ( +
+
{key}
+
{value}
+
+ )) : null} +
+ +
+ + + + 日期 + 状态 + 备注 + + + + {historyMyItems.length ? ( + historyMyItems.map((item) => ( + + {item.date} + {statusLabel(item.presence?.status ?? null)} + {item.presence?.note ?? "-"} + + )) + ) : ( + + + {myHistoryQuery.isLoading ? "加载中..." : "暂无历史记录"} + + + )} + +
+
+
+ +
+
+

团队历史汇总

+
+ {teamHistoryQuery.data ? `${teamHistoryQuery.data.dateFrom} ~ ${teamHistoryQuery.data.dateTo}` : "按所选区间查看团队历史汇总"} +
+
+ +
+ {teamHistoryQuery.data?.summary ? Object.entries(teamHistoryQuery.data.summary).map(([key, value]) => ( +
+
{key}
+
{value}
+
+ )) : ( +
+ {teamHistoryQuery.isLoading ? "加载历史汇总中..." : "暂无历史汇总数据"} +
+ )} +
+
+
+ +
+
+

团队历史明细

+
+ {teamHistoryQuery.data ? `${teamHistoryQuery.data.summary.total} 条记录` : "按筛选条件查看明细"} +
+
+ + + + + 日期 + 成员 + 部门 + 状态 + 时间段 + 来源 + 备注 + {canManagePresence ? 操作 : null} + + + + {historyTeamItems.length ? ( + historyTeamItems.map((item) => ( + + {item.date} + +
{item.employee.name}
+
{item.employee.employeeNo} · {item.employee.title}
+
+ {item.employee.department?.name ?? "-"} + {statusLabel(item.presence?.status ?? null)} + {timeRangeLabel(item.presence?.timeRange)} + {sourceLabel(item.presence?.source)} + {item.presence?.note ?? "-"} + {canManagePresence ? ( + + + + ) : null} +
+ )) + ) : ( + + + {teamHistoryQuery.isLoading ? "加载历史明细中..." : "暂无匹配的历史记录"} + + + )} +
+
+
+ + )} + + + + + 管理员修正团队状态 + + 修正会写入管理员覆盖记录,并同步刷新历史明细、团队今日状态与首页团队状态卡片。 + + + +
+
+
+
员工
+
+ {correctionTarget ? `${correctionTarget.employee.name} · ${correctionTarget.employee.employeeNo}` : "-"} +
+
+
+
日期
+
+ {correctionTarget?.date ?? "-"} +
+
+
+ +
+
状态
+ +
+ +
+
时间段
+ +
+ +
+
备注
+