Merge branch 'develop'
This commit is contained in:
commit
8d833f6410
@ -115,6 +115,7 @@ from .admin_serializers import (
|
||||
PayrollPeriodSerializer,
|
||||
PayrollPeriodWriteSerializer,
|
||||
PayrollRunSerializer,
|
||||
PayrollUserRefSerializer,
|
||||
PayrollWorkRecordRevisionSerializer,
|
||||
PayrollWorkRecordSerializer,
|
||||
PayrollWorkRecordWriteSerializer,
|
||||
@ -215,6 +216,20 @@ def _audit_api_mutation(*, actor, action, instance, period=None, metadata=None):
|
||||
)
|
||||
|
||||
|
||||
def _payroll_history_action_label(action):
|
||||
if "voided" in action or "cancelled" in action:
|
||||
return "Запись аннулирована"
|
||||
if action.endswith("_approved"):
|
||||
return "Запись утверждена"
|
||||
if "revision" in action and action.endswith("_created"):
|
||||
return "Создана новая версия"
|
||||
if action.endswith("_created"):
|
||||
return "Запись добавлена"
|
||||
if action.endswith("_updated") or action.endswith("_replaced"):
|
||||
return "Запись изменена"
|
||||
return "Состояние записи изменено"
|
||||
|
||||
|
||||
def _validate_payroll_table_column(column_id):
|
||||
if column_id in PAYROLL_TABLE_FIXED_COLUMN_IDS:
|
||||
return column_id
|
||||
@ -519,9 +534,7 @@ class PayrollAdminWorkspaceView(PayrollAdminAPIView):
|
||||
)
|
||||
draft_input_count = draft_inputs.count()
|
||||
draft_input_employee_ids = list(
|
||||
draft_inputs.order_by()
|
||||
.values_list("employee_id", flat=True)
|
||||
.distinct()
|
||||
draft_inputs.order_by().values_list("employee_id", flat=True).distinct()
|
||||
)
|
||||
pending_rates = EmployeePayRate.objects.filter(
|
||||
employee_id__in=roster_employee_ids,
|
||||
@ -1161,6 +1174,91 @@ class DraftCollectionView(PayrollAdminAPIView):
|
||||
)
|
||||
|
||||
|
||||
class PayrollInputHistoryView(PayrollAdminAPIView):
|
||||
allowed_permissions = INPUT_VIEW_PERMISSIONS
|
||||
|
||||
def get(self, request):
|
||||
period_id = _optional_int(request, "period_id")
|
||||
if period_id is None:
|
||||
raise ValidationError({"period_id": "Укажите расчётный период."})
|
||||
period = get_object_or_404(PayrollPeriod, pk=period_id)
|
||||
kind = request.query_params.get("kind", "").strip()
|
||||
|
||||
if kind == "rates":
|
||||
records = pay_rates_relevant_to_period(
|
||||
period,
|
||||
queryset=EmployeePayRate.objects.select_related(
|
||||
"employee",
|
||||
"employee__position",
|
||||
).prefetch_related("employee__departments_links__department"),
|
||||
)
|
||||
object_type = EmployeePayRate._meta.label_lower
|
||||
subjects = {record.pk: "Ставка" for record in records}
|
||||
elif kind == "work":
|
||||
records = (
|
||||
PayrollWorkRecord.objects.filter(period=period)
|
||||
.select_related(
|
||||
"employee",
|
||||
"employee__position",
|
||||
)
|
||||
.prefetch_related("employee__departments_links__department")
|
||||
)
|
||||
object_type = PayrollWorkRecord._meta.label_lower
|
||||
subjects = {record.pk: "Выработка" for record in records}
|
||||
elif kind == "inputs":
|
||||
records = (
|
||||
PayrollInputLine.objects.filter(period=period)
|
||||
.select_related(
|
||||
"employee",
|
||||
"employee__position",
|
||||
"component",
|
||||
)
|
||||
.prefetch_related("employee__departments_links__department")
|
||||
)
|
||||
object_type = PayrollInputLine._meta.label_lower
|
||||
subjects = {record.pk: record.component.name for record in records}
|
||||
else:
|
||||
raise ValidationError(
|
||||
{"kind": "Допустимые значения: rates, work или inputs."}
|
||||
)
|
||||
|
||||
records_by_id = {record.pk: record for record in records}
|
||||
events = (
|
||||
PayrollAuditEvent.objects.filter(
|
||||
object_type=object_type,
|
||||
object_id__in=[str(record_id) for record_id in records_by_id],
|
||||
)
|
||||
.select_related("actor")
|
||||
.order_by("-created_at", "-id")
|
||||
)
|
||||
result = []
|
||||
for event in events:
|
||||
try:
|
||||
record_id = int(event.object_id)
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
record = records_by_id.get(record_id)
|
||||
if record is None:
|
||||
continue
|
||||
result.append(
|
||||
{
|
||||
"id": event.pk,
|
||||
"record_id": record_id,
|
||||
"action": event.action,
|
||||
"action_label": _payroll_history_action_label(event.action),
|
||||
"subject": subjects[record_id],
|
||||
"employee": PayrollEmployeeMiniSerializer(record.employee).data,
|
||||
"actor": (
|
||||
PayrollUserRefSerializer(event.actor).data
|
||||
if event.actor is not None
|
||||
else None
|
||||
),
|
||||
"created_at": event.created_at,
|
||||
}
|
||||
)
|
||||
return Response(result)
|
||||
|
||||
|
||||
class DraftDetailView(PayrollAdminAPIView):
|
||||
allowed_permissions = INPUT_VIEW_PERMISSIONS
|
||||
model = None
|
||||
@ -1256,12 +1354,16 @@ class EmployeePayRateListCreateView(DraftCollectionView):
|
||||
audit_label = "rate"
|
||||
|
||||
def get_queryset(self):
|
||||
return EmployeePayRate.objects.select_related(
|
||||
"employee",
|
||||
"employee__position",
|
||||
"created_by",
|
||||
"approved_by",
|
||||
).prefetch_related("employee__departments_links__department")
|
||||
return (
|
||||
EmployeePayRate.objects.select_related(
|
||||
"employee",
|
||||
"employee__position",
|
||||
"created_by",
|
||||
"approved_by",
|
||||
)
|
||||
.prefetch_related("employee__departments_links__department")
|
||||
.order_by("-created_at", "-id")
|
||||
)
|
||||
|
||||
def filter_queryset(self, request, queryset):
|
||||
queryset = super().filter_queryset(request, queryset)
|
||||
@ -1375,13 +1477,17 @@ class PayrollWorkRecordListCreateView(DraftCollectionView):
|
||||
)
|
||||
|
||||
def get_queryset(self):
|
||||
return PayrollWorkRecord.objects.select_related(
|
||||
"period",
|
||||
"employee",
|
||||
"employee__position",
|
||||
"created_by",
|
||||
"approved_by",
|
||||
).prefetch_related("employee__departments_links__department")
|
||||
return (
|
||||
PayrollWorkRecord.objects.select_related(
|
||||
"period",
|
||||
"employee",
|
||||
"employee__position",
|
||||
"created_by",
|
||||
"approved_by",
|
||||
)
|
||||
.prefetch_related("employee__departments_links__department")
|
||||
.order_by("-created_at", "-id")
|
||||
)
|
||||
|
||||
def filter_queryset(self, request, queryset):
|
||||
queryset = super().filter_queryset(request, queryset)
|
||||
@ -1422,15 +1528,19 @@ class PayrollInputLineListCreateView(DraftCollectionView):
|
||||
period_scoped = True
|
||||
|
||||
def get_queryset(self):
|
||||
return PayrollInputLine.objects.select_related(
|
||||
"period",
|
||||
"employee",
|
||||
"employee__position",
|
||||
"component",
|
||||
"relates_to_period",
|
||||
"created_by",
|
||||
"approved_by",
|
||||
).prefetch_related("employee__departments_links__department")
|
||||
return (
|
||||
PayrollInputLine.objects.select_related(
|
||||
"period",
|
||||
"employee",
|
||||
"employee__position",
|
||||
"component",
|
||||
"relates_to_period",
|
||||
"created_by",
|
||||
"approved_by",
|
||||
)
|
||||
.prefetch_related("employee__departments_links__department")
|
||||
.order_by("-created_at", "-id")
|
||||
)
|
||||
|
||||
def filter_queryset(self, request, queryset):
|
||||
queryset = super().filter_queryset(request, queryset)
|
||||
|
||||
@ -28,6 +28,7 @@ from .admin_views import (
|
||||
PayrollInputLineApproveView,
|
||||
PayrollInputLineDetailView,
|
||||
PayrollInputLineListCreateView,
|
||||
PayrollInputHistoryView,
|
||||
PayrollPeriodCalculateView,
|
||||
PayrollPeriodCarryoverListCreateView,
|
||||
PayrollPeriodCompleteWorkflowView,
|
||||
@ -161,6 +162,11 @@ urlpatterns = [
|
||||
PayrollPeriodCloseView.as_view(),
|
||||
name="admin-period-close",
|
||||
),
|
||||
path(
|
||||
"payroll/admin/history/",
|
||||
PayrollInputHistoryView.as_view(),
|
||||
name="admin-input-history",
|
||||
),
|
||||
path(
|
||||
"payroll/admin/pay-rates/",
|
||||
EmployeePayRateListCreateView.as_view(),
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from datetime import date, datetime, timedelta
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
@ -92,6 +92,170 @@ def make_approved_period_inputs(
|
||||
)
|
||||
|
||||
|
||||
def test_admin_input_lists_show_newest_records_first(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
manager = user_factory(email="chronology.manager@example.test")
|
||||
older_employee = user_factory(email="chronology.older@example.test")
|
||||
newer_employee = user_factory(email="chronology.newer@example.test")
|
||||
grant(manager, "manage_payroll_inputs")
|
||||
period = make_period(creator=manager)
|
||||
component = PayrollComponent.objects.get(code="BONUS")
|
||||
|
||||
older_rate = EmployeePayRate.objects.create(
|
||||
employee=older_employee,
|
||||
amount="80000.0000",
|
||||
effective_from="2026-06-01",
|
||||
created_by=manager,
|
||||
)
|
||||
newer_rate = EmployeePayRate.objects.create(
|
||||
employee=newer_employee,
|
||||
amount="90000.0000",
|
||||
effective_from="2026-06-01",
|
||||
created_by=manager,
|
||||
)
|
||||
older_work = PayrollWorkRecord.objects.create(
|
||||
period=period,
|
||||
employee=older_employee,
|
||||
target_points="100.0000",
|
||||
actual_points="90.0000",
|
||||
created_by=manager,
|
||||
)
|
||||
newer_work = PayrollWorkRecord.objects.create(
|
||||
period=period,
|
||||
employee=newer_employee,
|
||||
target_points="100.0000",
|
||||
actual_points="95.0000",
|
||||
created_by=manager,
|
||||
)
|
||||
older_input = PayrollInputLine.objects.create(
|
||||
period=period,
|
||||
employee=older_employee,
|
||||
component=component,
|
||||
amount="1000.00",
|
||||
created_by=manager,
|
||||
)
|
||||
newer_input = PayrollInputLine.objects.create(
|
||||
period=period,
|
||||
employee=newer_employee,
|
||||
component=component,
|
||||
amount="2000.00",
|
||||
created_by=manager,
|
||||
)
|
||||
|
||||
older_created_at = timezone.now() - timedelta(days=1)
|
||||
for model, instance in (
|
||||
(EmployeePayRate, older_rate),
|
||||
(PayrollWorkRecord, older_work),
|
||||
(PayrollInputLine, older_input),
|
||||
):
|
||||
model.objects.filter(pk=instance.pk).update(created_at=older_created_at)
|
||||
|
||||
client = auth_client_factory(manager)
|
||||
cases = (
|
||||
("admin-pay-rate-list", newer_rate.pk, older_rate.pk),
|
||||
("admin-work-record-list", newer_work.pk, older_work.pk),
|
||||
("admin-input-line-list", newer_input.pk, older_input.pk),
|
||||
)
|
||||
for route_name, newer_id, older_id in cases:
|
||||
response = client.get(api_url(route_name), {"period_id": period.pk})
|
||||
assert response.status_code == 200, response.content
|
||||
assert [item["id"] for item in response.json()] == [newer_id, older_id]
|
||||
|
||||
|
||||
def test_admin_input_history_returns_all_record_events_in_reverse_chronology(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
):
|
||||
manager = user_factory(
|
||||
email="history.manager@example.test",
|
||||
first_name="Мария",
|
||||
last_name="Историк",
|
||||
)
|
||||
employee = user_factory(
|
||||
email="history.employee@example.test",
|
||||
first_name="Иван",
|
||||
last_name="Хронологов",
|
||||
)
|
||||
grant(manager, "manage_payroll_inputs")
|
||||
period = make_period(creator=manager)
|
||||
client = auth_client_factory(manager)
|
||||
|
||||
rate = client.post(
|
||||
api_url("admin-pay-rate-list"),
|
||||
{
|
||||
"employee_id": employee.pk,
|
||||
"rate_code": "BASE",
|
||||
"amount": "80000.0000",
|
||||
"currency": "RUB",
|
||||
"effective_from": "2026-06-01",
|
||||
"reason": "Первая ставка",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert rate.status_code == 201, rate.content
|
||||
changed_rate = client.patch(
|
||||
api_url("admin-pay-rate-detail", pk=rate.json()["id"]),
|
||||
{
|
||||
"amount": "85000.0000",
|
||||
"expected_lock_version": rate.json()["lock_version"],
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert changed_rate.status_code == 200, changed_rate.content
|
||||
|
||||
work = client.post(
|
||||
api_url("admin-work-record-list"),
|
||||
{
|
||||
"period_id": period.pk,
|
||||
"employee_id": employee.pk,
|
||||
"target_points": "100.0000",
|
||||
"actual_points": "90.0000",
|
||||
"reason": "Ручной ввод",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert work.status_code == 201, work.content
|
||||
component = PayrollComponent.objects.get(code="BONUS")
|
||||
input_line = client.post(
|
||||
api_url("admin-input-line-list"),
|
||||
{
|
||||
"period_id": period.pk,
|
||||
"employee_id": employee.pk,
|
||||
"component_id": component.pk,
|
||||
"amount": "1000.00",
|
||||
"reason": "Премия",
|
||||
},
|
||||
format="json",
|
||||
)
|
||||
assert input_line.status_code == 201, input_line.content
|
||||
|
||||
rate_history = client.get(
|
||||
api_url("admin-input-history"),
|
||||
{"period_id": period.pk, "kind": "rates"},
|
||||
)
|
||||
assert rate_history.status_code == 200, rate_history.content
|
||||
assert [item["action"] for item in rate_history.json()] == [
|
||||
"payroll.rate_draft_updated",
|
||||
"payroll.rate_draft_created",
|
||||
]
|
||||
assert rate_history.json()[0]["action_label"] == "Запись изменена"
|
||||
assert rate_history.json()[0]["employee"]["id"] == employee.pk
|
||||
assert rate_history.json()[0]["actor"]["id"] == manager.pk
|
||||
|
||||
for kind, expected_action in (
|
||||
("work", "payroll.work_record_draft_created"),
|
||||
("inputs", "payroll.input_line_draft_created"),
|
||||
):
|
||||
response = client.get(
|
||||
api_url("admin-input-history"),
|
||||
{"period_id": period.pk, "kind": kind},
|
||||
)
|
||||
assert response.status_code == 200, response.content
|
||||
assert [item["action"] for item in response.json()] == [expected_action]
|
||||
|
||||
|
||||
def test_work_record_target_is_optional_and_can_return_to_automatic(
|
||||
user_factory,
|
||||
auth_client_factory,
|
||||
@ -450,11 +614,14 @@ def test_period_table_cell_comments_use_shared_discussion_flow(
|
||||
assert created.status_code == 201, created.content
|
||||
assert created.json()["text"] == "Проверить итоговое начисление"
|
||||
assert created.json()["author"]["id"] == author.pk
|
||||
assert PayrollCellCommentThread.objects.filter(
|
||||
period=period,
|
||||
subject_employee=employee,
|
||||
column_id="gross_total",
|
||||
).count() == 1
|
||||
assert (
|
||||
PayrollCellCommentThread.objects.filter(
|
||||
period=period,
|
||||
subject_employee=employee,
|
||||
column_id="gross_total",
|
||||
).count()
|
||||
== 1
|
||||
)
|
||||
|
||||
listed = auth_client_factory(colleague).get(comments_url)
|
||||
assert listed.status_code == 200
|
||||
@ -486,9 +653,12 @@ def test_period_table_cell_comments_use_shared_discussion_flow(
|
||||
deleted = author_client.delete(detail_url)
|
||||
assert deleted.status_code == 204
|
||||
assert author_client.get(comments_url).json() == []
|
||||
assert auth_client_factory(colleague).get(
|
||||
api_url("admin-period-table", pk=period.pk)
|
||||
).json()["cell_comments"] == []
|
||||
assert (
|
||||
auth_client_factory(colleague)
|
||||
.get(api_url("admin-period-table", pk=period.pk))
|
||||
.json()["cell_comments"]
|
||||
== []
|
||||
)
|
||||
|
||||
|
||||
def test_cell_comments_reject_columns_not_present_in_table(
|
||||
@ -756,35 +926,44 @@ def test_period_point_breakdown_returns_daily_sources(
|
||||
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",
|
||||
)} == {
|
||||
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["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",
|
||||
)} == {
|
||||
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",
|
||||
@ -796,13 +975,16 @@ def test_period_point_breakdown_returns_daily_sources(
|
||||
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",
|
||||
)} == {
|
||||
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,
|
||||
|
||||
@ -644,6 +644,7 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
|
||||
|
||||
.payroll-admin-toolbar-search,
|
||||
.payroll-admin-toolbar-status,
|
||||
.payroll-admin-toolbar-sort,
|
||||
.payroll-admin-toolbar-actions {
|
||||
width: 100%;
|
||||
}
|
||||
@ -662,6 +663,10 @@ html[data-bs-theme="dark"] .app-input::-webkit-calendar-picker-indicator {
|
||||
width: 11rem;
|
||||
}
|
||||
|
||||
.payroll-admin-toolbar-sort {
|
||||
width: 12rem;
|
||||
}
|
||||
|
||||
.payroll-admin-toolbar-actions {
|
||||
width: auto;
|
||||
flex-shrink: 0;
|
||||
|
||||
@ -1,15 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { CalendarCheck2, Check, ChevronDown, Loader2, Pencil, RotateCcw } from "lucide-react";
|
||||
import { CalendarCheck2, Check, ChevronDown, History, Loader2, Pencil, RotateCcw } from "lucide-react";
|
||||
import { type ReactNode, useState } from "react";
|
||||
|
||||
import type {
|
||||
PayrollAdminInputLine,
|
||||
PayrollInputHistoryEvent,
|
||||
PayrollAdminPayRate,
|
||||
PayrollAdminWorkRecord,
|
||||
PayrollApprovalStatus,
|
||||
} from "@/lib/api/finance";
|
||||
import { formatPayrollDate, formatPayrollMoney } from "@/lib/payroll";
|
||||
import { formatPayrollDate, formatPayrollDateTime, formatPayrollMoney } from "@/lib/payroll";
|
||||
import { formatPayrollWorkMetric, isAttendancePayrollWorkRecord } from "@/lib/payroll-attendance";
|
||||
import {
|
||||
approvalStatusMeta,
|
||||
@ -85,6 +86,74 @@ function ListState({ loading, error, empty }: { loading: boolean; error: string
|
||||
return null;
|
||||
}
|
||||
|
||||
export function PayrollInputHistory({
|
||||
events,
|
||||
loading,
|
||||
error,
|
||||
}: {
|
||||
events: PayrollInputHistoryEvent[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const visibleEvents = expanded ? events : events.slice(0, 5);
|
||||
|
||||
return (
|
||||
<section className="app-surface-muted mb-4 overflow-hidden rounded-xl">
|
||||
<div className="flex items-center gap-3 px-3 py-2.5 sm:px-4">
|
||||
<span className="app-action-secondary inline-flex h-8 w-8 shrink-0 items-center justify-center rounded-lg">
|
||||
<History size={15} />
|
||||
</span>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-sm font-semibold text-[var(--foreground)]">История изменений</h3>
|
||||
<p className="app-text-muted text-xs">Все действия с записями, новые события сверху</p>
|
||||
</div>
|
||||
{!loading && !error ? <span className="app-counter">{events.length}</span> : null}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="app-text-muted flex items-center gap-2 border-t border-[var(--border-subtle)] px-4 py-3 text-xs">
|
||||
<Loader2 className="animate-spin" size={14} /> Загрузка истории…
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="app-feedback-danger border-t border-[var(--border-subtle)] px-4 py-3 text-xs">{error}</div>
|
||||
) : events.length === 0 ? (
|
||||
<div className="app-text-muted border-t border-[var(--border-subtle)] px-4 py-3 text-xs">История пока пуста.</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="divide-y divide-[var(--border-subtle)] border-t border-[var(--border-subtle)]">
|
||||
{visibleEvents.map((event) => (
|
||||
<div key={event.id} className="flex items-start gap-3 px-3 py-2.5 sm:px-4">
|
||||
<span className="mt-1.5 h-2 w-2 shrink-0 rounded-full bg-[var(--accent-primary)]" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm text-[var(--foreground)]">
|
||||
<span className="font-medium">{event.action_label}</span>
|
||||
<span className="app-text-muted"> · {event.subject}</span>
|
||||
</p>
|
||||
<p className="app-text-muted mt-0.5 truncate text-xs">{event.employee.display_name}</p>
|
||||
</div>
|
||||
<div className="shrink-0 text-right">
|
||||
<p className="text-xs text-[var(--foreground)]">{formatPayrollDateTime(event.created_at)}</p>
|
||||
<p className="app-text-muted mt-0.5 max-w-40 truncate text-[10px]">{event.actor?.display_name || "Система"}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{events.length > 5 ? (
|
||||
<button
|
||||
type="button"
|
||||
className="app-action-secondary m-3 inline-flex h-8 items-center rounded-lg px-3 text-xs font-medium sm:mx-4"
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
>
|
||||
{expanded ? "Свернуть историю" : `Показать всю историю (${events.length})`}
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function ActionButton({
|
||||
label,
|
||||
icon,
|
||||
@ -195,6 +264,7 @@ export function PayrollRatesTable({
|
||||
{record.point_rate === null ? "Как в норме" : formatPayrollMoney(record.point_rate, record.currency)}
|
||||
</CompactField>
|
||||
<CompactField label="Действует с">{formatPayrollDate(record.effective_from)}</CompactField>
|
||||
<CompactField label="Добавлено">{formatPayrollDateTime(record.created_at)}</CompactField>
|
||||
</div>
|
||||
<div className="mt-3 border-t border-[var(--border-subtle)] pt-3">
|
||||
<RecordActions status={record.status} isAuthor={record.created_by.id === currentUserId} canManage={canManage} canApprove={canApprove} canOverrideApproval={canOverrideApproval} fullAccess={fullAccess} onEdit={() => onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} />
|
||||
@ -204,17 +274,18 @@ export function PayrollRatesTable({
|
||||
</div>
|
||||
|
||||
<div className="payroll-admin-records-table overflow-x-auto rounded-xl border border-[var(--border-subtle)]">
|
||||
<div className="min-w-[900px]">
|
||||
<div className="app-surface-muted grid grid-cols-[minmax(180px,1.5fr)_minmax(100px,.8fr)_minmax(105px,.8fr)_120px_115px_minmax(150px,1fr)] gap-3 border-0 border-b border-[var(--border-subtle)] px-4 py-2.5 text-xs font-medium text-[var(--muted-foreground)]">
|
||||
<span>Сотрудник</span><span>Оклад</span><span>Цена сверх нормы</span><span>Действует с</span><span>Статус</span><span className="text-right">Действия</span>
|
||||
<div className="min-w-[1060px]">
|
||||
<div className="app-surface-muted grid grid-cols-[minmax(180px,1.5fr)_minmax(100px,.8fr)_minmax(105px,.8fr)_120px_160px_115px_minmax(150px,1fr)] gap-3 border-0 border-b border-[var(--border-subtle)] px-4 py-2.5 text-xs font-medium text-[var(--muted-foreground)]">
|
||||
<span>Сотрудник</span><span>Оклад</span><span>Цена сверх нормы</span><span>Действует с</span><span>Добавлено</span><span>Статус</span><span className="text-right">Действия</span>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--border-subtle)]">
|
||||
{records.map((record) => (
|
||||
<div key={record.id} className="grid grid-cols-[minmax(180px,1.5fr)_minmax(100px,.8fr)_minmax(105px,.8fr)_120px_115px_minmax(150px,1fr)] items-center gap-3 bg-[var(--surface-primary)] px-4 py-3">
|
||||
<div key={record.id} className="grid grid-cols-[minmax(180px,1.5fr)_minmax(100px,.8fr)_minmax(105px,.8fr)_120px_160px_115px_minmax(150px,1fr)] items-center gap-3 bg-[var(--surface-primary)] px-4 py-3">
|
||||
<div className="min-w-0"><p className="truncate text-sm font-semibold text-[var(--foreground)]">{record.employee.display_name}</p><p className="app-text-muted mt-0.5 truncate text-xs">{record.employee.position || record.employee.department || "Сотрудник"}</p></div>
|
||||
<div><span className="text-sm font-medium text-[var(--foreground)]">{formatPayrollMoney(record.amount, record.currency)}</span></div>
|
||||
<div><span className={`text-sm ${record.point_rate === null ? "text-[var(--muted-foreground)]" : "text-[var(--foreground)]"}`}>{record.point_rate === null ? "Как в норме" : formatPayrollMoney(record.point_rate, record.currency)}</span></div>
|
||||
<div><span className="text-sm text-[var(--foreground)]">{formatPayrollDate(record.effective_from)}</span></div>
|
||||
<div><span className="text-sm text-[var(--foreground)]">{formatPayrollDateTime(record.created_at)}</span></div>
|
||||
<RecordStatus status={record.status} />
|
||||
<RecordActions status={record.status} isAuthor={record.created_by.id === currentUserId} canManage={canManage} canApprove={canApprove} canOverrideApproval={canOverrideApproval} fullAccess={fullAccess} onEdit={() => onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} />
|
||||
</div>
|
||||
@ -281,6 +352,7 @@ export function PayrollWorkRecordsTable({
|
||||
<CompactField label="Факт">{formatPayrollWorkMetric(record.actual_points, record.source)}</CompactField>
|
||||
<CompactField label="Контроль">{record.expected_payable ? formatPayrollMoney(record.expected_payable) : "—"}</CompactField>
|
||||
<CompactField label="Источник">{fromAttendance ? "Посещаемость" : "Введено вручную"}</CompactField>
|
||||
<CompactField label="Добавлено">{formatPayrollDateTime(record.created_at)}</CompactField>
|
||||
</div>
|
||||
<div className="mt-3 border-t border-[var(--border-subtle)] pt-3">
|
||||
<RecordActions status={record.status} isAuthor={record.created_by.id === currentUserId} canManage={canManage} canApprove={canApprove} canOverrideApproval={canOverrideApproval} fullAccess={fullAccess} onEdit={() => onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} />
|
||||
@ -291,15 +363,15 @@ export function PayrollWorkRecordsTable({
|
||||
</div>
|
||||
|
||||
<div className="payroll-admin-records-table overflow-x-auto rounded-xl border border-[var(--border-subtle)]">
|
||||
<div className="min-w-[880px]">
|
||||
<div className="app-surface-muted grid grid-cols-[minmax(190px,1.5fr)_110px_110px_120px_115px_minmax(150px,1fr)] gap-3 border-0 border-b border-[var(--border-subtle)] px-4 py-2.5 text-xs font-medium text-[var(--muted-foreground)]">
|
||||
<span>Сотрудник</span><span>Норма</span><span>Факт</span><span>Контроль</span><span>Статус</span><span className="text-right">Действия</span>
|
||||
<div className="min-w-[1040px]">
|
||||
<div className="app-surface-muted grid grid-cols-[minmax(190px,1.5fr)_110px_110px_120px_160px_115px_minmax(150px,1fr)] gap-3 border-0 border-b border-[var(--border-subtle)] px-4 py-2.5 text-xs font-medium text-[var(--muted-foreground)]">
|
||||
<span>Сотрудник</span><span>Норма</span><span>Факт</span><span>Контроль</span><span>Добавлено</span><span>Статус</span><span className="text-right">Действия</span>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--border-subtle)]">
|
||||
{records.map((record) => {
|
||||
const fromAttendance = isAttendancePayrollWorkRecord(record.source);
|
||||
return (
|
||||
<div key={record.id} className="grid grid-cols-[minmax(190px,1.5fr)_110px_110px_120px_115px_minmax(150px,1fr)] items-center gap-3 bg-[var(--surface-primary)] px-4 py-3">
|
||||
<div key={record.id} className="grid grid-cols-[minmax(190px,1.5fr)_110px_110px_120px_160px_115px_minmax(150px,1fr)] items-center gap-3 bg-[var(--surface-primary)] px-4 py-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold text-[var(--foreground)]">{record.employee.display_name}</p>
|
||||
<p className="app-text-muted mt-0.5 truncate text-xs">{record.employee.position || record.employee.department || "Сотрудник"}</p>
|
||||
@ -312,6 +384,7 @@ export function PayrollWorkRecordsTable({
|
||||
<div><span className="text-sm font-medium text-[var(--foreground)]">{formatPayrollWorkMetric(record.target_points, record.source)}</span></div>
|
||||
<div><span className="text-sm font-medium text-[var(--foreground)]">{formatPayrollWorkMetric(record.actual_points, record.source)}</span></div>
|
||||
<div><span className="text-sm text-[var(--foreground)]">{record.expected_payable ? formatPayrollMoney(record.expected_payable) : "—"}</span></div>
|
||||
<div><span className="text-sm text-[var(--foreground)]">{formatPayrollDateTime(record.created_at)}</span></div>
|
||||
<RecordStatus status={record.status} />
|
||||
<RecordActions status={record.status} isAuthor={record.created_by.id === currentUserId} canManage={canManage} canApprove={canApprove} canOverrideApproval={canOverrideApproval} fullAccess={fullAccess} onEdit={() => onEdit(record)} onApprove={() => onApprove(record)} onRevise={() => onRevise(record)} />
|
||||
</div>
|
||||
@ -375,6 +448,7 @@ export function PayrollInputLinesTable({
|
||||
</CompactField>
|
||||
) : null}
|
||||
{record.reason ? <CompactField label="Причина">{record.reason}</CompactField> : null}
|
||||
<CompactField label="Добавлено">{formatPayrollDateTime(record.created_at)}</CompactField>
|
||||
</div>
|
||||
<div className="mt-3 border-t border-[var(--border-subtle)] pt-3">
|
||||
<RecordActions status={record.status} isAuthor={record.created_by.id === currentUserId} canManage={canManage} canApprove={canApprove} canOverrideApproval={canOverrideApproval} fullAccess={fullAccess} onEdit={() => onEdit(record)} onApprove={() => onApprove(record)} />
|
||||
@ -384,13 +458,13 @@ export function PayrollInputLinesTable({
|
||||
</div>
|
||||
|
||||
<div className="payroll-admin-records-table overflow-x-auto rounded-xl border border-[var(--border-subtle)]">
|
||||
<div className="min-w-[800px]">
|
||||
<div className="app-surface-muted grid grid-cols-[minmax(170px,1.35fr)_minmax(150px,1.2fr)_120px_115px_minmax(150px,1fr)] gap-3 border-0 border-b border-[var(--border-subtle)] px-4 py-2.5 text-xs font-medium text-[var(--muted-foreground)]">
|
||||
<span>Сотрудник</span><span>Операция</span><span>Сумма</span><span>Статус</span><span className="text-right">Действия</span>
|
||||
<div className="min-w-[960px]">
|
||||
<div className="app-surface-muted grid grid-cols-[minmax(170px,1.35fr)_minmax(150px,1.2fr)_120px_160px_115px_minmax(150px,1fr)] gap-3 border-0 border-b border-[var(--border-subtle)] px-4 py-2.5 text-xs font-medium text-[var(--muted-foreground)]">
|
||||
<span>Сотрудник</span><span>Операция</span><span>Сумма</span><span>Добавлено</span><span>Статус</span><span className="text-right">Действия</span>
|
||||
</div>
|
||||
<div className="divide-y divide-[var(--border-subtle)]">
|
||||
{records.map((record) => (
|
||||
<div key={record.id} className="grid grid-cols-[minmax(170px,1.35fr)_minmax(150px,1.2fr)_120px_115px_minmax(150px,1fr)] items-center gap-3 bg-[var(--surface-primary)] px-4 py-3">
|
||||
<div key={record.id} className="grid grid-cols-[minmax(170px,1.35fr)_minmax(150px,1.2fr)_120px_160px_115px_minmax(150px,1fr)] items-center gap-3 bg-[var(--surface-primary)] px-4 py-3">
|
||||
<div className="min-w-0"><p className="truncate text-sm font-semibold text-[var(--foreground)]">{record.employee.display_name}</p><p className="app-text-muted mt-0.5 truncate text-xs">{record.employee.position || record.employee.department || "Сотрудник"}</p></div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm text-[var(--foreground)]">{record.component.name}</p>
|
||||
@ -405,6 +479,7 @@ export function PayrollInputLinesTable({
|
||||
{record.reason ? <p className="app-text-muted mt-1 truncate text-xs" title={record.reason}>{record.reason}</p> : null}
|
||||
</div>
|
||||
<div><span className="text-sm font-medium text-[var(--foreground)]">{formatPayrollMoney(record.amount)}</span></div>
|
||||
<div><span className="text-sm text-[var(--foreground)]">{formatPayrollDateTime(record.created_at)}</span></div>
|
||||
<RecordStatus status={record.status} />
|
||||
<RecordActions status={record.status} isAuthor={record.created_by.id === currentUserId} canManage={canManage} canApprove={canApprove} canOverrideApproval={canOverrideApproval} fullAccess={fullAccess} onEdit={() => onEdit(record)} onApprove={() => onApprove(record)} />
|
||||
</div>
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
import {
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
ArrowUpDown,
|
||||
Banknote,
|
||||
CalendarCheck2,
|
||||
Calculator,
|
||||
@ -43,6 +44,7 @@ import { PayrollManagementAccessModal } from "@/components/finance/admin/Payroll
|
||||
import { PayrollWorkbookImportModal } from "@/components/finance/admin/PayrollWorkbookImportModal";
|
||||
import {
|
||||
PayrollApprovalQueue,
|
||||
PayrollInputHistory,
|
||||
PayrollInputLinesTable,
|
||||
PayrollRatesTable,
|
||||
PayrollWorkRecordsTable,
|
||||
@ -64,6 +66,8 @@ import type {
|
||||
PayrollBulkTargetPointsResult,
|
||||
PayrollCalculationBlocker,
|
||||
PayrollInputLineWrite,
|
||||
PayrollInputHistoryEvent,
|
||||
PayrollInputHistoryKind,
|
||||
PayrollPayRateWrite,
|
||||
PayrollPeriodTable,
|
||||
PayrollPeriodTableRow,
|
||||
@ -97,6 +101,27 @@ type PendingAction = {
|
||||
execute: (reason: string) => Promise<unknown>;
|
||||
};
|
||||
|
||||
type PayrollInputSort = "newest" | "oldest" | "employee_asc" | "employee_desc";
|
||||
|
||||
function sortPayrollInputRecords<
|
||||
T extends { id: number; created_at: string; employee: { display_name: string } },
|
||||
>(records: T[], sort: PayrollInputSort): T[] {
|
||||
return [...records].sort((left, right) => {
|
||||
if (sort === "employee_asc" || sort === "employee_desc") {
|
||||
const direction = sort === "employee_asc" ? 1 : -1;
|
||||
const byEmployee = left.employee.display_name.localeCompare(
|
||||
right.employee.display_name,
|
||||
"ru-RU",
|
||||
{ sensitivity: "base" },
|
||||
);
|
||||
return byEmployee !== 0 ? byEmployee * direction : right.id - left.id;
|
||||
}
|
||||
const direction = sort === "newest" ? -1 : 1;
|
||||
const byCreatedAt = new Date(left.created_at).getTime() - new Date(right.created_at).getTime();
|
||||
return byCreatedAt !== 0 ? byCreatedAt * direction : (left.id - right.id) * direction;
|
||||
});
|
||||
}
|
||||
|
||||
function StatusPill({ period }: { period: PayrollAdminPeriod }) {
|
||||
const meta = periodStatusMeta[period.status];
|
||||
return <span className={`app-status-pill ${meta.className}`}>{meta.label}</span>;
|
||||
@ -217,8 +242,10 @@ function ReadinessBlockers({
|
||||
function TableToolbar({
|
||||
search,
|
||||
status,
|
||||
sort,
|
||||
onSearch,
|
||||
onStatus,
|
||||
onSort,
|
||||
onAdd,
|
||||
onFillFromAttendance,
|
||||
onImportWorkbook,
|
||||
@ -228,8 +255,10 @@ function TableToolbar({
|
||||
}: {
|
||||
search: string;
|
||||
status: "" | PayrollApprovalStatus;
|
||||
sort: PayrollInputSort;
|
||||
onSearch: (value: string) => void;
|
||||
onStatus: (value: "" | PayrollApprovalStatus) => void;
|
||||
onSort: (value: PayrollInputSort) => void;
|
||||
onAdd?: () => void;
|
||||
onFillFromAttendance?: () => void;
|
||||
onImportWorkbook?: () => void;
|
||||
@ -274,6 +303,15 @@ function TableToolbar({
|
||||
<select className="payroll-admin-toolbar-status app-select min-w-0 rounded-lg px-3 py-2.5 text-sm" value={status} onChange={(event) => onStatus(event.target.value as "" | PayrollApprovalStatus)}>
|
||||
<option value="">Все статусы</option><option value="draft">Черновики</option><option value="approved">Утверждённые</option><option value="voided">Аннулированные</option>
|
||||
</select>
|
||||
<label className="payroll-admin-toolbar-sort relative min-w-0">
|
||||
<ArrowUpDown className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" size={15} />
|
||||
<select className="app-select w-full rounded-lg py-2.5 pl-9 pr-8 text-sm" value={sort} onChange={(event) => onSort(event.target.value as PayrollInputSort)} aria-label="Сортировка записей">
|
||||
<option value="newest">Сначала новые</option>
|
||||
<option value="oldest">Сначала старые</option>
|
||||
<option value="employee_asc">По сотруднику А–Я</option>
|
||||
<option value="employee_desc">По сотруднику Я–А</option>
|
||||
</select>
|
||||
</label>
|
||||
{onAdd ? (
|
||||
<div ref={addMenuRef} className="payroll-admin-toolbar-actions relative flex min-w-0 items-center gap-1">
|
||||
<button type="button" className="app-action-primary inline-flex min-w-0 flex-1 items-center justify-center gap-2 rounded-lg px-3.5 py-2.5 text-sm font-semibold" onClick={onAdd}>
|
||||
@ -402,9 +440,13 @@ export function PayrollAdminWorkspace({
|
||||
const [tab, setTab] = usePayrollAdminTab(user?.id);
|
||||
const [search, setSearch] = useState("");
|
||||
const [status, setStatus] = useState<"" | PayrollApprovalStatus>("");
|
||||
const [inputSort, setInputSort] = useState<PayrollInputSort>("newest");
|
||||
const [rates, setRates] = useState<PayrollAdminPayRate[]>([]);
|
||||
const [workRecords, setWorkRecords] = useState<PayrollAdminWorkRecord[]>([]);
|
||||
const [inputLines, setInputLines] = useState<PayrollAdminInputLine[]>([]);
|
||||
const [inputHistory, setInputHistory] = useState<PayrollInputHistoryEvent[]>([]);
|
||||
const [historyLoading, setHistoryLoading] = useState(false);
|
||||
const [historyError, setHistoryError] = useState<string | null>(null);
|
||||
const [periodTable, setPeriodTable] = useState<PayrollPeriodTable | null>(null);
|
||||
const [tableLoading, setTableLoading] = useState(false);
|
||||
const [tableError, setTableError] = useState<string | null>(null);
|
||||
@ -560,6 +602,32 @@ export function PayrollAdminWorkspace({
|
||||
workspace?.permissions.view_all,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPeriod || !["rates", "work", "inputs"].includes(tab)) return;
|
||||
|
||||
let cancelled = false;
|
||||
const loadHistory = async () => {
|
||||
setHistoryLoading(true);
|
||||
setHistoryError(null);
|
||||
try {
|
||||
const events = await apiClient.getPayrollAdminInputHistory(
|
||||
selectedPeriod.id,
|
||||
tab as PayrollInputHistoryKind,
|
||||
);
|
||||
if (!cancelled) setInputHistory(events);
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
setInputHistory([]);
|
||||
setHistoryError(getPayrollAdminError(error, "Не удалось загрузить историю изменений."));
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setHistoryLoading(false);
|
||||
}
|
||||
};
|
||||
void loadHistory();
|
||||
return () => { cancelled = true; };
|
||||
}, [selectedPeriod, tab, tableVersion]);
|
||||
|
||||
const refreshView = useCallback(async (message?: string) => {
|
||||
if (message) setNotice(message);
|
||||
await loadWorkspace(selectedPeriod?.id, true);
|
||||
@ -571,18 +639,18 @@ export function PayrollAdminWorkspace({
|
||||
await refreshView("Данные изменились в другой сессии. Экран обновлён — повторите действие.");
|
||||
}, [refreshView]);
|
||||
|
||||
const filteredRates = useMemo(() => rates.filter((record) => {
|
||||
const filteredRates = useMemo(() => sortPayrollInputRecords(rates.filter((record) => {
|
||||
const needle = search.trim().toLocaleLowerCase("ru-RU");
|
||||
return (!status || record.status === status) && (!needle || `${record.employee.display_name} ${record.reason}`.toLocaleLowerCase("ru-RU").includes(needle));
|
||||
}), [rates, search, status]);
|
||||
const filteredWork = useMemo(() => workRecords.filter((record) => {
|
||||
}), inputSort), [inputSort, rates, search, status]);
|
||||
const filteredWork = useMemo(() => sortPayrollInputRecords(workRecords.filter((record) => {
|
||||
const needle = search.trim().toLocaleLowerCase("ru-RU");
|
||||
return (!status || record.status === status) && (!needle || `${record.employee.display_name} ${record.reason}`.toLocaleLowerCase("ru-RU").includes(needle));
|
||||
}), [workRecords, search, status]);
|
||||
const filteredInputs = useMemo(() => inputLines.filter((record) => {
|
||||
}), inputSort), [inputSort, workRecords, search, status]);
|
||||
const filteredInputs = useMemo(() => sortPayrollInputRecords(inputLines.filter((record) => {
|
||||
const needle = search.trim().toLocaleLowerCase("ru-RU");
|
||||
return (!status || record.status === status) && (!needle || `${record.employee.display_name} ${record.component.name} ${record.reason}`.toLocaleLowerCase("ru-RU").includes(needle));
|
||||
}), [inputLines, search, status]);
|
||||
}), inputSort), [inputLines, inputSort, search, status]);
|
||||
|
||||
const openApproveRate = (record: PayrollAdminPayRate) => setPendingAction({
|
||||
title: "Утвердить ставку", description: `${record.employee.display_name}: ${formatPayrollMoney(record.amount, record.currency)}.`, confirmLabel: "Утвердить", warning: "После утверждения ставку нельзя редактировать — только создать новую версию.", successMessage: "Ставка утверждена.", execute: () => apiClient.approvePayrollAdminPayRate(record.id, record.lock_version),
|
||||
@ -1087,16 +1155,19 @@ export function PayrollAdminWorkspace({
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{tab === "rates" ? <section className="app-surface rounded-2xl p-4 sm:p-5"><SectionIntro title="Ставки" description="Оклад и цена балла сверх нормы действуют с указанной даты. Цена балла в норме рассчитывается по Excel: оклад плюс премия, разделённые на норму." />{sourceDataLocked ? <div className="app-feedback-warning mb-4 rounded-lg px-3 py-2.5 text-sm">Исходные данные заблокированы текущим этапом расчёта. Для исправления сначала верните расчёт.</div> : null}<TableToolbar search={search} status={status} onSearch={setSearch} onStatus={setStatus} onAdd={canManageSourceData ? () => { setEditingRate(null); setRateModalOpen(true); } : undefined} onBulkPayRate={canManageSourceData ? () => setBulkPayRateModalOpen(true) : undefined} onBulkPointRate={canManageSourceData && rates.some((record) => record.status !== "voided") ? () => setBulkPointRateModalOpen(true) : undefined} addLabel="Добавить ставку" /><PayrollRatesTable records={filteredRates} loading={tableLoading} error={tableError} currentUserId={user?.id} canManage={canManageSourceData} canApprove={canApproveSourceData} canOverrideApproval={permissions.override_approval} fullAccess={permissions.full_access} onEdit={(record) => { setEditingRate(record); setRateModalOpen(true); }} onApprove={openApproveRate} onRevise={openReviseRate} /></section> : null}
|
||||
{tab === "rates" ? <section className="app-surface rounded-2xl p-4 sm:p-5"><SectionIntro title="Ставки" description="Оклад и цена балла сверх нормы действуют с указанной даты. Цена балла в норме рассчитывается по Excel: оклад плюс премия, разделённые на норму." />{sourceDataLocked ? <div className="app-feedback-warning mb-4 rounded-lg px-3 py-2.5 text-sm">Исходные данные заблокированы текущим этапом расчёта. Для исправления сначала верните расчёт.</div> : null}<PayrollInputHistory events={inputHistory} loading={historyLoading} error={historyError} /><TableToolbar search={search} status={status} sort={inputSort} onSearch={setSearch} onStatus={setStatus} onSort={setInputSort} onAdd={canManageSourceData ? () => { setEditingRate(null); setRateModalOpen(true); } : undefined} onBulkPayRate={canManageSourceData ? () => setBulkPayRateModalOpen(true) : undefined} onBulkPointRate={canManageSourceData && rates.some((record) => record.status !== "voided") ? () => setBulkPointRateModalOpen(true) : undefined} addLabel="Добавить ставку" /><PayrollRatesTable records={filteredRates} loading={tableLoading} error={tableError} currentUserId={user?.id} canManage={canManageSourceData} canApprove={canApproveSourceData} canOverrideApproval={permissions.override_approval} fullAccess={permissions.full_access} onEdit={(record) => { setEditingRate(record); setRateModalOpen(true); }} onApprove={openApproveRate} onRevise={openReviseRate} /></section> : null}
|
||||
{tab === "work" ? (
|
||||
<section className="app-surface rounded-2xl p-4 sm:p-5">
|
||||
<SectionIntro title="Выработка" description="Норму и факт можно ввести вручную в баллах или рассчитать по посещаемости пропорционально дневной норме баллов." />
|
||||
{sourceDataLocked ? <div className="app-feedback-warning mb-4 rounded-lg px-3 py-2.5 text-sm">Исходные данные заблокированы текущим этапом расчёта. Для исправления сначала верните расчёт.</div> : null}
|
||||
<PayrollInputHistory events={inputHistory} loading={historyLoading} error={historyError} />
|
||||
<TableToolbar
|
||||
search={search}
|
||||
status={status}
|
||||
sort={inputSort}
|
||||
onSearch={setSearch}
|
||||
onStatus={setStatus}
|
||||
onSort={setInputSort}
|
||||
onFillFromAttendance={canManageSourceData && selectedPeriod.status !== "published" ? () => setAttendanceWorkModalOpen(true) : undefined}
|
||||
onImportWorkbook={canManageSourceData && selectedPeriod.status !== "published" ? () => setWorkbookImportModalOpen(true) : undefined}
|
||||
onAdd={canManageSourceData && selectedPeriod.status !== "published" ? () => { setEditingWork(null); setWorkModalOpen(true); } : undefined}
|
||||
@ -1105,7 +1176,7 @@ export function PayrollAdminWorkspace({
|
||||
<PayrollWorkRecordsTable records={filteredWork} loading={tableLoading} error={tableError} currentUserId={user?.id} canManage={canManageSourceData} canApprove={canApproveSourceData} canOverrideApproval={permissions.override_approval} fullAccess={permissions.full_access} onEdit={(record) => { setEditingWork(record); setWorkModalOpen(true); }} onApprove={openApproveWork} onRevise={openReviseWork} />
|
||||
</section>
|
||||
) : null}
|
||||
{tab === "inputs" ? <section className="app-surface rounded-2xl p-4 sm:p-5"><SectionIntro title="Начисления и выплаты" description="Премии, корректировки, удержания и авансы вводятся отдельными строками. Утверждённые строки исправляются новой корректировкой." />{sourceDataLocked ? <div className="app-feedback-warning mb-4 rounded-lg px-3 py-2.5 text-sm">Исходные данные заблокированы текущим этапом расчёта. Для исправления сначала верните расчёт.</div> : null}<TableToolbar search={search} status={status} onSearch={setSearch} onStatus={setStatus} onAdd={canManageSourceData ? () => { setEditingInput(null); setInputModalOpen(true); } : undefined} addLabel="Добавить операцию" /><PayrollInputLinesTable records={filteredInputs} loading={tableLoading} error={tableError} currentUserId={user?.id} canManage={canManageSourceData} canApprove={canApproveSourceData} canOverrideApproval={permissions.override_approval} fullAccess={permissions.full_access} onEdit={(record) => { setEditingInput(record); setInputModalOpen(true); }} onApprove={openApproveInput} /></section> : null}
|
||||
{tab === "inputs" ? <section className="app-surface rounded-2xl p-4 sm:p-5"><SectionIntro title="Начисления и выплаты" description="Премии, корректировки, удержания и авансы вводятся отдельными строками. Утверждённые строки исправляются новой корректировкой." />{sourceDataLocked ? <div className="app-feedback-warning mb-4 rounded-lg px-3 py-2.5 text-sm">Исходные данные заблокированы текущим этапом расчёта. Для исправления сначала верните расчёт.</div> : null}<PayrollInputHistory events={inputHistory} loading={historyLoading} error={historyError} /><TableToolbar search={search} status={status} sort={inputSort} onSearch={setSearch} onStatus={setStatus} onSort={setInputSort} onAdd={canManageSourceData ? () => { setEditingInput(null); setInputModalOpen(true); } : undefined} addLabel="Добавить операцию" /><PayrollInputLinesTable records={filteredInputs} loading={tableLoading} error={tableError} currentUserId={user?.id} canManage={canManageSourceData} canApprove={canApproveSourceData} canOverrideApproval={permissions.override_approval} fullAccess={permissions.full_access} onEdit={(record) => { setEditingInput(record); setInputModalOpen(true); }} onApprove={openApproveInput} /></section> : null}
|
||||
{tab === "summary" ? (
|
||||
<section className="app-surface min-w-0 rounded-2xl p-4 sm:p-5">
|
||||
<SectionIntro title="Итоговая таблица" description="Сводные исходные данные, начисления, удержания и выплаты по всем сотрудникам за выбранный период." />
|
||||
|
||||
@ -140,6 +140,19 @@ export type PayrollAdminEmployee = PayrollAdminUserRef & {
|
||||
department?: string | null;
|
||||
};
|
||||
|
||||
export type PayrollInputHistoryKind = "rates" | "work" | "inputs";
|
||||
|
||||
export type PayrollInputHistoryEvent = {
|
||||
id: number;
|
||||
record_id: number;
|
||||
action: string;
|
||||
action_label: string;
|
||||
subject: string;
|
||||
employee: PayrollAdminEmployee;
|
||||
actor: PayrollAdminUserRef | null;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type PayrollManagementAccessCandidate = {
|
||||
id: number;
|
||||
first_name: string;
|
||||
@ -936,6 +949,11 @@ export function createFinanceApi(request: RequestFn) {
|
||||
request(`${adminPrefix}/periods/${periodId}/`, jsonOptions("PATCH", payload)),
|
||||
getPayrollAdminPayRates: (params: PayrollAdminListParams = {}): Promise<PayrollAdminListPayload<PayrollAdminPayRate>> =>
|
||||
request(`${adminPrefix}/pay-rates/${buildQuery({ ...params, page_size: 200 })}`),
|
||||
getPayrollAdminInputHistory: (
|
||||
periodId: number,
|
||||
kind: PayrollInputHistoryKind,
|
||||
): Promise<PayrollInputHistoryEvent[]> =>
|
||||
request(`${adminPrefix}/history/${buildQuery({ period_id: periodId, kind })}`),
|
||||
createPayrollAdminPayRate: (payload: PayrollPayRateWrite): Promise<PayrollAdminPayRate> =>
|
||||
request(`${adminPrefix}/pay-rates/`, jsonOptions("POST", payload)),
|
||||
updatePayrollAdminPayRate: (
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user