Merge branch 'develop'

This commit is contained in:
lizerk 2026-08-10 18:29:16 +03:00
commit 8d833f6410
7 changed files with 548 additions and 81 deletions

View File

@ -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)

View File

@ -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(),

View File

@ -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,

View File

@ -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;

View File

@ -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>

View File

@ -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="Сводные исходные данные, начисления, удержания и выплаты по всем сотрудникам за выбранный период." />

View File

@ -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: (