From 69c3ccc75bf5d36faa4b94f4f2710403b0901e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=B3=D0=BE=D1=80=D1=8C=20=D0=9D=D0=B0=D0=B4=D0=B5?= =?UTF-8?q?=D0=B8=D0=BD?= Date: Tue, 1 Sep 2026 15:15:23 +0300 Subject: [PATCH] Add payroll work date picker with period-aware selection --- .../finance/PayrollDailyWorkSection.tsx | 72 ++++--- .../finance/PayrollWorkDatePicker.tsx | 183 ++++++++++++++++++ .../src/lib/payroll-work-calendar.test.js | 30 +++ frontend/src/lib/payroll-work-calendar.ts | 76 ++++++++ frontend/src/lib/payroll.test.js | 13 ++ frontend/src/lib/payroll.ts | 10 + 6 files changed, 353 insertions(+), 31 deletions(-) create mode 100644 frontend/src/components/finance/PayrollWorkDatePicker.tsx create mode 100644 frontend/src/lib/payroll-work-calendar.test.js create mode 100644 frontend/src/lib/payroll-work-calendar.ts diff --git a/frontend/src/components/finance/PayrollDailyWorkSection.tsx b/frontend/src/components/finance/PayrollDailyWorkSection.tsx index 17757064..1b04d72f 100644 --- a/frontend/src/components/finance/PayrollDailyWorkSection.tsx +++ b/frontend/src/components/finance/PayrollDailyWorkSection.tsx @@ -11,7 +11,7 @@ import { import { useCallback, useEffect, useMemo, useState, type FormEvent } from "react"; import { createPortal } from "react-dom"; -import { ProjectSelectMenu } from "@/components/ui/ProjectSelectMenu"; +import { PayrollWorkDatePicker } from "@/components/finance/PayrollWorkDatePicker"; import { apiClient } from "@/lib/api"; import type { PayrollOwnDailyWorkEntry, @@ -19,7 +19,14 @@ import type { PayrollOwnWorkPeriod, } from "@/lib/api/finance"; import { approvalStatusMeta, getPayrollAdminError, periodStatusMeta } from "@/lib/payroll-admin"; -import { formatPayrollDate, formatPayrollPoints, getPayrollPeriodRange, normalizePayrollPoints } from "@/lib/payroll"; +import { + findPayrollPeriodForDate, + formatPayrollDate, + formatPayrollPoints, + getPayrollPeriodLabel, + getPayrollPeriodRange, + normalizePayrollPoints, +} from "@/lib/payroll"; function localDateInput(): string { const now = new Date(); @@ -130,6 +137,20 @@ export function PayrollDailyWorkSection({ setNotice(null); }; + const selectAvailableWorkDate = (date: string) => { + if (!workspace) return; + const period = findPayrollPeriodForDate(workspace.periods, date); + if (!period) { + setError("Для выбранной даты нет расчётного периода."); + return; + } + if (period.id !== selectedPeriodId) { + void loadWorkspace(period.id, date); + return; + } + selectWorkDate(date); + }; + const submit = async (event: FormEvent) => { event.preventDefault(); if (!selectedPeriod || !canSubmit) return; @@ -177,17 +198,14 @@ export function PayrollDailyWorkSection({ const aggregateStatus = aggregate ? approvalStatusMeta[aggregate.status] : null; const periodStatus = selectedPeriod ? periodStatusMeta[selectedPeriod.status] : null; const periodSelector = ( - ({ - value: period.id, - label: period.name || period.code, - icon: , - }))} - onChange={(periodId) => void loadWorkspace(periodId)} - ariaLabel="Расчётный период выработки" - variant="field" - className="w-52 max-w-full" + void loadWorkspace(periodId)} /> ); const periodSelectorTarget = periodSelectorTargetId && typeof document !== "undefined" @@ -199,26 +217,18 @@ export function PayrollDailyWorkSection({ {periodSelectorTarget ? createPortal(periodSelector, periodSelectorTarget) : null}
-
+
-

- {selectedEntry ? "Изменить запись за день" : "Добавить запись за день"} -

+
+

+ {selectedEntry ? "Изменить запись за день" : "Добавить запись за день"} +

+

+ {formatPayrollDate(workDate)}{selectedPeriod ? ` · ${getPayrollPeriodLabel(selectedPeriod)}` : ""} +

+
-
- +
+ ); +} diff --git a/frontend/src/lib/payroll-work-calendar.test.js b/frontend/src/lib/payroll-work-calendar.test.js new file mode 100644 index 00000000..955a8ece --- /dev/null +++ b/frontend/src/lib/payroll-work-calendar.test.js @@ -0,0 +1,30 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { buildPayrollWorkCalendarDays } from "./payroll-work-calendar.ts"; + +test("payroll work calendar marks recorded, empty and unavailable dates", () => { + const days = buildPayrollWorkCalendarDays({ + period: { date_from: "2026-09-01", date_to: "2026-09-30" }, + selectedDate: "2026-09-14", + entryDates: ["2026-09-01", "2026-09-14"], + today: "2026-09-20", + }); + + const recorded = days.find((day) => day.date === "2026-09-14"); + const empty = days.find((day) => day.date === "2026-09-15"); + const future = days.find((day) => day.date === "2026-09-21"); + const outside = days.find((day) => day.date === "2026-08-31"); + + assert.deepEqual( + { hasEntry: recorded?.hasEntry, isSelected: recorded?.isSelected, selectable: recorded?.selectable }, + { hasEntry: true, isSelected: true, selectable: true }, + ); + assert.deepEqual( + { hasEntry: empty?.hasEntry, selectable: empty?.selectable }, + { hasEntry: false, selectable: true }, + ); + assert.equal(future?.selectable, false); + assert.equal(outside?.inPeriod, false); + assert.equal(outside?.selectable, false); +}); diff --git a/frontend/src/lib/payroll-work-calendar.ts b/frontend/src/lib/payroll-work-calendar.ts new file mode 100644 index 00000000..2558f6f0 --- /dev/null +++ b/frontend/src/lib/payroll-work-calendar.ts @@ -0,0 +1,76 @@ +type PayrollWorkCalendarPeriod = { + date_from: string; + date_to: string; +}; + +export type PayrollWorkCalendarDay = { + date: string; + day: number; + inDisplayMonth: boolean; + inPeriod: boolean; + isFuture: boolean; + isSelected: boolean; + isToday: boolean; + hasEntry: boolean; + selectable: boolean; +}; + +const DATE_ONLY_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/; + +const formatDateKey = (date: Date): string => { + 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}`; +}; + +const parseDateOnly = (value: string): Date | null => { + const match = DATE_ONLY_PATTERN.exec(value); + if (!match) return null; + const date = new Date(Number(match[1]), Number(match[2]) - 1, Number(match[3])); + return Number.isNaN(date.getTime()) ? null : date; +}; + +export function buildPayrollWorkCalendarDays({ + period, + selectedDate, + entryDates, + today, +}: { + period: PayrollWorkCalendarPeriod; + selectedDate: string; + entryDates: readonly string[]; + today: string; +}): PayrollWorkCalendarDay[] { + const selected = parseDateOnly(selectedDate); + const periodStart = parseDateOnly(period.date_from); + if (!periodStart) return []; + + const displayDate = selected && selectedDate >= period.date_from && selectedDate <= period.date_to + ? selected + : periodStart; + const firstOfMonth = new Date(displayDate.getFullYear(), displayDate.getMonth(), 1); + const mondayIndex = (firstOfMonth.getDay() + 6) % 7; + const gridStart = new Date(firstOfMonth); + gridStart.setDate(firstOfMonth.getDate() - mondayIndex); + const entries = new Set(entryDates); + + return Array.from({ length: 42 }, (_, index) => { + const date = new Date(gridStart); + date.setDate(gridStart.getDate() + index); + const dateKey = formatDateKey(date); + const inPeriod = dateKey >= period.date_from && dateKey <= period.date_to; + const isFuture = dateKey > today; + return { + date: dateKey, + day: date.getDate(), + inDisplayMonth: date.getMonth() === displayDate.getMonth(), + inPeriod, + isFuture, + isSelected: dateKey === selectedDate, + isToday: dateKey === today, + hasEntry: entries.has(dateKey), + selectable: inPeriod && !isFuture, + }; + }); +} diff --git a/frontend/src/lib/payroll.test.js b/frontend/src/lib/payroll.test.js index 58193e0b..dd9d5fe8 100644 --- a/frontend/src/lib/payroll.test.js +++ b/frontend/src/lib/payroll.test.js @@ -2,6 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import { + findPayrollPeriodForDate, formatPayrollDate, formatPayrollMoney, getPayrollLineDirection, @@ -34,6 +35,18 @@ test("payroll formatters keep date-only values in their calendar day", () => { assert.equal(formatPayrollMoney("not-a-number", "RUB"), "—"); }); +test("payroll work date selects the period containing that date", () => { + const periods = [ + { id: 2, date_from: "2026-09-01", date_to: "2026-09-30" }, + { id: 1, date_from: "2026-08-01", date_to: "2026-08-31" }, + ]; + + assert.equal(findPayrollPeriodForDate(periods, "2026-09-14")?.id, 2); + assert.equal(findPayrollPeriodForDate(periods, "2026-08-31")?.id, 1); + assert.equal(findPayrollPeriodForDate(periods, "2026-07-31"), null); + assert.equal(findPayrollPeriodForDate(periods, "not-a-date"), null); +}); + test("payroll lines are grouped and debit-like lines have a negative direction", () => { const base = { code: "BASE", diff --git a/frontend/src/lib/payroll.ts b/frontend/src/lib/payroll.ts index 9fae52f2..70d36ee2 100644 --- a/frontend/src/lib/payroll.ts +++ b/frontend/src/lib/payroll.ts @@ -15,6 +15,8 @@ export type PayrollLineGroup = { lines: PayrollStatementLine[]; }; +type PayrollPeriodDateRange = Pick; + function parseDateOnly(value: string): Date | null { const match = DATE_ONLY_PATTERN.exec(value); if (!match) return null; @@ -107,6 +109,14 @@ export function getPayrollPeriodRange(period: PayrollPeriodSummary): string { return `${formatPayrollDate(period.date_from)} — ${formatPayrollDate(period.date_to)}`; } +export function findPayrollPeriodForDate( + periods: readonly T[], + date: string, +): T | null { + if (!DATE_ONLY_PATTERN.test(date)) return null; + return periods.find((period) => date >= period.date_from && date <= period.date_to) || null; +} + export function groupPayrollLines(lines: PayrollStatementLine[]): PayrollLineGroup[] { const groups: Record = { accruals: [],