Merge branch 'develop'

This commit is contained in:
admin i 2026-09-01 15:17:49 +03:00
commit a0c3254855
6 changed files with 353 additions and 31 deletions

View File

@ -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 = (
<ProjectSelectMenu
value={selectedPeriodId || workspace.periods[0].id}
options={workspace.periods.map((period) => ({
value: period.id,
label: period.name || period.code,
icon: <CalendarDays className="h-4 w-4" />,
}))}
onChange={(periodId) => void loadWorkspace(periodId)}
ariaLabel="Расчётный период выработки"
variant="field"
className="w-52 max-w-full"
<PayrollWorkDatePicker
periods={workspace.periods}
selectedPeriod={selectedPeriod}
selectedDate={workDate}
entries={workspace.entries}
disabled={loading || saving}
onSelectDate={selectAvailableWorkDate}
onSelectPeriod={(periodId) => void loadWorkspace(periodId)}
/>
);
const periodSelectorTarget = periodSelectorTargetId && typeof document !== "undefined"
@ -199,26 +217,18 @@ export function PayrollDailyWorkSection({
{periodSelectorTarget ? createPortal(periodSelector, periodSelectorTarget) : null}
<section className="app-surface rounded-2xl p-4 sm:p-5">
<form onSubmit={submit}>
<div className="flex items-center gap-2">
<div className="flex items-start gap-2">
<CalendarDays className="app-accent-text" size={18} />
<h2 className="text-base font-semibold text-[var(--foreground)]">
{selectedEntry ? "Изменить запись за день" : "Добавить запись за день"}
</h2>
<div className="min-w-0">
<h2 className="text-base font-semibold text-[var(--foreground)]">
{selectedEntry ? "Изменить запись за день" : "Добавить запись за день"}
</h2>
<p className="app-text-muted mt-0.5 text-xs">
{formatPayrollDate(workDate)}{selectedPeriod ? ` · ${getPayrollPeriodLabel(selectedPeriod)}` : ""}
</p>
</div>
</div>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-3">
<label>
<span className="app-field-label">Дата</span>
<input
type="date"
className="app-input w-full rounded-lg px-3 py-2.5 text-sm"
min={selectedPeriod?.date_from}
max={maxWorkDate}
value={workDate}
disabled={!selectedPeriod?.editable}
required
onChange={(event) => selectWorkDate(event.target.value)}
/>
</label>
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2">
<label>
<span className="app-field-label">Норма баллов</span>
<input

View File

@ -0,0 +1,183 @@
"use client";
import { CalendarDays, ChevronDown, ChevronLeft, ChevronRight } from "lucide-react";
import { useEffect, useMemo, useState } from "react";
import { ViewportAwareMenu } from "@/components/shared/ViewportAwareMenu";
import type {
PayrollOwnDailyWorkEntry,
PayrollOwnWorkPeriod,
} from "@/lib/api/finance";
import { getPayrollPeriodLabel } from "@/lib/payroll";
import { buildPayrollWorkCalendarDays } from "@/lib/payroll-work-calendar";
const weekdays = ["Пн", "Вт", "Ср", "Чт", "Пт", "Сб", "Вс"];
function localDateInput(): string {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, "0");
const day = String(now.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
}
function compactDate(value: string): string {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
return match ? `${match[3]}.${match[2]}.${match[1]}` : "Выберите дату";
}
export function PayrollWorkDatePicker({
periods,
selectedPeriod,
selectedDate,
entries,
disabled,
onSelectDate,
onSelectPeriod,
}: {
periods: PayrollOwnWorkPeriod[];
selectedPeriod: PayrollOwnWorkPeriod | null;
selectedDate: string;
entries: PayrollOwnDailyWorkEntry[];
disabled?: boolean;
onSelectDate: (date: string) => void;
onSelectPeriod: (periodId: number) => void;
}) {
const [open, setOpen] = useState(false);
const today = localDateInput();
const orderedPeriods = useMemo(
() => [...periods].sort((left, right) => left.date_from.localeCompare(right.date_from)),
[periods],
);
const selectedPeriodIndex = selectedPeriod
? orderedPeriods.findIndex((period) => period.id === selectedPeriod.id)
: -1;
const previousPeriod = selectedPeriodIndex > 0 ? orderedPeriods[selectedPeriodIndex - 1] : null;
const nextPeriod = selectedPeriodIndex >= 0 && selectedPeriodIndex < orderedPeriods.length - 1
? orderedPeriods[selectedPeriodIndex + 1]
: null;
const days = useMemo(
() => selectedPeriod
? buildPayrollWorkCalendarDays({
period: selectedPeriod,
selectedDate,
entryDates: entries.map((entry) => entry.work_date),
today,
})
: [],
[entries, selectedDate, selectedPeriod, today],
);
useEffect(() => {
if (!open) return undefined;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") setOpen(false);
};
window.addEventListener("keydown", closeOnEscape);
return () => window.removeEventListener("keydown", closeOnEscape);
}, [open]);
return (
<div className="relative w-44 max-w-full">
<button
type="button"
className="app-input flex h-11 w-full items-center gap-2 rounded-lg px-3 text-sm font-medium"
disabled={disabled || !selectedPeriod}
aria-label="Дата выработки"
aria-expanded={open}
aria-haspopup="dialog"
onClick={() => setOpen((value) => !value)}
>
<CalendarDays className="app-accent-text shrink-0" size={16} />
<span className="min-w-0 flex-1 truncate text-left">{compactDate(selectedDate)}</span>
<ChevronDown className={`app-text-muted shrink-0 transition ${open ? "rotate-180" : ""}`} size={15} />
</button>
{open && selectedPeriod ? (
<>
<button
type="button"
className="fixed inset-0 z-[50] cursor-default"
aria-label="Закрыть календарь"
onClick={() => setOpen(false)}
/>
<ViewportAwareMenu
role="dialog"
aria-label="Выбор даты выработки"
className="app-menu absolute right-0 top-full z-[60] mt-2 w-72 max-w-[calc(100vw-1.5rem)] rounded-xl p-3 shadow-xl"
>
<div className="flex items-center justify-between gap-2">
<button
type="button"
className="app-icon-button flex h-8 w-8 items-center justify-center rounded-full disabled:opacity-30"
disabled={!previousPeriod || disabled}
aria-label="Предыдущий расчётный период"
onClick={() => previousPeriod && onSelectPeriod(previousPeriod.id)}
>
<ChevronLeft size={16} />
</button>
<p className="min-w-0 flex-1 truncate text-center text-sm font-semibold text-[var(--foreground)]">
{getPayrollPeriodLabel(selectedPeriod)}
</p>
<button
type="button"
className="app-icon-button flex h-8 w-8 items-center justify-center rounded-full disabled:opacity-30"
disabled={!nextPeriod || disabled}
aria-label="Следующий расчётный период"
onClick={() => nextPeriod && onSelectPeriod(nextPeriod.id)}
>
<ChevronRight size={16} />
</button>
</div>
<div className="app-text-muted mt-3 grid grid-cols-7 gap-1 text-center text-[11px] font-medium">
{weekdays.map((weekday) => <span key={weekday} className="py-1">{weekday}</span>)}
</div>
<div className="mt-1 grid grid-cols-7 gap-1">
{days.map((day) => (
<button
type="button"
key={day.date}
disabled={!day.selectable || disabled}
aria-label={`${day.date}${day.hasEntry ? ", есть запись" : ", записи нет"}`}
aria-pressed={day.isSelected}
className={`relative flex h-9 items-center justify-center rounded-lg text-sm transition ${
day.isSelected
? "app-selected app-accent-text font-semibold"
: day.selectable
? "text-[var(--foreground)] hover:bg-[var(--accent-soft)]"
: "cursor-default text-[color:color-mix(in_srgb,var(--muted-foreground)_42%,transparent)]"
} ${day.isToday && !day.isSelected ? "ring-1 ring-inset ring-[var(--accent-primary)]" : ""}`}
onClick={() => {
onSelectDate(day.date);
setOpen(false);
}}
>
<span className={day.inDisplayMonth && day.inPeriod ? "" : "opacity-45"}>{day.day}</span>
{day.selectable ? (
<span
className={`absolute bottom-1 h-1.5 w-1.5 rounded-full ${
day.hasEntry
? "app-dot-accent"
: "border border-[var(--muted-foreground)] opacity-55"
}`}
/>
) : null}
</button>
))}
</div>
<div className="app-divider mt-3 flex flex-wrap items-center gap-x-4 gap-y-2 border-t pt-3 text-[11px]">
<span className="app-text-muted inline-flex items-center gap-1.5">
<span className="app-dot-accent h-1.5 w-1.5 rounded-full" />Есть запись
</span>
<span className="app-text-muted inline-flex items-center gap-1.5">
<span className="h-1.5 w-1.5 rounded-full border border-[var(--muted-foreground)] opacity-55" />Нет записи
</span>
</div>
</ViewportAwareMenu>
</>
) : null}
</div>
);
}

View File

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

View File

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

View File

@ -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",

View File

@ -15,6 +15,8 @@ export type PayrollLineGroup = {
lines: PayrollStatementLine[];
};
type PayrollPeriodDateRange = Pick<PayrollPeriodSummary, "date_from" | "date_to">;
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<T extends PayrollPeriodDateRange>(
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<PayrollLineGroupKey, PayrollStatementLine[]> = {
accruals: [],