diff --git a/.docs/kleer.md b/.docs/kleer.md index a829e269..b5a9014c 100644 --- a/.docs/kleer.md +++ b/.docs/kleer.md @@ -68,6 +68,8 @@ Permanent notes for Toki's Kleer time-tracking integration. Read this before cha - `POST /company/{companyId}/event/{eventId}` - `DELETE /company/{companyId}/event/{eventId}` - `GET /company/{companyId}/event/statuses` +- `POST /company/{companyId}/event/approve` +- `POST /company/{companyId}/event/unlock` - `GET /company/{companyId}/payroll/user/{userId}` - `GET /company/{companyId}/payroll/user/{userId}/schedule/{startDate}/to/{endDate}` - `GET /company/{companyId}/payroll/monthreport/user/{userId}/year/{year}/month/{month}` @@ -92,6 +94,18 @@ Permanent notes for Toki's Kleer time-tracking integration. Read this before cha - `periodFlexHours` - Toki estimates period flex as `coveredHours - scheduledHours`, where `coveredHours = workedHours + absenceHours`. `periodFlexHours` is a selected-period estimate, not Kleer's stored flex balance. +## Locking + +Toki's lock and unlock are Kleer's `POST /event/approve` and `POST /event/unlock`, with `{ "user-id": { "id": 1 }, "start-date": "...", "end-date": "..." }`. `POST /event/certify` takes the same body; Toki does not call it. Validated against `test-api` on 2026-10-30; production has not been exercised: + +- A range must stay inside one calendar month, otherwise Kleer answers `500` with `Start month and end month must be the same`. This is why Kleer's own menu splits a week crossing a month end. The adapter splits ranges and sends one request per month, so a failure can leave earlier months locked. Locking is idempotent, so a retry finishes the job. +- A reversed range is accepted with `200` and acts on its mirror image. The domain service rejects it. +- Locking covers empty and future days. `GET /event/statuses` omits days that were never locked; once locked or unlocked they are listed, as `OPEN` after an unlock. +- A locked day refuses new and edited events with a `500`. +- Locking over a day certified by someone else leaves it certified. +- Unlocking a day certified by someone else answers `200` with a `message` listing the refused days and leaves them certified, so read the statuses back. Two weekend days in a mixed range opened anyway; unexplained, so Toki sends only approved days. A day the integration account certified itself could be unlocked. +- Kleer records the integration account, not the person, as `registration-user` on the events it locks. + ## Flex Balance - Kleer support said on 2026-04-27 that the flex balance was not available through the API. It is now: `GET /payroll/user/{userId}` returns `comp-time-balance`, the comp time (flex) balance in hours, negative when the employee owes hours. It reads the same from the production and test APIs. diff --git a/Cargo.lock b/Cargo.lock index 80c842de..cb33eadb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3063,9 +3063,9 @@ dependencies = [ [[package]] name = "libz-sys" -version = "1.1.30" +version = "1.1.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f710a23e6dbf193214fd46ca56a9d6864e550abe86202184532ae7275e46de19" +checksum = "ff20308ecabd635a2dc1dd5834a86c462cca20efba1d5d3ddab1f8aea3455cb2" dependencies = [ "cc", "libc", diff --git a/app/src/components/side-nav.tsx b/app/src/components/side-nav.tsx index aab3cc32..2d73d5a3 100644 --- a/app/src/components/side-nav.tsx +++ b/app/src/components/side-nav.tsx @@ -10,10 +10,18 @@ import { } from "lucide-react"; import { cn } from "@/lib/utils"; import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip"; -import { Link, LinkProps } from "@tanstack/react-router"; +import { + Link, + LinkProps, + useNavigate, + useRouterState, +} from "@tanstack/react-router"; import { ScrollArea } from "./ui/scroll-area"; import { router } from "@/main"; import { useQuery } from "@tanstack/react-query"; +import { toast } from "sonner"; +import { useDueLockAlert } from "@/lib/period-lock/use-lock-days"; +import type { DueLock } from "@/lib/period-lock/lock-periods"; import { differsQueries } from "@/lib/api/queries/differs"; import { userQueries } from "@/lib/api/queries/user"; import { NotificationsPopover } from "./notifications-popover/notifications-popover"; @@ -74,7 +82,35 @@ const BOARD_MENU_ORGANIZATION = "lerumsdjur"; type UsedLink = (typeof MENU_ITEMS)[number]["to"]; +const LOCK_REMINDER_KEY = "time-tracking-lock-reminded-on"; + +/** Overdue time gets one toast a day, wherever the person is working. */ +function useOverdueLockReminder(due: DueLock | null) { + const navigate = useNavigate(); + const pathname = useRouterState({ select: (s) => s.location.pathname }); + const isOverdue = due !== null && due.severity !== "due"; + const title = due?.title; + + React.useEffect(() => { + if (!isOverdue || pathname.startsWith("/time-tracking")) return; + + const today = new Date().toDateString(); + if (localStorage.getItem(LOCK_REMINDER_KEY) === today) return; + + localStorage.setItem(LOCK_REMINDER_KEY, today); + toast.warning(title, { + action: { + label: "Review", + onClick: () => void navigate({ to: "/time-tracking" }), + }, + }); + }, [isOverdue, pathname, title, navigate]); +} + export function SideNavWrapper({ children }: { children: React.ReactNode }) { + const dueLock = useDueLockAlert(); + useOverdueLockReminder(dueLock); + const { data: differs } = useQuery({ ...differsQueries.differs(), }); @@ -126,7 +162,7 @@ export function SideNavWrapper({ children }: { children: React.ReactNode }) {
{/* Navigation items */} -