Merge branch 'develop'

This commit is contained in:
lizerk 2026-07-21 13:34:37 +03:00
commit a20f972bb2
16 changed files with 1575 additions and 45 deletions

View File

@ -87,6 +87,19 @@ def _records_visible_to(user):
return queryset.filter(employee_id=user.id)
def _day_event_records_visible_to(user):
"""Allow payroll viewers to inspect evidence used by point projections."""
queryset = AttendanceRecord.objects.all()
if (
user.is_staff
or user.is_superuser
or user.has_perm("finance.view_all_payroll")
):
return queryset
return queryset.filter(employee_id=user.id)
def _check_staff_access(user) -> None:
if not (user and user.is_authenticated and (user.is_staff or user.is_superuser)):
raise PermissionDenied("You don't have permission to manage attendance")
@ -662,7 +675,7 @@ class AttendanceRecordDayEventsAPIView(APIView):
def get_record(self, request, record_id):
return get_object_or_404(
_records_visible_to(request.user).select_related("employee"),
_day_event_records_visible_to(request.user).select_related("employee"),
pk=record_id,
)
@ -708,7 +721,7 @@ class AttendanceRecordDayEventPhotoAPIView(APIView):
def get_record(self, request, record_id):
return get_object_or_404(
_records_visible_to(request.user).select_related("employee"),
_day_event_records_visible_to(request.user).select_related("employee"),
pk=record_id,
)

View File

@ -54,7 +54,10 @@ from finance.payroll.services import (
return_run_for_correction,
submit_run_for_review,
)
from finance.payroll.reporting import build_payroll_period_table
from finance.payroll.reporting import (
build_payroll_employee_point_breakdown,
build_payroll_period_table,
)
from finance.payroll.attendance import (
apply_attendance_work_preview,
build_attendance_work_preview,
@ -594,6 +597,23 @@ class PayrollPeriodTableView(PayrollAdminAPIView):
return Response(build_payroll_period_table(period))
class PayrollPeriodPointBreakdownView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["view_all"],)
def get(self, request, pk, employee_id):
period = get_object_or_404(
PayrollPeriod.objects.select_related("current_run"),
pk=pk,
)
employee = get_object_or_404(
Employee.objects.select_related("position").prefetch_related(
"departments_links__department"
),
pk=employee_id,
)
return Response(build_payroll_employee_point_breakdown(period, employee))
class PayrollPeriodApproveDraftsView(PayrollAdminAPIView):
allowed_permissions = (PAYROLL_PERMISSIONS["approve_inputs"],)

View File

@ -27,6 +27,7 @@ from .admin_views import (
PayrollPeriodCalculateView,
PayrollPeriodApproveDraftsView,
PayrollPeriodCloseView,
PayrollPeriodPointBreakdownView,
PayrollPeriodTableView,
PayrollRunApproveView,
PayrollRunDetailView,
@ -82,6 +83,11 @@ urlpatterns = [
PayrollPeriodTableView.as_view(),
name="admin-period-table",
),
path(
"payroll/admin/periods/<int:pk>/employees/<int:employee_id>/point-breakdown/",
PayrollPeriodPointBreakdownView.as_view(),
name="admin-period-point-breakdown",
),
path(
"payroll/admin/periods/<int:pk>/attendance-work-records/",
PayrollAttendanceWorkRecordsView.as_view(),

View File

@ -55,7 +55,7 @@ def resolve_employee_personnel_state(
relevant_actions = [
action
for action in employee_actions
if action.date and action.date.date() <= target_date
if _action_date(action) is not None and _action_date(action) <= target_date
]
if not relevant_actions:
return EmployeePersonnelState()
@ -66,7 +66,7 @@ def resolve_employee_personnel_state(
status=ACTION_DISMISSED,
label=ACTION_LABELS[ACTION_DISMISSED],
action_id=permanent_action.id,
date_from=permanent_action.date.date(),
date_from=_action_date(permanent_action),
expects_attendance=False,
)
@ -77,7 +77,7 @@ def resolve_employee_personnel_state(
status=action.action,
label=action.get_action_display(),
action_id=action.id,
date_from=action.date.date(),
date_from=_action_date(action),
date_to=date_to,
expects_attendance=False,
)
@ -88,7 +88,7 @@ def resolve_employee_personnel_state(
status=marker_action.action,
label=marker_action.get_action_display(),
action_id=marker_action.id,
date_from=marker_action.date.date(),
date_from=_action_date(marker_action),
expects_attendance=True,
)
@ -97,7 +97,7 @@ def resolve_employee_personnel_state(
status=permanent_action.action,
label=permanent_action.get_action_display(),
action_id=permanent_action.id,
date_from=permanent_action.date.date(),
date_from=_action_date(permanent_action),
)
return EmployeePersonnelState(
@ -209,7 +209,7 @@ def _active_marker_action(
for action in actions
if action.action in MARKER_ACTIONS
and action.date
and action.date.date() == target_date
and _action_date(action) == target_date
]
return marker_actions[-1] if marker_actions else None
@ -223,7 +223,9 @@ def _active_temporary_actions(
if action.action not in TEMPORARY_START_ACTIONS or not action.date:
continue
date_from = action.date.date()
date_from = _action_date(action)
if date_from is None:
continue
if date_from > target_date:
continue
@ -247,7 +249,7 @@ def _resolve_temporary_action_date_to(action: EmployeeAction) -> date | None:
if action.date_to:
return action.date_to
if action.date:
return action.date.date()
return _action_date(action)
return None

View File

@ -94,6 +94,52 @@ def _is_explicit_absence(record: AttendanceRecord) -> bool:
)
def _calculate_attendance_day_points(
record: AttendanceRecord,
*,
daily_point_value: Decimal,
) -> Decimal | None:
worked = _finite_decimal(record.work_hours)
expected = _finite_decimal(record.expected_hours)
if worked is not None and worked > 0 and not record.effective_is_workday:
return None
if not record.effective_is_workday or record.technical_issues:
return None
if record.personnel_status == ACTION_REMOTE and not (
record.is_manually_edited
and "work_hours" in (record.manual_edit_payload or {})
):
return None
if bool(record.arrival_time) != bool(record.departure_time):
return None
if (
expected is None
or worked is None
or expected <= 0
or expected > 24
or worked < 0
or worked > 24
):
return None
if worked == 0 and not record.arrival_time and not _is_explicit_absence(record):
return None
return daily_point_value * worked / expected
def calculate_attendance_day_points(
record: AttendanceRecord,
*,
daily_point_value: Decimal,
) -> Decimal | None:
"""Return a comparable daily projection or ``None`` for unusable data."""
points = _calculate_attendance_day_points(
record,
daily_point_value=daily_point_value,
)
return points.quantize(QUANTUM) if points is not None else None
def calculate_attendance_points_projection(
records: list[AttendanceRecord],
*,
@ -108,31 +154,12 @@ def calculate_attendance_points_projection(
actual_points = Decimal("0")
for record in records:
worked = _finite_decimal(record.work_hours)
expected = _finite_decimal(record.expected_hours)
if worked is not None and worked > 0 and not record.effective_is_workday:
continue
if not record.effective_is_workday or record.technical_issues:
continue
if record.personnel_status == ACTION_REMOTE and not (
record.is_manually_edited
and "work_hours" in (record.manual_edit_payload or {})
):
continue
if bool(record.arrival_time) != bool(record.departure_time):
continue
if (
expected is None
or worked is None
or expected <= 0
or expected > 24
or worked < 0
or worked > 24
):
continue
if worked == 0 and not record.arrival_time and not _is_explicit_absence(record):
continue
actual_points += daily_point_value * worked / expected
day_points = _calculate_attendance_day_points(
record,
daily_point_value=daily_point_value,
)
if day_points is not None:
actual_points += day_points
return actual_points.quantize(QUANTUM)

View File

@ -4,20 +4,26 @@ from __future__ import annotations
from collections import defaultdict
from dataclasses import replace
from datetime import timedelta
from decimal import Decimal
from django.contrib.auth import get_user_model
from django.db.models import Prefetch, Q
from django.utils import timezone
from payroll_core import DeterministicPayrollCalculator
from attendance.models import AttendanceRecord
from employees.constants import ACTIVATING_MARKER_ACTIONS
from employees.models import EmployeeAction
from employees.services.personnel_state import resolve_employee_personnel_state
from requests_app.enums import RequestStatus
from requests_app.models import Request
from finance.enums import ApprovalStatus
from finance.models import (
EmployeePayRate,
PayrollComponent,
PayrollDailyWorkEntry,
PayrollInputLine,
PayrollPeriod,
PayrollWorkRecord,
@ -25,11 +31,16 @@ from finance.models import (
)
from .adapter import POINT_BASE_COMPONENT_CODES, build_core_preview_request
from .attendance import calculate_attendance_points_projection
from .attendance import (
calculate_attendance_day_points,
calculate_attendance_points_projection,
)
from .config import base_rate_code, build_rules
from .work_norm import (
calculate_period_personnel_points,
calculate_period_personnel_daily_points,
calculate_period_target_points,
period_workdates,
resolve_employee_schedule,
)
@ -240,7 +251,6 @@ def build_payroll_period_table(period: PayrollPeriod):
queryset=EmployeeAction.objects.filter(
Q(source_request__isnull=True)
| Q(source_request__status=RequestStatus.APPROVED),
date__date__lte=period.date_to,
).select_related("source_request"),
to_attr="payroll_personnel_actions",
),
@ -468,3 +478,288 @@ def build_payroll_period_table(period: PayrollPeriod):
"payable_total": _row_money_total(rows, "payable"),
},
}
def build_payroll_employee_point_breakdown(
period: PayrollPeriod,
employee,
):
"""Build a read-only day-by-day comparison of the three point sources."""
actions = list(
EmployeeAction.objects.filter(employee=employee)
.filter(
Q(source_request__isnull=True)
| Q(source_request__status=RequestStatus.APPROVED)
)
.select_related("source_request")
.order_by("date", "id")
)
employment_start_dates = [
timezone.localtime(action.date).date()
for action in actions
if action.action in ACTIVATING_MARKER_ACTIONS and action.date
]
first_employment_date = (
min(employment_start_dates) if employment_start_dates else None
)
requests = list(
Request.objects.filter(employee=employee)
.filter(
Q(
date_from__lte=period.date_to,
date_to__gte=period.date_from,
)
| Q(
date_from__range=(period.date_from, period.date_to),
date_to__isnull=True,
)
| Q(
date_from__isnull=True,
created_at__date__range=(period.date_from, period.date_to),
)
)
.select_related("approver")
.order_by("date_from", "created_at", "id")
)
schedule, _ = resolve_employee_schedule(employee)
workdates = period_workdates(period, schedule)
workdate_set = set(workdates)
automatic_target_points, _, _ = calculate_period_target_points(
period,
employee=employee,
schedule=schedule,
)
work = _latest_work_by_employee(period, [employee.pk]).get(employee.pk)
statement = None
if period.current_run_id:
statement = period.current_run.statements.filter(employee=employee).first()
if statement is not None:
snapshot = statement.input_snapshot or {}
target_points = Decimal(
str(snapshot.get("target_points", automatic_target_points))
)
actual_points = Decimal(str(snapshot.get("actual_points", "0")))
else:
target_points = work.target_points if work else automatic_target_points
actual_points = work.actual_points if work else None
daily_target_points = (
target_points / Decimal(len(workdates)) if workdates else Decimal("0")
)
attendance_records = {
record.date: record
for record in AttendanceRecord.objects.filter(
employee=employee,
date__range=(period.date_from, period.date_to),
).select_related("analysis_run")
}
personnel_points = calculate_period_personnel_daily_points(
period,
employee=employee,
actions=actions,
period_target_points=target_points,
schedule=schedule,
)
work_entries = {
entry.work_date: entry
for entry in PayrollDailyWorkEntry.objects.filter(
period=period,
employee=employee,
)
}
dated_work_points_total = sum(
(entry.actual_points for entry in work_entries.values()),
Decimal("0"),
)
undated_work_points = (
(actual_points - dated_work_points_total).quantize(Decimal("0.0001"))
if actual_points is not None
else None
)
if undated_work_points == 0:
undated_work_points = None
if work_entries:
work_points_mode = "daily_entries"
work_points = {
workdate: entry.actual_points
for workdate, entry in work_entries.items()
}
else:
work_points_mode = "unavailable"
work_points = {}
dates = []
current = period.date_from
while current <= period.date_to:
attendance_record = attendance_records.get(current)
attendance_day_points = (
calculate_attendance_day_points(
attendance_record,
daily_point_value=daily_target_points,
)
if attendance_record is not None
else None
)
personnel_state = resolve_employee_personnel_state(
employee,
current,
actions=actions,
)
related_actions = []
for action in actions:
action_date = (
timezone.localtime(action.date).date() if action.date else None
)
if action_date is None:
continue
action_is_related = action_date == current or (
action.date_to is not None
and action_date <= current <= action.date_to
)
if personnel_state.action_id == action.pk:
action_is_related = True
if not action_is_related:
continue
related_actions.append(
{
"id": action.pk,
"action": action.action,
"label": action.get_action_display(),
"date": action_date.isoformat(),
"date_to": action.date_to.isoformat() if action.date_to else None,
"comment": action.comment,
"source_request_id": action.source_request_id,
}
)
related_requests = []
for personnel_request in requests:
request_date_from = personnel_request.date_from
request_date_to = personnel_request.date_to or request_date_from
request_is_related = bool(
request_date_from
and request_date_to
and request_date_from <= current <= request_date_to
)
if request_date_from is None:
request_is_related = (
timezone.localtime(personnel_request.created_at).date() == current
)
if not request_is_related:
continue
related_requests.append(
{
"id": personnel_request.pk,
"type": personnel_request.type,
"type_label": personnel_request.get_type_display(),
"title": personnel_request.display_title,
"status": personnel_request.status,
"status_label": personnel_request.get_status_display(),
"date_from": (
personnel_request.date_from.isoformat()
if personnel_request.date_from
else None
),
"date_to": (
personnel_request.date_to.isoformat()
if personnel_request.date_to
else None
),
"comment": personnel_request.comment,
}
)
attendance_payload = None
if attendance_record is not None:
attendance_payload = {
"id": attendance_record.pk,
"arrival_time": attendance_record.arrival_time,
"departure_time": attendance_record.departure_time,
"work_hours": attendance_record.work_hours,
"expected_hours": attendance_record.expected_hours,
"status_label": (
attendance_record.personnel_status_label
or ("Отсутствие" if attendance_record.is_absent else "Рабочий день")
),
"issues": list(
dict.fromkeys(
str(item)
for item in [
*(attendance_record.statuses or []),
*(attendance_record.employee_issues or []),
*(attendance_record.technical_issues or []),
]
if str(item).strip()
)
),
"is_manually_edited": attendance_record.is_manually_edited,
}
work_entry = work_entries.get(current)
dates.append(
{
"date": current.isoformat(),
"is_workday": current in workdate_set,
"attendance_points": _decimal_text(attendance_day_points),
"personnel_points": _decimal_text(personnel_points.get(current)),
"work_points": _decimal_text(work_points.get(current)),
"attendance_record": attendance_payload,
"personnel_detail": {
"state": (
{
"status": "before_employment",
"label": "До приёма на работу",
"expects_attendance": False,
}
if first_employment_date is not None
and current < first_employment_date
else {
"status": personnel_state.status,
"label": personnel_state.label
or "Обычный рабочий день",
"expects_attendance": personnel_state.expects_attendance,
}
),
"actions": related_actions,
"requests": related_requests,
},
"work_entry": (
{
"id": work_entry.pk,
"target_points": _decimal_text(work_entry.target_points),
"actual_points": _decimal_text(work_entry.actual_points),
"note": work_entry.note,
"created_at": work_entry.created_at.isoformat(),
"updated_at": work_entry.updated_at.isoformat(),
}
if work_entry is not None
else None
),
}
)
current += timedelta(days=1)
return {
"period": {
"id": period.pk,
"name": period.name,
"date_from": period.date_from.isoformat(),
"date_to": period.date_to.isoformat(),
},
"employee": _employee_payload(employee),
"target_points": _decimal_text(target_points),
"actual_points": _decimal_text(actual_points),
"undated_work_points": _decimal_text(undated_work_points),
"undated_work_record": (
{
"id": work.pk,
"actual_points": _decimal_text(undated_work_points),
"note": work.reason,
"created_at": work.created_at.isoformat(),
"updated_at": work.updated_at.isoformat(),
}
if undated_work_points is not None and work is not None
else None
),
"work_points_mode": work_points_mode,
"dates": dates,
}

View File

@ -10,6 +10,7 @@ from attendance.services import (
get_standard_work_schedule,
get_standard_work_schedule_payload,
)
from employees.constants import ACTIVATING_MARKER_ACTIONS
from employees.services.personnel_state import resolve_employee_personnel_state
from finance.models import PayrollPeriod, PayrollWorkSettings
@ -112,16 +113,26 @@ def calculate_period_personnel_points(
if schedule is None:
schedule, _ = resolve_employee_schedule(employee)
personnel_actions = list(actions)
employment_start_dates = [
timezone.localtime(action.date).date()
for action in personnel_actions
if action.action in ACTIVATING_MARKER_ACTIONS and action.date
]
first_employment_date = (
min(employment_start_dates) if employment_start_dates else None
)
workdates = period_workdates(period, schedule)
today = timezone.localdate()
completed_workdates = [workdate for workdate in workdates if workdate < today]
attended_dates = [
workdate
for workdate in completed_workdates
if resolve_employee_personnel_state(
if (first_employment_date is None or workdate >= first_employment_date)
and resolve_employee_personnel_state(
employee,
workdate,
actions=actions,
actions=personnel_actions,
).expects_attendance
]
points = (
@ -130,3 +141,52 @@ def calculate_period_personnel_points(
else Decimal("0")
).quantize(QUANTUM)
return points, len(attended_dates)
def calculate_period_personnel_daily_points(
period: PayrollPeriod,
*,
employee,
actions,
period_target_points: Decimal,
schedule: dict | None = None,
) -> dict[date, Decimal | None]:
"""Return daily personnel projections for scheduled dates in a period.
Future and current dates deliberately stay unknown. Completed scheduled
dates receive their share of the period norm when attendance was expected,
otherwise zero.
"""
if schedule is None:
schedule, _ = resolve_employee_schedule(employee)
personnel_actions = list(actions)
employment_start_dates = [
timezone.localtime(action.date).date()
for action in personnel_actions
if action.action in ACTIVATING_MARKER_ACTIONS and action.date
]
first_employment_date = (
min(employment_start_dates) if employment_start_dates else None
)
workdates = period_workdates(period, schedule)
if not workdates:
return {}
daily_point_value = period_target_points / Decimal(len(workdates))
today = timezone.localdate()
result = {}
for workdate in workdates:
if workdate >= today:
result[workdate] = None
continue
expects_attendance = (
first_employment_date is None or workdate >= first_employment_date
) and resolve_employee_personnel_state(
employee,
workdate,
actions=personnel_actions,
).expects_attendance
result[workdate] = (
daily_point_value if expects_attendance else Decimal("0")
).quantize(QUANTUM)
return result

View File

@ -3,6 +3,7 @@ from io import BytesIO
from datetime import date, datetime, timedelta
import pytest
from django.contrib.auth.models import Permission
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.urls import reverse
@ -994,6 +995,39 @@ def test_user_cannot_load_other_employee_attendance_day_events(
assert response.status_code == 404
def test_payroll_viewer_can_load_source_attendance_day_events(
auth_client_factory,
user_factory,
):
staff = user_factory(staff=True)
payroll_viewer = user_factory(staff=False)
employee = user_factory()
permission = Permission.objects.get(
content_type__app_label="finance",
codename="view_all_payroll",
)
payroll_viewer.user_permissions.add(permission)
staff_client = auth_client_factory(staff)
with patch(
"api.v1.attendance.views.analyze_employee_attendance",
return_value=_logstorm_result(),
):
staff_client.post(_analyze_url(), _payload(employee.id), format="json")
record = AttendanceRecord.objects.get(employee=employee, date="2026-04-20")
with patch(
"api.v1.attendance.views.LogStormClient.get_attendance_day_events",
return_value=[],
):
response = auth_client_factory(payroll_viewer).get(
_record_day_events_url(record.id)
)
assert response.status_code == 200
assert response.json() == []
def test_attendance_day_events_logstorm_error_returns_502(
auth_client_factory,
user_factory,

View File

@ -1,5 +1,5 @@
import uuid
from datetime import date
from datetime import date, datetime
from decimal import Decimal
import pytest
@ -9,11 +9,13 @@ from django.urls import reverse
from django.utils import timezone
from attendance.models import AttendanceAnalysisRun, AttendanceRecord
from employees.constants import ACTION_DISMISSED, ACTION_HIRED, ACTION_REHIRED
from employees.models import EmployeeAction
from finance.models import (
EmployeePayRate,
PayrollAuditEvent,
PayrollComponent,
PayrollDailyWorkEntry,
PayrollInputLine,
PayrollPeriod,
PayrollRun,
@ -251,7 +253,7 @@ def test_period_table_lists_all_employees_and_requires_view_all(
assert row["in_norm_point_rate"] == "736.3636"
assert row["target_points"] == "110.0000"
assert row["attendance_points"] is None
assert row["personnel_points"] == "110.0000"
assert row["personnel_points"] == "0.0000"
assert row["actual_points"] == "115.0000"
assert row["point_delta"] == "5.0000"
assert row["point_amount"] == "750.00"
@ -311,6 +313,11 @@ def test_period_table_projects_attendance_and_official_personnel_points(
actual_points="0",
created_by=manager,
)
EmployeeAction.objects.create(
employee=employee,
action=ACTION_HIRED,
date=timezone.make_aware(datetime(2026, 6, 1)),
)
AttendanceRecord.objects.create(
analysis_run=attendance_run,
employee=employee,
@ -356,6 +363,218 @@ def test_period_table_projects_attendance_and_official_personnel_points(
assert row["personnel_points"] == "20.0000"
def test_period_table_personnel_points_respect_employment_boundaries(
user_factory,
auth_client_factory,
monkeypatch,
):
manager = user_factory(email="table.employment.manager@example.test")
employee = user_factory(email="table.employment.employee@example.test")
grant(manager, "view_all_payroll")
period = make_period(creator=manager)
PayrollWorkRecord.objects.create(
period=period,
employee=employee,
target_points="220",
target_points_overridden=True,
actual_points="0",
created_by=manager,
)
for action, action_date in (
(ACTION_HIRED, datetime(2026, 6, 5)),
(ACTION_DISMISSED, datetime(2026, 6, 10)),
(ACTION_REHIRED, datetime(2026, 6, 15)),
):
EmployeeAction.objects.create(
employee=employee,
action=action,
date=timezone.make_aware(action_date),
)
monkeypatch.setattr(
"finance.payroll.work_norm.timezone.localdate",
lambda: date(2026, 7, 1),
)
response = auth_client_factory(manager).get(
api_url("admin-period-table", pk=period.pk),
)
assert response.status_code == 200, response.content
row = next(
item
for item in response.json()["rows"]
if item["employee"]["id"] == employee.pk
)
assert row["personnel_points"] == "150.0000"
def test_period_point_breakdown_returns_daily_sources(
user_factory,
auth_client_factory,
monkeypatch,
):
viewer = user_factory(email="point.breakdown.viewer@example.test")
employee = user_factory(
email="point.breakdown.employee@example.test",
first_name="Ирина",
last_name="Подневная",
)
grant(viewer, "view_all_payroll")
period = make_period(creator=viewer)
attendance_run = AttendanceAnalysisRun.objects.create(
employee=employee,
period_start="2026-06-01",
period_end="2026-06-30",
status=AttendanceAnalysisRun.STATUS_SUCCESS,
schedule_payload={
"workdays": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"]
},
)
for day, worked in ((date(2026, 6, 1), 9), (date(2026, 6, 2), 4.5)):
AttendanceRecord.objects.create(
analysis_run=attendance_run,
employee=employee,
date=day,
arrival_time="08:00",
departure_time="17:00" if worked == 9 else "12:30",
work_hours=worked,
expected_hours=9,
effective_is_workday=True,
)
PayrollWorkRecord.objects.create(
period=period,
employee=employee,
target_points="220",
target_points_overridden=True,
actual_points="15",
created_by=viewer,
)
PayrollDailyWorkEntry.objects.create(
period=period,
employee=employee,
work_date=date(2026, 6, 1),
target_points="10",
actual_points="15",
)
EmployeeAction.objects.create(
employee=employee,
action=ACTION_HIRED,
date=timezone.make_aware(datetime(2026, 6, 1)),
)
vacation = Request.objects.create(
employee=employee,
type=RequestType.VACATION,
date_from=date(2026, 6, 2),
date_to=date(2026, 6, 2),
status=RequestStatus.PENDING,
)
vacation.status = RequestStatus.APPROVED
vacation.approver = viewer
vacation.decided_at = timezone.now()
vacation.save(update_fields=["status", "approver", "decided_at", "updated_at"])
assert EmployeeAction.objects.filter(source_request=vacation).exists()
monkeypatch.setattr(
"finance.payroll.work_norm.timezone.localdate",
lambda: date(2026, 6, 3),
)
response = auth_client_factory(viewer).get(
api_url(
"admin-period-point-breakdown",
pk=period.pk,
employee_id=employee.pk,
)
)
assert response.status_code == 200, response.content
assert_no_store(response)
body = response.json()
assert body["employee"]["display_name"] == "Ирина Подневная"
assert body["target_points"] == "220.0000"
assert body["actual_points"] == "15.0000"
assert body["undated_work_points"] is None
assert body["work_points_mode"] == "daily_entries"
june_first = next(item for item in body["dates"] if item["date"] == "2026-06-01")
june_second = next(item for item in body["dates"] if item["date"] == "2026-06-02")
weekend = next(item for item in body["dates"] if item["date"] == "2026-06-06")
assert {key: june_first[key] for key in (
"date",
"is_workday",
"attendance_points",
"personnel_points",
"work_points",
)} == {
"date": "2026-06-01",
"is_workday": True,
"attendance_points": "10.0000",
"personnel_points": "10.0000",
"work_points": "15.0000",
}
assert june_first["attendance_record"]["id"] == AttendanceRecord.objects.get(
employee=employee,
date="2026-06-01",
).pk
assert june_first["work_entry"]["actual_points"] == "15.0000"
assert june_first["work_entry"]["note"] == ""
assert [item["action"] for item in june_first["personnel_detail"]["actions"]] == [
ACTION_HIRED
]
assert {key: june_second[key] for key in (
"date",
"is_workday",
"attendance_points",
"personnel_points",
"work_points",
)} == {
"date": "2026-06-02",
"is_workday": True,
"attendance_points": "5.0000",
"personnel_points": "0.0000",
"work_points": None,
}
assert june_second["work_entry"] is None
assert [item["id"] for item in june_second["personnel_detail"]["requests"]] == [
vacation.pk
]
assert june_second["personnel_detail"]["requests"][0]["status"] == "approved"
assert {key: weekend[key] for key in (
"date",
"is_workday",
"attendance_points",
"personnel_points",
"work_points",
)} == {
"date": "2026-06-06",
"is_workday": False,
"attendance_points": None,
"personnel_points": None,
"work_points": None,
}
undated_employee = user_factory(email="point.breakdown.undated@example.test")
PayrollWorkRecord.objects.create(
period=period,
employee=undated_employee,
target_points="220",
target_points_overridden=True,
actual_points="42",
created_by=viewer,
)
undated_response = auth_client_factory(viewer).get(
api_url(
"admin-period-point-breakdown",
pk=period.pk,
employee_id=undated_employee.pk,
)
)
assert undated_response.status_code == 200, undated_response.content
undated_body = undated_response.json()
assert undated_body["undated_work_points"] == "42.0000"
assert undated_body["undated_work_record"]["actual_points"] == "42.0000"
assert undated_body["work_points_mode"] == "unavailable"
assert all(item["work_points"] is None for item in undated_body["dates"])
def test_component_cell_can_be_cleared_and_voids_all_active_lines(
user_factory,
auth_client_factory,

View File

@ -25,6 +25,7 @@ type AttendanceDayEventsModalProps = {
onClose: () => void;
onOpenComments?: (record: AttendanceDayEventsPreview) => void;
record: AttendanceDayEventsPreview | null;
stackLevel?: number;
};
function getErrorMessage(error: unknown, fallback: string) {
@ -81,6 +82,7 @@ export function AttendanceDayEventsModal({
onClose,
onOpenComments,
record,
stackLevel = 0,
}: AttendanceDayEventsModalProps) {
const [events, setEvents] = useState<AttendanceDayEvent[]>([]);
const [photoUrls, setPhotoUrls] = useState<Record<string, string>>({});
@ -171,6 +173,7 @@ export function AttendanceDayEventsModal({
onClose={close}
title="События посещения"
size="xl"
stackLevel={stackLevel}
>
{record ? (
<div className="space-y-4">

View File

@ -4,6 +4,7 @@ import { CircleHelp, Eye, EyeOff, Loader2, Pencil, Search, X } from "lucide-reac
import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
import { createPortal } from "react-dom";
import { PayrollPointBreakdownModal } from "@/components/finance/admin/PayrollPointBreakdownModal";
import type {
PayrollPeriodTable,
PayrollPeriodTableComponent,
@ -12,6 +13,7 @@ import type {
} from "@/lib/api/finance";
import { formatPayrollMoney } from "@/lib/payroll";
import { getPayrollAdminError } from "@/lib/payroll-admin";
import { comparePayrollProjection, type PayrollProjectionDifference } from "@/lib/payroll-projection";
const statusMeta: Record<PayrollPeriodTableStatus, { label: string; className: string }> = {
calculated: { label: "Рассчитано", className: "app-feedback-success" },
@ -293,7 +295,7 @@ function calculationHelps(pointPolicy: PayrollPeriodTable["calculation_rules"]["
},
personnelPoints: {
title: "Баллы по кадровым событиям",
logic: "Это накопительный показатель на начало текущего дня. Он вычисляется на ходу от нормы баллов сотрудника, рабочего графика и официальных кадровых событий. Текущий и будущие дни не учитываются. Каждый завершённый плановый рабочий день считается отработанным полностью, пока одобренное заявление или кадровое событие не подтверждает отсутствие. Отпуск, больничный, отгул, декрет и увольнение дают 0 баллов; удалённая работа считается присутствием.",
logic: "Это накопительный показатель на начало текущего дня. Он вычисляется на ходу от нормы баллов сотрудника, рабочего графика и официальных кадровых событий. До первого приёма, после увольнения и до повторного приёма баллы не начисляются; день приёма или восстановления уже учитывается. Текущий и будущие дни не входят в расчёт. Отпуск, больничный, отгул и декрет дают 0 баллов; удалённая работа считается присутствием.",
formula: "Баллы по кадровым событиям = Норма баллов × Завершённые рабочие дни без подтверждённого отсутствия ÷ Все плановые рабочие дни периода",
manual: "Нет. Значение меняется через норму баллов, заявление, кадровое событие или рабочий график.",
},
@ -400,6 +402,48 @@ function CalculatedMoney({ value, currency, preliminary }: { value: string | nul
);
}
const projectionDifferenceClasses: Record<PayrollProjectionDifference["color"], string> = {
green: "text-emerald-300 font-medium",
orange: "text-orange-400 font-medium",
red: "text-red-400 font-medium",
};
function ProjectionPointsCell({
value,
actualPoints,
label,
onOpen,
}: {
value: string | null;
actualPoints: string | null;
label: string;
onOpen: () => void;
}) {
const difference = comparePayrollProjection(value, actualPoints);
const className = difference
? projectionDifferenceClasses[difference.color]
: "text-[var(--muted-foreground)]";
const directionLabel = difference?.direction === "higher" ? "выше" : "ниже";
const differenceLabel = difference?.percentage == null
? "выработка равна нулю"
: `расхождение ${difference.percentage.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%`;
const title = difference
? `${label}: значение ${directionLabel} выработки, ${differenceLabel}. Нажмите для детализации по дням.`
: `${label}. Нажмите для детализации по дням.`;
return (
<td className={`${bodyCellClass} p-0`} title={title}>
<button
type="button"
className={`min-h-10 w-full px-1.5 py-2 text-right text-[11px] tabular-nums transition-colors hover:bg-[var(--surface-tertiary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--accent-primary)] ${className}`}
onClick={onOpen}
aria-label={`${label}. Открыть детализацию по дням`}
>
{formatPoints(value)}
</button>
</td>
);
}
function editableNumber(value: string | null): string {
if (value == null || value === "") return "";
const numeric = Number(value);
@ -526,6 +570,10 @@ export function PayrollPeriodTableView({
));
}, [data?.rows, search]);
const [activeHelp, setActiveHelp] = useState<CalculationHelp | null>(null);
const [breakdownEmployee, setBreakdownEmployee] = useState<{
id: number;
displayName: string;
} | null>(null);
const [columnsMenuOpen, setColumnsMenuOpen] = useState(false);
const columnsMenuRef = useRef<HTMLDivElement>(null);
const [hiddenColumns, setHiddenColumns] = useHiddenPayrollColumns(storageUserId);
@ -678,6 +726,9 @@ export function PayrollPeriodTableView({
<div className="app-text-muted mb-2 flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
{canEdit ? <span>Enter или переход в другую ячейку — сохранить, Esc — отменить.</span> : null}
<span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-[var(--muted-foreground)]" aria-hidden="true" />Серый текст — заполнено автоматически</span>
<span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-emerald-300" aria-hidden="true" />Зелёный — значение выше выработки больше чем на 25%</span>
<span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-orange-400" aria-hidden="true" />Оранжевый — ниже на 5–25%</span>
<span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-red-400" aria-hidden="true" />Красный — ниже больше чем на 25%</span>
<span className="inline-flex items-center gap-1.5"><CircleHelp className="app-accent-text" size={13} />Знак вопроса — логика и формула расчёта</span>
</div>
@ -750,8 +801,8 @@ export function PayrollPeriodTableView({
{isVisible(columnIds.inNormPointRate) ? <InlineEditableCell value={row.in_norm_point_rate} formattedValue={formatMoney(row.in_norm_point_rate, data.currency)} label={`Цена балла в пределах нормы: ${row.employee.display_name}`} automatic /> : null}
{isVisible(columnIds.excessPointRate) ? <InlineEditableCell value={row.point_rate ?? row.in_norm_point_rate} formattedValue={formatMoney(row.point_rate ?? row.in_norm_point_rate, data.currency)} onSave={canEdit && onSaveRate ? (value) => onSaveRate(row, "point_rate", value) : undefined} label={`Цена балла сверх нормы: ${row.employee.display_name}`} automatic={row.point_rate === null} /> : null}
{isVisible(columnIds.targetPoints) ? <InlineEditableCell value={row.target_points} formattedValue={formatPoints(row.target_points)} onSave={canEdit && onSaveWork ? (value) => onSaveWork(row, "target_points", value) : undefined} label={`Норма: ${row.employee.display_name}`} automatic={row.target_points_automatic} /> : null}
{isVisible(columnIds.attendancePoints) ? <InlineEditableCell value={row.attendance_points} formattedValue={formatPoints(row.attendance_points)} label={`Баллы по посещаемости: ${row.employee.display_name}`} automatic /> : null}
{isVisible(columnIds.personnelPoints) ? <InlineEditableCell value={row.personnel_points} formattedValue={formatPoints(row.personnel_points)} label={`Баллы по кадровым событиям: ${row.employee.display_name}`} automatic /> : null}
{isVisible(columnIds.attendancePoints) ? <ProjectionPointsCell value={row.attendance_points} actualPoints={row.actual_points} label={`Баллы по посещаемости: ${row.employee.display_name}`} onOpen={() => setBreakdownEmployee({ id: row.employee.id, displayName: row.employee.display_name })} /> : null}
{isVisible(columnIds.personnelPoints) ? <ProjectionPointsCell value={row.personnel_points} actualPoints={row.actual_points} label={`Баллы по кадровым событиям: ${row.employee.display_name}`} onOpen={() => setBreakdownEmployee({ id: row.employee.id, displayName: row.employee.display_name })} /> : null}
{isVisible(columnIds.actualPoints) ? <InlineEditableCell value={row.actual_points} formattedValue={formatPoints(row.actual_points)} onSave={canEdit && onSaveWork ? (value) => onSaveWork(row, "actual_points", value) : undefined} label={`Баллы: ${row.employee.display_name}`} /> : null}
{isVisible(columnIds.pointDelta) ? <td className={bodyCellClass}>{formatPoints(row.point_delta)}</td> : null}
{isVisible(columnIds.pointAmount) ? <td className={bodyCellClass}><CalculatedMoney value={row.point_amount} currency={data.currency} preliminary={row.totals_preliminary} /></td> : null}
@ -802,6 +853,14 @@ export function PayrollPeriodTableView({
</table>
</div>
{activeHelp ? <CalculationHelpModal help={activeHelp} rules={data.calculation_rules} onClose={() => setActiveHelp(null)} /> : null}
{breakdownEmployee ? (
<PayrollPointBreakdownModal
periodId={data.period_id}
employeeId={breakdownEmployee.id}
employeeName={breakdownEmployee.displayName}
onClose={() => setBreakdownEmployee(null)}
/>
) : null}
</div>
);
}

View File

@ -0,0 +1,372 @@
"use client";
import { CalendarDays, Loader2, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import {
AttendanceDayEventsModal,
type AttendanceDayEventsPreview,
} from "@/components/attendance/AttendanceDayEventsModal";
import {
PayrollPersonnelDayModal,
PayrollWorkEntryDayModal,
} from "@/components/finance/admin/PayrollPointSourceDetailModals";
import { Modal } from "@/components/ui";
import { apiClient } from "@/lib/api";
import type {
PayrollPointBreakdown,
PayrollPointBreakdownDay,
} from "@/lib/api/finance";
import { getPayrollAdminError } from "@/lib/payroll-admin";
import {
compareDailyPayrollProjection,
type PayrollProjectionDifference,
} from "@/lib/payroll-projection";
type PayrollPointBreakdownModalProps = {
periodId: number;
employeeId: number;
employeeName: string;
onClose: () => void;
};
type PointRow = {
id: "attendance_points" | "personnel_points" | "work_points";
label: string;
};
const pointRows: PointRow[] = [
{ id: "attendance_points", label: "Баллы по посещаемости" },
{ id: "personnel_points", label: "Баллы по кадровым событиям" },
{ id: "work_points", label: "Баллы по выработке" },
];
const differenceClasses: Record<PayrollProjectionDifference["color"], string> = {
green: "bg-emerald-500/15 text-emerald-300",
orange: "bg-orange-500/15 text-orange-300",
red: "bg-red-500/15 text-red-300",
};
function formatPoints(value: string | null): string {
if (value == null || value === "") return "—";
const numeric = Number(value);
return Number.isFinite(numeric)
? numeric.toLocaleString("ru-RU", { maximumFractionDigits: 4 })
: value;
}
function dateParts(value: string): { date: string; weekday: string } {
const parsed = new Date(`${value}T00:00:00`);
return {
date: parsed.toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" }),
weekday: parsed.toLocaleDateString("ru-RU", { weekday: "short" }),
};
}
function formatDate(value: string): string {
return new Date(`${value}T00:00:00`).toLocaleDateString("ru-RU");
}
function differenceTitle(
row: PointRow,
day: PayrollPointBreakdownDay,
difference: PayrollProjectionDifference | null,
): string {
if (!difference) return `${row.label}: ${formatPoints(day[row.id])}`;
const direction = difference.direction === "higher" ? "выше" : "ниже";
const amount = difference.percentage == null
? "выработка равна нулю"
: `на ${difference.percentage.toLocaleString("ru-RU", { maximumFractionDigits: 1 })}%`;
return `${row.label}: ${direction} выработки ${amount}`;
}
export function PayrollPointBreakdownModal({
periodId,
employeeId,
employeeName,
onClose,
}: PayrollPointBreakdownModalProps) {
const [data, setData] = useState<PayrollPointBreakdown | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [attendanceDay, setAttendanceDay] = useState<PayrollPointBreakdownDay | null>(null);
const [personnelDay, setPersonnelDay] = useState<PayrollPointBreakdownDay | null>(null);
const [workDay, setWorkDay] = useState<PayrollPointBreakdownDay | null>(null);
const [undatedWorkOpen, setUndatedWorkOpen] = useState(false);
const load = useCallback(async () => {
setLoading(true);
setError(null);
try {
const payload = await apiClient.getPayrollAdminPointBreakdown(periodId, employeeId);
setData(payload);
} catch (loadError) {
setError(getPayrollAdminError(loadError, "Не удалось загрузить дневную детализацию баллов."));
} finally {
setLoading(false);
}
}, [employeeId, periodId]);
useEffect(() => {
let active = true;
setData(null);
setLoading(true);
setError(null);
apiClient
.getPayrollAdminPointBreakdown(periodId, employeeId)
.then((payload) => {
if (active) setData(payload);
})
.catch((loadError: unknown) => {
if (active) {
setError(getPayrollAdminError(loadError, "Не удалось загрузить дневную детализацию баллов."));
}
})
.finally(() => {
if (active) setLoading(false);
});
return () => {
active = false;
};
}, [employeeId, periodId]);
const tableWidth = useMemo(
() => 190 + ((data?.dates.length || 0) + (data?.undated_work_points ? 1 : 0)) * 68,
[data?.dates.length, data?.undated_work_points],
);
const attendancePreview = useMemo<AttendanceDayEventsPreview | null>(() => {
const record = attendanceDay?.attendance_record;
if (!attendanceDay || !record) return null;
const time = (value: string | null) => value || "—";
return {
recordId: record.id,
employeeName,
date: formatDate(attendanceDay.date),
statusLabel: record.status_label,
displayText: `Приход ${time(record.arrival_time)} · уход ${time(record.departure_time)}`,
detailLines: [
`Отработано: ${record.work_hours ?? "—"} ч.`,
`План: ${record.expected_hours ?? "—"} ч.`,
`Баллы: ${formatPoints(attendanceDay.attendance_points)}`,
],
issues: record.issues,
isManuallyEdited: record.is_manually_edited,
};
}, [attendanceDay, employeeName]);
const openDayDetail = (row: PointRow, day: PayrollPointBreakdownDay) => {
if (row.id === "attendance_points" && day.attendance_record) {
setAttendanceDay(day);
} else if (row.id === "personnel_points") {
setPersonnelDay(day);
} else if (row.id === "work_points" && day.work_entry) {
setWorkDay(day);
}
};
return (
<>
<Modal
isOpen
onClose={onClose}
title={`Детализация баллов · ${employeeName}`}
size="full"
closeOnClickOutside
>
{loading ? (
<div className="flex min-h-72 items-center justify-center gap-2 text-sm text-[var(--muted-foreground)]">
<Loader2 className="app-accent-text animate-spin" size={20} />
Загружаем данные по дням…
</div>
) : error || !data ? (
<div className="py-5">
<div className="app-feedback-danger rounded-xl px-4 py-3 text-sm">
{error || "Детализация недоступна."}
</div>
<button
type="button"
className="app-action-secondary mt-3 inline-flex items-center gap-2 rounded-lg px-4 py-2.5 text-sm font-medium"
onClick={() => void load()}
>
<RefreshCw size={15} /> Повторить
</button>
</div>
) : (
<div className="space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--foreground)]">{data.period.name}</p>
<p className="app-text-muted mt-1 text-xs">
{new Date(`${data.period.date_from}T00:00:00`).toLocaleDateString("ru-RU")} — {new Date(`${data.period.date_to}T00:00:00`).toLocaleDateString("ru-RU")}
</p>
</div>
<div className="app-surface-muted flex shrink-0 items-start gap-2 rounded-xl px-3 py-2.5 text-xs">
<CalendarDays className="app-accent-text mt-0.5 shrink-0" size={15} />
<div>
<p className="text-[var(--foreground)]">
Норма за период: <b>{formatPoints(data.target_points)}</b> · выработка: <b>{formatPoints(data.actual_points)}</b>
</p>
<p className="app-text-muted mt-1">
{data.work_points_mode === "daily_entries"
? "Выработка показана по фактическим ежедневным записям."
: data.undated_work_points
? "Выработка введена отдельной суммой без привязки к дате."
: "Дневных записей выработки нет."}
</p>
{data.undated_work_points ? (
<p className="app-text-muted mt-1">
Не привязанная к дням сумма показана отдельной колонкой и не распределяется по периоду.
</p>
) : null}
</div>
</div>
</div>
<div className="app-text-muted flex flex-wrap items-center gap-x-4 gap-y-1 text-xs">
<span className="text-[var(--foreground)]">Нажмите на значение, чтобы открыть исходные данные дня.</span>
<span>Подсвечивается любое дневное расхождение с выработкой:</span>
<span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-emerald-400" />выше — зелёный</span>
<span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-orange-400" />ниже до 25% — оранжевый</span>
<span className="inline-flex items-center gap-1.5"><span className="h-2.5 w-2.5 rounded-sm bg-red-400" />ниже больше 25% — красный</span>
</div>
<div className="app-table-scroll max-h-[min(65vh,620px)] w-full overflow-auto rounded-xl border border-[var(--border-subtle)]">
<table
className="table-fixed border-separate border-spacing-0"
style={{ width: `max(100%, ${tableWidth}px)` }}
aria-label={`Дневная детализация баллов: ${data.employee.display_name}`}
>
<colgroup>
<col style={{ width: 190 }} />
{data.dates.map((day) => <col key={day.date} style={{ width: 68 }} />)}
{data.undated_work_points ? <col style={{ width: 68 }} /> : null}
</colgroup>
<thead className="sticky top-0 z-20">
<tr>
<th className="app-surface-muted sticky left-0 z-30 border-b border-r border-[var(--border-subtle)] px-3 py-2.5 text-left text-[10px] font-semibold text-[var(--muted-foreground)]">
Источник баллов
</th>
{data.dates.map((day) => {
const parts = dateParts(day.date);
return (
<th
key={day.date}
className={`app-surface-muted border-b border-r border-[var(--border-subtle)] px-1 py-2 text-center text-[10px] font-semibold ${day.is_workday ? "text-[var(--muted-foreground)]" : "opacity-55 text-[var(--muted-foreground)]"}`}
title={day.is_workday ? "Плановый рабочий день" : "Нерабочий день"}
>
<span className="block tabular-nums">{parts.date}</span>
<span className="mt-0.5 block font-normal uppercase">{parts.weekday}</span>
</th>
);
})}
{data.undated_work_points ? (
<th
className="app-surface-muted border-b border-r border-[var(--border-subtle)] px-1 py-2 text-center text-[10px] font-semibold text-[var(--muted-foreground)]"
title="Выработка, введённая общей суммой без дневной записи внутри периода"
>
<span className="block">Вне</span>
<span className="mt-0.5 block font-normal">периода</span>
</th>
) : null}
</tr>
</thead>
<tbody>
{pointRows.map((row) => (
<tr key={row.id} className="bg-[var(--surface-primary)]">
<th className="sticky left-0 z-10 border-b border-r border-[var(--border-subtle)] bg-[var(--surface-primary)] px-3 py-3 text-left text-xs font-semibold text-[var(--foreground)]">
{row.label}
</th>
{data.dates.map((day) => {
const difference = row.id === "work_points"
? null
: compareDailyPayrollProjection(day[row.id], day.work_points);
const canOpen = row.id === "attendance_points"
? Boolean(day.attendance_record)
: row.id === "personnel_points"
? day.personnel_points != null || day.personnel_detail.actions.length > 0 || day.personnel_detail.requests.length > 0
: Boolean(day.work_entry);
return (
<td
key={day.date}
className={`border-b border-r border-[var(--border-subtle)] p-0 text-center text-[11px] tabular-nums ${difference ? differenceClasses[difference.color] : day.is_workday ? "text-[var(--foreground)]" : "bg-[var(--surface-secondary)] text-[var(--muted-foreground)]"}`}
title={differenceTitle(row, day, difference)}
>
{canOpen ? (
<button
type="button"
className="min-h-10 w-full cursor-pointer px-1 py-3 text-center transition-colors hover:bg-[var(--surface-tertiary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--accent-primary)]"
onClick={() => openDayDetail(row, day)}
aria-label={`${row.label}, ${formatDate(day.date)}. Открыть исходную запись`}
>
{formatPoints(day[row.id])}
</button>
) : (
<span className="inline-block px-1 py-3">{formatPoints(day[row.id])}</span>
)}
</td>
);
})}
{data.undated_work_points ? (
<td
className="app-surface-muted border-b border-r border-[var(--border-subtle)] p-0 text-center text-[11px] font-medium text-[var(--foreground)] tabular-nums"
title={row.id === "work_points" ? "Выработка без привязки к дате внутри периода" : undefined}
>
{row.id === "work_points" && data.undated_work_record ? (
<button
type="button"
className="min-h-10 w-full cursor-pointer px-1 py-3 text-center transition-colors hover:bg-[var(--surface-tertiary)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--accent-primary)]"
onClick={() => setUndatedWorkOpen(true)}
>
{formatPoints(data.undated_work_points)}
</button>
) : <span className="inline-block px-1 py-3">—</span>}
</td>
) : null}
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</Modal>
{typeof document !== "undefined" && data ? createPortal(
<>
<AttendanceDayEventsModal
isOpen={Boolean(attendancePreview)}
onClose={() => setAttendanceDay(null)}
record={attendancePreview}
stackLevel={1}
/>
{personnelDay ? (
<PayrollPersonnelDayModal
day={personnelDay}
employeeName={employeeName}
onClose={() => setPersonnelDay(null)}
/>
) : null}
{workDay?.work_entry ? (
<PayrollWorkEntryDayModal
entry={workDay.work_entry}
date={workDay.date}
employeeName={employeeName}
onClose={() => setWorkDay(null)}
/>
) : null}
{undatedWorkOpen && data.undated_work_record ? (
<PayrollWorkEntryDayModal
entry={data.undated_work_record}
date={null}
employeeName={employeeName}
undated
onClose={() => setUndatedWorkOpen(false)}
/>
) : null}
</>,
document.body,
) : null}
</>
);
}

View File

@ -0,0 +1,200 @@
"use client";
import { BriefcaseBusiness, CalendarDays, FileText, UserRoundCog } from "lucide-react";
import { Modal } from "@/components/ui";
import type { PayrollPointBreakdownDay } from "@/lib/api/finance";
function formatDate(value: string | null): string {
if (!value) return "—";
return new Date(`${value}T00:00:00`).toLocaleDateString("ru-RU");
}
function formatDateTime(value: string): string {
return new Date(value).toLocaleString("ru-RU", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
function formatPoints(value: string | null): string {
if (value == null || value === "") return "—";
const number = Number(value);
return Number.isFinite(number)
? number.toLocaleString("ru-RU", { maximumFractionDigits: 4 })
: value;
}
function requestStatusClass(status: string): string {
if (status === "approved") return "app-feedback-success";
if (status === "rejected" || status === "cancelled") return "app-feedback-danger";
if (status === "pending") return "app-feedback-warning";
return "app-surface-muted";
}
export function PayrollPersonnelDayModal({
day,
employeeName,
onClose,
}: {
day: PayrollPointBreakdownDay;
employeeName: string;
onClose: () => void;
}) {
const detail = day.personnel_detail;
return (
<Modal
isOpen
onClose={onClose}
title={`Кадровые события · ${employeeName}`}
size="lg"
stackLevel={1}
closeOnClickOutside
>
<div className="space-y-4">
<section className="app-surface-muted rounded-xl p-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="app-card-caption">Состояние на {formatDate(day.date)}</p>
<p className="mt-2 text-base font-semibold text-[var(--foreground)]">
{detail.state.label}
</p>
</div>
<span className={`app-status-pill ${detail.state.expects_attendance ? "app-feedback-success" : "app-feedback-warning"}`}>
{detail.state.expects_attendance ? "Явка ожидается" : "Явка не ожидается"}
</span>
</div>
</section>
<section>
<div className="mb-2 flex items-center gap-2">
<UserRoundCog className="app-text-muted" size={16} />
<h4 className="text-sm font-semibold text-[var(--foreground)]">
Кадровые события ({detail.actions.length})
</h4>
</div>
{detail.actions.length ? (
<div className="space-y-2">
{detail.actions.map((action) => (
<article key={action.id} className="app-surface rounded-xl border border-[var(--border-subtle)] p-3.5">
<div className="flex flex-wrap items-start justify-between gap-2">
<p className="text-sm font-semibold text-[var(--foreground)]">{action.label}</p>
<span className="app-status-pill app-surface-muted">#{action.id}</span>
</div>
<p className="app-text-muted mt-2 text-xs">
{formatDate(action.date)}{action.date_to ? ` — ${formatDate(action.date_to)}` : ""}
</p>
{action.comment ? <p className="mt-2 whitespace-pre-wrap text-sm text-[var(--foreground)]">{action.comment}</p> : null}
{action.source_request_id ? (
<p className="app-accent-text mt-2 text-xs">Создано по заявлению #{action.source_request_id}</p>
) : null}
</article>
))}
</div>
) : (
<div className="app-surface-muted rounded-xl px-4 py-6 text-center text-sm text-[var(--muted-foreground)]">
Отдельных кадровых событий для этой даты нет.
</div>
)}
</section>
<section>
<div className="mb-2 flex items-center gap-2">
<FileText className="app-text-muted" size={16} />
<h4 className="text-sm font-semibold text-[var(--foreground)]">
Заявления ({detail.requests.length})
</h4>
</div>
{detail.requests.length ? (
<div className="space-y-2">
{detail.requests.map((request) => (
<article key={request.id} className="app-surface rounded-xl border border-[var(--border-subtle)] p-3.5">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--foreground)]">{request.title || request.type_label}</p>
<p className="app-text-muted mt-1 text-xs">{request.type_label} · заявление #{request.id}</p>
</div>
<span className={`app-status-pill ${requestStatusClass(request.status)}`}>{request.status_label}</span>
</div>
<p className="app-text-muted mt-2 text-xs">
{formatDate(request.date_from)}{request.date_to ? ` — ${formatDate(request.date_to)}` : ""}
</p>
{request.comment ? <p className="mt-2 whitespace-pre-wrap text-sm text-[var(--foreground)]">{request.comment}</p> : null}
</article>
))}
</div>
) : (
<div className="app-surface-muted rounded-xl px-4 py-6 text-center text-sm text-[var(--muted-foreground)]">
Заявлений, относящихся к этой дате, нет.
</div>
)}
</section>
</div>
</Modal>
);
}
type WorkEntryDetail = {
id: number;
target_points?: string | null;
actual_points: string | null;
note: string;
created_at: string;
updated_at: string;
};
export function PayrollWorkEntryDayModal({
entry,
date,
employeeName,
undated = false,
onClose,
}: {
entry: WorkEntryDetail;
date: string | null;
employeeName: string;
undated?: boolean;
onClose: () => void;
}) {
return (
<Modal
isOpen
onClose={onClose}
title={`Запись выработки · ${employeeName}`}
size="md"
stackLevel={1}
closeOnClickOutside
>
<div className="space-y-4">
<section className="app-selected flex items-start gap-3 rounded-xl p-4">
{undated ? <BriefcaseBusiness className="app-accent-text mt-0.5 shrink-0" size={19} /> : <CalendarDays className="app-accent-text mt-0.5 shrink-0" size={19} />}
<div className="min-w-0">
<p className="app-card-caption">{undated ? "Без привязки к дате" : formatDate(date)}</p>
<p className="mt-2 text-xl font-semibold text-[var(--foreground)]">
{formatPoints(entry.actual_points)} балл.
</p>
{entry.target_points != null ? (
<p className="app-text-muted mt-1 text-xs">Дневная норма: {formatPoints(entry.target_points)}</p>
) : null}
</div>
</section>
<section className="app-surface-muted rounded-xl p-4">
<p className="app-card-caption">Комментарий</p>
<p className={`mt-2 whitespace-pre-wrap text-sm ${entry.note ? "text-[var(--foreground)]" : "text-[var(--muted-foreground)]"}`}>
{entry.note || "Комментарий не указан."}
</p>
</section>
<div className="grid gap-2 text-xs text-[var(--muted-foreground)] sm:grid-cols-2">
<p>Создано: {formatDateTime(entry.created_at)}</p>
<p>Изменено: {formatDateTime(entry.updated_at)}</p>
</div>
</div>
</Modal>
);
}

View File

@ -286,6 +286,81 @@ export type PayrollPeriodTable = {
};
};
export type PayrollPointBreakdownDay = {
date: string;
is_workday: boolean;
attendance_points: string | null;
personnel_points: string | null;
work_points: string | null;
attendance_record: {
id: number;
arrival_time: string | null;
departure_time: string | null;
work_hours: number | null;
expected_hours: number | null;
status_label: string;
issues: string[];
is_manually_edited: boolean;
} | null;
personnel_detail: {
state: {
status: string;
label: string;
expects_attendance: boolean;
};
actions: Array<{
id: number;
action: string;
label: string;
date: string;
date_to: string | null;
comment: string;
source_request_id: number | null;
}>;
requests: Array<{
id: number;
type: string;
type_label: string;
title: string;
status: string;
status_label: string;
date_from: string | null;
date_to: string | null;
comment: string;
}>;
};
work_entry: {
id: number;
target_points: string | null;
actual_points: string | null;
note: string;
created_at: string;
updated_at: string;
} | null;
};
export type PayrollPointBreakdown = {
period: {
id: number;
name: string;
date_from: string;
date_to: string;
};
employee: PayrollAdminEmployee & { is_active: boolean };
target_points: string | null;
actual_points: string | null;
undated_work_points: string | null;
undated_work_record: {
id: number;
actual_points: string | null;
note: string;
created_at: string;
updated_at: string;
} | null;
work_points_mode: "daily_entries" | "unavailable";
dates: PayrollPointBreakdownDay[];
};
export type PayrollComponent = {
id: number;
code: string;
@ -617,6 +692,11 @@ export function createFinanceApi(request: RequestFn) {
request(`${adminPrefix}/periods/`),
getPayrollAdminPeriodTable: (periodId: number): Promise<PayrollPeriodTable> =>
request(`${adminPrefix}/periods/${periodId}/table/`),
getPayrollAdminPointBreakdown: (
periodId: number,
employeeId: number,
): Promise<PayrollPointBreakdown> =>
request(`${adminPrefix}/periods/${periodId}/employees/${employeeId}/point-breakdown/`),
createPayrollAdminPeriod: (payload: PayrollPeriodWrite): Promise<PayrollAdminPeriod> =>
request(`${adminPrefix}/periods/`, jsonOptions("POST", payload)),
updatePayrollAdminPeriod: (

View File

@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
compareDailyPayrollProjection,
comparePayrollProjection,
} from "./payroll-projection.ts";
test("payroll projection keeps small and moderate upward differences neutral", () => {
assert.equal(comparePayrollProjection("105", "100"), null);
assert.equal(comparePayrollProjection("95", "100"), null);
assert.equal(comparePayrollProjection("125", "100"), null);
});
test("payroll projection reports direction and traffic-light color", () => {
assert.deepEqual(comparePayrollProjection("85", "100"), {
direction: "lower",
color: "orange",
percentage: 15,
});
assert.deepEqual(comparePayrollProjection("130", "100"), {
direction: "higher",
color: "green",
percentage: 30,
});
assert.deepEqual(comparePayrollProjection("70", "100"), {
direction: "lower",
color: "red",
percentage: 30,
});
});
test("positive projection against zero work is green", () => {
assert.deepEqual(comparePayrollProjection("10", "0"), {
direction: "higher",
color: "green",
percentage: null,
});
assert.equal(comparePayrollProjection("0", "0"), null);
assert.equal(comparePayrollProjection(null, "100"), null);
});
test("daily projection highlights every discrepancy", () => {
assert.deepEqual(compareDailyPayrollProjection("101", "100"), {
direction: "higher",
color: "green",
percentage: 1,
});
assert.deepEqual(compareDailyPayrollProjection("80", "100"), {
direction: "lower",
color: "orange",
percentage: 20,
});
assert.deepEqual(compareDailyPayrollProjection("50", "100"), {
direction: "lower",
color: "red",
percentage: 50,
});
assert.equal(compareDailyPayrollProjection("100", "100"), null);
assert.equal(compareDailyPayrollProjection(null, "100"), null);
});

View File

@ -0,0 +1,79 @@
export type PayrollProjectionDifference = {
direction: "higher" | "lower";
color: "green" | "orange" | "red";
percentage: number | null;
};
const DIFFERENCE_THRESHOLD = 0.05;
const RED_DIFFERENCE_THRESHOLD = 0.25;
function finiteNumber(value: string | null): number | null {
if (value == null || value === "") return null;
const numeric = Number(value);
return Number.isFinite(numeric) ? numeric : null;
}
export function comparePayrollProjection(
projectedValue: string | null,
actualValue: string | null,
): PayrollProjectionDifference | null {
const projected = finiteNumber(projectedValue);
const actual = finiteNumber(actualValue);
if (projected == null || actual == null) return null;
const difference = projected - actual;
if (difference === 0) return null;
if (actual === 0) {
return {
direction: difference > 0 ? "higher" : "lower",
color: difference > 0 ? "green" : "red",
percentage: null,
};
}
const ratio = Math.abs(difference) / Math.abs(actual);
if (ratio <= DIFFERENCE_THRESHOLD) return null;
const direction = difference > 0 ? "higher" : "lower";
if (direction === "higher" && ratio <= RED_DIFFERENCE_THRESHOLD) return null;
const color = direction === "higher"
? "green"
: ratio > RED_DIFFERENCE_THRESHOLD
? "red"
: "orange";
return {
direction,
color,
percentage: ratio * 100,
};
}
export function compareDailyPayrollProjection(
projectedValue: string | null,
actualValue: string | null,
): PayrollProjectionDifference | null {
const projected = finiteNumber(projectedValue);
const actual = finiteNumber(actualValue);
if (projected == null || actual == null) return null;
const difference = projected - actual;
if (difference === 0) return null;
if (actual === 0) {
return {
direction: difference > 0 ? "higher" : "lower",
color: difference > 0 ? "green" : "red",
percentage: null,
};
}
const ratio = Math.abs(difference) / Math.abs(actual);
const direction = difference > 0 ? "higher" : "lower";
return {
direction,
color: direction === "higher"
? "green"
: ratio > RED_DIFFERENCE_THRESHOLD
? "red"
: "orange",
percentage: ratio * 100,
};
}