614 行
26 KiB
TypeScript
614 行
26 KiB
TypeScript
import { type ReactNode, useMemo, useState } from "react";
|
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import { toast } from "sonner";
|
|
import {
|
|
cancelAttendanceCorrection,
|
|
createAttendanceCorrection,
|
|
fetchAttendanceCorrections,
|
|
fetchAttendanceMonthly,
|
|
fetchWecomAttendanceSyncRuns,
|
|
triggerWecomAttendanceSync,
|
|
type AttendanceCorrectionKind,
|
|
type AttendanceCorrectionStatus,
|
|
type AttendanceDailyStatus,
|
|
type AttendanceMonthlyDay,
|
|
} from "../api/attendance";
|
|
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 { TableSkeleton } from "../components/common/TableSkeleton";
|
|
import { MetricCard } from "../components/patterns/MetricCard";
|
|
import { SectionPanel } from "../components/patterns/SectionPanel";
|
|
import { WorkspaceNotice } from "../components/patterns/ListWorkspace";
|
|
import { Button } from "../components/ui/button";
|
|
import { Input } from "../components/ui/input";
|
|
import { Label } from "../components/ui/label";
|
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "../components/ui/tabs";
|
|
import { Textarea } from "../components/ui/textarea";
|
|
|
|
type AttendanceTab = "summary" | "corrections" | "sync";
|
|
|
|
type CorrectionFormState = {
|
|
kind: AttendanceCorrectionKind;
|
|
workDate: string;
|
|
missedPunchAt: string;
|
|
startAt: string;
|
|
endAt: string;
|
|
reason: string;
|
|
locationSummary: string;
|
|
compensationPolicy: string;
|
|
};
|
|
|
|
function currentDateInput() {
|
|
const now = new Date();
|
|
const year = now.getFullYear();
|
|
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
const day = String(now.getDate()).padStart(2, "0");
|
|
return `${year}-${month}-${day}`;
|
|
}
|
|
|
|
function currentMonthInput() {
|
|
const now = new Date();
|
|
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}`;
|
|
}
|
|
|
|
function firstDayOfMonth(month: string) {
|
|
return `${month}-01`;
|
|
}
|
|
|
|
function toIsoOrUndefined(value: string) {
|
|
const normalized = value.trim();
|
|
if (!normalized) return undefined;
|
|
return new Date(normalized).toISOString();
|
|
}
|
|
|
|
function formatDateTime(value: string | null) {
|
|
if (!value) return "-";
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return "-";
|
|
return date.toLocaleString("zh-CN", {
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
hour12: false,
|
|
});
|
|
}
|
|
|
|
function formatTime(value: string | null) {
|
|
if (!value) return "-";
|
|
const date = new Date(value);
|
|
if (Number.isNaN(date.getTime())) return "-";
|
|
return date.toLocaleTimeString("zh-CN", {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
hour12: false,
|
|
});
|
|
}
|
|
|
|
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 isUnifiedDisabledError(error: unknown) {
|
|
return isApiError(error) && error.code === "VALID_001" && error.details?.featureFlag === "attendance_unified_v1_disabled";
|
|
}
|
|
|
|
function correctionKindLabel(kind: AttendanceCorrectionKind) {
|
|
if (kind === "missed_punch") return "补卡";
|
|
if (kind === "out") return "外出";
|
|
return "加班";
|
|
}
|
|
|
|
function correctionStatusLabel(status: AttendanceCorrectionStatus) {
|
|
if (status === "reviewing") return "审批中";
|
|
if (status === "approved") return "已通过";
|
|
if (status === "rejected") return "已驳回";
|
|
return "已撤回";
|
|
}
|
|
|
|
function syncRunStatusLabel(status: string) {
|
|
if (status === "running") return "同步中";
|
|
if (status === "success") return "已完成";
|
|
if (status === "skipped") return "已跳过";
|
|
if (status === "failed") return "失败";
|
|
return "待确认";
|
|
}
|
|
|
|
function dayStatusLabel(status: AttendanceDailyStatus) {
|
|
if (status === "normal") return "正常";
|
|
if (status === "exception") return "异常";
|
|
if (status === "missing") return "缺卡";
|
|
if (status === "leave") return "请假";
|
|
if (status === "corrected") return "已解释";
|
|
if (status === "overtime") return "加班";
|
|
return "周末";
|
|
}
|
|
|
|
function statusClass(status: AttendanceDailyStatus | AttendanceCorrectionStatus) {
|
|
if (status === "normal" || status === "approved") return "border-emerald-200 bg-emerald-50 text-emerald-700";
|
|
if (status === "missing" || status === "rejected") return "border-red-200 bg-red-50 text-red-700";
|
|
if (status === "exception" || status === "reviewing") return "border-amber-200 bg-amber-50 text-amber-700";
|
|
return "border-slate-200 bg-slate-50 text-slate-600";
|
|
}
|
|
|
|
function buildDefaultForm(): CorrectionFormState {
|
|
const today = currentDateInput();
|
|
return {
|
|
kind: "missed_punch",
|
|
workDate: today,
|
|
missedPunchAt: `${today}T09:00`,
|
|
startAt: `${today}T09:00`,
|
|
endAt: `${today}T18:00`,
|
|
reason: "",
|
|
locationSummary: "",
|
|
compensationPolicy: "",
|
|
};
|
|
}
|
|
|
|
function dailyCheckinText(day: AttendanceMonthlyDay) {
|
|
if (!day.checkins.length) return "-";
|
|
if (day.firstCheckinAt === day.lastCheckinAt) return formatTime(day.firstCheckinAt);
|
|
return `${formatTime(day.firstCheckinAt)} - ${formatTime(day.lastCheckinAt)}`;
|
|
}
|
|
|
|
export function AttendancePage() {
|
|
const queryClient = useQueryClient();
|
|
const { me, permissions } = useAuth();
|
|
const canSync = hasPermission(permissions, "attendance.sync");
|
|
const canViewAll = hasPermission(permissions, "attendance.view_all");
|
|
const canUseOaEmergencyCorrection = hasPermission(permissions, "attendance.manage");
|
|
const [activeTab, setActiveTab] = useState<AttendanceTab>("summary");
|
|
const [month, setMonth] = useState(currentMonthInput());
|
|
const [form, setForm] = useState<CorrectionFormState>(() => buildDefaultForm());
|
|
const [syncRange, setSyncRange] = useState({
|
|
startDate: firstDayOfMonth(currentMonthInput()),
|
|
endDate: currentDateInput(),
|
|
});
|
|
|
|
const monthlyQuery = useQuery({
|
|
queryKey: ["attendance", "unified", "monthly", month],
|
|
queryFn: () => fetchAttendanceMonthly({ month }),
|
|
});
|
|
|
|
const correctionsQuery = useQuery({
|
|
queryKey: ["attendance", "corrections", month],
|
|
queryFn: () => fetchAttendanceCorrections({ month, pageSize: 50 }),
|
|
});
|
|
|
|
const syncRunsQuery = useQuery({
|
|
enabled: canSync,
|
|
queryKey: ["attendance", "wecom", "sync-runs"],
|
|
queryFn: () => fetchWecomAttendanceSyncRuns({ pageSize: 10 }),
|
|
});
|
|
|
|
const createMutation = useMutation({
|
|
mutationFn: () =>
|
|
createAttendanceCorrection({
|
|
kind: form.kind,
|
|
workDate: form.workDate,
|
|
reason: form.reason.trim(),
|
|
missedPunchAt: form.kind === "missed_punch" ? toIsoOrUndefined(form.missedPunchAt) : undefined,
|
|
startAt: form.kind !== "missed_punch" ? toIsoOrUndefined(form.startAt) : undefined,
|
|
endAt: form.kind !== "missed_punch" ? toIsoOrUndefined(form.endAt) : undefined,
|
|
locationSummary: form.kind === "out" ? form.locationSummary.trim() || undefined : undefined,
|
|
compensationPolicy: form.kind === "overtime" ? form.compensationPolicy.trim() || undefined : undefined,
|
|
}),
|
|
onSuccess: async () => {
|
|
toast.success("考勤应急修正已提交");
|
|
setForm(buildDefaultForm());
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ["attendance", "unified", "monthly"] }),
|
|
queryClient.invalidateQueries({ queryKey: ["attendance", "corrections"] }),
|
|
queryClient.invalidateQueries({ queryKey: ["workflow", "tasks"] }),
|
|
]);
|
|
},
|
|
onError: (error) => {
|
|
toast.error(resolveErrorMessage(error));
|
|
},
|
|
});
|
|
|
|
const cancelMutation = useMutation({
|
|
mutationFn: (id: string) => cancelAttendanceCorrection(id),
|
|
onSuccess: async () => {
|
|
toast.success("考勤修正已撤回");
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ["attendance", "unified", "monthly"] }),
|
|
queryClient.invalidateQueries({ queryKey: ["attendance", "corrections"] }),
|
|
]);
|
|
},
|
|
onError: (error) => {
|
|
toast.error(resolveErrorMessage(error));
|
|
},
|
|
});
|
|
|
|
const syncMutation = useMutation({
|
|
mutationFn: () => triggerWecomAttendanceSync(syncRange),
|
|
onSuccess: async (response) => {
|
|
toast.success(response.skipped ? `企微同步跳过:${response.code}` : `企微同步完成:${response.recordCount} 条`);
|
|
await Promise.all([
|
|
queryClient.invalidateQueries({ queryKey: ["attendance", "unified", "monthly"] }),
|
|
queryClient.invalidateQueries({ queryKey: ["attendance", "wecom", "sync-runs"] }),
|
|
]);
|
|
},
|
|
onError: (error) => {
|
|
toast.error(resolveErrorMessage(error));
|
|
},
|
|
});
|
|
|
|
const featureDisabled = isUnifiedDisabledError(monthlyQuery.error);
|
|
const monthly = monthlyQuery.data;
|
|
const correctionItems = correctionsQuery.data?.items ?? [];
|
|
const ownCorrectionIds = useMemo(
|
|
() => new Set(correctionItems.filter((item) => item.owner?.id === me?.id).map((item) => item.id)),
|
|
[correctionItems, me?.id],
|
|
);
|
|
|
|
if (featureDisabled) {
|
|
return (
|
|
<PageScaffoldLike>
|
|
<PageHeader title="考勤" subtitle="企业微信主办打卡、补卡、外出和加班;OA 只保留汇总、解释和审计。" />
|
|
<WorkspaceNotice title="企业微信主办" tone="info">
|
|
日常打卡、请假和假期余额由企业微信承载;OA 只保留汇总、解释和审计入口。
|
|
</WorkspaceNotice>
|
|
<SectionPanel title="企微考勤统一汇总未启用">
|
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-4 py-3 text-sm text-amber-800">
|
|
当前 `attendance_unified_v1` 未开启。历史 OA 自建上下班打卡已关闭;正式启用前请由管理员打开统一考勤功能开关。
|
|
</div>
|
|
</SectionPanel>
|
|
</PageScaffoldLike>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<PageScaffoldLike>
|
|
<PageHeader title="考勤" subtitle="企业微信主办打卡、补卡、外出和加班;OA 展示月度摘要、异常解释、同步状态和审计记录。" />
|
|
<WorkspaceNotice title="企业微信主办" tone="info">
|
|
日常打卡和考勤类申请仍在企业微信完成。OA 不写回企业微信;只有 HR 或管理员确认应急接管时,才在 OA 记录应急修正。
|
|
</WorkspaceNotice>
|
|
|
|
<div className="grid gap-4 md:grid-cols-4">
|
|
<MetricCard title="人员" value={monthly?.summary.employeeCount ?? 0} />
|
|
<MetricCard title="工作日" value={monthly?.summary.workDays ?? 0} />
|
|
<MetricCard title="已同步打卡日" value={monthly?.summary.checkinDays ?? 0} />
|
|
<MetricCard title="待解释缺卡" value={monthly?.summary.missingDays ?? 0} />
|
|
</div>
|
|
|
|
<Tabs value={activeTab} onValueChange={(value) => setActiveTab(value as AttendanceTab)}>
|
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
<TabsList>
|
|
<TabsTrigger value="summary">月度汇总</TabsTrigger>
|
|
<TabsTrigger value="corrections">修正申请</TabsTrigger>
|
|
{canSync ? <TabsTrigger value="sync">企微同步</TabsTrigger> : null}
|
|
</TabsList>
|
|
<div className="flex items-center gap-2">
|
|
<Label htmlFor="attendanceMonth" className="text-sm text-slate-500">月份</Label>
|
|
<Input
|
|
id="attendanceMonth"
|
|
className="w-40"
|
|
type="month"
|
|
value={month}
|
|
onChange={(event) => setMonth(event.target.value)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<TabsContent value="summary" className="mt-4 space-y-4">
|
|
{monthlyQuery.isLoading ? (
|
|
<TableSkeleton rows={5} />
|
|
) : monthlyQuery.isError ? (
|
|
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
|
{resolveErrorMessage(monthlyQuery.error)}
|
|
</div>
|
|
) : (
|
|
monthly?.employees.map((employeeItem) => (
|
|
<SectionPanel
|
|
key={employeeItem.employee?.id ?? "self"}
|
|
title={employeeItem.employee?.name ?? "我的考勤"}
|
|
description={joinSmallText([
|
|
employeeItem.employee?.employeeNo,
|
|
employeeItem.employee?.departmentName,
|
|
canViewAll ? "团队视图" : "个人视图",
|
|
])}
|
|
>
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>日期</TableHead>
|
|
<TableHead>企微打卡</TableHead>
|
|
<TableHead>状态</TableHead>
|
|
<TableHead>OA 解释</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{employeeItem.days.map((day) => (
|
|
<TableRow key={`${employeeItem.employee?.id}-${day.date}`}>
|
|
<TableCell className="whitespace-nowrap font-medium">{day.date}</TableCell>
|
|
<TableCell className="whitespace-nowrap">{dailyCheckinText(day)}</TableCell>
|
|
<TableCell>
|
|
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${statusClass(day.status)}`}>
|
|
{dayStatusLabel(day.status)}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="min-w-[220px] text-sm text-slate-600">
|
|
{joinSmallText([
|
|
day.explanation,
|
|
day.leaveRequests.map((item) => item.leaveTypeName).join("、"),
|
|
day.corrections.map((item) => `${correctionKindLabel(item.kind)}(${correctionStatusLabel(item.status)})`).join("、"),
|
|
]) || "-"}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</SectionPanel>
|
|
))
|
|
)}
|
|
</TabsContent>
|
|
|
|
<TabsContent value="corrections" className="mt-4 grid gap-4 xl:grid-cols-[minmax(0,420px)_minmax(0,1fr)]">
|
|
{canUseOaEmergencyCorrection ? (
|
|
<SectionPanel title="OA 应急修正" description="仅在企业微信不可用且 HR/管理员确认应急接管时使用;不会写回企业微信。">
|
|
<div className="space-y-4">
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="correctionKind">类型</Label>
|
|
<select
|
|
id="correctionKind"
|
|
className="h-10 w-full rounded-md border border-slate-200 bg-white px-3 text-sm"
|
|
value={form.kind}
|
|
onChange={(event) => setForm((prev) => ({ ...prev, kind: event.target.value as AttendanceCorrectionKind }))}
|
|
>
|
|
<option value="missed_punch">补卡</option>
|
|
<option value="out">外出</option>
|
|
<option value="overtime">加班</option>
|
|
</select>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="correctionWorkDate">日期</Label>
|
|
<Input
|
|
id="correctionWorkDate"
|
|
type="date"
|
|
value={form.workDate}
|
|
onChange={(event) => setForm((prev) => ({ ...prev, workDate: event.target.value }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{form.kind === "missed_punch" ? (
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="missedPunchAt">补卡时间</Label>
|
|
<Input
|
|
id="missedPunchAt"
|
|
type="datetime-local"
|
|
value={form.missedPunchAt}
|
|
onChange={(event) => setForm((prev) => ({ ...prev, missedPunchAt: event.target.value }))}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-3 sm:grid-cols-2">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="correctionStartAt">开始时间</Label>
|
|
<Input
|
|
id="correctionStartAt"
|
|
type="datetime-local"
|
|
value={form.startAt}
|
|
onChange={(event) => setForm((prev) => ({ ...prev, startAt: event.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="correctionEndAt">结束时间</Label>
|
|
<Input
|
|
id="correctionEndAt"
|
|
type="datetime-local"
|
|
value={form.endAt}
|
|
onChange={(event) => setForm((prev) => ({ ...prev, endAt: event.target.value }))}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{form.kind === "out" ? (
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="locationSummary">外出地点摘要</Label>
|
|
<Input
|
|
id="locationSummary"
|
|
value={form.locationSummary}
|
|
onChange={(event) => setForm((prev) => ({ ...prev, locationSummary: event.target.value }))}
|
|
placeholder="客户现场 / 银行 / 办事窗口"
|
|
/>
|
|
</div>
|
|
) : null}
|
|
|
|
{form.kind === "overtime" ? (
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="compensationPolicy">补偿口径</Label>
|
|
<Input
|
|
id="compensationPolicy"
|
|
value={form.compensationPolicy}
|
|
onChange={(event) => setForm((prev) => ({ ...prev, compensationPolicy: event.target.value }))}
|
|
placeholder="调休 / 加班记录 / 其他"
|
|
/>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="correctionReason">原因</Label>
|
|
<Textarea
|
|
id="correctionReason"
|
|
value={form.reason}
|
|
onChange={(event) => setForm((prev) => ({ ...prev, reason: event.target.value }))}
|
|
placeholder="说明需要审批留痕的原因"
|
|
/>
|
|
</div>
|
|
|
|
<Button
|
|
type="button"
|
|
onClick={() => createMutation.mutate()}
|
|
disabled={createMutation.isPending || !form.reason.trim()}
|
|
>
|
|
提交应急修正
|
|
</Button>
|
|
</div>
|
|
</SectionPanel>
|
|
) : (
|
|
<SectionPanel title="企微处理入口" description="普通员工不在 OA 里重复发起补卡、外出或加班。">
|
|
<div className="rounded-lg border border-emerald-200 bg-emerald-50 px-4 py-4 text-sm leading-6 text-emerald-800">
|
|
请在企业微信提交补卡、外出和加班。OA 当前只展示已同步的解释、历史记录和异常摘要;需要应急接管时由 HR 或管理员处理。
|
|
</div>
|
|
</SectionPanel>
|
|
)}
|
|
|
|
<SectionPanel title="修正记录" description="展示已留存的解释和应急修正记录,作为月度考勤摘要依据。">
|
|
{correctionsQuery.isLoading ? (
|
|
<TableSkeleton rows={5} />
|
|
) : correctionsQuery.isError ? (
|
|
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
|
{resolveErrorMessage(correctionsQuery.error)}
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>日期</TableHead>
|
|
<TableHead>类型</TableHead>
|
|
<TableHead>申请人</TableHead>
|
|
<TableHead>状态</TableHead>
|
|
<TableHead>原因</TableHead>
|
|
<TableHead>操作</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{correctionItems.map((item) => (
|
|
<TableRow key={item.id}>
|
|
<TableCell className="whitespace-nowrap">{item.workDate ?? "-"}</TableCell>
|
|
<TableCell>{correctionKindLabel(item.kind)}</TableCell>
|
|
<TableCell>{item.owner?.name ?? "-"}</TableCell>
|
|
<TableCell>
|
|
<span className={`inline-flex rounded-full border px-2 py-0.5 text-xs font-medium ${statusClass(item.status)}`}>
|
|
{correctionStatusLabel(item.status)}
|
|
</span>
|
|
</TableCell>
|
|
<TableCell className="max-w-[320px] truncate">{item.reason}</TableCell>
|
|
<TableCell>
|
|
{item.status === "reviewing" && ownCorrectionIds.has(item.id) ? (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => cancelMutation.mutate(item.id)}
|
|
disabled={cancelMutation.isPending}
|
|
>
|
|
撤回
|
|
</Button>
|
|
) : (
|
|
<span className="text-sm text-slate-400">-</span>
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
{!correctionItems.length ? (
|
|
<TableRow>
|
|
<TableCell colSpan={6} className="py-8 text-center text-sm text-slate-500">
|
|
暂无修正申请
|
|
</TableCell>
|
|
</TableRow>
|
|
) : null}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</SectionPanel>
|
|
</TabsContent>
|
|
|
|
{canSync ? (
|
|
<TabsContent value="sync" className="mt-4 grid gap-4 lg:grid-cols-[minmax(0,360px)_minmax(0,1fr)]">
|
|
<SectionPanel title="手动同步" description="只同步企微打卡摘要;缺少企微打卡 Secret 或权限时记录为跳过。">
|
|
<div className="space-y-4">
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="syncStartDate">开始日期</Label>
|
|
<Input
|
|
id="syncStartDate"
|
|
type="date"
|
|
value={syncRange.startDate}
|
|
onChange={(event) => setSyncRange((prev) => ({ ...prev, startDate: event.target.value }))}
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="syncEndDate">结束日期</Label>
|
|
<Input
|
|
id="syncEndDate"
|
|
type="date"
|
|
value={syncRange.endDate}
|
|
onChange={(event) => setSyncRange((prev) => ({ ...prev, endDate: event.target.value }))}
|
|
/>
|
|
</div>
|
|
<Button type="button" onClick={() => syncMutation.mutate()} disabled={syncMutation.isPending}>
|
|
同步企微打卡
|
|
</Button>
|
|
</div>
|
|
</SectionPanel>
|
|
|
|
<SectionPanel title="同步批次" description="仅显示状态、数量和错误码,不显示密钥、Token、位置或原始 payload。">
|
|
{syncRunsQuery.isLoading ? (
|
|
<TableSkeleton rows={5} />
|
|
) : syncRunsQuery.isError ? (
|
|
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
|
|
{resolveErrorMessage(syncRunsQuery.error)}
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>时间范围</TableHead>
|
|
<TableHead>状态</TableHead>
|
|
<TableHead>人员/记录</TableHead>
|
|
<TableHead>错误码</TableHead>
|
|
<TableHead>触发时间</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{(syncRunsQuery.data?.items ?? []).map((item) => (
|
|
<TableRow key={item.id}>
|
|
<TableCell>{item.startDate ?? "-"} ~ {item.endDate ?? "-"}</TableCell>
|
|
<TableCell>{syncRunStatusLabel(item.status)}</TableCell>
|
|
<TableCell>{item.employeeCount} / {item.recordCount}</TableCell>
|
|
<TableCell>{item.errorCode ?? "-"}</TableCell>
|
|
<TableCell>{formatDateTime(item.createdAt)}</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)}
|
|
</SectionPanel>
|
|
</TabsContent>
|
|
) : null}
|
|
</Tabs>
|
|
</PageScaffoldLike>
|
|
);
|
|
}
|
|
|
|
function joinSmallText(items: Array<string | null | undefined>) {
|
|
return items.map((item) => item?.trim()).filter((item): item is string => Boolean(item)).join(" · ");
|
|
}
|
|
|
|
function PageScaffoldLike({ children }: { children: ReactNode }) {
|
|
return <div className="space-y-6">{children}</div>;
|
|
}
|
|
|
|
export default AttendancePage;
|