Sync payroll review marks across devices

This commit is contained in:
lizerk 2026-09-04 19:21:39 +03:00
parent 2277d58967
commit aca40baded
13 changed files with 433 additions and 8 deletions

View File

@ -39,6 +39,7 @@ from .periods import (
PayrollPeriodCarryoverListCreateView,
PayrollPeriodPointBreakdownView,
PayrollPeriodTableView,
PayrollTableReviewMarksView,
PayrollStatementPaymentConfirmationView,
)
from .workflow import (
@ -92,6 +93,7 @@ __all__ = [
"PayrollPeriodCompleteWorkflowView",
"PayrollPeriodPointBreakdownView",
"PayrollPeriodTableView",
"PayrollTableReviewMarksView",
"PayrollRunApproveView",
"PayrollRunDetailView",
"PayrollRunPublishView",

View File

@ -17,6 +17,7 @@ from .comments import PayrollCellCommentDetailView, PayrollCellCommentsView
from .statements import (
PayrollPeriodTableView,
PayrollStatementPaymentConfirmationView,
PayrollTableReviewMarksView,
)
__all__ = [
@ -32,5 +33,6 @@ __all__ = [
"PayrollPeriodCarryoverListCreateView",
"PayrollPeriodPointBreakdownView",
"PayrollPeriodTableView",
"PayrollTableReviewMarksView",
"PayrollStatementPaymentConfirmationView",
]

View File

@ -7,6 +7,7 @@ access to payroll data.
from __future__ import annotations
from django.contrib.auth import get_user_model
from django.db import transaction
from django.shortcuts import get_object_or_404
from rest_framework.response import Response
@ -18,6 +19,7 @@ from finance.models import (
PayrollPeriod,
PayrollStatement,
PayrollStatementPaymentConfirmation,
PayrollTableReviewMark,
)
from finance.payroll.reporting import (
build_payroll_period_table,
@ -27,6 +29,8 @@ from ...admin_serializers import (
PayrollPaymentConfirmationCommandSerializer,
PayrollPaymentConfirmationSerializer,
PayrollPeriodTableSerializer,
PayrollTableReviewMarkCommandSerializer,
PayrollTableReviewMarksSerializer,
PayrollUserRefSerializer,
)
from ..common import (
@ -53,9 +57,63 @@ class PayrollPeriodTableView(PayrollAdminAPIView):
payload = build_payroll_period_table(period)
payload["cell_comments"] = _cell_comment_summaries(period)
payload["carryovers"] = _automatic_balance_summaries(payload["rows"])
payload["review_mark_employee_ids"] = list(
PayrollTableReviewMark.objects.filter(
period=period,
reviewer=request.user,
).values_list("employee_id", flat=True)
)
return Response(payload)
class PayrollTableReviewMarksView(PayrollAdminAPIView):
serializer_class = PayrollTableReviewMarksSerializer
allowed_permissions = (PAYROLL_PERMISSIONS["view_all"],)
@staticmethod
def _payload(period, reviewer):
return {
"employee_ids": list(
PayrollTableReviewMark.objects.filter(
period=period,
reviewer=reviewer,
).values_list("employee_id", flat=True)
)
}
@transaction.atomic
def post(self, request, pk):
command = PayrollTableReviewMarkCommandSerializer(data=request.data)
command.is_valid(raise_exception=True)
period = get_object_or_404(PayrollPeriod, pk=pk)
employee = get_object_or_404(
get_user_model().objects.only("pk"),
pk=command.validated_data["employee_id"],
)
if command.validated_data["checked"]:
PayrollTableReviewMark.objects.get_or_create(
period=period,
reviewer=request.user,
employee=employee,
)
else:
PayrollTableReviewMark.objects.filter(
period=period,
reviewer=request.user,
employee=employee,
).delete()
return Response(self._payload(period, request.user))
@transaction.atomic
def delete(self, request, pk):
period = get_object_or_404(PayrollPeriod, pk=pk)
PayrollTableReviewMark.objects.filter(
period=period,
reviewer=request.user,
).delete()
return Response(self._payload(period, request.user))
class PayrollStatementPaymentConfirmationView(PayrollAdminAPIView):
serializer_class = PayrollPaymentConfirmationSerializer
allowed_permissions = (PAYROLL_PERMISSIONS["manage_inputs"],)

View File

@ -5,6 +5,7 @@ from .commands import (
ClearComponentCellCommandSerializer,
CopyPayrollInputLinesCommandSerializer,
PayrollPaymentConfirmationCommandSerializer,
PayrollTableReviewMarkCommandSerializer,
ReturnPayrollCommandSerializer,
SetComponentCellCommandSerializer,
WorkbookWorkImportSerializer,
@ -48,6 +49,7 @@ from .presentation import (
PayrollPaymentConfirmationSerializer,
PayrollPeriodDraftApprovalSerializer,
PayrollPeriodTableSerializer,
PayrollTableReviewMarksSerializer,
PayrollRunSerializer,
)
from .work_records import (
@ -87,6 +89,8 @@ __all__ = [
"PayrollOperationResultSerializer",
"PayrollPaymentConfirmationCommandSerializer",
"PayrollPaymentConfirmationSerializer",
"PayrollTableReviewMarkCommandSerializer",
"PayrollTableReviewMarksSerializer",
"PayrollPeriodDraftApprovalSerializer",
"PayrollPeriodSerializer",
"PayrollPeriodTableSerializer",

View File

@ -50,6 +50,11 @@ class PayrollPaymentConfirmationCommandSerializer(serializers.Serializer):
confirmed = serializers.BooleanField()
class PayrollTableReviewMarkCommandSerializer(serializers.Serializer):
employee_id = serializers.IntegerField(min_value=1)
checked = serializers.BooleanField()
class CopyPayrollInputLinesCommandSerializer(serializers.Serializer):
source_period_id = serializers.IntegerField(min_value=1)
component_codes = serializers.ListField(

View File

@ -123,10 +123,21 @@ class PayrollPeriodTableSerializer(serializers.Serializer):
child=serializers.JSONField(),
read_only=True,
)
review_mark_employee_ids = serializers.ListField(
child=serializers.IntegerField(),
read_only=True,
)
rows = serializers.ListField(child=serializers.JSONField(), read_only=True)
summary = serializers.JSONField(read_only=True)
class PayrollTableReviewMarksSerializer(serializers.Serializer):
employee_ids = serializers.ListField(
child=serializers.IntegerField(),
read_only=True,
)
class PayrollPaymentConfirmationSerializer(serializers.Serializer):
statement_id = serializers.IntegerField(read_only=True)
employee_id = serializers.IntegerField(read_only=True)

View File

@ -41,6 +41,7 @@ from .admin import (
PayrollPeriodPointBreakdownView,
PayrollStatementPaymentConfirmationView,
PayrollPeriodTableView,
PayrollTableReviewMarksView,
PayrollRunApproveView,
PayrollRunDetailView,
PayrollRunPublishView,
@ -107,6 +108,11 @@ urlpatterns = [
PayrollPeriodTableView.as_view(),
name="admin-period-table",
),
path(
"payroll/admin/periods/<int:pk>/table-review-marks/",
PayrollTableReviewMarksView.as_view(),
name="admin-period-table-review-marks",
),
path(
"payroll/admin/periods/<int:pk>/employees/<int:employee_id>/payment-confirmation/",
PayrollStatementPaymentConfirmationView.as_view(),

View File

@ -0,0 +1,75 @@
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
("finance", "0030_alter_payroll_component_kind_labels"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="PayrollTableReviewMark",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True, verbose_name="Отмечено")),
(
"employee",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="payroll_table_row_review_marks",
to=settings.AUTH_USER_MODEL,
verbose_name="Сотрудник",
),
),
(
"period",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="table_review_marks",
to="finance.payrollperiod",
verbose_name="Расчётный период",
),
),
(
"reviewer",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="payroll_table_review_marks",
to=settings.AUTH_USER_MODEL,
verbose_name="Проверяющий",
),
),
],
options={
"verbose_name": "Контрольная отметка расчётной таблицы",
"verbose_name_plural": "Контрольные отметки расчётной таблицы",
"ordering": ["employee_id"],
},
),
migrations.AddConstraint(
model_name="payrolltablereviewmark",
constraint=models.UniqueConstraint(
fields=("period", "reviewer", "employee"),
name="pay_table_review_mark_unique",
),
),
migrations.AddIndex(
model_name="payrolltablereviewmark",
index=models.Index(
fields=["reviewer", "period"],
name="pay_review_user_period_idx",
),
),
]

View File

@ -228,6 +228,50 @@ class PayrollPeriod(models.Model):
return super().save(*args, **kwargs)
class PayrollTableReviewMark(models.Model):
"""Personal visual review progress for a payroll period table row."""
period = models.ForeignKey(
PayrollPeriod,
on_delete=models.CASCADE,
related_name="table_review_marks",
verbose_name="Расчётный период",
)
reviewer = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="payroll_table_review_marks",
verbose_name="Проверяющий",
)
employee = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="payroll_table_row_review_marks",
verbose_name="Сотрудник",
)
created_at = models.DateTimeField("Отмечено", auto_now_add=True)
class Meta:
verbose_name = "Контрольная отметка расчётной таблицы"
verbose_name_plural = "Контрольные отметки расчётной таблицы"
ordering = ["employee_id"]
constraints = [
models.UniqueConstraint(
fields=["period", "reviewer", "employee"],
name="pay_table_review_mark_unique",
),
]
indexes = [
models.Index(
fields=["reviewer", "period"],
name="pay_review_user_period_idx",
),
]
def __str__(self):
return f"{self.reviewer_id}: {self.period_id}/{self.employee_id}"
class PayrollCellCommentThread(models.Model):
"""Stable address of one employee cell used by generic comments."""

View File

@ -28,6 +28,7 @@ from finance.models import (
PayrollInputLine,
PayrollPeriod,
PayrollRun,
PayrollTableReviewMark,
PayrollWorkRecord,
)
from requests_app.enums import RequestStatus, RequestType
@ -1156,6 +1157,49 @@ def test_period_table_cell_comments_use_shared_discussion_flow(
)
def test_period_table_review_marks_are_personal_and_idempotent(
user_factory,
auth_client_factory,
):
first_reviewer = user_factory(email="review.mark.first@example.test")
second_reviewer = user_factory(email="review.mark.second@example.test")
employee = user_factory(email="review.mark.employee@example.test")
grant(first_reviewer, "view_all_payroll")
grant(second_reviewer, "view_all_payroll")
period = make_period(creator=first_reviewer)
url = api_url("admin-period-table-review-marks", pk=period.pk)
first_client = auth_client_factory(first_reviewer)
for _ in range(2):
response = first_client.post(
url,
{"employee_id": employee.pk, "checked": True},
format="json",
)
assert response.status_code == 200, response.content
assert response.json() == {"employee_ids": [employee.pk]}
assert PayrollTableReviewMark.objects.filter(
period=period,
reviewer=first_reviewer,
employee=employee,
).count() == 1
assert first_client.get(api_url("admin-period-table", pk=period.pk)).json()[
"review_mark_employee_ids"
] == [employee.pk]
assert auth_client_factory(second_reviewer).get(
api_url("admin-period-table", pk=period.pk)
).json()["review_mark_employee_ids"] == []
cleared = first_client.delete(url)
assert cleared.status_code == 200, cleared.content
assert cleared.json() == {"employee_ids": []}
assert not PayrollTableReviewMark.objects.filter(
period=period,
reviewer=first_reviewer,
).exists()
def test_cell_comments_reject_columns_not_present_in_table(
user_factory,
auth_client_factory,

View File

@ -1028,6 +1028,57 @@ export function PayrollAdminWorkspace({
: `Отметка о выплате сотруднику ${row.employee.display_name} снята.`);
};
const setTableReviewMark = async (employeeId: number, checked: boolean) => {
if (!selectedPeriod) return;
const periodId = selectedPeriod.id;
setPeriodTable((current) => {
if (!current || current.period_id !== periodId) return current;
const employeeIds = new Set(current.review_mark_employee_ids);
if (checked) employeeIds.add(employeeId);
else employeeIds.delete(employeeId);
return { ...current, review_mark_employee_ids: [...employeeIds] };
});
try {
await apiClient.setPayrollAdminTableReviewMark(
periodId,
employeeId,
checked,
);
} catch (error) {
setPeriodTable((current) => {
if (!current || current.period_id !== periodId) return current;
const employeeIds = new Set(current.review_mark_employee_ids);
if (checked) employeeIds.delete(employeeId);
else employeeIds.add(employeeId);
return { ...current, review_mark_employee_ids: [...employeeIds] };
});
throw error;
}
};
const clearTableReviewMarks = async () => {
if (!selectedPeriod) return;
const periodId = selectedPeriod.id;
const previousEmployeeIds = periodTable?.period_id === periodId
? periodTable.review_mark_employee_ids
: [];
setPeriodTable((current) => {
if (!current || current.period_id !== periodId) return current;
return { ...current, review_mark_employee_ids: [] };
});
try {
const result = await apiClient.clearPayrollAdminTableReviewMarks(periodId);
setPeriodTable((current) => current?.period_id === periodId
? { ...current, review_mark_employee_ids: result.employee_ids }
: current);
} catch (error) {
setPeriodTable((current) => current?.period_id === periodId
? { ...current, review_mark_employee_ids: previousEmployeeIds }
: current);
throw error;
}
};
const managementInfo = (
<div className="min-w-0">
{!embedded ? <Link href="/finances" className="app-link-accent inline-flex items-center gap-1.5 text-xs font-medium"><ArrowLeft size={14} /> Финансы</Link> : null}
@ -1268,7 +1319,7 @@ export function PayrollAdminWorkspace({
{tab === "inputs" ? <section className="app-surface rounded-2xl p-4 sm:p-5"><SectionIntro title="Начисления и выплаты" description="Премии, корректировки, удержания и авансы вводятся отдельными строками. Утверждённые строки исправляются новой корректировкой." />{sourceDataLocked ? <div className="app-feedback-warning mb-4 rounded-lg px-3 py-2.5 text-sm">Исходные данные заблокированы текущим этапом расчёта. Для исправления сначала верните расчёт.</div> : null}<PayrollInputHistory events={inputHistory} loading={historyLoading} error={historyError} /><TableToolbar search={search} status={status} sort={inputSort} onSearch={setSearch} onStatus={setStatus} onSort={setInputSort} onAdd={canManageSourceData ? () => { setEditingInput(null); setInputModalOpen(true); } : undefined} onCopyInputs={canManageSourceData ? () => setInputCopyModalOpen(true) : undefined} addLabel="Добавить операцию" /><PayrollInputLinesTable records={filteredInputs} loading={tableLoading} error={tableError} currentUserId={user?.id} canManage={canManageSourceData} canApprove={canApproveSourceData} canOverrideApproval={permissions.override_approval} fullAccess={permissions.full_access} onEdit={(record) => { setEditingInput(record); setInputModalOpen(true); }} onApprove={openApproveInput} /></section> : null}
{tab === "summary" ? (
<section className="app-surface min-w-0 rounded-2xl p-4 sm:p-5">
<PayrollPeriodTableView compact data={periodTable} loading={tableLoading} error={tableError} search={search} onSearch={setSearch} storageUserId={user?.id} canEdit={canManageSourceData} onBulkEditRate={canManageSourceData ? () => setBulkPayRateModalOpen(true) : undefined} onBulkEditPointRate={canManageSourceData && rates.some((record) => record.status !== "voided") ? () => setBulkPointRateModalOpen(true) : undefined} onBulkEditTargetPoints={canManageSourceData ? () => setBulkTargetPointsModalOpen(true) : undefined} onSaveRate={saveRateCell} onSaveWork={saveWorkCell} onSaveComponent={saveComponentCell} onAddComponent={canManageSourceData ? addComponentColumn : undefined} onOpenCarryovers={(employee) => { setCarryoversEmployeeId(employee.id); setCarryoversModalOpen(true); }} onSetPaymentConfirmed={permissions.manage_inputs && selectedPeriod.status === "closed" ? setPaymentConfirmed : undefined} />
<PayrollPeriodTableView compact data={periodTable} loading={tableLoading} error={tableError} search={search} onSearch={setSearch} storageUserId={user?.id} canEdit={canManageSourceData} onBulkEditRate={canManageSourceData ? () => setBulkPayRateModalOpen(true) : undefined} onBulkEditPointRate={canManageSourceData && rates.some((record) => record.status !== "voided") ? () => setBulkPointRateModalOpen(true) : undefined} onBulkEditTargetPoints={canManageSourceData ? () => setBulkTargetPointsModalOpen(true) : undefined} onSaveRate={saveRateCell} onSaveWork={saveWorkCell} onSaveComponent={saveComponentCell} onAddComponent={canManageSourceData ? addComponentColumn : undefined} onOpenCarryovers={(employee) => { setCarryoversEmployeeId(employee.id); setCarryoversModalOpen(true); }} onSetPaymentConfirmed={permissions.manage_inputs && selectedPeriod.status === "closed" ? setPaymentConfirmed : undefined} onSetReviewMark={setTableReviewMark} onClearReviewMarks={clearTableReviewMarks} />
</section>
) : null}
{tab === "approval" ? (

View File

@ -101,6 +101,10 @@ const COLUMN_ORDER_STORAGE_PREFIX = "finances.payroll.period-table.column-order"
const COLUMN_ORDER_CHANGE_EVENT = "payroll-period-table-column-order-change";
const HIDDEN_ROWS_STORAGE_PREFIX = "finances.payroll.period-table.hidden-rows";
const HIDDEN_ROWS_CHANGE_EVENT = "payroll-period-table-hidden-rows-change";
const REVIEW_MARKS_STORAGE_PREFIX = "finances.payroll.period-table.review-marks";
const REVIEW_MARKS_CHANGE_EVENT = "payroll-period-table-review-marks-change";
const REVIEW_MARK_COLUMN_WIDTH = 36;
const EMPLOYEE_COLUMN_WIDTH = 150;
const columnIds = {
rateAmount: "rate_amount",
@ -293,6 +297,32 @@ function useHiddenPayrollRows(userId?: number | null) {
return [hiddenRows, setHiddenRows] as const;
}
function usePayrollReviewMarks(userId: number | null | undefined, periodId: number | null | undefined) {
const storageKey = `${REVIEW_MARKS_STORAGE_PREFIX}.${userId || "default"}.${periodId || "unknown"}`;
const subscribe = useCallback((notify: () => void) => {
const handleStorage = (event: StorageEvent) => {
if (event.key === storageKey) notify();
};
window.addEventListener("storage", handleStorage);
window.addEventListener(REVIEW_MARKS_CHANGE_EVENT, notify);
return () => {
window.removeEventListener("storage", handleStorage);
window.removeEventListener(REVIEW_MARKS_CHANGE_EVENT, notify);
};
}, [storageKey]);
const getSnapshot = useCallback(
() => window.localStorage.getItem(storageKey) || "[]",
[storageKey],
);
const serialized = useSyncExternalStore(subscribe, getSnapshot, () => "[]");
const reviewMarks = useMemo(() => parseHiddenColumns(serialized), [serialized]);
const setReviewMarks = useCallback((next: Set<string>) => {
window.localStorage.setItem(storageKey, JSON.stringify([...next].sort()));
window.dispatchEvent(new Event(REVIEW_MARKS_CHANGE_EVENT));
}, [storageKey]);
return [reviewMarks, setReviewMarks] as const;
}
type CalculationHelp = {
title: string;
logic: string;
@ -1049,6 +1079,8 @@ export function PayrollPeriodTableView({
onAddComponent,
onOpenCarryovers,
onSetPaymentConfirmed,
onSetReviewMark,
onClearReviewMarks,
compact = false,
}: {
data: PayrollPeriodTable | null;
@ -1067,6 +1099,8 @@ export function PayrollPeriodTableView({
onAddComponent?: (payload: PayrollCustomComponentWrite) => Promise<void>;
onOpenCarryovers?: (employee: PayrollPeriodTableRow["employee"]) => void;
onSetPaymentConfirmed?: (row: PayrollPeriodTableRow, confirmed: boolean) => Promise<void>;
onSetReviewMark?: (employeeId: number, checked: boolean) => Promise<void>;
onClearReviewMarks?: () => Promise<void>;
compact?: boolean;
}) {
const searchedRows = useMemo(() => {
@ -1091,6 +1125,9 @@ export function PayrollPeriodTableView({
const [compactPanel, setCompactPanel] = useState<CompactPeriodPanel | null>("table");
const [paymentConfirmationBusyIds, setPaymentConfirmationBusyIds] = useState<Set<number>>(new Set());
const [paymentConfirmationError, setPaymentConfirmationError] = useState<string | null>(null);
const [reviewMarkBusyIds, setReviewMarkBusyIds] = useState<Set<number>>(new Set());
const [reviewMarkError, setReviewMarkError] = useState<string | null>(null);
const [clearingReviewMarks, setClearingReviewMarks] = useState(false);
const columnsMenuRef = useRef<HTMLDivElement>(null);
const rowsMenuRef = useRef<HTMLDivElement>(null);
const {
@ -1101,6 +1138,10 @@ export function PayrollPeriodTableView({
} = useMiddleMousePan<HTMLDivElement>();
const [hiddenColumns, setHiddenColumns] = useHiddenPayrollColumns(storageUserId);
const [hiddenRows, setHiddenRows] = useHiddenPayrollRows(storageUserId);
const [cachedReviewMarks, setCachedReviewMarks] = usePayrollReviewMarks(storageUserId, data?.period_id);
const reviewMarks = data?.review_mark_employee_ids
? new Set(data.review_mark_employee_ids.map(String))
: cachedReviewMarks;
const [storedColumnOrder, setStoredColumnOrder] = usePayrollColumnOrder(storageUserId);
const hiddenCurrentRows = useMemo(
() => (data?.rows || []).filter((row) => hiddenRows.has(String(row.employee.id))),
@ -1160,7 +1201,7 @@ export function PayrollPeriodTableView({
const isVisible = (columnId: string) => !hiddenColumns.has(columnId);
const visibleColumns = orderedColumns.filter((column) => isVisible(column.id));
const visibleColumnIds = visibleColumns.map((column) => column.id);
const visibleColumnCount = 1 + visibleColumns.length;
const visibleColumnCount = 2 + visibleColumns.length;
const groupSegments = buildColumnGroupSegments(visibleColumns);
const hasCustomColumnOrder = columnOrder.some(
(columnId, index) => columnId !== defaultColumnOrder[index],
@ -1188,6 +1229,45 @@ export function PayrollPeriodTableView({
setHiddenRows(next);
if (hiddenCurrentRows.length === 1) setRowsMenuOpen(false);
};
const toggleReviewMark = async (employeeId: number) => {
if (reviewMarkBusyIds.has(employeeId)) return;
const key = String(employeeId);
const next = new Set(reviewMarks);
const checked = !next.has(key);
if (checked) next.add(key);
else next.delete(key);
setCachedReviewMarks(next);
if (!onSetReviewMark) return;
setReviewMarkError(null);
setReviewMarkBusyIds((current) => new Set(current).add(employeeId));
try {
await onSetReviewMark(employeeId, checked);
} catch (error) {
setCachedReviewMarks(reviewMarks);
setReviewMarkError(error instanceof Error ? error.message : "Не удалось сохранить контрольную отметку.");
} finally {
setReviewMarkBusyIds((current) => {
const nextBusy = new Set(current);
nextBusy.delete(employeeId);
return nextBusy;
});
}
};
const clearReviewMarks = async () => {
const previous = reviewMarks;
setCachedReviewMarks(new Set<string>());
if (!onClearReviewMarks) return;
setReviewMarkError(null);
setClearingReviewMarks(true);
try {
await onClearReviewMarks();
} catch (error) {
setCachedReviewMarks(previous);
setReviewMarkError(error instanceof Error ? error.message : "Не удалось очистить контрольные отметки.");
} finally {
setClearingReviewMarks(false);
}
};
const handleColumnDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) return;
const oldIndex = columnOrder.indexOf(String(active.id));
@ -1246,7 +1326,7 @@ export function PayrollPeriodTableView({
setCompactPanel(nextPanel);
};
const tableWidth = 150 + visibleColumns.reduce((width, column) => width + column.width, 0);
const tableWidth = REVIEW_MARK_COLUMN_WIDTH + EMPLOYEE_COLUMN_WIDTH + visibleColumns.reduce((width, column) => width + column.width, 0);
const helps = calculationHelps(data.calculation_rules.point_policy);
const total = (field: keyof PayrollPeriodTableRow) => sumRows(rows, (row) => {
const value = row[field];
@ -1753,6 +1833,7 @@ export function PayrollPeriodTableView({
</div>
) : null}
{paymentConfirmationError ? <div className="app-feedback-danger mb-3 rounded-lg px-3 py-2.5 text-sm">{paymentConfirmationError}</div> : null}
{reviewMarkError ? <div className="app-feedback-danger mb-3 rounded-lg px-3 py-2.5 text-sm">{reviewMarkError}</div> : null}
{showHelp && data.summary.preliminary_count ? (
<div className="app-feedback-warning mb-3 rounded-lg px-3 py-2.5 text-sm">
@ -1787,12 +1868,25 @@ export function PayrollPeriodTableView({
>
<table className="table-fixed border-separate border-spacing-0" style={{ width: `max(100%, ${tableWidth}px)` }} aria-label="Итоговая таблица расчётного периода">
<colgroup>
<col style={{ width: 150 }} />
<col style={{ width: REVIEW_MARK_COLUMN_WIDTH }} />
<col style={{ width: EMPLOYEE_COLUMN_WIDTH }} />
{visibleColumns.map((column) => <col key={column.id} style={{ width: column.width }} />)}
</colgroup>
<thead className="sticky top-0 z-30">
<thead className="sticky top-0 z-40">
<tr>
<th rowSpan={2} className={`${headerCellClass} sticky left-0 z-40 align-middle`}>Сотрудник</th>
<th rowSpan={2} className={`${headerCellClass} sticky left-0 z-50 !px-0 text-center align-middle`}>
<button
type="button"
className="app-action-ghost inline-flex h-7 w-7 items-center justify-center rounded-md disabled:cursor-default disabled:opacity-35"
onClick={() => void clearReviewMarks()}
disabled={reviewMarks.size === 0 || clearingReviewMarks}
aria-label="Очистить все контрольные отметки"
title="Очистить все"
>
<RotateCcw size={13} />
</button>
</th>
<th rowSpan={2} className={`${headerCellClass} sticky z-40 align-middle`} style={{ left: REVIEW_MARK_COLUMN_WIDTH }}>Сотрудник</th>
{groupSegments.map((segment, index) => (
<th key={`${segment.group}-${index}`} colSpan={segment.columns.length} className={`${headerCellClass} text-center uppercase tracking-wide ${index > 0 ? "!border-l-2 !border-l-[var(--border-strong)]" : ""}`}>
<span className="inline-flex items-center justify-center gap-1.5">
@ -1825,7 +1919,18 @@ export function PayrollPeriodTableView({
<tbody>
{rows.length ? rows.map((row) => (
<tr key={row.employee.id} className={row.payment_confirmation ? "bg-emerald-500/[0.08] hover:bg-emerald-500/[0.12]" : "bg-[var(--surface-primary)] hover:bg-[var(--surface-secondary)]"}>
<td className={`sticky left-0 z-20 overflow-hidden border-b border-r border-[var(--border-subtle)] px-2 py-2.5 ${row.payment_confirmation ? "bg-emerald-500/[0.12] backdrop-blur-xl" : "bg-[var(--surface-primary)]"}`}>
<td className={`sticky left-0 z-30 border-b border-r border-[var(--border-subtle)] px-0 text-center ${row.payment_confirmation ? "bg-emerald-500/[0.12] backdrop-blur-xl" : "bg-[var(--surface-primary)]"}`}>
<input
type="checkbox"
checked={reviewMarks.has(String(row.employee.id))}
onChange={() => void toggleReviewMark(row.employee.id)}
disabled={clearingReviewMarks || reviewMarkBusyIds.has(row.employee.id)}
className="h-4 w-4 cursor-pointer rounded border-[var(--border-strong)] accent-[var(--accent-primary)]"
aria-label={`Контрольная отметка: ${formatEmployeeName(row.employee)}`}
title="Контрольная отметка"
/>
</td>
<td className={`sticky z-20 overflow-hidden border-b border-r border-[var(--border-subtle)] px-2 py-2.5 ${row.payment_confirmation ? "bg-emerald-500/[0.12] backdrop-blur-xl" : "bg-[var(--surface-primary)]"}`} style={{ left: REVIEW_MARK_COLUMN_WIDTH }}>
<CellCommentButton
count={commentCountByCell.get(commentCellKey(row.employee.id, "employee")) || 0}
label={`Сотрудник: ${formatEmployeeName(row.employee)}`}
@ -1877,7 +1982,8 @@ export function PayrollPeriodTableView({
{rows.length ? (
<tfoot>
<tr className="app-surface-muted font-semibold">
<td className="sticky left-0 z-20 border-r border-[var(--border-subtle)] bg-[var(--surface-secondary)] px-3 py-3 text-xs text-[var(--foreground)]">Итого · {rows.length}</td>
<td className="sticky left-0 z-30 border-r border-[var(--border-subtle)] bg-[var(--surface-secondary)]" />
<td className="sticky z-20 border-r border-[var(--border-subtle)] bg-[var(--surface-secondary)] px-3 py-3 text-xs text-[var(--foreground)]" style={{ left: REVIEW_MARK_COLUMN_WIDTH }}>Итого · {rows.length}</td>
{visibleColumns.map((column) => (
<Fragment key={column.id}>{renderFooterCell(column)}</Fragment>
))}

View File

@ -476,6 +476,7 @@ export type PayrollPeriodTable = {
component_columns: PayrollPeriodTableComponent[];
cell_comments: PayrollCellCommentSummary[];
carryovers: PayrollPeriodTableCarryoverSummary[];
review_mark_employee_ids: number[];
rows: PayrollPeriodTableRow[];
summary: {
employee_count: number;
@ -1051,6 +1052,22 @@ export function createFinanceApi(request: RequestFn) {
request(`${adminPrefix}/periods/`),
getPayrollAdminPeriodTable: (periodId: number): Promise<PayrollPeriodTable> =>
request(`${adminPrefix}/periods/${periodId}/table/`),
setPayrollAdminTableReviewMark: (
periodId: number,
employeeId: number,
checked: boolean,
): Promise<{ employee_ids: number[] }> =>
request(
`${adminPrefix}/periods/${periodId}/table-review-marks/`,
jsonOptions("POST", { employee_id: employeeId, checked }),
),
clearPayrollAdminTableReviewMarks: (
periodId: number,
): Promise<{ employee_ids: number[] }> =>
request(
`${adminPrefix}/periods/${periodId}/table-review-marks/`,
{ method: "DELETE" },
),
setPayrollAdminPaymentConfirmation: (
periodId: number,
employeeId: number,