Files
edr-platform/apps/edr-hr-web/src/features/attendance/components/ClockWidget.tsx
2026-08-26 23:57:26 +03:00

128 lines
4.2 KiB
TypeScript

import {
Alert,
Badge,
Button,
Card,
Group,
Stack,
Text,
} from "@mantine/core";
import { IconAlertTriangle, IconLogin2, IconLogout2 } from "@tabler/icons-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { apiErrorMessage } from "@/auth/http";
import { checkIn, checkOut, getToday } from "../api";
import { AttendanceStatusBadge, formatMinutes, formatTime } from "./AttendanceStatusBadge";
/**
* Clock in and out.
*
* The button offered is derived from today's record rather than from a local
* flag: a page reload, a second tab or a punch from the door reader must all
* lead to the same state, and a client-side "have I clocked in?" would disagree
* with the server the moment any of those happened.
*/
export function ClockWidget() {
const queryClient = useQueryClient();
const [now, setNow] = useState(new Date());
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const timer = window.setInterval(() => setNow(new Date()), 30_000);
return () => window.clearInterval(timer);
}, []);
const today = useQuery({ queryKey: ["attendance-today"], queryFn: getToday });
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ["attendance-today"] });
queryClient.invalidateQueries({ queryKey: ["my-attendance"] });
queryClient.invalidateQueries({ queryKey: ["attendance-summary"] });
};
const punchIn = useMutation({
mutationFn: () => checkIn(),
onSuccess: () => { invalidate(); setError(null); },
onError: (err) => setError(apiErrorMessage(err)),
});
const punchOut = useMutation({
mutationFn: () => checkOut(),
onSuccess: () => { invalidate(); setError(null); },
onError: (err) => setError(apiErrorMessage(err)),
});
const record = today.data;
const clockedIn = Boolean(record?.checkIn && !record?.checkOut);
const done = Boolean(record?.checkIn && record?.checkOut);
return (
<Card withBorder radius="md" padding="lg">
<Group justify="space-between" align="flex-start" wrap="nowrap">
<Stack gap={2}>
<Text size="xs" c="dimmed">
{now.toLocaleDateString(undefined, {
weekday: "long",
day: "numeric",
month: "long",
})}
</Text>
<Text size="2rem" fw={700} lh={1.1}>
{now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })}
</Text>
<Group gap="xs" mt={6}>
{record && <AttendanceStatusBadge status={record.status} />}
{record?.isRegularized && (
<Badge size="sm" variant="light" color="gray">
corrected
</Badge>
)}
</Group>
</Stack>
<Stack gap="xs" align="flex-end">
{!done && (
<Button
size="md"
color={clockedIn ? "red" : "blue"}
leftSection={
clockedIn ? <IconLogout2 size={18} /> : <IconLogin2 size={18} />
}
loading={punchIn.isPending || punchOut.isPending}
onClick={() => (clockedIn ? punchOut.mutate() : punchIn.mutate())}
data-testid={clockedIn ? "attendance-clock-out" : "attendance-clock-in"}
>
{clockedIn ? "Clock out" : "Clock in"}
</Button>
)}
{done && (
<Text size="sm" c="dimmed">
Done for today
</Text>
)}
{record?.checkIn && (
<Text size="xs" c="dimmed">
In {formatTime(record.checkIn)}
{record.checkOut && ` · Out ${formatTime(record.checkOut)}`}
</Text>
)}
{record?.workedMinutes ? (
<Text size="xs" c="dimmed">
{formatMinutes(record.workedMinutes)} worked
{record.lateMinutes > 0 &&
` · ${formatMinutes(record.lateMinutes)} late`}
</Text>
) : null}
</Stack>
</Group>
{error && (
<Alert color="red" mt="md" variant="light" icon={<IconAlertTriangle size={18} />}>
{error}
</Alert>
)}
</Card>
);
}