Simplify payroll employee selection and make operation reasons optional
All checks were successful
Deploy production / deploy (push) Successful in 11m22s

This commit is contained in:
admin i 2026-10-15 12:40:26 +03:00
parent cfd39404fa
commit 4c44882ca5
16 changed files with 336 additions and 141 deletions

View File

@ -33,17 +33,30 @@ def _validation_detail(exc: DjangoValidationError):
class PayrollEmployeeMiniSerializer(serializers.ModelSerializer):
display_name = serializers.SerializerMethodField()
full_name = serializers.SerializerMethodField()
position = serializers.SerializerMethodField()
department = serializers.SerializerMethodField()
class Meta:
model = Employee
fields = ["id", "display_name", "position", "department"]
fields = [
"id",
"display_name",
"full_name",
"is_active",
"position",
"department",
]
read_only_fields = fields
def get_display_name(self, obj) -> str:
return obj.get_full_name().strip() or f"Сотрудник #{obj.pk}"
def get_full_name(self, obj) -> str:
return " ".join(
" ".join([obj.last_name, obj.first_name, obj.patronymic]).split()
) or self.get_display_name(obj)
def get_position(self, obj) -> str | None:
position = getattr(obj, "position", None)
return position.name if position is not None else None

View File

@ -141,7 +141,9 @@ class PayrollInputLineWriteSerializer(
]
def validate(self, attrs):
component_code = attrs.get("component_code")
component_code = attrs.get("component_code") or getattr(
self.instance, "component_code", None
)
if not component_code:
raise serializers.ValidationError({"component_code": "Обязательное поле."})
period = attrs.get("period") or getattr(self.instance, "period", None)

View File

@ -1418,7 +1418,7 @@ class PayrollInputLine(DraftVersionedModel):
{
"reversal_of": (
"Связанное сторно ещё не поддерживается. Используйте "
"утверждённую корректировку с обязательным основанием."
"утверждённую корректировку."
)
}
)
@ -1431,16 +1431,12 @@ class PayrollInputLine(DraftVersionedModel):
)
except ValueError as exc:
raise ValidationError({"component_code": str(exc)}) from exc
if component.requires_reason and not self.reason:
raise ValidationError({"reason": "Для компонента требуется основание."})
# The operation note is optional, including retroactive input lines.
# Keep legacy component metadata without enforcing it on payroll inputs.
if self.relates_to_period_id == self.period_id:
raise ValidationError(
{"relates_to_period": "Для текущего периода это поле не требуется."}
)
if self.relates_to_period_id and not self.reason:
raise ValidationError(
{"reason": "Для перерасчёта прошлого периода укажите основание."}
)
if self.value_sign not in (-1, 1):
raise ValidationError({"value_sign": "Некорректный знак значения."})
if (

View File

@ -756,18 +756,6 @@ class DeterministicPayrollCalculator:
message="Every input line must identify its source.",
)
)
if (
kind_is_valid
and line.kind in {LineKind.ADJUSTMENT_CREDIT, LineKind.ADJUSTMENT_DEBIT}
and not self._is_nonempty_string(line.reason)
):
issues.append(
PayrollIssue(
code="MISSING_ADJUSTMENT_REASON",
field=f"{prefix}.reason",
message="Adjustment reason is required.",
)
)
self._validate_retro_line(
line,
prefix=prefix,

View File

@ -325,7 +325,7 @@ def test_rejects_float_and_non_finite_decimal(period, rules):
}
def test_rejects_duplicate_ids_and_unsafe_adjustment(period, rules):
def test_rejects_duplicate_ids(period, rules):
duplicate_lines = (
input_line("same", "BONUS", LineKind.EARNING, "1"),
input_line(
@ -340,10 +340,23 @@ def test_rejects_duplicate_ids_and_unsafe_adjustment(period, rules):
with pytest.raises(PayrollValidationError) as error:
DeterministicPayrollCalculator().calculate(request, rules)
assert issue_codes(error) == {
"DUPLICATE_COMPONENT_ID",
"MISSING_ADJUSTMENT_REASON",
}
assert issue_codes(error) == {"DUPLICATE_COMPONENT_ID"}
@pytest.mark.parametrize("kind,expected", [
(LineKind.ADJUSTMENT_CREDIT, "81000.00"),
(LineKind.ADJUSTMENT_DEBIT, "79000.00"),
])
@pytest.mark.parametrize("retroactive", [False, True])
def test_adjustments_calculate_without_a_reason(period, rules, kind, expected, retroactive):
request = make_request(period, lines=(input_line(
"correction:1", "CORRECTION", kind, "1000", reason="",
is_retro=retroactive,
source_period=PayrollPeriod(date(2026, 5, 1), date(2026, 5, 31))
if retroactive else None,
),))
result = DeterministicPayrollCalculator().calculate(request, rules)
assert result.totals.gross_after_adjustments == Decimal(expected)
def test_retro_line_requires_source_period(period, rules):

View File

@ -1213,6 +1213,8 @@ def test_workspace_contract_is_minimal_and_redacts_money_without_view_all(
assert employee == {
"id": manager.pk,
"display_name": "Мария Расчётова",
"full_name": "Расчётова Мария",
"is_active": True,
"position": None,
"department": None,
}
@ -2794,6 +2796,63 @@ def test_workspace_blocks_period_outside_ruleset_effective_dates(
assert PayrollRun.objects.count() == 0
@pytest.mark.parametrize("component_code", [
"VACATION_PAY", "BONUS", "CORRECTION_CREDIT", "CORRECTION_DEBIT",
"ONE_TIME_PAYMENT", "DEDUCTION", "ADVANCE",
])
@pytest.mark.parametrize("retroactive", [False, True])
@pytest.mark.parametrize("include_empty_reason", [False, True])
def test_input_reason_is_optional_for_every_operation_and_period(
user_factory, auth_client_factory, component_code, retroactive,
include_empty_reason,
):
maker = user_factory(email="optional.reason.maker@example.test")
checker = user_factory(email="optional.reason.checker@example.test")
employee = user_factory(email="optional.reason.employee@example.test", active=False)
grant(maker, "manage_payroll_inputs")
grant(checker, "approve_payroll_inputs")
period = make_period(creator=maker)
previous = PayrollPeriod.objects.create(
code="2026-05", date_from="2026-05-01", date_to="2026-05-31",
pay_date="2026-06-05", currency="RUB", created_by=maker,
status="closed",
)
client = auth_client_factory(maker)
payload = {
"period_id": period.pk, "employee_id": employee.pk,
"component_code": component_code, "amount": "100.00",
"relates_to_period_id": previous.pk if retroactive else None,
}
if include_empty_reason:
payload["reason"] = ""
created = client.post(api_url("admin-input-line-list"), payload, format="json")
assert created.status_code == 201, created.content
data = created.json()
assert data["reason"] == ""
assert data["relates_to_period_id"] == payload["relates_to_period_id"]
line = PayrollInputLine.objects.get(pk=data["id"])
line.full_clean()
# A previously entered note can also be removed from an existing draft.
detail_url = api_url("admin-input-line-detail", pk=data["id"])
noted = client.patch(detail_url, {
"reason": "Дополнительная заметка", "expected_lock_version": data["lock_version"],
}, format="json")
assert noted.status_code == 200, noted.content
cleared = client.patch(detail_url, {
"reason": "", "expected_lock_version": noted.json()["lock_version"],
}, format="json")
assert cleared.status_code == 200, cleared.content
assert cleared.json()["reason"] == ""
approved = auth_client_factory(checker).post(
api_url("admin-input-line-approve", pk=data["id"]),
{"expected_lock_version": cleared.json()["lock_version"]}, format="json",
)
assert approved.status_code == 200, approved.content
assert approved.json()["status"] == "approved"
assert approved.json()["reason"] == ""
def test_period_inputs_can_be_created_for_inactive_employees(
user_factory,
auth_client_factory,
@ -2802,11 +2861,26 @@ def test_period_inputs_can_be_created_for_inactive_employees(
inactive_employee = user_factory(
email="inactive.inputs.employee@example.test",
active=False,
last_name="Семёнов",
first_name="Иван",
patronymic="Петрович",
)
grant(manager, "manage_payroll_inputs")
period = make_period(creator=manager)
client = auth_client_factory(manager)
workspace = client.get(api_url("admin-workspace"), {"period_id": period.pk})
assert workspace.status_code == 200
assert inactive_employee.pk not in {
employee["id"] for employee in workspace.json()["employees"]
}
employee_option = next(
employee for employee in workspace.json()["rate_employees"]
if employee["id"] == inactive_employee.pk
)
assert employee_option["full_name"] == "Семёнов Иван Петрович"
assert employee_option["is_active"] is False
work_record = client.post(
api_url("admin-work-record-list"),
{
@ -2834,6 +2908,8 @@ def test_period_inputs_can_be_created_for_inactive_employees(
)
assert input_line.status_code == 201, input_line.content
assert input_line.json()["employee_id"] == inactive_employee.pk
assert input_line.json()["status"] == "draft"
assert input_line.json()["employee"]["is_active"] is False
def test_period_and_rate_drafts_use_exact_optimistic_versions_and_maker_checker(

View File

@ -347,6 +347,19 @@ def test_only_manual_corrections_move_between_periods_and_can_be_settled(
assert august_statement.closing_balance == Decimal("0.00")
def test_optional_reason_survives_full_calculation(june_period, payroll_users):
_, work, _, correction = create_approved_excel_inputs(june_period, payroll_users)
# Use a legacy component marked requires_reason=True, but leave the note empty.
PayrollInputLine.objects.filter(pk=correction.pk).update(reason="")
correction.refresh_from_db()
assert correction.component.requires_reason is True
correction.full_clean()
run = calculate_period(june_period.pk, actor=payroll_users["operator"])
statement = run.statements.get(employee=payroll_users["employee"])
assert statement.payable == work.expected_payable == Decimal("115000.00")
assert statement.lines.get(code="CORRECTION_CREDIT").reason == ""
def test_excel_row_is_persisted_as_a_deterministic_statement(
june_period,
payroll_users,

View File

@ -4,6 +4,11 @@ import { AlertTriangle, Check, Loader2, Search } from "lucide-react";
import { useMemo, useState, type FormEvent } from "react";
import { Modal } from "@/components/ui";
import { SearchableSelectSingle } from "@/components/shared/SearchableSelect";
import {
matchesPayrollEmployeeSearch,
payrollInputEmployeeOptions,
} from "@/lib/payroll-employee-search";
import type {
PayrollAdminEmployee,
PayrollAdminInputLine,
@ -1574,13 +1579,30 @@ export function PayrollInputLineFormModal({
}));
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const component = useMemo(
() => components.find((item) => item.code === form.component_code),
[components, form.component_code],
const [includeInactive, setIncludeInactive] = useState(
line?.employee.is_active === false,
);
const employeeOptions = useMemo(
() =>
payrollInputEmployeeOptions(
line && !employees.some((employee) => employee.id === line.employee.id)
? [...employees, line.employee]
: employees,
includeInactive,
),
[employees, includeInactive, line],
);
const submit = (event: FormEvent) => {
event.preventDefault();
if (
!employeeOptions.some(
(employee) => employee.id === Number(form.employee_id),
)
) {
setError("Выберите сотрудника.");
return;
}
void submitModalForm(
() =>
onSubmit({
@ -1612,26 +1634,46 @@ export function PayrollInputLineFormModal({
>
<form onSubmit={submit}>
<div className="grid gap-4 sm:grid-cols-2">
<label className="sm:col-span-2">
<span className="app-field-label">Сотрудник</span>
<select
className={selectClass}
required
disabled={Boolean(line)}
value={form.employee_id}
onChange={(event) =>
setForm({ ...form, employee_id: event.target.value })
<div className="sm:col-span-2">
<SearchableSelectSingle
label="Сотрудник"
labelClassName="app-field-label"
items={employeeOptions}
selectedId={form.employee_id ? Number(form.employee_id) : null}
onSelect={(id) =>
setForm({ ...form, employee_id: id === null ? "" : String(id) })
}
>
<option value="">Выберите сотрудника</option>
{employees.map((employee) => (
<option value={employee.id} key={employee.id}>
{employee.display_name}
{employee.position ? ` — ${employee.position}` : ""}
</option>
))}
</select>
</label>
placeholder="Выберите сотрудника"
searchPlaceholder="Поиск по фамилии, имени или отчеству"
filterItem={(item, query) =>
matchesPayrollEmployeeSearch(item.name, query)
}
disabled={Boolean(line)}
/>
<label className="app-text-muted mt-2 flex cursor-pointer items-center gap-2 text-xs">
<input
type="checkbox"
className="rounded accent-[var(--accent-primary)]"
checked={includeInactive}
disabled={Boolean(line)}
onChange={(event) => {
const checked = event.target.checked;
setIncludeInactive(checked);
if (
!checked &&
employees.some(
(employee) =>
employee.id === Number(form.employee_id) &&
employee.is_active === false,
)
) {
setForm({ ...form, employee_id: "" });
}
}}
/>
Искать среди всех сотрудников
</label>
</div>
<label className="sm:col-span-2">
<span className="app-field-label">Вид операции</span>
<select
@ -1690,17 +1732,9 @@ export function PayrollInputLineFormModal({
</select>
</label>
<label className="sm:col-span-2">
<span className="app-field-label">
Основание
{component?.requires_reason || form.relates_to_period_id
? " *"
: ""}
</span>
<span className="app-field-label">Основание (необязательно)</span>
<textarea
className={`${inputClass} min-h-20 resize-y`}
required={Boolean(
component?.requires_reason || form.relates_to_period_id,
)}
value={form.reason}
placeholder="Премия, приказ, причина корректировки"
onChange={(event) =>

View File

@ -60,7 +60,6 @@ import {
PayrollWorkRecordsTable,
} from "@/components/finance/admin/PayrollAdminTables";
import { PayrollPeriodTableView } from "@/components/finance/admin/PayrollPeriodTable";
import { PayrollOffCyclePayments } from "@/components/finance/admin/PayrollOffCyclePayments";
import { useUser } from "@/contexts/UserContext";
import { usePayrollAdminTab } from "@/hooks/usePayrollTabs";
import { apiClient } from "@/lib/api";
@ -800,7 +799,7 @@ export function PayrollAdminWorkspace({
});
return cancel;
}
if (tab === "readiness" || tab === "off_cycle") return;
if (tab === "readiness") return;
const canLoadInputs = Boolean(
workspace?.permissions.manage_inputs ||
workspace?.permissions.approve_inputs ||
@ -1246,9 +1245,6 @@ export function PayrollAdminWorkspace({
const visibleTabs = PAYROLL_ADMIN_TABS.filter(
(item) =>
(item.value !== "summary" || permissions.view_all) &&
(item.value !== "off_cycle" ||
permissions.manage_inputs ||
permissions.view_all) &&
(!["rates", "work", "inputs"].includes(item.value) || canOpenInputTabs),
);
const primaryAction = selectedPeriod
@ -1858,7 +1854,7 @@ export function PayrollAdminWorkspace({
role="tab"
aria-selected={tab === item.value}
key={item.value}
disabled={!selectedPeriod && item.value !== "off_cycle"}
disabled={!selectedPeriod}
className={`${tab === item.value ? "app-pill-active" : "app-pill"} inline-flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium transition disabled:opacity-50`}
onClick={() => {
setTab(item.value);
@ -1880,17 +1876,7 @@ export function PayrollAdminWorkspace({
</HorizontalChipScroller>
</div>
{tab === "off_cycle" &&
(permissions.manage_inputs || permissions.view_all) ? (
<PayrollOffCyclePayments
key={`payments-${selectedPeriod?.id || "all"}`}
employees={workspace.rate_employees}
periods={workspace.periods}
period={selectedPeriod}
canManage={permissions.manage_inputs}
onChanged={() => setTableVersion((value) => value + 1)}
/>
) : !selectedPeriod ? (
{!selectedPeriod ? (
<section className="app-surface rounded-2xl p-8 text-center">
<CalendarEmpty />
<p className="app-text-muted mx-auto mt-2 max-w-md text-sm">
@ -2289,15 +2275,6 @@ export function PayrollAdminWorkspace({
onSetReviewMark={setTableReviewMark}
onClearReviewMarks={clearTableReviewMarks}
/>
<PayrollOffCyclePayments
compact
key={`summary-payments-${selectedPeriod.id}`}
employees={workspace.rate_employees}
periods={workspace.periods}
period={selectedPeriod}
canManage={permissions.manage_inputs}
onChanged={() => setTableVersion((value) => value + 1)}
/>
</section>
) : null}
{tab === "approval" ? (
@ -2709,7 +2686,7 @@ export function PayrollAdminWorkspace({
isOpen
period={selectedPeriod}
periods={workspace.periods}
employees={workspace.employees}
employees={workspace.rate_employees}
components={workspace.components}
line={editingInput}
onClose={() => setInputModalOpen(false)}

View File

@ -739,9 +739,7 @@ function PayrollComponentSettingsModal({
const [kind, setKind] = useState<PayrollLineKind>(
component?.kind || "earning",
);
const [requiresReason, setRequiresReason] = useState(
component?.requires_reason || false,
);
const requiresReason = component?.requires_reason || false;
const [includeInPointBase, setIncludeInPointBase] = useState(
component?.include_in_point_base || false,
);
@ -1070,23 +1068,6 @@ function PayrollComponentSettingsModal({
автоматически не копируются.
</span>
</label>
<label className="app-surface-muted flex cursor-pointer items-start gap-3 rounded-xl px-3 py-3">
<input
type="checkbox"
checked={requiresReason}
onChange={(event) => setRequiresReason(event.target.checked)}
className="mt-0.5 h-4 w-4 rounded"
disabled={pending}
/>
<span>
<span className="block text-sm font-medium text-[var(--foreground)]">
Требовать основание
</span>
<span className="app-text-muted mt-0.5 block text-xs">
При вводе операции пользователь должен будет указать причину.
</span>
</span>
</label>
<label className="app-surface-muted flex cursor-pointer items-start gap-3 rounded-xl px-3 py-3">
<input
type="checkbox"
@ -1576,9 +1557,7 @@ function componentCalculationHelp(
const employeeEffect = component.visible_to_employee
? "В расчётке сотрудника колонка видна."
: "Колонка внутренняя и в расчётке сотрудника скрыта; итоговые суммы сохраняются.";
const reasonEffect = component.requires_reason
? "При вводе обязательно основание."
: "Основание при вводе необязательно.";
const reasonEffect = "Основание при вводе необязательно.";
return {
title: component.label,
logic: `Показана сумма всех действующих операций «${component.label}» сотрудника за период. Она ${flow.effect}. ${pointBaseEffect} ${carryoverEffect} ${employeeEffect} ${reasonEffect}`,
@ -3242,30 +3221,6 @@ export function PayrollPeriodTableView({
</div>
) : null}
{(showSummary || showTable) && data.recorded_payments ? (
<div className="app-surface-muted mb-4 grid gap-3 rounded-xl p-4 sm:grid-cols-3">
{[
[
"Выплаты в расчёте периода",
data.recorded_payments.payroll_recorded,
],
["Выдано вне периода", data.recorded_payments.outside_period],
["Всего учтено выплат", data.recorded_payments.total_recorded],
].map(([label, value]) => (
<div key={label}>
<p className="app-text-muted text-xs">{label}</p>
<p className="mt-1 text-lg font-semibold">
{formatMoney(value, data.currency)}
</p>
</div>
))}
<p className="app-text-muted text-xs sm:col-span-3">
Общий учёт за весь период, без фильтров таблицы. Выдачи вне периода
учтены по фактической дате и не меняют начисленную зарплату.
</p>
</div>
) : null}
{showSummary || showTable ? (
<div
className={`${compact ? "" : "flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between"} mb-3`}

View File

@ -102,6 +102,9 @@ export function SearchableSelectSingle<T extends string | number>({
onSelect,
placeholder,
disabled = false,
searchPlaceholder = "Поиск...",
filterItem,
labelClassName = "app-text-muted mb-1 block text-xs font-medium",
}: {
label: string;
items: { id: T; name: string }[];
@ -109,6 +112,9 @@ export function SearchableSelectSingle<T extends string | number>({
onSelect: (id: T | null) => void;
placeholder?: string;
disabled?: boolean;
searchPlaceholder?: string;
filterItem?: (item: { id: T; name: string }, query: string) => boolean;
labelClassName?: string;
}) {
const [open, setOpen] = useState(false);
const [q, setQ] = useState("");
@ -128,8 +134,10 @@ export function SearchableSelectSingle<T extends string | number>({
return () => document.removeEventListener("mousedown", handler);
}, []);
const filtered = items.filter((i) =>
i.name.toLowerCase().includes(q.toLowerCase()),
const filtered = items.filter((item) =>
filterItem
? filterItem(item, q)
: item.name.toLowerCase().includes(q.toLowerCase()),
);
const selectedName = items.find((i) => i.id === selectedId)?.name;
const toggleOpen = () => {
@ -139,12 +147,12 @@ export function SearchableSelectSingle<T extends string | number>({
return (
<div ref={ref} className="relative">
<label className="app-text-muted mb-1 block text-xs font-medium">
{label}
</label>
<label className={labelClassName}>{label}</label>
<button
type="button"
disabled={disabled}
aria-label={label}
aria-expanded={open}
onClick={toggleOpen}
className="app-select flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm disabled:cursor-not-allowed disabled:opacity-60"
>
@ -177,7 +185,23 @@ export function SearchableSelectSingle<T extends string | number>({
<input
value={q}
onChange={(e) => setQ(e.target.value)}
placeholder="Поиск..."
placeholder={searchPlaceholder}
aria-label={searchPlaceholder}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.preventDefault();
if (filtered[0]) {
onSelect(filtered[0].id);
setOpen(false);
ref.current?.querySelector("button")?.focus();
}
} else if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
setOpen(false);
ref.current?.querySelector("button")?.focus();
}
}}
className="app-input w-full rounded px-2 py-1.5 text-sm"
autoFocus
/>

View File

@ -29,7 +29,6 @@ const ADMIN_TABS: ReadonlySet<string> = new Set([
"work",
"inputs",
"summary",
"off_cycle",
"approval",
]);

View File

@ -167,6 +167,8 @@ export type PayrollAdminUserRef = {
};
export type PayrollAdminEmployee = PayrollAdminUserRef & {
full_name?: string;
is_active?: boolean;
position: string | null;
department?: string | null;
};

View File

@ -20,7 +20,6 @@ export type PayrollAdminTab =
| "work"
| "inputs"
| "summary"
| "off_cycle"
| "approval";
export type PayrollBlockerTarget = {
@ -77,7 +76,6 @@ export const PAYROLL_ADMIN_TABS: Array<{
{ value: "work", label: "Выработка" },
{ value: "inputs", label: "Начисления и выплаты" },
{ value: "summary", label: "Итоговая таблица" },
{ value: "off_cycle", label: "Выплаты вне периода" },
{ value: "approval", label: "Проверка" },
];

View File

@ -0,0 +1,76 @@
import test from "node:test";
import assert from "node:assert/strict";
import {
matchesPayrollEmployeeSearch,
payrollInputEmployeeOptions,
} from "./payroll-employee-search.ts";
import { PAYROLL_ADMIN_TABS } from "./payroll-admin.ts";
const employees = [
{
id: 1,
display_name: "Иван Семёнов",
full_name: "Семёнов Иван Петрович",
is_active: true,
position: "Инженер",
},
{
id: 2,
display_name: "Анна Иванова",
full_name: "Иванова Анна Сергеевна",
is_active: false,
position: null,
},
];
test("input form lists only active employees unless all-employees search is enabled", () => {
assert.deepEqual(
payrollInputEmployeeOptions(employees, false).map((item) => item.id),
[1],
);
const all = payrollInputEmployeeOptions(employees, true);
assert.equal(all.length, 2);
assert.equal(all[0].name, "Семёнов Иван Петрович — Инженер");
assert.equal(all[1].name, "Иванова Анна Сергеевна — Неактивен");
assert.equal(
payrollInputEmployeeOptions(employees, false).some((item) =>
item.name.includes("Неактивен"),
),
false,
);
});
test("employee search matches name parts in any order, ignores case, spaces, and ё/е", () => {
const name = payrollInputEmployeeOptions(employees, false)[0].name;
for (const query of [
"семенов",
"ПЕТРОВИЧ",
" иван СЕМЕНОВ ",
"Петров семё",
"",
]) {
assert.equal(matchesPayrollEmployeeSearch(name, query), true, query);
}
assert.equal(matchesPayrollEmployeeSearch(name, "Иванова"), false);
});
test("employee options remain compatible with older or incomplete name payloads", () => {
assert.deepEqual(
payrollInputEmployeeOptions(
[{ id: 3, display_name: "Иван", position: null }],
false,
),
[{ id: 3, name: "Иван" }],
);
});
test("off-cycle payments have no navigation entry", () => {
assert.equal(
PAYROLL_ADMIN_TABS.some((tab) => tab.value === "off_cycle"),
false,
);
assert.equal(
PAYROLL_ADMIN_TABS.some((tab) => tab.value === "inputs"),
true,
);
});

View File

@ -0,0 +1,29 @@
import type { PayrollAdminEmployee } from "./api/finance";
export function payrollInputEmployeeOptions(
employees: PayrollAdminEmployee[],
includeInactive: boolean,
) {
return employees
.filter((employee) => includeInactive || employee.is_active !== false)
.map((employee) => ({
id: employee.id,
name: [
employee.full_name || employee.display_name,
employee.is_active === false ? "Неактивен" : "",
employee.position || "",
]
.filter(Boolean)
.join(" — "),
}));
}
export function matchesPayrollEmployeeSearch(name: string, query: string) {
const normalize = (value: string) =>
value.toLocaleLowerCase("ru").replaceAll("ё", "е");
const text = normalize(name);
return normalize(query)
.trim()
.split(/\s+/)
.every((part) => text.includes(part));
}