Sync payroll review marks across devices
This commit is contained in:
parent
2277d58967
commit
aca40baded
@ -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",
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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"],)
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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(),
|
||||
|
||||
75
backend/finance/migrations/0031_payrolltablereviewmark.py
Normal file
75
backend/finance/migrations/0031_payrolltablereviewmark.py
Normal 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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@ -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."""
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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" ? (
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user