From b14fd0f04a287ba5965f87891ea938eb0e1872f8 Mon Sep 17 00:00:00 2001 From: lizerk Date: Tue, 5 May 2026 09:34:48 +0300 Subject: [PATCH] Fix attendance personnel date ranges --- .../attendance/EmployeeAttendanceCard.tsx | 105 +---------- .../attendance/personnel-day-meta.js | 163 ++++++++++++++++++ .../attendance/personnel-day-meta.test.js | 86 +++++++++ 3 files changed, 250 insertions(+), 104 deletions(-) create mode 100644 frontend/src/components/attendance/personnel-day-meta.js create mode 100644 frontend/src/components/attendance/personnel-day-meta.test.js diff --git a/frontend/src/components/attendance/EmployeeAttendanceCard.tsx b/frontend/src/components/attendance/EmployeeAttendanceCard.tsx index 3d99e257..ef7f6cef 100644 --- a/frontend/src/components/attendance/EmployeeAttendanceCard.tsx +++ b/frontend/src/components/attendance/EmployeeAttendanceCard.tsx @@ -33,6 +33,7 @@ import type { PaginatedAttendanceRecords, } from "@/lib/api/attendance"; import type { EmployeeAction } from "@/types/api"; +import { getPersonnelDayMeta } from "./personnel-day-meta.js"; type EmployeeAttendanceCardProps = { attendanceAliases?: string[] | null; @@ -173,110 +174,6 @@ function hasWorked(record: AttendanceRecord) { return Boolean(record.arrival_time || record.departure_time || hours > 0); } -function getDateEnd(value: unknown) { - if (!value) return null; - const date = new Date(String(value)); - if (Number.isNaN(date.getTime())) return null; - date.setHours(23, 59, 59, 999); - return date; -} - -function getPersonnelDayMeta( - actions: EmployeeAction[] | null | undefined, - dateValue: unknown, - record?: AttendanceRecord, -): PersonnelDayMeta | null { - if (record?.personnel_status && record.personnel_status !== "normal") { - const personnelStatus = String(record.personnel_status); - return { - action: { - id: Number(record.personnel_action || 0), - employee: 0, - action: personnelStatus, - action_display: record.personnel_status_label || record.personnel_status, - date: String(dateValue || ""), - }, - className: personnelStatus === "dismissed" - ? "text-red-400" - : personnelStatus === "remote" - ? "text-sky-400" - : "text-amber-500", - label: record.personnel_status_label || record.personnel_status, - nonWorking: record.effective_is_workday === false, - }; - } - - if (!actions?.length) return null; - - const dayEnd = getDateEnd(dateValue); - if (!dayEnd) return null; - - const activeAction = actions - .filter((action) => { - const actionDate = new Date(action.date); - return !Number.isNaN(actionDate.getTime()) && actionDate <= dayEnd; - }) - .sort((left, right) => new Date(right.date).getTime() - new Date(left.date).getTime())[0]; - - if (!activeAction) return null; - - if (activeAction.action === "on_leave") { - return { - action: activeAction, - className: "text-amber-500", - label: activeAction.action_display || "В отпуске", - nonWorking: true, - }; - } - - if (activeAction.action === "on_sick_leave") { - return { - action: activeAction, - className: "text-amber-500", - label: activeAction.action_display || "На больничном", - nonWorking: true, - }; - } - - if (activeAction.action === "on_day_off") { - return { - action: activeAction, - className: "text-amber-500", - label: activeAction.action_display || "В отгуле", - nonWorking: true, - }; - } - - if (activeAction.action === "on_maternity") { - return { - action: activeAction, - className: "text-amber-500", - label: activeAction.action_display || "В декрете", - nonWorking: true, - }; - } - - if (activeAction.action === "dismissed") { - return { - action: activeAction, - className: "text-red-400", - label: activeAction.action_display || "Уволен", - nonWorking: true, - }; - } - - if (activeAction.action === "remote") { - return { - action: activeAction, - className: "text-sky-400", - label: activeAction.action_display || "На удалёнке", - nonWorking: false, - }; - } - - return null; -} - function isRemotePersonnelMeta(personnelMeta?: PersonnelDayMeta | null) { return personnelMeta?.action.action === "remote"; } diff --git a/frontend/src/components/attendance/personnel-day-meta.js b/frontend/src/components/attendance/personnel-day-meta.js new file mode 100644 index 00000000..42ca1512 --- /dev/null +++ b/frontend/src/components/attendance/personnel-day-meta.js @@ -0,0 +1,163 @@ +const TEMPORARY_ACTION_META = { + on_leave: { + className: "text-amber-500", + label: "В отпуске", + nonWorking: true, + priority: 2, + }, + on_sick_leave: { + className: "text-amber-500", + label: "На больничном", + nonWorking: true, + priority: 0, + }, + on_day_off: { + className: "text-amber-500", + label: "В отгуле", + nonWorking: true, + priority: 3, + }, + on_maternity: { + className: "text-amber-500", + label: "В декрете", + nonWorking: true, + priority: 1, + }, +}; + +const PERMANENT_ACTION_META = { + dismissed: { + className: "text-red-400", + label: "Уволен", + nonWorking: true, + }, + remote: { + className: "text-sky-400", + label: "На удалёнке", + nonWorking: false, + }, +}; + +function dateKey(value) { + if (!value) return null; + const stringValue = String(value); + const isoDate = stringValue.match(/^(\d{4}-\d{2}-\d{2})/); + if (isoDate) return isoDate[1]; + + const date = new Date(stringValue); + if (Number.isNaN(date.getTime())) return null; + + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; +} + +function actionTimestamp(action) { + const date = new Date(action.date); + return Number.isNaN(date.getTime()) ? 0 : date.getTime(); +} + +function actionId(action) { + return Number(action.id || 0); +} + +function actionLabel(action, fallback) { + return action.action_display || fallback; +} + +function metaFromAction(action, meta) { + return { + action, + className: meta.className, + label: actionLabel(action, meta.label), + nonWorking: meta.nonWorking, + }; +} + +function metaFromRecord(record, dateValue) { + if (!record?.personnel_status || record.personnel_status === "normal") { + return null; + } + + const personnelStatus = String(record.personnel_status); + return { + action: { + id: Number(record.personnel_action || 0), + employee: 0, + action: personnelStatus, + action_display: record.personnel_status_label || record.personnel_status, + date: String(dateValue || ""), + }, + className: personnelStatus === "dismissed" + ? "text-red-400" + : personnelStatus === "remote" + ? "text-sky-400" + : "text-amber-500", + label: record.personnel_status_label || record.personnel_status, + nonWorking: record.effective_is_workday === false, + }; +} + +function activeTemporaryAction(actions, targetDateKey) { + return actions + .filter((action) => { + const meta = TEMPORARY_ACTION_META[action.action]; + if (!meta) return false; + + const from = dateKey(action.date); + if (!from || from > targetDateKey) return false; + + const to = dateKey(action.date_to) || from; + return to >= targetDateKey; + }) + .sort((left, right) => { + const leftMeta = TEMPORARY_ACTION_META[left.action]; + const rightMeta = TEMPORARY_ACTION_META[right.action]; + return ( + leftMeta.priority - rightMeta.priority + || actionTimestamp(right) - actionTimestamp(left) + || actionId(right) - actionId(left) + ); + })[0] || null; +} + +function latestRelevantAction(actions, targetDateKey) { + return actions + .filter((action) => { + const from = dateKey(action.date); + return Boolean(from && from <= targetDateKey); + }) + .sort((left, right) => ( + actionTimestamp(right) - actionTimestamp(left) + || actionId(right) - actionId(left) + ))[0] || null; +} + +export function getPersonnelDayMeta(actions, dateValue, record) { + const recordMeta = metaFromRecord(record, dateValue); + if (recordMeta) return recordMeta; + + if (!actions?.length) return null; + + const targetDateKey = dateKey(dateValue); + if (!targetDateKey) return null; + + const temporaryAction = activeTemporaryAction(actions, targetDateKey); + if (temporaryAction) { + return metaFromAction( + temporaryAction, + TEMPORARY_ACTION_META[temporaryAction.action], + ); + } + + const permanentAction = latestRelevantAction(actions, targetDateKey); + if (permanentAction && PERMANENT_ACTION_META[permanentAction.action]) { + return metaFromAction( + permanentAction, + PERMANENT_ACTION_META[permanentAction.action], + ); + } + + return null; +} diff --git a/frontend/src/components/attendance/personnel-day-meta.test.js b/frontend/src/components/attendance/personnel-day-meta.test.js new file mode 100644 index 00000000..6bf1f282 --- /dev/null +++ b/frontend/src/components/attendance/personnel-day-meta.test.js @@ -0,0 +1,86 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { getPersonnelDayMeta } from "./personnel-day-meta.js"; + +test("single-day day off does not leak into following attendance days", () => { + const actions = [ + { + id: 10, + employee: 1, + action: "on_day_off", + action_display: "В отгуле", + date: "2026-04-21T00:00:00+03:00", + date_to: "2026-04-21", + }, + ]; + + assert.equal( + getPersonnelDayMeta(actions, "2026-04-21", { + personnel_status: "normal", + effective_is_workday: true, + })?.label, + "В отгуле", + ); + + assert.equal( + getPersonnelDayMeta(actions, "2026-04-22", { + personnel_status: "normal", + effective_is_workday: true, + }), + null, + ); +}); + +test("temporary action without date_to is limited to its own day", () => { + const actions = [ + { + id: 11, + employee: 1, + action: "on_day_off", + action_display: "В отгуле", + date: "2026-04-21T00:00:00+03:00", + date_to: null, + }, + ]; + + assert.equal(getPersonnelDayMeta(actions, "2026-04-22"), null); +}); + +test("multi-day leave remains active until date_to inclusively", () => { + const actions = [ + { + id: 12, + employee: 1, + action: "on_leave", + action_display: "В отпуске", + date: "2026-04-20T00:00:00+03:00", + date_to: "2026-04-25", + }, + ]; + + assert.equal(getPersonnelDayMeta(actions, "2026-04-25")?.label, "В отпуске"); + assert.equal(getPersonnelDayMeta(actions, "2026-04-26"), null); +}); + +test("activating action after dismissal clears dismissed fallback", () => { + const actions = [ + { + id: 20, + employee: 1, + action: "dismissed", + action_display: "Уволен", + date: "2026-04-20T00:00:00+03:00", + }, + { + id: 21, + employee: 1, + action: "rehired", + action_display: "Восстановлен", + date: "2026-04-22T00:00:00+03:00", + }, + ]; + + assert.equal(getPersonnelDayMeta(actions, "2026-04-21")?.label, "Уволен"); + assert.equal(getPersonnelDayMeta(actions, "2026-04-22"), null); +});