mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-02 11:33:41 +00:00
128 lines
4.2 KiB
TypeScript
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>
|
|
);
|
|
}
|