diff --git a/backend/api/v1/finance/admin/__init__.py b/backend/api/v1/finance/admin/__init__.py index f6714c7e..6487c7ce 100644 --- a/backend/api/v1/finance/admin/__init__.py +++ b/backend/api/v1/finance/admin/__init__.py @@ -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", diff --git a/backend/api/v1/finance/admin/period_views/__init__.py b/backend/api/v1/finance/admin/period_views/__init__.py index 5f2c1dd0..df04ec52 100644 --- a/backend/api/v1/finance/admin/period_views/__init__.py +++ b/backend/api/v1/finance/admin/period_views/__init__.py @@ -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", ] diff --git a/backend/api/v1/finance/admin/period_views/statements.py b/backend/api/v1/finance/admin/period_views/statements.py index 16abb67c..62748920 100644 --- a/backend/api/v1/finance/admin/period_views/statements.py +++ b/backend/api/v1/finance/admin/period_views/statements.py @@ -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"],) diff --git a/backend/api/v1/finance/payroll_serializers/__init__.py b/backend/api/v1/finance/payroll_serializers/__init__.py index 96819a01..ef543773 100644 --- a/backend/api/v1/finance/payroll_serializers/__init__.py +++ b/backend/api/v1/finance/payroll_serializers/__init__.py @@ -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", diff --git a/backend/api/v1/finance/payroll_serializers/commands.py b/backend/api/v1/finance/payroll_serializers/commands.py index f180905d..fc7a13a9 100644 --- a/backend/api/v1/finance/payroll_serializers/commands.py +++ b/backend/api/v1/finance/payroll_serializers/commands.py @@ -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( diff --git a/backend/api/v1/finance/payroll_serializers/presentation.py b/backend/api/v1/finance/payroll_serializers/presentation.py index 5421dca8..9545a276 100644 --- a/backend/api/v1/finance/payroll_serializers/presentation.py +++ b/backend/api/v1/finance/payroll_serializers/presentation.py @@ -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) diff --git a/backend/api/v1/finance/urls.py b/backend/api/v1/finance/urls.py index f4d505a9..1e81bfdd 100644 --- a/backend/api/v1/finance/urls.py +++ b/backend/api/v1/finance/urls.py @@ -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//table-review-marks/", + PayrollTableReviewMarksView.as_view(), + name="admin-period-table-review-marks", + ), path( "payroll/admin/periods//employees//payment-confirmation/", PayrollStatementPaymentConfirmationView.as_view(), diff --git a/backend/finance/migrations/0031_payrolltablereviewmark.py b/backend/finance/migrations/0031_payrolltablereviewmark.py new file mode 100644 index 00000000..e9099f14 --- /dev/null +++ b/backend/finance/migrations/0031_payrolltablereviewmark.py @@ -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", + ), + ), + ] diff --git a/backend/finance/models.py b/backend/finance/models.py index 8121f03a..2022469d 100644 --- a/backend/finance/models.py +++ b/backend/finance/models.py @@ -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.""" diff --git a/backend/tests/finance/test_payroll_admin_api.py b/backend/tests/finance/test_payroll_admin_api.py index 5eb9be45..d8b11660 100644 --- a/backend/tests/finance/test_payroll_admin_api.py +++ b/backend/tests/finance/test_payroll_admin_api.py @@ -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, diff --git a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx index 9af80f4b..9fce31c1 100644 --- a/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx +++ b/frontend/src/components/finance/admin/PayrollAdminWorkspace.tsx @@ -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 = (
{!embedded ? Финансы : null} @@ -1268,7 +1319,7 @@ export function PayrollAdminWorkspace({ {tab === "inputs" ?
{sourceDataLocked ?
Исходные данные заблокированы текущим этапом расчёта. Для исправления сначала верните расчёт.
: null} { setEditingInput(null); setInputModalOpen(true); } : undefined} onCopyInputs={canManageSourceData ? () => setInputCopyModalOpen(true) : undefined} addLabel="Добавить операцию" /> { setEditingInput(record); setInputModalOpen(true); }} onApprove={openApproveInput} />
: null} {tab === "summary" ? (
- 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} /> + 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} />
) : null} {tab === "approval" ? ( diff --git a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx index ba0acf7d..39044340 100644 --- a/frontend/src/components/finance/admin/PayrollPeriodTable.tsx +++ b/frontend/src/components/finance/admin/PayrollPeriodTable.tsx @@ -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) => { + 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; onOpenCarryovers?: (employee: PayrollPeriodTableRow["employee"]) => void; onSetPaymentConfirmed?: (row: PayrollPeriodTableRow, confirmed: boolean) => Promise; + onSetReviewMark?: (employeeId: number, checked: boolean) => Promise; + onClearReviewMarks?: () => Promise; compact?: boolean; }) { const searchedRows = useMemo(() => { @@ -1091,6 +1125,9 @@ export function PayrollPeriodTableView({ const [compactPanel, setCompactPanel] = useState("table"); const [paymentConfirmationBusyIds, setPaymentConfirmationBusyIds] = useState>(new Set()); const [paymentConfirmationError, setPaymentConfirmationError] = useState(null); + const [reviewMarkBusyIds, setReviewMarkBusyIds] = useState>(new Set()); + const [reviewMarkError, setReviewMarkError] = useState(null); + const [clearingReviewMarks, setClearingReviewMarks] = useState(false); const columnsMenuRef = useRef(null); const rowsMenuRef = useRef(null); const { @@ -1101,6 +1138,10 @@ export function PayrollPeriodTableView({ } = useMiddleMousePan(); 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()); + 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({
) : null} {paymentConfirmationError ?
{paymentConfirmationError}
: null} + {reviewMarkError ?
{reviewMarkError}
: null} {showHelp && data.summary.preliminary_count ? (
@@ -1787,12 +1868,25 @@ export function PayrollPeriodTableView({ > - + + {visibleColumns.map((column) => )} - + - + + {groupSegments.map((segment, index) => ( {rows.length ? rows.map((row) => ( - + - + {visibleColumns.map((column) => ( {renderFooterCell(column)} ))} diff --git a/frontend/src/lib/api/finance.ts b/frontend/src/lib/api/finance.ts index b98202e6..7197dfb8 100644 --- a/frontend/src/lib/api/finance.ts +++ b/frontend/src/lib/api/finance.ts @@ -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 => 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,
Сотрудник + + Сотрудник 0 ? "!border-l-2 !border-l-[var(--border-strong)]" : ""}`}> @@ -1825,7 +1919,18 @@ export function PayrollPeriodTableView({
+ + 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="Контрольная отметка" + /> +
Итого · {rows.length} + Итого · {rows.length}