Fix attendance personnel date ranges
This commit is contained in:
parent
612c057b17
commit
b14fd0f04a
@ -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";
|
||||
}
|
||||
|
||||
163
frontend/src/components/attendance/personnel-day-meta.js
Normal file
163
frontend/src/components/attendance/personnel-day-meta.js
Normal 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;
|
||||
}
|
||||
@ -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);
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user