R42: 完成考勤增强首轮交付
这个提交包含在:
+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),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
在新工单中引用
屏蔽一个用户