Fix attendance personnel date ranges

This commit is contained in:
lizerk 2026-05-05 09:34:48 +03:00
parent 612c057b17
commit b14fd0f04a
3 changed files with 250 additions and 104 deletions

View File

@ -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";
}

View File

@ -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;
}

View File

@ -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);
});