Merge branch 'develop'
This commit is contained in:
commit
a0c3254855
@ -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
|
||||
|
||||
183
frontend/src/components/finance/PayrollWorkDatePicker.tsx
Normal file
183
frontend/src/components/finance/PayrollWorkDatePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
frontend/src/lib/payroll-work-calendar.test.js
Normal file
30
frontend/src/lib/payroll-work-calendar.test.js
Normal 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);
|
||||
});
|
||||
76
frontend/src/lib/payroll-work-calendar.ts
Normal file
76
frontend/src/lib/payroll-work-calendar.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@ -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",
|
||||
|
||||
@ -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: [],
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user