From 80a2ef8f2938b59d2e1160f7afef421e49f10496 Mon Sep 17 00:00:00 2001 From: Pontus Backman Date: Fri, 30 Oct 2026 22:20:15 +0100 Subject: [PATCH 1/3] feat(time-tracking): lock and unlock time from a sign-off panel Replace the unlocked-period alerts with a panel beside the entries that reviews what's due and locks it in Kleer. It stays one quiet line until a week or month lock is due, then escalates up to a nav dot and a daily toast when overdue. The Kleer adapter splits ranges at month ends because Kleer refuses a range across months. Unlock reads statuses back because Kleer answers success for days it refuses. Co-Authored-By: Claude Opus 5.5 --- .docs/kleer.md | 14 + app/src/components/side-nav.tsx | 62 +- app/src/lib/api/mutations/time-tracking.ts | 49 ++ app/src/lib/period-lock/lock-periods.test.js | 95 +++ app/src/lib/period-lock/lock-periods.ts | 319 ++++++++ app/src/lib/period-lock/use-lock-days.ts | 200 +++++ app/src/lib/time-tracking-provider.ts | 13 - .../-components/not-locked-alert.tsx | 149 ---- .../-components/period-lock/lock-glyph.tsx | 42 + .../-components/period-lock/sign-off.tsx | 746 ++++++++++++++++++ .../-components/time-entries-list.tsx | 2 + .../routes/_layout/time-tracking/index.tsx | 24 +- kleer/src/client.rs | 19 +- kleer/src/types.rs | 11 + toki-api/src/adapters/outbound/kleer/mod.rs | 74 +- .../src/domain/ports/inbound/time_tracking.rs | 9 + .../domain/ports/outbound/time_tracking.rs | 11 + toki-api/src/domain/services/time_tracking.rs | 112 ++- toki-api/src/routes/time_tracking/calendar.rs | 52 +- toki-api/src/routes/time_tracking/mod.rs | 4 +- 20 files changed, 1833 insertions(+), 174 deletions(-) create mode 100644 app/src/lib/period-lock/lock-periods.test.js create mode 100644 app/src/lib/period-lock/lock-periods.ts create mode 100644 app/src/lib/period-lock/use-lock-days.ts delete mode 100644 app/src/routes/_layout/time-tracking/-components/not-locked-alert.tsx create mode 100644 app/src/routes/_layout/time-tracking/-components/period-lock/lock-glyph.tsx create mode 100644 app/src/routes/_layout/time-tracking/-components/period-lock/sign-off.tsx 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/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 */} -