diff --git a/backend/api/v1/finance/payroll_serializers/presentation.py b/backend/api/v1/finance/payroll_serializers/presentation.py index 20bae5ee..1df8435c 100644 --- a/backend/api/v1/finance/payroll_serializers/presentation.py +++ b/backend/api/v1/finance/payroll_serializers/presentation.py @@ -106,6 +106,10 @@ class PayrollAdminWorkspaceSerializer(serializers.Serializer): class PayrollPeriodTableSerializer(serializers.Serializer): + later_balance_adjustments = serializers.ListField( + child=serializers.JSONField(), + read_only=True, + ) recorded_payments = serializers.JSONField(read_only=True) period_id = serializers.IntegerField(read_only=True) period_status = serializers.CharField(read_only=True) diff --git a/backend/finance/payroll/reporting.py b/backend/finance/payroll/reporting.py index e1340508..689b681c 100644 --- a/backend/finance/payroll/reporting.py +++ b/backend/finance/payroll/reporting.py @@ -19,6 +19,7 @@ from employees.services.personnel_state import resolve_employee_personnel_state from finance.enums import ApprovalStatus, InputSource from finance.models import ( EmployeePayRate, + PayrollBalanceAdjustment, PayrollDailyWorkEntry, PayrollInputLine, PayrollPeriod, @@ -336,6 +337,39 @@ def _attendance_alert( } +def _balance_adjustment_reference(adjustment): + """Read-only links, intentionally outside payroll snapshots and totals.""" + effective = adjustment.effective_period + return { + "id": adjustment.pk, + "employee": { + "id": adjustment.employee_id, + "display_name": adjustment.employee.get_full_name().strip() + or f"Сотрудник #{adjustment.employee_id}", + "is_active": adjustment.employee.is_active, + }, + "effective_period": { + "id": effective.pk, + "code": effective.code, + "name": effective.name, + }, + "source_period_id": adjustment.source_period_id, + "source_period": ( + { + "id": adjustment.source_period_id, + "code": adjustment.source_period.code, + "name": adjustment.source_period.name, + } + if adjustment.source_period_id + else None + ), + "amount": str(adjustment.amount), + "contribution": str(adjustment.contribution), + "reason": adjustment.reason, + "is_voided": adjustment.voided_at is not None, + } + + def build_payroll_period_table(period: PayrollPeriod): """Include active employees and inactive employees with period data.""" @@ -364,7 +398,7 @@ def build_payroll_period_table(period: PayrollPeriod): status=ApprovalStatus.APPROVED, ).values_list("employee_id", flat=True) ) - employee_ids = ( + existing_employee_ids = ( set( Employee.objects.visible_in_ui() .filter(is_active=True) @@ -374,12 +408,57 @@ def build_payroll_period_table(period: PayrollPeriod): | approved_input_employee_ids | set(statements) ) + # References to later adjustments are presentation metadata, never inputs + # to the original period's calculation or its immutable statements. + adjustments = list( + PayrollBalanceAdjustment.objects.filter( + Q(effective_period__date_from__lte=period.date_from) + | Q( + source_period__date_from__lte=period.date_from, + effective_period__date_from__gt=period.date_to, + ), + effective_period__currency=period.currency, + employee__is_hidden_in_ui=False, + ) + .select_related("employee", "effective_period", "source_period") + .order_by("effective_period__date_from", "created_at", "pk") + ) + active_balance_employee_ids = { + item.employee_id + for item in adjustments + if item.voided_at is None + and item.effective_period.date_from <= period.date_from + } + current_adjustment_employee_ids = { + item.employee_id + for item in adjustments + if item.voided_at is None and item.effective_period_id == period.pk + } visible_employee_ids = set( Employee.objects.visible_in_ui() - .filter(id__in=employee_ids) + .filter(id__in=existing_employee_ids | active_balance_employee_ids) .values_list("id", flat=True) ) - employee_ids = visible_employee_ids + balances = opening_balances(period, visible_employee_ids) + balance_employee_ids = { + employee_id + for employee_id in active_balance_employee_ids + if employee_id in current_adjustment_employee_ids + or balances.get(employee_id, Decimal("0")) != 0 + } + employee_ids = visible_employee_ids & (existing_employee_ids | balance_employee_ids) + current_adjustments = defaultdict(list) + later_adjustments = [] + for adjustment in adjustments: + reference = _balance_adjustment_reference(adjustment) + if adjustment.effective_period_id == period.pk: + current_adjustments[adjustment.employee_id].append(reference) + if ( + adjustment.source_period_id is not None + and adjustment.source_period.date_from <= period.date_from + and adjustment.effective_period.date_from > period.date_to + ): + later_adjustments.append(reference) employees = list( Employee.objects.filter(id__in=employee_ids) .select_related("position") @@ -408,7 +487,6 @@ def build_payroll_period_table(period: PayrollPeriod): rates = _latest_rate_by_employee(period, employee_ids) work_records = _latest_work_by_employee(period, employee_ids) input_lines = _input_lines_by_employee(period, employee_ids) - balances = opening_balances(period, employee_ids) components = get_payroll_components(period) components_by_code = {component.code: component for component in components} component_definitions = { @@ -458,17 +536,17 @@ def build_payroll_period_table(period: PayrollPeriod): raw_source_lines, scheduled_codes=scheduled_component_codes, ) - salary_excluded_only = bool( + balance_only = bool( statement is None and work is None - and source_lines - and all( - not components_by_code[line.component_code].include_in_salary + and (source_lines or employee.pk in balance_employee_ids) + and not any( + components_by_code[line.component_code].include_in_salary for line in source_lines ) ) - if salary_excluded_only: - # A former employee's debt payment is not a new salary or payslip. + if balance_only: + # A balance correction/payment is not a new salary or payslip. rate = None display_source_lines = [ line @@ -633,7 +711,7 @@ def build_payroll_period_table(period: PayrollPeriod): status = "ready" if calculation_issue is not None: status = "incomplete" - if salary_excluded_only: + if balance_only: status = "balance_only" projection_target_points = Decimal(str(target_points or "0")) @@ -658,14 +736,18 @@ def build_payroll_period_table(period: PayrollPeriod): period_target_points=projection_target_points, schedule=schedule, ) - attendance_alert = _attendance_alert( - period, - employee=employee, - actions=employee.payroll_personnel_actions, - records=attendance_records_for_employee, - schedule=schedule, - target_points=target_points, - actual_points=actual_points, + attendance_alert = ( + None + if balance_only + else _attendance_alert( + period, + employee=employee, + actions=employee.payroll_personnel_actions, + records=attendance_records_for_employee, + schedule=schedule, + target_points=target_points, + actual_points=actual_points, + ) ) point_base_amount = _point_base_amount( @@ -704,6 +786,7 @@ def build_payroll_period_table(period: PayrollPeriod): "payment_confirmation": payment_confirmation, "calculation_issue": calculation_issue, "applied_scheduled_changes": applied_scheduled_changes, + "manual_balance_adjustments": current_adjustments.get(employee.pk, []), **totals, } ) @@ -732,6 +815,7 @@ def build_payroll_period_table(period: PayrollPeriod): else None ), "component_columns": component_columns, + "later_balance_adjustments": later_adjustments, "rows": rows, "summary": { "employee_count": len(rows), diff --git a/backend/tests/finance/test_balance_adjustments.py b/backend/tests/finance/test_balance_adjustments.py index fb50277b..1db39f63 100644 --- a/backend/tests/finance/test_balance_adjustments.py +++ b/backend/tests/finance/test_balance_adjustments.py @@ -17,7 +17,9 @@ from finance.models import ( PayrollBalanceAdjustment, PayrollInputLine, PayrollPeriod, + PayrollRun, PayrollStatement, + PayrollStatementLine, PayrollWorkRecord, ) from finance.payroll.balances import opening_balances @@ -374,3 +376,203 @@ def test_preview_and_calculation_change_only_balance_preserve_september_snapshot ) with pytest.raises(PayrollOperationError): void_adjustment(adjustment.pk, actor=manager) + + +@pytest.mark.parametrize( + "direction,expected", [("company_owes", "1234.56"), ("employee_owes", "-1234.56")] +) +def test_inactive_employee_with_only_manual_balance_is_visible_without_salary( + setup, user_factory, direction, expected +): + manager, employee, periods, _, client = setup + # An old approved salary rate must not turn a balance-only row into salary. + make_approved_period_inputs( + period=periods[8], employee=employee, creator=manager, approver=user_factory() + ) + employee.is_active = False + employee.save(update_fields=["is_active"]) + assert create(setup, direction=direction).status_code == 201 + for month in (10, 11): + table = client.get(url("admin-period-table", pk=periods[month].pk)) + assert table.status_code == 200, table.content + row = next( + r for r in table.json()["rows"] if r["employee"]["id"] == employee.pk + ) + assert row["status"] == "balance_only" + assert row["opening_balance"] == expected + assert row["closing_balance"] == expected + assert row["rate_amount"] is None + assert row["attendance_alert"] is None + for key in ( + "gross_total", + "deduction_total", + "net_pay", + "payment_total", + "payable", + ): + assert Decimal(row[key]) == 0, key + assert len(row["manual_balance_adjustments"]) == int(month == 10) + assert not PayrollStatement.objects.exists() + assert not PayrollInputLine.objects.exists() + + +def test_current_offsetting_adjustments_are_visible_but_settled_balance_is_not_carried( + setup, +): + employee, periods = setup[1:3] + employee.is_active = False + employee.save(update_fields=["is_active"]) + assert create(setup).status_code == 201 + assert ( + create( + setup, direction="employee_owes", request_id=str(uuid.uuid4()) + ).status_code + == 201 + ) + october = build_payroll_period_table(periods[10]) + row = next(r for r in october["rows"] if r["employee"]["id"] == employee.pk) + assert row["status"] == "balance_only" and Decimal(row["closing_balance"]) == 0 + assert len(row["manual_balance_adjustments"]) == 2 + assert employee.pk not in { + r["employee"]["id"] for r in build_payroll_period_table(periods[11])["rows"] + } + + +def test_later_adjustment_is_reference_only_in_original_closed_period( + setup, user_factory +): + manager, employee, periods, _, client = setup + source = periods[8] + source.status = "open" + source.save(update_fields=["status"]) + make_approved_period_inputs( + period=source, employee=employee, creator=manager, approver=user_factory() + ) + calculate_period(source.pk, actor=manager) + source.refresh_from_db() + source.status = "closed" + source.save(update_fields=["status"]) + before_table = client.get(url("admin-period-table", pk=source.pk)).json() + tracked_models = ( + PayrollPeriod, + PayrollRun, + PayrollStatement, + PayrollStatementLine, + PayrollInputLine, + ) + before = { + model: list(model.objects.order_by("pk").values()) for model in tracked_models + } + assert ( + create(setup, direction="employee_owes", reason="Доплата за август").status_code + == 201 + ) + adjustment = PayrollBalanceAdjustment.objects.get() + for _ in range(2): + response = client.get(url("admin-period-table", pk=source.pk)) + assert response.status_code == 200, response.content + after_table = response.json() + reference = after_table["later_balance_adjustments"][0] + assert reference["id"] == adjustment.pk + assert reference["employee"]["id"] == employee.pk + assert reference["effective_period"]["id"] == periods[10].pk + assert reference["source_period_id"] == source.pk + assert reference["contribution"] == "-1234.56" + assert reference["reason"] == "Доплата за август" + assert reference["is_voided"] is False + assert after_table["rows"] == before_table["rows"] + assert after_table["summary"] == before_table["summary"] + for model in tracked_models: + assert list(model.objects.order_by("pk").values()) == before[model], model + + +def test_reference_is_shown_even_without_historical_employee_row_and_tracks_voiding( + setup, +): + _, employee, periods, _, client = setup + employee.is_active = False + employee.save(update_fields=["is_active"]) + assert create(setup).status_code == 201 + adjustment = PayrollBalanceAdjustment.objects.get() + source = build_payroll_period_table(periods[8]) + assert employee.pk not in {row["employee"]["id"] for row in source["rows"]} + assert source["later_balance_adjustments"][0]["employee"]["id"] == employee.pk + assert ( + build_payroll_period_table(periods[9])["later_balance_adjustments"][0]["id"] + == adjustment.pk + ) + assert not build_payroll_period_table(periods[10])["later_balance_adjustments"] + assert ( + client.post( + url("admin-balance-adjustment-void", pk=adjustment.pk), {}, format="json" + ).status_code + == 200 + ) + after = build_payroll_period_table(periods[8]) + assert after["later_balance_adjustments"][0]["is_voided"] is True + assert employee.pk not in { + row["employee"]["id"] for row in build_payroll_period_table(periods[10])["rows"] + } + employee.is_hidden_in_ui = True + employee.save(update_fields=["is_hidden_in_ui"]) + assert not build_payroll_period_table(periods[8])["later_balance_adjustments"] + + +def test_balance_only_employee_is_not_added_to_calculation_or_payslips( + setup, user_factory +): + manager, former, periods, _, client = setup + former.is_active = False + former.save(update_fields=["is_active"]) + worker = user_factory() + make_approved_period_inputs( + period=periods[10], employee=worker, creator=manager, approver=user_factory() + ) + assert create(setup).status_code == 201 + run = calculate_period(periods[10].pk, actor=manager) + assert set(run.statements.values_list("employee_id", flat=True)) == {worker.pk} + periods[10].refresh_from_db() + table = client.get(url("admin-period-table", pk=periods[10].pk)).json() + row = next(r for r in table["rows"] if r["employee"]["id"] == former.pk) + assert row["status"] == "balance_only" + assert row["closing_balance"] == "1234.56" + assert Decimal(row["payable"]) == 0 + assert row["payment_confirmation"] is None + + +def test_future_notice_covers_source_and_intermediate_months_without_changing_totals( + setup, +): + _, employee, periods, _, _ = setup + july = PayrollPeriod.objects.create( + code="2026-07", + date_from=date(2026, 7, 1), + date_to=date(2026, 7, 31), + status="closed", + ) + before = { + month: build_payroll_period_table(periods[month]) for month in (8, 9, 10, 11) + } + frozen = list(PayrollPeriod.objects.order_by("pk").values()) + assert create(setup, effective_period_id=periods[11].pk).status_code == 201 + adjustment = PayrollBalanceAdjustment.objects.get() + for month in (8, 9, 10): + after = build_payroll_period_table(periods[month]) + assert after["rows"] == before[month]["rows"] + assert after["summary"] == before[month]["summary"] + reference = after["later_balance_adjustments"][0] + assert reference["id"] == adjustment.pk + assert reference["source_period"]["id"] == periods[8].pk + assert reference["effective_period"]["id"] == periods[11].pk + assert opening_balances(periods[month], [employee.pk]) == {} + assert not build_payroll_period_table(july)["later_balance_adjustments"] + assert not build_payroll_period_table(periods[11])["later_balance_adjustments"] + assert frozen == list(PayrollPeriod.objects.order_by("pk").values()) + + +def test_no_intermediate_notice_when_source_period_was_not_specified(setup): + assert create(setup, source_period_id=None).status_code == 201 + for month in (8, 9): + assert not build_payroll_period_table(setup[2][month])[ + "later_balance_adjustments" + ] diff --git a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx index d4072390..d386d966 100644 --- a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx @@ -84,6 +84,7 @@ import type { PayrollPeriodTable, PayrollPeriodTableComponent, PayrollPeriodTableRow, + PayrollTableBalanceAdjustment, PayrollPeriodWrite, PayrollWorkRecordWrite, PayrollWorkbookImportResult, @@ -698,6 +699,8 @@ export function PayrollAdminWorkspace({ const [attendanceWorkModalOpen, setAttendanceWorkModalOpen] = useState(false); const [workbookImportModalOpen, setWorkbookImportModalOpen] = useState(false); const [carryoversModalOpen, setCarryoversModalOpen] = useState(false); + const [carryoversReference, setCarryoversReference] = + useState(null); const [carryoversEmployeeId, setCarryoversEmployeeId] = useState< number | null >(null); @@ -2262,9 +2265,15 @@ export function PayrollAdminWorkspace({ canManageSourceData ? deleteComponentColumn : undefined } onOpenCarryovers={(employee) => { + setCarryoversReference(null); setCarryoversEmployeeId(employee?.id || null); setCarryoversModalOpen(true); }} + onOpenBalanceAdjustment={(adjustment) => { + setCarryoversReference(adjustment); + setCarryoversEmployeeId(adjustment.employee.id); + setCarryoversModalOpen(true); + }} onSetPaymentConfirmed={ permissions.manage_inputs && selectedPeriod.status === "closed" @@ -2608,12 +2617,14 @@ export function PayrollAdminWorkspace({ ) : null} {selectedPeriod && carryoversModalOpen ? ( refreshInlineTable("inputs")} onClose={() => setCarryoversModalOpen(false)} diff --git a/frontend/src/components/finance/admin/PayrollCarryoversModal.tsx b/frontend/src/components/finance/admin/PayrollCarryoversModal.tsx index e6545926..a8ef2c44 100644 --- a/frontend/src/components/finance/admin/PayrollCarryoversModal.tsx +++ b/frontend/src/components/finance/admin/PayrollCarryoversModal.tsx @@ -20,6 +20,8 @@ type PayrollCarryoversModalProps = { canManage: boolean; employees: PayrollAdminEmployee[]; initialEmployeeId?: number | null; + initialPeriodId?: number | null; + initialAdjustmentId?: number | null; isOpen: boolean; onChanged?: () => void | Promise; onClose: () => void; @@ -39,13 +41,15 @@ export function PayrollCarryoversModal({ canManage, employees, initialEmployeeId = null, + initialPeriodId = null, + initialAdjustmentId = null, isOpen, onChanged, onClose, periods, selectedPeriod, }: PayrollCarryoversModalProps) { - const [periodId, setPeriodId] = useState(selectedPeriod.id); + const [periodId, setPeriodId] = useState(initialPeriodId || selectedPeriod.id); const [ledger, setLedger] = useState(null); const [loading, setLoading] = useState(true); const [savingId, setSavingId] = useState(null); @@ -130,6 +134,21 @@ export function PayrollCarryoversModal({ }, [entries]); const currency = ledger?.currency || selectedPeriod.currency; + useEffect(() => { + if ( + loading || + !initialAdjustmentId || + !entries.some( + (entry) => + entry.entry_type === "manual_adjustment" && + entry.id === initialAdjustmentId, + ) + ) return; + document + .getElementById(`payroll-manual-adjustment-${initialAdjustmentId}`) + ?.scrollIntoView({ block: "nearest" }); + }, [entries, initialAdjustmentId, loading]); + const toggleEntry = async (entryId: number, included: boolean) => { setSavingId(entryId); setError(null); @@ -280,7 +299,12 @@ export function PayrollCarryoversModal({ return (
void; +}) { + const active = adjustments.filter((item) => !item.is_voided); + if (!active.length) return null; + const details = active + .map((item) => { + const amount = Number(item.contribution); + const period = item.effective_period.name || item.effective_period.code; + const source = item.source_period?.name || item.source_period?.code; + return `${period}: ${amount < 0 ? "−" : "+"}${formatPayrollMoney(Math.abs(amount), currency)}${source ? ` · за ${source}` : ""}`; + }) + .join("\n"); + return ( + + ); +} diff --git a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx index 937c1644..aca5c9c6 100644 --- a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx +++ b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx @@ -45,6 +45,7 @@ import { import { createPortal } from "react-dom"; import { PayrollPointBreakdownModal } from "@/components/finance/admin/PayrollPointBreakdownModal"; +import { PayrollLaterBalanceAdjustments } from "@/components/finance/admin/PayrollLaterBalanceAdjustments"; import { PayrollCellCommentsModal, type PayrollCommentCell, @@ -59,6 +60,7 @@ import type { PayrollPeriodTableComponent, PayrollPeriodTableRow, PayrollPeriodTableStatus, + PayrollTableBalanceAdjustment, } from "@/lib/api/finance"; import { formatPayrollMoney, formatPayrollPoints } from "@/lib/payroll"; import { getPayrollAdminError } from "@/lib/payroll-admin"; @@ -1974,6 +1976,7 @@ export function PayrollPeriodTableView({ onUpdateComponent, onDeleteComponent, onOpenCarryovers, + onOpenBalanceAdjustment, onSetPaymentConfirmed, onSetReviewMark, onClearReviewMarks, @@ -2014,6 +2017,7 @@ export function PayrollPeriodTableView({ mode: "current" | "current_and_future", ) => Promise; onOpenCarryovers?: (employee?: PayrollPeriodTableRow["employee"]) => void; + onOpenBalanceAdjustment?: (adjustment: PayrollTableBalanceAdjustment) => void; onSetPaymentConfirmed?: ( row: PayrollPeriodTableRow, confirmed: boolean, @@ -2982,6 +2986,9 @@ export function PayrollPeriodTableView({ } case columnIds.carryovers: { const summary = carryoversByEmployeeId.get(row.employee.id); + const laterAdjustments = (data.later_balance_adjustments || []).filter( + (item) => item.employee.id === row.employee.id, + ); const count = summary?.count || 0; const balance = summary ? Number(summary.company_owes) - Number(summary.employee_owes) @@ -3008,31 +3015,38 @@ export function PayrollPeriodTableView({ label={`Остаток к переносу: ${row.employee.display_name}`} onClick={comments.onOpenComment} /> - + {summary?.pending ? ( + + {summary.pending} + + ) : null} + + +
); } diff --git a/frontend/src/lib/api/finance.ts b/frontend/src/lib/api/finance.ts index daa2a6be..186d5a6d 100644 --- a/frontend/src/lib/api/finance.ts +++ b/frontend/src/lib/api/finance.ts @@ -460,7 +460,22 @@ export type PayrollAttendanceAlert = { points_in_norm: boolean; }; +export type PayrollTableBalanceAdjustment = { + id: number; + employee: Pick & { + is_active: boolean; + }; + effective_period: Pick; + source_period_id: number | null; + source_period?: Pick | null; + amount: string; + contribution: string; + reason: string; + is_voided: boolean; +}; + export type PayrollPeriodTableRow = { + manual_balance_adjustments?: PayrollTableBalanceAdjustment[]; non_salary_payment_total?: string; off_cycle_settlement?: PayrollOffCycleBalance | null; employee: PayrollAdminEmployee & { @@ -523,6 +538,7 @@ export type PayrollPeriodTableCarryoverSummary = { }; export type PayrollPeriodTable = { + later_balance_adjustments?: PayrollTableBalanceAdjustment[]; balance_rules_version?: number; recorded_payments?: { payroll_recorded: string; diff --git a/frontend/src/lib/payroll-balance-adjustment-visibility.test.js b/frontend/src/lib/payroll-balance-adjustment-visibility.test.js new file mode 100644 index 00000000..33f0300e --- /dev/null +++ b/frontend/src/lib/payroll-balance-adjustment-visibility.test.js @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; +import { PayrollLaterBalanceAdjustments } from "../components/finance/admin/PayrollLaterBalanceAdjustments.tsx"; + +const adjustment = { + id: 51, + employee: { id: 7, display_name: "Антон Тестовый", is_active: false }, + effective_period: { id: 10, code: "2026-10", name: "Октябрь 2026" }, + source_period_id: 8, + source_period: { id: 8, code: "2026-08", name: "Август 2026" }, + amount: "10000.00", + contribution: "-10000.00", + reason: "Уже выдано <проверка>", + is_voided: false, +}; + +test("historical annotation is absent without linked adjustments", () => { + assert.equal( + renderToStaticMarkup( + createElement(PayrollLaterBalanceAdjustments, { + adjustments: [], + currency: "RUB", + }), + ), + "", + ); +}); + +test("compact warning identifies employee, source month and signed later correction", () => { + const html = renderToStaticMarkup( + createElement(PayrollLaterBalanceAdjustments, { + adjustments: [adjustment], + currency: "RUB", + onOpen: () => {}, + }), + ); + assert(html.includes("Антон Тестовый")); + assert(html.includes("Октябрь 2026")); + assert(html.includes("Август 2026")); + assert(html.includes("−")); + assert(html.includes("10")); + assert(html.includes("Сумма этого месяца и снимок не изменены")); + assert(!html.includes("!")); + assert(!html.includes('disabled=""')); +}); + +test("voided references do not display an upcoming warning", () => { + const html = renderToStaticMarkup( + createElement(PayrollLaterBalanceAdjustments, { + adjustments: [{ ...adjustment, is_voided: true }], + currency: "RUB", + }), + ); + assert.equal(html, ""); +}); + +test("open callback receives the exact adjustment and its effective period", () => { + let opened = null; + const tree = PayrollLaterBalanceAdjustments({ + adjustments: [adjustment], + currency: "RUB", + onOpen: (item) => { + opened = item; + }, + }); + const buttons = []; + const walk = (element) => { + if (Array.isArray(element)) return element.forEach(walk); + if (!element?.props) return; + if (element.type === "button") buttons.push(element); + walk(element.props.children); + }; + walk(tree); + assert.equal(buttons.length, 1); + buttons[0].props.onClick(); + assert.equal(opened, adjustment); + assert.equal(opened.effective_period.id, 10); +}); + +test("one marker describes all active later corrections and skips voided ones", () => { + const html = renderToStaticMarkup( + createElement(PayrollLaterBalanceAdjustments, { + adjustments: [ + { + ...adjustment, + id: 50, + is_voided: true, + effective_period: { id: 9, name: "Сентябрь 2026" }, + }, + adjustment, + { + ...adjustment, + id: 52, + effective_period: { id: 11, name: "Ноябрь 2026" }, + contribution: "500.00", + }, + ], + currency: "RUB", + onOpen: () => {}, + }), + ); + assert.equal((html.match(/