文件
hyb-oa/client/src/pages/AttendancePage.tsx
T

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;