feat: add custom payroll columns

This commit is contained in:
lizerk 2026-08-26 14:50:51 +03:00
parent 58034245cb
commit c7a9fbe347
23 changed files with 988 additions and 54 deletions

View File

@ -23,9 +23,7 @@ from finance.models import (
PayrollAuditEvent,
PayrollPeriod,
)
from finance.payroll.components import (
PAYROLL_COMPONENTS_BY_CODE,
)
from finance.payroll.components import get_payroll_component
from finance.payroll.access import (
can_access_payroll_management,
has_payroll_permission,
@ -146,15 +144,19 @@ def _payroll_history_action_label(action):
return "Состояние записи изменено"
def _validate_payroll_table_column(column_id):
def _validate_payroll_table_column(column_id, *, period=None):
if column_id in PAYROLL_TABLE_FIXED_COLUMN_IDS:
return column_id
prefix, separator, component_code = column_id.partition(":")
try:
component = get_payroll_component(component_code, period=period)
except ValueError:
component = None
if (
prefix == "component"
and separator
and component_code not in SYSTEM_COMPONENT_CODES
and component_code in PAYROLL_COMPONENTS_BY_CODE
and component is not None
):
return column_id
raise ValidationError({"column_id": "Колонка отсутствует в расчётной таблице."})

View File

@ -15,7 +15,7 @@ from finance.models import (
PayrollInputCopyConfiguration,
PayrollInputLine,
)
from finance.payroll.components import PAYROLL_COMPONENTS, PAYROLL_COMPONENTS_BY_CODE
from finance.payroll.components import get_payroll_components
from ..admin_serializers import (
PayrollComponentSerializer,
@ -42,13 +42,20 @@ def _validate_input_copy_periods(target_period, source_period):
)
def _copyable_input_lines(source_period):
def _copyable_input_lines(source_period, *, target_period=None):
component_codes = {
component.code for component in get_payroll_components(source_period)
}
if target_period is not None:
component_codes &= {
component.code for component in get_payroll_components(target_period)
}
return (
PayrollInputLine.objects.filter(
period=source_period,
employee__is_hidden_in_ui=False,
status__in=(ApprovalStatus.DRAFT, ApprovalStatus.APPROVED),
component_code__in=PAYROLL_COMPONENTS_BY_CODE,
component_code__in=component_codes,
)
.exclude(
source=InputSource.API,
@ -109,7 +116,9 @@ def _input_copy_configuration_payload(actor):
def _input_copy_preview(target_period, source_period, *, actor):
_validate_input_copy_periods(target_period, source_period)
lines = list(_copyable_input_lines(source_period))
lines = list(
_copyable_input_lines(source_period, target_period=target_period)
)
component_rows = {
component.code: {
"component": component,
@ -117,7 +126,7 @@ def _input_copy_preview(target_period, source_period, *, actor):
"employee_ids": set(),
"amount_total": Decimal("0.00"),
}
for component in PAYROLL_COMPONENTS
for component in get_payroll_components(target_period)
}
employee_rows = {}
for line in lines:
@ -228,7 +237,10 @@ def _apply_input_copy(*, target_period, source_period, actor, data):
"idempotent_replay": True,
}
lines = list(
_copyable_input_lines(source_period).filter(
_copyable_input_lines(
source_period,
target_period=target_period,
).filter(
component_code__in=data["component_codes"]
)
)

View File

@ -18,15 +18,17 @@ from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
from finance.models import (
PayrollCustomComponent,
PayrollInputCopyConfiguration,
PayrollPeriod,
)
from finance.payroll.components import (
PAYROLL_COMPONENTS,
get_payroll_components,
)
from ...admin_serializers import (
PayrollComponentSerializer,
PayrollCustomComponentWriteSerializer,
PayrollEmployeeMiniSerializer,
PayrollPeriodSerializer,
PayrollPeriodWriteSerializer,
@ -39,6 +41,7 @@ from ..common import (
PayrollAdminAPIView,
_audit_api_mutation,
_conflict,
_optional_int,
)
from ..input_copy import _apply_input_copy
@ -61,7 +64,54 @@ class PayrollAdminComponentListView(PayrollAdminAPIView):
allowed_permissions = INPUT_VIEW_PERMISSIONS
def get(self, request):
return Response(PayrollComponentSerializer(PAYROLL_COMPONENTS, many=True).data)
period_id = _optional_int(request, "period_id")
period = (
get_object_or_404(PayrollPeriod, pk=period_id)
if period_id is not None
else None
)
return Response(
PayrollComponentSerializer(
get_payroll_components(period),
many=True,
).data
)
@transaction.atomic
def post(self, request):
self.require_permission(request, PAYROLL_PERMISSIONS["manage_inputs"])
serializer = PayrollCustomComponentWriteSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
next_order = (
PayrollCustomComponent.objects.order_by("-display_order")
.values_list("display_order", flat=True)
.first()
or 890
) + 10
component = PayrollCustomComponent(
**serializer.validated_data,
display_order=next_order,
created_by=request.user,
)
try:
component.full_clean()
component.save(force_insert=True)
except DjangoValidationError as exc:
raise ValidationError(_validation_detail(exc)) from exc
except IntegrityError as exc:
raise ValidationError(
{"non_field_errors": "Не удалось сохранить колонку."}
) from exc
_audit_api_mutation(
actor=request.user,
action="payroll.component_created",
instance=component,
metadata={"kind": component.kind},
)
return Response(
PayrollComponentSerializer(component).data,
status=status.HTTP_201_CREATED,
)
@extend_schema_view(

View File

@ -43,7 +43,10 @@ class PayrollCellCommentsView(PayrollAdminAPIView):
def get_context(self, pk, employee_id, column_id):
period = get_object_or_404(PayrollPeriod, pk=pk)
employee = get_object_or_404(Employee, pk=employee_id)
return period, employee, _validate_payroll_table_column(column_id)
return period, employee, _validate_payroll_table_column(
column_id,
period=period,
)
def get(self, request, pk, employee_id, column_id):
period, employee, column_id = self.get_context(
@ -94,7 +97,7 @@ class PayrollCellCommentDetailView(PayrollAdminAPIView):
def delete(self, request, pk, employee_id, column_id, comment_id):
period = get_object_or_404(PayrollPeriod, pk=pk)
employee = get_object_or_404(Employee, pk=employee_id)
column_id = _validate_payroll_table_column(column_id)
column_id = _validate_payroll_table_column(column_id, period=period)
thread = get_object_or_404(
PayrollCellCommentThread,
period=period,

View File

@ -13,7 +13,7 @@ from finance.payroll.access import (
has_payroll_permission,
has_simple_admin_access,
)
from finance.payroll.components import PAYROLL_COMPONENTS
from finance.payroll.components import get_payroll_components
from ...employees.serializers import EmployeeBriefSerializer
from ..admin_serializers import (
@ -119,7 +119,7 @@ class PayrollAdminWorkspaceView(PayrollAdminAPIView):
many=True,
).data,
"components": PayrollComponentSerializer(
PAYROLL_COMPONENTS,
get_payroll_components(selected_period),
many=True,
).data,
"periods": PayrollPeriodSerializer(periods, many=True).data,

View File

@ -14,6 +14,7 @@ from .common import (
PayrollCellCommentSerializer,
PayrollCellCommentWriteSerializer,
PayrollComponentSerializer,
PayrollCustomComponentWriteSerializer,
PayrollEmployeeMiniSerializer,
PayrollUserRefSerializer,
)
@ -79,6 +80,7 @@ __all__ = [
"PayrollCellCommentWriteSerializer",
"PayrollCompleteWorkflowSerializer",
"PayrollComponentSerializer",
"PayrollCustomComponentWriteSerializer",
"PayrollEmployeeMiniSerializer",
"PayrollInputLineSerializer",
"PayrollInputLineWriteSerializer",

View File

@ -12,9 +12,7 @@ from decimal import Decimal
from django.contrib.auth import get_user_model
from rest_framework import serializers
from finance.payroll.components import (
PAYROLL_COMPONENT_CODE_CHOICES,
)
from .common import PayrollComponentCodeField
Employee = get_user_model()
@ -25,13 +23,11 @@ class ApprovalCommandSerializer(serializers.Serializer):
class ClearComponentCellCommandSerializer(serializers.Serializer):
employee_id = serializers.IntegerField(min_value=1)
component_code = serializers.ChoiceField(
choices=PAYROLL_COMPONENT_CODE_CHOICES,
component_code = PayrollComponentCodeField(
required=False,
)
component_id = serializers.ChoiceField(
component_id = PayrollComponentCodeField(
source="component_code",
choices=PAYROLL_COMPONENT_CODE_CHOICES,
required=False,
write_only=True,
)
@ -57,13 +53,13 @@ class PayrollPaymentConfirmationCommandSerializer(serializers.Serializer):
class CopyPayrollInputLinesCommandSerializer(serializers.Serializer):
source_period_id = serializers.IntegerField(min_value=1)
component_codes = serializers.ListField(
child=serializers.ChoiceField(choices=PAYROLL_COMPONENT_CODE_CHOICES),
child=PayrollComponentCodeField(),
allow_empty=False,
required=False,
)
component_ids = serializers.ListField(
source="component_codes",
child=serializers.ChoiceField(choices=PAYROLL_COMPONENT_CODE_CHOICES),
child=PayrollComponentCodeField(),
allow_empty=False,
required=False,
write_only=True,

View File

@ -15,7 +15,10 @@ from api.v1.employees.serializers import EmployeeBriefSerializer
from finance.enums import (
ApprovalStatus,
InputSource,
PayrollComponentPeriodScope,
)
from finance.models import PayrollCustomComponent, PayrollPeriod
from finance.payroll.components import get_payroll_component
Employee = get_user_model()
@ -93,7 +96,7 @@ class PayrollCellCommentWriteSerializer(serializers.Serializer):
class PayrollComponentSerializer(serializers.Serializer):
id = serializers.CharField(read_only=True)
id = serializers.SerializerMethodField()
code = serializers.CharField(read_only=True)
name = serializers.CharField(read_only=True)
kind = serializers.CharField(read_only=True)
@ -101,6 +104,60 @@ class PayrollComponentSerializer(serializers.Serializer):
requires_reason = serializers.BooleanField(read_only=True)
is_active = serializers.BooleanField(read_only=True)
display_order = serializers.IntegerField(read_only=True)
period_scope = serializers.CharField(read_only=True)
scope_period_id = serializers.IntegerField(read_only=True, allow_null=True)
def get_id(self, obj) -> str:
return obj.code
class PayrollComponentCodeField(serializers.CharField):
default_error_messages = {
"unknown": "Неизвестный вид зарплатной операции.",
}
def to_internal_value(self, data):
value = super().to_internal_value(data)
try:
get_payroll_component(value)
except ValueError:
self.fail("unknown")
return value
class PayrollCustomComponentWriteSerializer(serializers.ModelSerializer):
name = serializers.CharField(max_length=80, trim_whitespace=True)
period_scope = serializers.ChoiceField(
choices=PayrollComponentPeriodScope.choices,
default=PayrollComponentPeriodScope.CURRENT_AND_FUTURE,
)
period_id = serializers.PrimaryKeyRelatedField(
source="scope_period",
queryset=PayrollPeriod.objects.all(),
required=False,
write_only=True,
)
class Meta:
model = PayrollCustomComponent
fields = [
"name",
"kind",
"requires_reason",
"period_scope",
"period_id",
]
def validate(self, attrs):
attrs = super().validate(attrs)
scope = attrs["period_scope"]
if scope == PayrollComponentPeriodScope.ALL:
attrs.pop("scope_period", None)
elif attrs.get("scope_period") is None:
raise serializers.ValidationError(
{"period_id": "Для выбранной области укажите расчётный период."}
)
return attrs
class DraftWriteSerializerMixin:

View File

@ -17,16 +17,14 @@ from finance.models import (
PayrollInputLine,
PayrollPeriod,
)
from finance.payroll.components import (
PAYROLL_COMPONENT_CODE_CHOICES,
)
from .common import (
DraftWriteSerializerMixin,
PayrollComponentCodeField,
PayrollComponentSerializer,
PayrollEmployeeMiniSerializer,
PayrollUserRefSerializer,
)
from finance.payroll.components import get_payroll_component
from .periods import PayrollCarryoverPeriodSerializer
Employee = get_user_model()
@ -106,13 +104,11 @@ class PayrollInputLineWriteSerializer(
source="employee",
queryset=Employee.objects.visible_in_ui(),
)
component_code = serializers.ChoiceField(
choices=PAYROLL_COMPONENT_CODE_CHOICES,
component_code = PayrollComponentCodeField(
required=False,
)
component_id = serializers.ChoiceField(
component_id = PayrollComponentCodeField(
source="component_code",
choices=PAYROLL_COMPONENT_CODE_CHOICES,
required=False,
write_only=True,
)
@ -137,6 +133,14 @@ class PayrollInputLineWriteSerializer(
]
def validate(self, attrs):
if not attrs.get("component_code"):
component_code = attrs.get("component_code")
if not component_code:
raise serializers.ValidationError({"component_code": "Обязательное поле."})
period = attrs.get("period") or getattr(self.instance, "period", None)
try:
get_payroll_component(component_code, period=period)
except ValueError as exc:
raise serializers.ValidationError(
{"component_code": "Колонка недоступна в выбранном периоде."}
) from exc
return attrs

View File

@ -31,6 +31,12 @@ class PayrollComponentKind(models.TextChoices):
PAYMENT = "payment", "Выплата"
class PayrollComponentPeriodScope(models.TextChoices):
CURRENT_ONLY = "current_only", "Только выбранный период"
CURRENT_AND_FUTURE = "current_and_future", "Выбранный и будущие периоды"
ALL = "all", "Все периоды"
class PayrollPeriodStatus(models.TextChoices):
OPEN = "open", "Открыт"
CALCULATED = "calculated", "Рассчитан"

View File

@ -0,0 +1,37 @@
# Generated by Django 5.2.4 on 2026-08-26 10:37
import django.core.validators
import django.db.models.deletion
import finance.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0026_alter_employeepayrate_source_and_more'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='PayrollCustomComponent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('code', models.CharField(default=finance.models.generate_custom_payroll_component_code, editable=False, max_length=64, unique=True, validators=[django.core.validators.RegexValidator(message='Код должен начинаться с A-Z и содержать только A-Z, 0-9, _.-', regex='^[A-Z][A-Z0-9_.-]{0,63}$')], verbose_name='Код')),
('name', models.CharField(max_length=80, unique=True, verbose_name='Название')),
('kind', models.CharField(choices=[('earning', 'Начисление'), ('adjustment_credit', 'Коррекция +'), ('adjustment_debit', 'Коррекция −'), ('deduction', 'Удержание'), ('payment', 'Выплата')], max_length=24, verbose_name='Влияние на расчёт')),
('requires_reason', models.BooleanField(default=False, verbose_name='Требовать основание')),
('is_active', models.BooleanField(default=True, verbose_name='Активна')),
('display_order', models.PositiveIntegerField(default=900, verbose_name='Порядок')),
('created_at', models.DateTimeField(auto_now_add=True, verbose_name='Создана')),
('created_by', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='created_payroll_components', to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
],
options={
'verbose_name': 'Пользовательская зарплатная колонка',
'verbose_name_plural': 'Пользовательские зарплатные колонки',
'ordering': ['display_order', 'name', 'id'],
},
),
]

View File

@ -0,0 +1,24 @@
# Generated by Django 5.2.4 on 2026-08-26 11:27
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0027_payrollcustomcomponent'),
]
operations = [
migrations.AddField(
model_name='payrollcustomcomponent',
name='period_scope',
field=models.CharField(choices=[('current_only', 'Только выбранный период'), ('current_and_future', 'Выбранный и будущие периоды'), ('all', 'Все периоды')], default='all', max_length=24, verbose_name='Периоды отображения'),
),
migrations.AddField(
model_name='payrollcustomcomponent',
name='scope_period',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='scoped_custom_components', to='finance.payrollperiod', verbose_name='Начальный период'),
),
]

View File

@ -0,0 +1,18 @@
# Generated by Django 5.2.4 on 2026-08-26 11:44
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('finance', '0028_payrollcustomcomponent_period_scope_and_more'),
]
operations = [
migrations.AlterField(
model_name='payrollcustomcomponent',
name='name',
field=models.CharField(max_length=80, verbose_name='Название'),
),
]

View File

@ -21,6 +21,7 @@ from .enums import (
PayrollCarryoverDirection,
PayrollCarryoverStatus,
PayrollComponentKind,
PayrollComponentPeriodScope,
PayrollPeriodStatus,
PayrollRunStatus,
VacationPayInstructionStatus,
@ -42,6 +43,10 @@ payroll_cell_column_validator = RegexValidator(
DEFAULT_DAILY_TARGET_POINTS = Decimal("5")
def generate_custom_payroll_component_code() -> str:
return f"CUSTOM_{uuid.uuid4().hex.upper()}"
class FinanceSettings(models.Model):
"""Global access settings for the native finance management workspace."""
@ -920,6 +925,132 @@ class PayrollComponent:
objects = _PayrollComponentRegistryManager()
class PayrollCustomComponent(models.Model):
"""User-created payroll column with immutable calculation semantics."""
code = models.CharField(
"Код",
max_length=64,
unique=True,
default=generate_custom_payroll_component_code,
editable=False,
validators=[component_code_validator],
)
name = models.CharField("Название", max_length=80)
kind = models.CharField(
"Влияние на расчёт",
max_length=24,
choices=PayrollComponentKind.choices,
)
requires_reason = models.BooleanField(
"Требовать основание",
default=False,
)
is_active = models.BooleanField("Активна", default=True)
display_order = models.PositiveIntegerField("Порядок", default=900)
period_scope = models.CharField(
"Периоды отображения",
max_length=24,
choices=PayrollComponentPeriodScope.choices,
default=PayrollComponentPeriodScope.ALL,
)
scope_period = models.ForeignKey(
PayrollPeriod,
on_delete=models.PROTECT,
null=True,
blank=True,
related_name="scoped_custom_components",
verbose_name="Начальный период",
)
created_by = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.PROTECT,
related_name="created_payroll_components",
verbose_name="Создал",
)
created_at = models.DateTimeField("Создана", auto_now_add=True)
class Meta:
verbose_name = "Пользовательская зарплатная колонка"
verbose_name_plural = "Пользовательские зарплатные колонки"
ordering = ["display_order", "name", "id"]
def __str__(self):
return self.name
def clean(self):
super().clean()
self.name = self.name.strip()
if not self.name:
raise ValidationError({"name": "Укажите название колонки."})
if self.period_scope == PayrollComponentPeriodScope.ALL:
self.scope_period = None
elif self.scope_period_id is None:
raise ValidationError(
{"scope_period": "Для выбранной области укажите расчётный период."}
)
if not self.is_active:
return
from finance.payroll.components import PAYROLL_COMPONENTS
normalized_name = self.name.casefold()
has_builtin_conflict = any(
component.name.casefold() == normalized_name
for component in PAYROLL_COMPONENTS
)
custom_components = (
PayrollCustomComponent.objects.filter(is_active=True)
.exclude(pk=self.pk)
.select_related("scope_period")
)
has_custom_conflict = any(
component.name.casefold() == normalized_name
and self.period_scope_overlaps(component)
for component in custom_components
)
if has_builtin_conflict or has_custom_conflict:
raise ValidationError(
{
"name": (
"Колонка с таким названием уже существует "
"в одном из выбранных периодов."
)
}
)
def period_scope_overlaps(self, other):
"""Return whether two component columns can appear in the same period."""
if (
self.period_scope == PayrollComponentPeriodScope.ALL
or other.period_scope == PayrollComponentPeriodScope.ALL
):
return True
if self.scope_period_id is None or other.scope_period_id is None:
return False
if (
self.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY
and other.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY
):
return self.scope_period_id == other.scope_period_id
if self.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY:
return self.scope_period.date_from >= other.scope_period.date_from
if other.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY:
return other.scope_period.date_from >= self.scope_period.date_from
return True
def applies_to_period(self, period):
if self.period_scope == PayrollComponentPeriodScope.ALL:
return True
if period is None or self.scope_period_id is None:
return False
if self.period_scope == PayrollComponentPeriodScope.CURRENT_ONLY:
return period.pk == self.scope_period_id
return period.date_from >= self.scope_period.date_from
class PayrollInputLine(DraftVersionedModel):
"""Append-only bonus, correction, deduction or recorded payment input."""
@ -1098,7 +1229,12 @@ class PayrollInputLine(DraftVersionedModel):
}
)
try:
component = self.component
from finance.payroll.components import get_payroll_component
component = get_payroll_component(
self.component_code,
period=self.period if self.period_id else None,
)
except ValueError as exc:
raise ValidationError({"component_code": str(exc)}) from exc
if component.requires_reason and not self.reason:

View File

@ -3,6 +3,7 @@
from dataclasses import dataclass
from finance.enums import PayrollComponentKind
from finance.enums import PayrollComponentPeriodScope
@dataclass(frozen=True, slots=True)
@ -25,6 +26,14 @@ class PayrollComponentDefinition:
def is_active(self) -> bool:
return True
@property
def period_scope(self) -> str:
return PayrollComponentPeriodScope.ALL
@property
def scope_period_id(self) -> None:
return None
def get_kind_display(self) -> str:
return PayrollComponentKind(self.kind).label
@ -92,11 +101,54 @@ class _PayrollComponentCollection(tuple):
class PayrollComponent:
"""Compatibility facade over the code registry; this is not a database model."""
objects = _PayrollComponentCollection(PAYROLL_COMPONENTS)
class _Manager:
def all(self):
return get_payroll_components()
def get(self, **criteria):
return self.all().get(**criteria)
def filter(self, **criteria):
return self.all().filter(**criteria)
def exclude(self, **criteria):
return self.all().exclude(**criteria)
def order_by(self, *fields):
return self.all().order_by(*fields)
objects = _Manager()
def get_payroll_component(code: str) -> PayrollComponentDefinition:
try:
return PAYROLL_COMPONENTS_BY_CODE[code]
except KeyError as exc:
raise ValueError(f"Неизвестный вид зарплатной операции: {code}") from exc
def get_payroll_components(period=None) -> _PayrollComponentCollection:
"""Return built-ins plus active user-created component columns."""
from finance.models import PayrollCustomComponent
custom = list(
PayrollCustomComponent.objects.filter(is_active=True)
.select_related("scope_period")
.order_by("display_order", "name", "id")
)
if period is not None:
custom = [component for component in custom if component.applies_to_period(period)]
return _PayrollComponentCollection((*PAYROLL_COMPONENTS, *custom))
def get_payroll_component(code: str, *, period=None) -> PayrollComponentDefinition:
component = PAYROLL_COMPONENTS_BY_CODE.get(code)
if component is not None:
return component
from finance.models import PayrollCustomComponent
component = (
PayrollCustomComponent.objects.filter(code=code, is_active=True)
.select_related("scope_period")
.first()
)
if component is not None and (
period is None or component.applies_to_period(period)
):
return component
raise ValueError(f"Неизвестный вид зарплатной операции: {code}")

View File

@ -29,7 +29,7 @@ from finance.models import (
PayrollWorkRecord,
PayrollWorkSettings,
)
from finance.payroll.components import PAYROLL_COMPONENTS
from finance.payroll.components import get_payroll_components
from .adapter import POINT_BASE_COMPONENT_CODES, build_core_preview_request
from .balances import opening_balances
@ -371,7 +371,7 @@ def build_payroll_period_table(period: PayrollPeriod):
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 = PAYROLL_COMPONENTS
components = get_payroll_components(period)
component_definitions = {
component.code: _component_definition(
component.code,

View File

@ -55,6 +55,7 @@ from .config import (
ruleset_not_effective_message,
ruleset_period_details,
)
from .components import get_payroll_component
from .exceptions import PayrollOperationError, PayrollPermissionDenied
from .work_norm import calculate_period_target_points
@ -1994,13 +1995,21 @@ def set_component_input_line_value(
) -> PayrollInputLine:
"""Replace the aggregate value of one editable spreadsheet component cell."""
period = PayrollPeriod.objects.select_for_update().get(pk=period_id)
try:
get_payroll_component(component_code, period=period)
except ValueError as exc:
raise PayrollOperationError(
"COMPONENT_NOT_AVAILABLE",
"Колонка недоступна в выбранном расчётном периоде.",
details={"component_code": component_code},
) from exc
clear_component_input_lines(
period_id,
employee_id=employee_id,
component_code=component_code,
actor=actor,
)
period = PayrollPeriod.objects.select_for_update().get(pk=period_id)
line = PayrollInputLine(
period=period,
employee_id=employee_id,

View File

@ -23,6 +23,7 @@ from finance.models import (
PayrollAuditEvent,
PayrollCellCommentThread,
PayrollComponent,
PayrollCustomComponent,
PayrollDailyWorkEntry,
PayrollInputLine,
PayrollPeriod,
@ -98,6 +99,236 @@ def make_approved_period_inputs(
)
def test_manager_can_add_custom_component_column(
user_factory,
auth_client_factory,
):
manager = user_factory(email="component.manager@example.test")
employee = user_factory(email="component.employee@example.test")
grant(manager, "manage_payroll_inputs", "view_all_payroll")
period = make_period(creator=manager)
client = auth_client_factory(manager)
created = client.post(
api_url("admin-component-list"),
{
"name": "Компенсация питания",
"kind": "earning",
"requires_reason": True,
"period_scope": "current_and_future",
"period_id": period.pk,
},
format="json",
)
assert created.status_code == 201, created.content
component = created.json()
assert component["id"] == component["code"]
assert component["code"].startswith("CUSTOM_")
assert component["name"] == "Компенсация питания"
assert component["kind"] == "earning"
assert component["requires_reason"] is True
assert component["period_scope"] == "current_and_future"
assert component["scope_period_id"] == period.pk
assert PayrollCustomComponent.objects.filter(code=component["code"]).exists()
saved = client.post(
api_url("admin-component-cell-set", pk=period.pk),
{
"employee_id": employee.pk,
"component_code": component["code"],
"amount": "1250.00",
},
format="json",
)
assert saved.status_code == 201, saved.content
line = PayrollInputLine.objects.get(pk=saved.json()["id"])
assert line.component.name == "Компенсация питания"
assert line.reason == "Перезаписано в итоговой таблице"
table = client.get(api_url("admin-period-table", pk=period.pk))
assert table.status_code == 200, table.content
assert component["code"] in {
item["code"] for item in table.json()["component_columns"]
}
row = next(
item for item in table.json()["rows"] if item["employee"]["id"] == employee.pk
)
assert row["component_amounts"] == {component["code"]: "1250.00"}
assert row["gross_before_adjustments"] == "1250.00"
assert row["gross_total"] == "1250.00"
assert row["net_pay"] == "1250.00"
assert row["payable"] == "1250.00"
@pytest.mark.parametrize(
("period_scope", "expected_visibility"),
[
("current_only", (False, True, False)),
("current_and_future", (False, True, True)),
("all", (True, True, True)),
],
)
def test_custom_component_period_scope_controls_table_visibility(
user_factory,
auth_client_factory,
period_scope,
expected_visibility,
):
manager = user_factory(email=f"scope.{period_scope}@example.test")
employee = user_factory(email=f"scope.employee.{period_scope}@example.test")
grant(manager, "manage_payroll_inputs", "view_all_payroll")
periods = [
PayrollPeriod.objects.create(
code="2026-05",
name="Май 2026",
date_from="2026-05-01",
date_to="2026-05-31",
pay_date="2026-06-05",
currency="RUB",
created_by=manager,
),
make_period(creator=manager),
PayrollPeriod.objects.create(
code="2026-07",
name="Июль 2026",
date_from="2026-07-01",
date_to="2026-07-31",
pay_date="2026-08-05",
currency="RUB",
created_by=manager,
),
]
client = auth_client_factory(manager)
created = client.post(
api_url("admin-component-list"),
{
"name": f"Колонка {period_scope}",
"kind": "payment",
"requires_reason": False,
"period_scope": period_scope,
"period_id": periods[1].pk,
},
format="json",
)
assert created.status_code == 201, created.content
component_code = created.json()["code"]
visibility = []
for period in periods:
response = client.get(api_url("admin-period-table", pk=period.pk))
assert response.status_code == 200, response.content
visibility.append(
component_code
in {item["code"] for item in response.json()["component_columns"]}
)
assert tuple(visibility) == expected_visibility
unavailable_period = next(
(
period
for period, visible in zip(periods, expected_visibility, strict=True)
if not visible
),
None,
)
if unavailable_period is not None:
rejected = client.post(
api_url("admin-component-cell-set", pk=unavailable_period.pk),
{
"employee_id": employee.pk,
"component_code": component_code,
"amount": "500.00",
},
format="json",
)
assert rejected.status_code == 409, rejected.content
assert rejected.json()["code"] == "COMPONENT_NOT_AVAILABLE"
assert not PayrollInputLine.objects.filter(
period=unavailable_period,
employee=employee,
component_code=component_code,
).exists()
def test_custom_component_name_is_unique_only_in_overlapping_periods(
user_factory,
auth_client_factory,
):
manager = user_factory(email="scope.name.manager@example.test")
grant(manager, "manage_payroll_inputs")
periods = [
PayrollPeriod.objects.create(
code="2026-05",
name="Май 2026",
date_from="2026-05-01",
date_to="2026-05-31",
pay_date="2026-06-05",
currency="RUB",
created_by=manager,
),
make_period(creator=manager),
PayrollPeriod.objects.create(
code="2026-07",
name="Июль 2026",
date_from="2026-07-01",
date_to="2026-07-31",
pay_date="2026-08-05",
currency="RUB",
created_by=manager,
),
]
client = auth_client_factory(manager)
def create_component(*, period, period_scope, name="Компенсация"):
return client.post(
api_url("admin-component-list"),
{
"name": name,
"kind": "earning",
"requires_reason": False,
"period_scope": period_scope,
"period_id": period.pk,
},
format="json",
)
may_only = create_component(
period=periods[0],
period_scope="current_only",
)
june_only = create_component(
period=periods[1],
period_scope="current_only",
name="КОМПЕНСАЦИЯ",
)
from_july = create_component(
period=periods[2],
period_scope="current_and_future",
)
assert may_only.status_code == 201, may_only.content
assert june_only.status_code == 201, june_only.content
assert from_july.status_code == 201, from_july.content
overlapping_requests = [
create_component(period=periods[2], period_scope="current_only"),
create_component(period=periods[1], period_scope="current_and_future"),
create_component(period=periods[0], period_scope="all"),
]
for response in overlapping_requests:
assert response.status_code == 400, response.content
assert "выбранных периодов" in response.json()["name"][0]
builtin_conflict = create_component(
period=periods[0],
period_scope="current_only",
name="ПРЕМИЯ",
)
assert builtin_conflict.status_code == 400, builtin_conflict.content
assert "выбранных периодов" in builtin_conflict.json()["name"][0]
def test_copy_input_lines_previews_scope_and_handles_existing_values(
user_factory,
auth_client_factory,

View File

@ -72,6 +72,7 @@ import type {
PayrollBulkPointRateResult,
PayrollBulkTargetPointsResult,
PayrollCalculationBlocker,
PayrollCustomComponentWrite,
PayrollInputLineWrite,
PayrollInputCopyResult,
PayrollInputHistoryEvent,
@ -902,6 +903,19 @@ export function PayrollAdminWorkspace({
}
};
const addComponentColumn = async (payload: PayrollCustomComponentWrite) => {
if (!canManageSourceData || !selectedPeriod) return;
await apiClient.createPayrollAdminComponent({
...payload,
period_id: selectedPeriod.id,
});
await Promise.all([
loadWorkspace(selectedPeriod.id, true),
refreshInlineTable("inputs"),
]);
setNotice(`Колонка «${payload.name}» добавлена.`);
};
const saveRateCell = async (row: PayrollPeriodTableRow, field: "amount" | "point_rate", value: string) => {
if (!canManageSourceData || !selectedPeriod) return;
const rawValue = value.trim().replace(",", ".");
@ -1254,7 +1268,7 @@ export function PayrollAdminWorkspace({
{tab === "inputs" ? <section className="app-surface rounded-2xl p-4 sm:p-5"><SectionIntro title="Начисления и выплаты" description="Премии, корректировки, удержания и авансы вводятся отдельными строками. Утверждённые строки исправляются новой корректировкой." />{sourceDataLocked ? <div className="app-feedback-warning mb-4 rounded-lg px-3 py-2.5 text-sm">Исходные данные заблокированы текущим этапом расчёта. Для исправления сначала верните расчёт.</div> : null}<PayrollInputHistory events={inputHistory} loading={historyLoading} error={historyError} /><TableToolbar search={search} status={status} sort={inputSort} onSearch={setSearch} onStatus={setStatus} onSort={setInputSort} onAdd={canManageSourceData ? () => { setEditingInput(null); setInputModalOpen(true); } : undefined} onCopyInputs={canManageSourceData ? () => setInputCopyModalOpen(true) : undefined} addLabel="Добавить операцию" /><PayrollInputLinesTable records={filteredInputs} loading={tableLoading} error={tableError} currentUserId={user?.id} canManage={canManageSourceData} canApprove={canApproveSourceData} canOverrideApproval={permissions.override_approval} fullAccess={permissions.full_access} onEdit={(record) => { setEditingInput(record); setInputModalOpen(true); }} onApprove={openApproveInput} /></section> : null}
{tab === "summary" ? (
<section className="app-surface min-w-0 rounded-2xl p-4 sm:p-5">
<PayrollPeriodTableView compact data={periodTable} loading={tableLoading} error={tableError} search={search} onSearch={setSearch} storageUserId={user?.id} canEdit={canManageSourceData} onBulkEditRate={canManageSourceData ? () => setBulkPayRateModalOpen(true) : undefined} onBulkEditPointRate={canManageSourceData && rates.some((record) => record.status !== "voided") ? () => setBulkPointRateModalOpen(true) : undefined} onBulkEditTargetPoints={canManageSourceData ? () => setBulkTargetPointsModalOpen(true) : undefined} onSaveRate={saveRateCell} onSaveWork={saveWorkCell} onSaveComponent={saveComponentCell} onOpenCarryovers={(employee) => { setCarryoversEmployeeId(employee.id); setCarryoversModalOpen(true); }} onSetPaymentConfirmed={permissions.manage_inputs && selectedPeriod.status === "closed" ? setPaymentConfirmed : undefined} />
<PayrollPeriodTableView compact data={periodTable} loading={tableLoading} error={tableError} search={search} onSearch={setSearch} storageUserId={user?.id} canEdit={canManageSourceData} onBulkEditRate={canManageSourceData ? () => setBulkPayRateModalOpen(true) : undefined} onBulkEditPointRate={canManageSourceData && rates.some((record) => record.status !== "voided") ? () => setBulkPointRateModalOpen(true) : undefined} onBulkEditTargetPoints={canManageSourceData ? () => setBulkTargetPointsModalOpen(true) : undefined} onSaveRate={saveRateCell} onSaveWork={saveWorkCell} onSaveComponent={saveComponentCell} onAddComponent={canManageSourceData ? addComponentColumn : undefined} onOpenCarryovers={(employee) => { setCarryoversEmployeeId(employee.id); setCarryoversModalOpen(true); }} onSetPaymentConfirmed={permissions.manage_inputs && selectedPeriod.status === "closed" ? setPaymentConfirmed : undefined} />
</section>
) : null}
{tab === "approval" ? (

View File

@ -16,8 +16,8 @@ import {
useSortable,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { AlertTriangle, ChartNoAxesColumn, CircleHelp, Eye, EyeOff, GripVertical, Loader2, Pencil, RotateCcw, Search, Table2, X } from "lucide-react";
import { Fragment, type ReactNode, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { AlertTriangle, ChartNoAxesColumn, CircleHelp, Eye, EyeOff, GripVertical, Loader2, Pencil, Plus, RotateCcw, Search, Table2, X } from "lucide-react";
import { Fragment, type FormEvent, type ReactNode, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { PayrollPointBreakdownModal } from "@/components/finance/admin/PayrollPointBreakdownModal";
@ -27,6 +27,9 @@ import {
} from "@/components/finance/admin/PayrollCellCommentsModal";
import { useMiddleMousePan } from "@/hooks/useMiddleMousePan";
import type {
PayrollComponentPeriodScope,
PayrollCustomComponentWrite,
PayrollLineKind,
PayrollPeriodTable,
PayrollPeriodTableComponent,
PayrollPeriodTableRow,
@ -52,6 +55,44 @@ const kindLabels = {
payment: "Выплата",
};
const kindHelpItems: Array<{
kind: PayrollLineKind;
description: string;
formula: string;
}> = [
{
kind: "earning",
description: "Основная часть дохода: например, премия, отпускные или компенсация. Входит в сумму до корректировок.",
formula: "Увеличивает «Начислено» и «К выплате».",
},
{
kind: "adjustment_credit",
description: "Доплата, которая исправляет или уточняет уже рассчитанные начисления.",
formula: "Прибавляется к «Корректировкам», «Начислено» и «К выплате».",
},
{
kind: "adjustment_debit",
description: "Уменьшение, которое исправляет или уточняет уже рассчитанные начисления.",
formula: "Вычитается из «Корректировок», «Начислено» и «К выплате».",
},
{
kind: "deduction",
description: "Сумма, которую удерживают из начисленного дохода. Само начисление при этом не меняется.",
formula: "Уменьшает «После удержаний» и «К выплате».",
},
{
kind: "payment",
description: "Деньги, которые уже выданы сотруднику: например, аванс или фактическая выплата.",
formula: "Уменьшает только остаток «К выплате», не меняя начисления и удержания.",
},
];
const periodScopeLabels: Record<PayrollComponentPeriodScope, string> = {
current_only: "Только текущий период",
current_and_future: "Текущий и будущие периоды",
all: "Все периоды",
};
const headerCellClass = "app-surface-muted overflow-hidden border-b border-r border-[var(--border-subtle)] px-2 py-2.5 text-left text-[10px] font-semibold text-[var(--muted-foreground)]";
const bodyCellClass = "overflow-hidden whitespace-normal border-b border-r border-[var(--border-subtle)] px-1.5 py-2 text-right text-[11px] leading-tight text-[var(--foreground)] tabular-nums";
const HIDDEN_COLUMNS_STORAGE_PREFIX = "finances.payroll.period-table.hidden-columns";
@ -415,6 +456,177 @@ function CalculationHelpModal({ help, rules, onClose }: { help: CalculationHelp;
);
}
function AddPayrollComponentModal({
onCreate,
onClose,
}: {
onCreate: (payload: PayrollCustomComponentWrite) => Promise<void>;
onClose: () => void;
}) {
const [name, setName] = useState("");
const [kind, setKind] = useState<PayrollLineKind>("earning");
const [requiresReason, setRequiresReason] = useState(false);
const [periodScope, setPeriodScope] = useState<PayrollComponentPeriodScope>("current_and_future");
const [pending, setPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [helpOpen, setHelpOpen] = useState(false);
const nameRef = useRef<HTMLInputElement>(null);
const helpOpenRef = useRef(false);
useEffect(() => {
helpOpenRef.current = helpOpen;
}, [helpOpen]);
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement | null;
nameRef.current?.focus();
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
if (helpOpenRef.current) {
setHelpOpen(false);
return;
}
if (!pending) onClose();
};
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("keydown", closeOnEscape);
previouslyFocused?.focus();
};
}, [onClose, pending]);
const submit = async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault();
const normalizedName = name.trim();
if (!normalizedName) {
setError("Укажите название колонки.");
return;
}
setPending(true);
setError(null);
try {
await onCreate({
name: normalizedName,
kind,
requires_reason: requiresReason,
period_scope: periodScope,
});
onClose();
} catch (createError) {
setError(getPayrollAdminError(createError, "Не удалось добавить колонку."));
} finally {
setPending(false);
}
};
if (typeof document === "undefined") return null;
return createPortal(
<div className="fixed inset-0 z-[1000] flex items-center justify-center p-4" role="presentation">
<button type="button" className="absolute inset-0 bg-black/60" onClick={pending ? undefined : onClose} aria-label="Закрыть добавление колонки" />
<section className="app-surface relative z-10 w-full max-w-md rounded-2xl border border-[var(--border-subtle)] p-5 shadow-2xl" role="dialog" aria-modal="true" aria-labelledby="payroll-add-component-title">
<div className="flex items-start justify-between gap-4">
<div>
<p className="app-text-muted text-xs uppercase tracking-wide">Начисления и выплаты</p>
<h3 id="payroll-add-component-title" className="mt-1 inline-flex items-center gap-1.5 text-lg font-semibold text-[var(--foreground)]">
<span>Добавить колонку</span>
<button
type="button"
onClick={() => setHelpOpen(true)}
className="app-accent-text inline-flex h-5 w-5 items-center justify-center rounded-full transition hover:bg-[var(--surface-tertiary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent-primary)]"
aria-label="Открыть справку о типах колонок"
aria-haspopup="dialog"
aria-expanded={helpOpen}
title="Как тип колонки влияет на расчёт"
>
<CircleHelp size={14} strokeWidth={2} />
</button>
</h3>
</div>
<button type="button" className="app-action-ghost inline-flex h-8 w-8 items-center justify-center rounded-lg" onClick={onClose} disabled={pending} aria-label="Закрыть"><X size={17} /></button>
</div>
<form className="mt-5 space-y-4" onSubmit={submit}>
<label className="block">
<span className="app-field-label">Название</span>
<input ref={nameRef} value={name} onChange={(event) => setName(event.target.value)} maxLength={80} className="app-input mt-1 w-full rounded-lg px-3 py-2.5 text-sm" placeholder="Например, Компенсация питания" disabled={pending} />
</label>
<label className="block">
<span className="app-field-label">Как влияет на расчёт</span>
<select value={kind} onChange={(event) => setKind(event.target.value as PayrollLineKind)} className="app-input mt-1 w-full rounded-lg px-3 py-2.5 text-sm" disabled={pending}>
{Object.entries(kindLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
</label>
<label className="block">
<span className="app-field-label">В каких периодах показывать</span>
<select value={periodScope} onChange={(event) => setPeriodScope(event.target.value as PayrollComponentPeriodScope)} className="app-input mt-1 w-full rounded-lg px-3 py-2.5 text-sm" disabled={pending}>
{Object.entries(periodScopeLabels).map(([value, label]) => <option key={value} value={value}>{label}</option>)}
</select>
<span className="app-text-muted mt-1.5 block text-xs leading-relaxed">
Новая колонка появится согласно выбранной области; суммы между периодами автоматически не копируются.
</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>
{error ? <div className="app-feedback-danger rounded-lg px-3 py-2.5 text-sm">{error}</div> : null}
<div className="flex justify-end gap-2 pt-1">
<button type="button" className="app-action-secondary rounded-lg px-4 py-2.5 text-sm font-medium" onClick={onClose} disabled={pending}>Отмена</button>
<button type="submit" className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-semibold" disabled={pending}>
{pending ? <Loader2 className="animate-spin" size={15} /> : <Plus size={15} />}
Добавить
</button>
</div>
</form>
</section>
{helpOpen ? <PayrollComponentKindsHelpModal onClose={() => setHelpOpen(false)} /> : null}
</div>,
document.body,
);
}
function PayrollComponentKindsHelpModal({ onClose }: { onClose: () => void }) {
const closeButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const previouslyFocused = document.activeElement as HTMLElement | null;
closeButtonRef.current?.focus();
return () => previouslyFocused?.focus();
}, []);
return (
<div className="fixed inset-0 z-[1010] flex items-center justify-center p-4" role="presentation">
<button type="button" className="absolute inset-0 bg-black/60" onClick={onClose} aria-label="Закрыть справку о типах колонок" />
<section className="app-surface relative z-10 w-full max-w-lg rounded-2xl border border-[var(--border-subtle)] p-5 shadow-2xl" role="dialog" aria-modal="true" aria-labelledby="payroll-component-kinds-help-title">
<div className="flex items-start justify-between gap-4">
<div className="min-w-0">
<p className="app-text-muted text-xs uppercase tracking-wide">Начисления и выплаты</p>
<h3 id="payroll-component-kinds-help-title" className="mt-1 text-lg font-semibold text-[var(--foreground)]">Как выбрать тип колонки</h3>
</div>
<button ref={closeButtonRef} type="button" className="app-action-ghost inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg" onClick={onClose} aria-label="Закрыть"><X size={17} /></button>
</div>
<div className="mt-5 space-y-2.5">
{kindHelpItems.map((item) => (
<section key={item.kind} className="app-surface-muted rounded-xl border border-[var(--border-subtle)] p-3">
<h4 className="text-sm font-semibold text-[var(--foreground)]">{kindLabels[item.kind]}</h4>
<p className="app-text-muted mt-1 text-xs leading-relaxed">{item.description}</p>
<p className="mt-1.5 text-xs font-medium text-[var(--foreground)]">{item.formula}</p>
</section>
))}
</div>
<div className="app-selected mt-4 rounded-xl px-3 py-2.5 text-xs leading-relaxed">
<span className="font-semibold">Итоговая формула: </span>
К выплате = Начислено − Удержано − Выплачено.
</div>
</section>
</div>
);
}
function calculationHelps(pointPolicy: PayrollPeriodTable["calculation_rules"]["point_policy"]): Record<string, CalculationHelp> {
const pointRecalculationLogic = pointPolicy === "proportional_with_excess"
? "Повторяет исходную Excel-таблицу: баллы изменяют общую базу из оклада и премии. Недовыполнение даёт отрицательный перерасчёт, перевыполнение — положительный."
@ -834,6 +1046,7 @@ export function PayrollPeriodTableView({
onSaveRate,
onSaveWork,
onSaveComponent,
onAddComponent,
onOpenCarryovers,
onSetPaymentConfirmed,
compact = false,
@ -851,6 +1064,7 @@ export function PayrollPeriodTableView({
onSaveRate?: (row: PayrollPeriodTableRow, field: "amount" | "point_rate", value: string) => Promise<void>;
onSaveWork?: (row: PayrollPeriodTableRow, field: "target_points" | "actual_points", value: string) => Promise<void>;
onSaveComponent?: (row: PayrollPeriodTableRow, componentCode: string, value: string) => Promise<void>;
onAddComponent?: (payload: PayrollCustomComponentWrite) => Promise<void>;
onOpenCarryovers?: (employee: PayrollPeriodTableRow["employee"]) => void;
onSetPaymentConfirmed?: (row: PayrollPeriodTableRow, confirmed: boolean) => Promise<void>;
compact?: boolean;
@ -872,6 +1086,7 @@ export function PayrollPeriodTableView({
displayName: string;
} | null>(null);
const [columnsMenuOpen, setColumnsMenuOpen] = useState(false);
const [addComponentOpen, setAddComponentOpen] = useState(false);
const [rowsMenuOpen, setRowsMenuOpen] = useState(false);
const [compactPanel, setCompactPanel] = useState<CompactPeriodPanel | null>("table");
const [paymentConfirmationBusyIds, setPaymentConfirmationBusyIds] = useState<Set<number>>(new Set());
@ -1580,7 +1795,20 @@ export function PayrollPeriodTableView({
<th rowSpan={2} className={`${headerCellClass} sticky left-0 z-40 align-middle`}>Сотрудник</th>
{groupSegments.map((segment, index) => (
<th key={`${segment.group}-${index}`} colSpan={segment.columns.length} className={`${headerCellClass} text-center uppercase tracking-wide ${index > 0 ? "!border-l-2 !border-l-[var(--border-strong)]" : ""}`}>
{segment.group}
<span className="inline-flex items-center justify-center gap-1.5">
<span>{segment.group}</span>
{onAddComponent && segment.group === "Начисления и выплаты" && groupSegments.findIndex((item) => item.group === segment.group) === index ? (
<button
type="button"
className="app-action-ghost inline-flex h-5 w-5 items-center justify-center rounded-md text-[var(--accent-primary)]"
onClick={() => setAddComponentOpen(true)}
aria-label="Добавить колонку начисления или выплаты"
title="Добавить колонку"
>
<Plus size={13} strokeWidth={2.5} />
</button>
) : null}
</span>
</th>
))}
</tr>
@ -1660,6 +1888,7 @@ export function PayrollPeriodTableView({
</div>
</DndContext> : null}
{activeHelp ? <CalculationHelpModal help={activeHelp} rules={data.calculation_rules} onClose={() => setActiveHelp(null)} /> : null}
{addComponentOpen && onAddComponent ? <AddPayrollComponentModal onCreate={onAddComponent} onClose={() => setAddComponentOpen(false)} /> : null}
{breakdownEmployee ? (
<PayrollPointBreakdownModal
periodId={data.period_id}

View File

@ -7,6 +7,11 @@ export type PayrollLineKind =
| "deduction"
| "payment";
export type PayrollComponentPeriodScope =
| "current_only"
| "current_and_future"
| "all";
export type PayrollPeriodSummary = {
code: string;
name: string;
@ -581,6 +586,15 @@ export type PayrollComponent = {
requires_reason: boolean;
is_active: boolean;
display_order: number;
period_scope: PayrollComponentPeriodScope;
scope_period_id: number | null;
};
export type PayrollCustomComponentWrite = {
name: string;
kind: PayrollLineKind;
requires_reason: boolean;
period_scope: PayrollComponentPeriodScope;
};
export type PayrollAdminPayRate = {
@ -1029,6 +1043,10 @@ export function createFinanceApi(request: RequestFn) {
),
getPayrollAdminWorkspace: (periodId?: number): Promise<PayrollAdminWorkspace> =>
request(`${adminPrefix}/workspace/${buildQuery({ period_id: periodId })}`),
createPayrollAdminComponent: (
payload: PayrollCustomComponentWrite & { period_id: number },
): Promise<PayrollComponent> =>
request(`${adminPrefix}/components/`, jsonOptions("POST", payload)),
getPayrollAdminPeriods: (): Promise<PayrollAdminListPayload<PayrollAdminPeriod>> =>
request(`${adminPrefix}/periods/`),
getPayrollAdminPeriodTable: (periodId: number): Promise<PayrollPeriodTable> =>

View File

@ -6,13 +6,37 @@ import {
normalizePayrollColumnOrder,
} from "./payroll-columns.ts";
test("payroll column order keeps saved columns and appends newly available ones", () => {
test("payroll column order keeps saved columns and places newly available ones by default neighbors", () => {
assert.deepEqual(
normalizePayrollColumnOrder(
["payable", "actual_points", "removed", "payable"],
["rate_amount", "actual_points", "payable", "status"],
),
["payable", "actual_points", "rate_amount", "status"],
["payable", "rate_amount", "actual_points", "status"],
);
});
test("new payroll component stays before the calculation group", () => {
assert.deepEqual(
normalizePayrollColumnOrder(
["rate_amount", "component:BONUS", "gross_total", "payable", "status"],
[
"rate_amount",
"component:BONUS",
"component:CUSTOM_ADVANCE",
"gross_total",
"payable",
"status",
],
),
[
"rate_amount",
"component:BONUS",
"component:CUSTOM_ADVANCE",
"gross_total",
"payable",
"status",
],
);
});

View File

@ -9,8 +9,18 @@ export function normalizePayrollColumnOrder(
seen.add(columnId);
return true;
});
for (const columnId of availableIds) {
if (!seen.has(columnId)) normalized.push(columnId);
for (const [availableIndex, columnId] of availableIds.entries()) {
if (seen.has(columnId)) continue;
const nextKnownId = availableIds
.slice(availableIndex + 1)
.find((candidateId) => seen.has(candidateId));
if (nextKnownId) {
normalized.splice(normalized.indexOf(nextKnownId), 0, columnId);
} else {
normalized.push(columnId);
}
seen.add(columnId);
}
return normalized;
}