Merge branch 'develop'
This commit is contained in:
commit
a20f972bb2
@ -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,
|
||||
)
|
||||
|
||||
|
||||
@ -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"],)
|
||||
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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: (
|
||||
|
||||
61
frontend/src/lib/payroll-projection.test.js
Normal file
61
frontend/src/lib/payroll-projection.test.js
Normal 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);
|
||||
});
|
||||
79
frontend/src/lib/payroll-projection.ts
Normal file
79
frontend/src/lib/payroll-projection.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user