Simplify payroll employee selection and make operation reasons optional
All checks were successful
Deploy production / deploy (push) Successful in 11m22s
All checks were successful
Deploy production / deploy (push) Successful in 11m22s
This commit is contained in:
parent
cfd39404fa
commit
4c44882ca5
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) =>
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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`}
|
||||
|
||||
@ -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
|
||||
/>
|
||||
|
||||
@ -29,7 +29,6 @@ const ADMIN_TABS: ReadonlySet<string> = new Set([
|
||||
"work",
|
||||
"inputs",
|
||||
"summary",
|
||||
"off_cycle",
|
||||
"approval",
|
||||
]);
|
||||
|
||||
|
||||
@ -167,6 +167,8 @@ export type PayrollAdminUserRef = {
|
||||
};
|
||||
|
||||
export type PayrollAdminEmployee = PayrollAdminUserRef & {
|
||||
full_name?: string;
|
||||
is_active?: boolean;
|
||||
position: string | null;
|
||||
department?: string | null;
|
||||
};
|
||||
|
||||
@ -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: "Проверка" },
|
||||
];
|
||||
|
||||
|
||||
76
frontend/src/lib/payroll-employee-search.test.js
Normal file
76
frontend/src/lib/payroll-employee-search.test.js
Normal 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,
|
||||
);
|
||||
});
|
||||
29
frontend/src/lib/payroll-employee-search.ts
Normal file
29
frontend/src/lib/payroll-employee-search.ts
Normal 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));
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user