R42: 完成考勤增强首轮交付

这个提交包含在:
2026-03-28 22:13:18 +08:00
父节点 2706717e6b
当前提交 e8d16c47f2
共修改 13 个文件,包含 2386 行新增和 365 行删除
+92 -2
查看文件
@@ -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<MyPresenceHistoryResponse>(`/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<TeamPresenceHistoryResponse>(`/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<ApiBlobResponse> {
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<PresenceStatus, "office" | "remote" | "out" | "travel">;
timeRange?: PresenceTimeRange;
note?: string | null;
},
) {
return apiRequestData<{ item: PresenceItem }>(`/api/presence/team/${employeeId}`, {
method: "PUT",
body: JSON.stringify(payload),
});
}
+570 -73
查看文件
@@ -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<PresenceStatus, "office" | "remote" | "out" | "travel">;
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<ViewMode>("today");
const [date, setDate] = useState("");
const [note, setNote] = useState("");
const [status, setStatus] = useState<"office" | "remote" | "out" | "travel">("office");
const [status, setStatus] = useState<EditablePresenceStatus>("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<TeamPresenceHistoryItem | null>(null);
const [correctionStatus, setCorrectionStatus] = useState<EditablePresenceStatus>("office");
const [correctionTimeRange, setCorrectionTimeRange] = useState<PresenceTimeRange>("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<string>();
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 (
<div className="space-y-6">
<PageHeader title="团队状态" subtitle="不做打卡,只管理今天谁在岗 / 居家 / 外出 / 出差 / 请假。" />
<div className="grid gap-6 xl:grid-cols-3">
<div className="card-std p-6 space-y-4">
<h3 className="text-base font-semibold text-zinc-900">更新我的今日状态</h3>
<Input type="date" value={date} onChange={(event) => setDate(event.target.value)} />
<select className="select-std" value={status} onChange={(event) => setStatus(event.target.value as typeof status)}>
<option value="office">在岗</option>
<option value="remote">居家</option>
<option value="out">外出</option>
<option value="travel">出差</option>
</select>
<Input value={note} onChange={(event) => setNote(event.target.value)} placeholder="可选备注,如下午外出办事" />
<Button type="button" onClick={() => mutation.mutate()} disabled={mutation.isPending}>保存状态</Button>
<div className="rounded-lg border border-zinc-200 p-3 text-sm text-zinc-600">
当前:{statusLabel(myPresenceQuery.data?.item?.status ?? null)}
</div>
</div>
<div className="card-std p-6 xl:col-span-2">
<h3 className="text-base font-semibold text-zinc-900">今日汇总</h3>
<div className="mt-4 grid gap-3 md:grid-cols-3 xl:grid-cols-6">
{teamPresenceQuery.data?.summary ? Object.entries(teamPresenceQuery.data.summary).map(([key, value]) => (
<div key={key} className="rounded-lg border border-zinc-200 p-3 text-sm">
<div className="text-zinc-500">{key}</div>
<div className="mt-2 text-lg font-semibold text-zinc-900">{value}</div>
</div>
)) : null}
</div>
</div>
</div>
<div className="card-std p-6 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-base font-semibold text-zinc-900">团队列表</h3>
<PageHeader
title="团队状态"
subtitle="不做打卡,只管理今天谁在岗 / 居家 / 外出 / 出差 / 请假。"
actions={
<div className="flex gap-2">
<Button size="sm" variant={filter === "all" ? "default" : "secondary"} type="button" onClick={() => setFilter("all")}>全部</Button>
<Button size="sm" variant={filter === "onsite" ? "default" : "secondary"} type="button" onClick={() => setFilter("onsite")}>在岗</Button>
<Button size="sm" variant={filter === "away" ? "default" : "secondary"} type="button" onClick={() => setFilter("away")}>不在岗</Button>
{viewMode === "history" && canManagePresence ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="secondary" type="button" disabled={exportMutation.isPending}>
{exportMutation.isPending ? "导出中..." : "导出"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onSelect={() => exportMutation.mutate("csv")}>
导出 CSV
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => exportMutation.mutate("xlsx")}>
导出 Excel
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : null}
<Button type="button" variant={viewMode === "today" ? "default" : "secondary"} onClick={() => setViewMode("today")}>
今日
</Button>
<Button type="button" variant={viewMode === "history" ? "default" : "secondary"} onClick={() => setViewMode("history")}>
历史
</Button>
</div>
</div>
}
/>
<Table>
<TableHeader>
<TableRow>
<TableHead>成员</TableHead>
<TableHead>部门</TableHead>
<TableHead>状态</TableHead>
<TableHead>备注</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{teamItems.map((item) => (
<TableRow key={item.employee.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.employee.name}</div>
<div className="text-xs text-zinc-500">{item.employee.title}</div>
</TableCell>
<TableCell>{item.employee.department?.name ?? "-"}</TableCell>
<TableCell>{statusLabel(item.presence?.status ?? null)}</TableCell>
<TableCell>{item.presence?.note ?? "-"}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{viewMode === "today" ? (
<>
<div className="grid gap-6 xl:grid-cols-3">
<div className="card-std p-6 space-y-4">
<h3 className="text-base font-semibold text-zinc-900">更新我的今日状态</h3>
<Input type="date" value={date} onChange={(event) => setDate(event.target.value)} />
<select className="select-std" value={status} onChange={(event) => setStatus(event.target.value as EditablePresenceStatus)}>
<option value="office">在岗</option>
<option value="remote">居家</option>
<option value="out">外出</option>
<option value="travel">出差</option>
</select>
<Input value={note} onChange={(event) => setNote(event.target.value)} placeholder="可选备注,如下午外出办事" />
<Button type="button" onClick={() => mutation.mutate()} disabled={mutation.isPending}>保存状态</Button>
<div className="rounded-lg border border-zinc-200 p-3 text-sm text-zinc-600">
当前:{statusLabel(myPresenceQuery.data?.item?.status ?? null)}
</div>
</div>
<div className="card-std p-6 xl:col-span-2">
<h3 className="text-base font-semibold text-zinc-900">今日汇总</h3>
<div className="mt-4 grid gap-3 md:grid-cols-3 xl:grid-cols-6">
{teamPresenceQuery.data?.summary ? Object.entries(teamPresenceQuery.data.summary).map(([key, value]) => (
<div key={key} className="rounded-lg border border-zinc-200 p-3 text-sm">
<div className="text-zinc-500">{key}</div>
<div className="mt-2 text-lg font-semibold text-zinc-900">{value}</div>
</div>
)) : null}
</div>
</div>
</div>
<div className="card-std p-6 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-base font-semibold text-zinc-900">团队列表</h3>
<div className="flex gap-2">
<Button size="sm" variant={filter === "all" ? "default" : "secondary"} type="button" onClick={() => setFilter("all")}>全部</Button>
<Button size="sm" variant={filter === "onsite" ? "default" : "secondary"} type="button" onClick={() => setFilter("onsite")}>在岗</Button>
<Button size="sm" variant={filter === "away" ? "default" : "secondary"} type="button" onClick={() => setFilter("away")}>不在岗</Button>
</div>
</div>
{teamPresenceQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(teamPresenceQuery.error)}
</p>
) : null}
<Table>
<TableHeader>
<TableRow>
<TableHead>成员</TableHead>
<TableHead>部门</TableHead>
<TableHead>状态</TableHead>
<TableHead>备注</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{teamItems.length ? (
teamItems.map((item) => (
<TableRow key={item.employee.id}>
<TableCell>
<div className="font-medium text-zinc-900">{item.employee.name}</div>
<div className="text-xs text-zinc-500">{item.employee.title}</div>
</TableCell>
<TableCell>{item.employee.department?.name ?? "-"}</TableCell>
<TableCell>{statusLabel(item.presence?.status ?? null)}</TableCell>
<TableCell>{item.presence?.note ?? "-"}</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="py-3 text-zinc-500" colSpan={4}>
暂无团队状态数据
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</>
) : (
<>
<div className="card-std p-4">
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-5">
<div className="space-y-2">
<div className="text-xs font-medium text-zinc-500">开始日期</div>
<Input type="date" value={historyDateFrom} onChange={(event) => setHistoryDateFrom(event.target.value)} />
</div>
<div className="space-y-2">
<div className="text-xs font-medium text-zinc-500">结束日期</div>
<Input type="date" value={historyDateTo} onChange={(event) => setHistoryDateTo(event.target.value)} />
</div>
<div className="space-y-2">
<div className="text-xs font-medium text-zinc-500">部门</div>
<select className="select-std" value={historyDepartmentId} onChange={(event) => setHistoryDepartmentId(event.target.value)}>
<option value="">全部部门</option>
{historyDepartments.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</div>
<div className="space-y-2">
<div className="text-xs font-medium text-zinc-500">成员</div>
<select className="select-std" value={historyEmployeeId} onChange={(event) => setHistoryEmployeeId(event.target.value)}>
<option value="">全部成员</option>
{historyEmployeeOptions.map((item) => (
<option key={item.id} value={item.id}>
{item.name} · {item.employeeNo}
</option>
))}
</select>
</div>
<div className="space-y-2">
<div className="text-xs font-medium text-zinc-500">状态</div>
<select className="select-std" value={historyStatus} onChange={(event) => setHistoryStatus(event.target.value as "" | PresenceHistoryStatusFilter)}>
<option value="">全部状态</option>
<option value="office">在岗</option>
<option value="remote">居家</option>
<option value="out">外出</option>
<option value="travel">出差</option>
<option value="leave">请假</option>
<option value="unspecified">未更新</option>
</select>
</div>
</div>
</div>
{myHistoryQuery.isError || teamHistoryQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(myHistoryQuery.error ?? teamHistoryQuery.error)}
</p>
) : null}
<div className="grid gap-6 xl:grid-cols-3">
<div className="card-std p-6 space-y-4">
<div>
<h3 className="text-base font-semibold text-zinc-900">我的历史概览</h3>
<div className="mt-1 text-sm text-zinc-500">
{myHistoryQuery.data ? `${myHistoryQuery.data.dateFrom} ~ ${myHistoryQuery.data.dateTo}` : "按所选区间查看我的历史状态"}
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-1">
{myHistoryQuery.data?.summary ? Object.entries(myHistoryQuery.data.summary).map(([key, value]) => (
<div key={key} className="rounded-lg border border-zinc-200 p-3 text-sm">
<div className="text-zinc-500">{key}</div>
<div className="mt-2 text-lg font-semibold text-zinc-900">{value}</div>
</div>
)) : null}
</div>
<div className="rounded-lg border border-zinc-200">
<Table>
<TableHeader>
<TableRow>
<TableHead>日期</TableHead>
<TableHead>状态</TableHead>
<TableHead>备注</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{historyMyItems.length ? (
historyMyItems.map((item) => (
<TableRow key={item.date}>
<TableCell>{item.date}</TableCell>
<TableCell>{statusLabel(item.presence?.status ?? null)}</TableCell>
<TableCell>{item.presence?.note ?? "-"}</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell className="py-3 text-zinc-500" colSpan={3}>
{myHistoryQuery.isLoading ? "加载中..." : "暂无历史记录"}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</div>
<div className="card-std p-6 xl:col-span-2">
<div>
<h3 className="text-base font-semibold text-zinc-900">团队历史汇总</h3>
<div className="mt-1 text-sm text-zinc-500">
{teamHistoryQuery.data ? `${teamHistoryQuery.data.dateFrom} ~ ${teamHistoryQuery.data.dateTo}` : "按所选区间查看团队历史汇总"}
</div>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-3 xl:grid-cols-6">
{teamHistoryQuery.data?.summary ? Object.entries(teamHistoryQuery.data.summary).map(([key, value]) => (
<div key={key} className="rounded-lg border border-zinc-200 p-3 text-sm">
<div className="text-zinc-500">{key}</div>
<div className="mt-2 text-lg font-semibold text-zinc-900">{value}</div>
</div>
)) : (
<div className="rounded-lg border border-dashed border-zinc-300 px-4 py-6 text-sm text-zinc-500 xl:col-span-6">
{teamHistoryQuery.isLoading ? "加载历史汇总中..." : "暂无历史汇总数据"}
</div>
)}
</div>
</div>
</div>
<div className="card-std p-6 space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<h3 className="text-base font-semibold text-zinc-900">团队历史明细</h3>
<div className="text-sm text-zinc-500">
{teamHistoryQuery.data ? `${teamHistoryQuery.data.summary.total} 条记录` : "按筛选条件查看明细"}
</div>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>日期</TableHead>
<TableHead>成员</TableHead>
<TableHead>部门</TableHead>
<TableHead>状态</TableHead>
<TableHead>时间段</TableHead>
<TableHead>来源</TableHead>
<TableHead>备注</TableHead>
{canManagePresence ? <TableHead>操作</TableHead> : null}
</TableRow>
</TableHeader>
<TableBody>
{historyTeamItems.length ? (
historyTeamItems.map((item) => (
<TableRow key={`${item.date}-${item.employee.id}`}>
<TableCell>{item.date}</TableCell>
<TableCell>
<div className="font-medium text-zinc-900">{item.employee.name}</div>
<div className="text-xs text-zinc-500">{item.employee.employeeNo} · {item.employee.title}</div>
</TableCell>
<TableCell>{item.employee.department?.name ?? "-"}</TableCell>
<TableCell>{statusLabel(item.presence?.status ?? null)}</TableCell>
<TableCell>{timeRangeLabel(item.presence?.timeRange)}</TableCell>
<TableCell>{sourceLabel(item.presence?.source)}</TableCell>
<TableCell>{item.presence?.note ?? "-"}</TableCell>
{canManagePresence ? (
<TableCell>
<Button type="button" size="sm" variant="secondary" onClick={() => openCorrectionDialog(item)}>
修正
</Button>
</TableCell>
) : null}
</TableRow>
))
) : (
<TableRow>
<TableCell className="py-3 text-zinc-500" colSpan={canManagePresence ? 8 : 7}>
{teamHistoryQuery.isLoading ? "加载历史明细中..." : "暂无匹配的历史记录"}
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</div>
</>
)}
<Dialog open={Boolean(correctionTarget)} onOpenChange={handleCorrectionDialogChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>管理员修正团队状态</DialogTitle>
<DialogDescription>
修正会写入管理员覆盖记录,并同步刷新历史明细、团队今日状态与首页团队状态卡片。
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<div className="text-xs font-medium text-zinc-500">员工</div>
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm text-zinc-700">
{correctionTarget ? `${correctionTarget.employee.name} · ${correctionTarget.employee.employeeNo}` : "-"}
</div>
</div>
<div className="space-y-2">
<div className="text-xs font-medium text-zinc-500">日期</div>
<div className="rounded-lg border border-zinc-200 px-3 py-2 text-sm text-zinc-700">
{correctionTarget?.date ?? "-"}
</div>
</div>
</div>
<div className="space-y-2">
<div className="text-xs font-medium text-zinc-500">状态</div>
<select className="select-std" value={correctionStatus} onChange={(event) => setCorrectionStatus(event.target.value as EditablePresenceStatus)}>
<option value="office">在岗</option>
<option value="remote">居家</option>
<option value="out">外出</option>
<option value="travel">出差</option>
</select>
</div>
<div className="space-y-2">
<div className="text-xs font-medium text-zinc-500">时间段</div>
<select className="select-std" value={correctionTimeRange} onChange={(event) => setCorrectionTimeRange(event.target.value as PresenceTimeRange)}>
<option value="full_day">全天</option>
<option value="morning">上午</option>
<option value="afternoon">下午</option>
</select>
</div>
<div className="space-y-2">
<div className="text-xs font-medium text-zinc-500">备注</div>
<Textarea
value={correctionNote}
onChange={(event) => setCorrectionNote(event.target.value)}
placeholder="可选备注,最多 300 字"
maxLength={300}
className="min-h-[100px]"
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => handleCorrectionDialogChange(false)}>
取消
</Button>
<Button type="button" onClick={() => correctionMutation.mutate()} disabled={!correctionTarget || correctionMutation.isPending}>
{correctionMutation.isPending ? "保存中..." : "保存修正"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}