Merge develop into master
All checks were successful
Deploy production / deploy (push) Successful in 9m12s

This commit is contained in:
lizerk 2026-09-04 19:21:49 +03:00
commit ed95c1e979
18 changed files with 491 additions and 119 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

@ -24,11 +24,11 @@ class VacationPayInstructionStatus(models.TextChoices):
class PayrollComponentKind(models.TextChoices):
EARNING = "earning", "Начисление"
ADJUSTMENT_CREDIT = "adjustment_credit", "Коррекция +"
ADJUSTMENT_DEBIT = "adjustment_debit", "Коррекция −"
DEDUCTION = "deduction", "Удержание"
PAYMENT = "payment", "Выплата"
EARNING = "earning", "Начислить сотруднику"
ADJUSTMENT_CREDIT = "adjustment_credit", "Положительная корректировка"
ADJUSTMENT_DEBIT = "adjustment_debit", "Отрицательная корректировка"
DEDUCTION = "deduction", "Удержать"
PAYMENT = "payment", "Уже выплачено"
class PayrollComponentPeriodScope(models.TextChoices):

View File

@ -0,0 +1,38 @@
from django.db import migrations, models
KIND_CHOICES = [
("earning", "Начислить сотруднику"),
("adjustment_credit", "Положительная корректировка"),
("adjustment_debit", "Отрицательная корректировка"),
("deduction", "Удержать"),
("payment", "Уже выплачено"),
]
class Migration(migrations.Migration):
dependencies = [
("finance", "0029_alter_payrollcustomcomponent_name"),
]
operations = [
migrations.AlterField(
model_name="payrollcustomcomponent",
name="kind",
field=models.CharField(
choices=KIND_CHOICES,
max_length=24,
verbose_name="Влияние на расчёт",
),
),
migrations.AlterField(
model_name="payrollstatementline",
name="kind",
field=models.CharField(
choices=KIND_CHOICES,
max_length=24,
verbose_name="Тип",
),
),
]

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

@ -18,7 +18,6 @@ from notifications.chat_suppression import suppress_for_active_chat
from notifications.push_actions import (
build_push_avatar_token,
build_push_read_token,
build_push_reply_token,
)
@ -154,7 +153,6 @@ class PushNotificationSender(BaseNotificationSender):
"message_id": message_id,
"chat_name": source_data.get("chat_name"),
"chat_type": source_data.get("chat_type"),
"can_reply": source_data.get("can_reply", True),
"actor_name": actor_name,
},
}
@ -171,40 +169,19 @@ class PushNotificationSender(BaseNotificationSender):
chat_id=int(chat_id),
message_id=int(message_id),
)
reply_token = build_push_reply_token(
notification_id=notification.id,
user_id=notification.recipient_id,
chat_id=int(chat_id),
message_id=int(message_id),
)
except (TypeError, ValueError):
read_token = ""
reply_token = ""
if read_token and reply_token:
actions = []
if show_preview and source_data.get("can_reply", True):
actions.append({
"action": "reply",
"type": "text",
"title": "Ответить",
"placeholder": "Введите ответ…",
})
actions.append({
if read_token:
message_data["actions"] = [{
"action": "mark-read",
"type": "button",
"title": "Прочитано",
})
message_data["actions"] = actions
}]
message_data["data"].update({
"mark_read_url": "/api/v1/notifications/push/action/read/",
"mark_read_token": read_token,
})
if show_preview and source_data.get("can_reply", True):
message_data["data"].update({
"reply_url": "/api/v1/notifications/push/action/reply/",
"reply_token": reply_token,
})
actor_icon = (
self._get_actor_avatar_url(notification)

View File

@ -99,21 +99,15 @@ class TestPushNotificationSenderPayload:
assert payload["data"]["actor_name"] == "Рубцова Олеся"
assert payload["data"]["mark_read_url"].endswith("/push/action/read/")
assert payload["data"]["mark_read_token"]
assert payload["data"]["reply_url"].endswith("/push/action/reply/")
assert payload["data"]["reply_token"]
assert payload["actions"] == [
{
"action": "reply",
"type": "text",
"title": "Ответить",
"placeholder": "Введите ответ…",
},
{
"action": "mark-read",
"type": "button",
"title": "Прочитано",
},
]
assert "reply_url" not in payload["data"]
assert "reply_token" not in payload["data"]
assert payload["tag"] == "chat-123"
assert "icon" not in payload
assert "chat_new_message" not in json.dumps(payload)

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

@ -6,7 +6,7 @@
* нативные уведомления даже когда страница закрыта.
*/
const SW_VERSION = '2.4.0';
const SW_VERSION = '2.4.1';
const SW_APP_KIND = self.CORP_SW_APP_KIND || 'main';
const STATIC_CACHE = `eusrr-static-${SW_VERSION}`;
const PRECACHE_URLS = [
@ -30,13 +30,6 @@ const DEFAULT_NOTIFICATION_OPTIONS = {
renotify: true,
};
function supportsNativeInlineReply() {
const userAgent = self.navigator?.userAgent || '';
const isChromium = /(?:Chrome|Chromium|Edg|OPR)\//.test(userAgent);
const isSupportedPlatform = /(?:Windows NT|Android|CrOS)/.test(userAgent);
return isChromium && isSupportedPlatform;
}
function buildNotificationData(payload = {}) {
const notificationData = {
title: payload.title || payload.head || 'Новое уведомление',
@ -58,7 +51,7 @@ function buildNotificationData(payload = {}) {
if (payload.image) notificationData.image = payload.image;
if (payload.actions) {
notificationData.actions = payload.actions.filter(
(action) => action.type !== 'text' || supportsNativeInlineReply(),
(action) => action.action !== 'reply',
);
}
return notificationData;
@ -139,48 +132,6 @@ async function markPushNotificationRead(notification) {
}));
}
async function sendPushQuickReply(notification, reply) {
const data = notification.data || {};
const normalizedReply = typeof reply === 'string' ? reply.trim() : '';
if (!normalizedReply) {
throw new Error('Native notification did not provide reply text');
}
if (!data.reply_url || !data.reply_token) {
throw new Error('Push notification has no signed reply action');
}
const response = await fetch(
new URL(data.reply_url, self.location.origin).toString(),
{
method: 'POST',
credentials: 'omit',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
token: data.reply_token,
reply: normalizedReply,
}),
},
);
if (!response.ok) {
throw new Error(`Push reply failed with HTTP ${response.status}`);
}
const result = await response.json();
const notifications = await self.registration.getNotifications({ includeTriggered: true });
notifications
.filter((item) => sameIdentity(item.data?.chat_id, data.chat_id))
.forEach((item) => item.close());
const windowClients = await clients.matchAll({ type: 'window', includeUncontrolled: true });
windowClients.forEach((client) => client.postMessage({
type: 'PUSH_CHAT_QUICK_REPLIED',
chatId: data.chat_id,
messageId: result.message_id,
replyToId: data.message_id,
notificationId: data.notification_id,
}));
}
/**
* Событие установки Service Worker
*/
@ -329,19 +280,6 @@ self.addEventListener('message', (event) => {
self.addEventListener('notificationclick', (event) => {
console.log('[SW] Notification clicked:', event.notification.tag);
if (event.action === 'reply') {
if (typeof event.reply !== 'string' || !event.reply.trim()) {
console.warn('[SW] Native inline reply is unavailable on this platform');
return;
}
event.waitUntil(
sendPushQuickReply(event.notification, event.reply).catch((error) => {
console.error('[SW] Failed to send push reply:', error);
}),
);
return;
}
if (event.action === 'mark-read') {
event.waitUntil(
markPushNotificationRead(event.notification).catch((error) => {

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

@ -48,11 +48,11 @@ const statusMeta: Record<PayrollPeriodTableStatus, { label: string; className: s
};
const kindLabels = {
earning: "Начисление",
adjustment_credit: "Корректировка +",
adjustment_debit: "Корректировка −",
deduction: "Удержание",
payment: "Выплата",
earning: "Начислить сотруднику",
adjustment_credit: "Положительная корректировка",
adjustment_debit: "Отрицательная корректировка",
deduction: "Удержать",
payment: "Уже выплачено",
};
const kindHelpItems: Array<{
@ -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;
@ -708,7 +738,7 @@ function calculationHelps(pointPolicy: PayrollPeriodTable["calculation_rules"]["
},
deductions: {
title: "Удержано",
logic: "Сумма всех действующих строк с типом «Удержание» за выбранный период.",
logic: "Сумма всех действующих строк с типом «Удержать» за выбранный период.",
formula: "Удержано = Σ удержаний",
manual: "Нет. Итог меняется через строки удержаний.",
},
@ -720,8 +750,8 @@ function calculationHelps(pointPolicy: PayrollPeriodTable["calculation_rules"]["
},
payments: {
title: "Выплачено",
logic: "Деньги, которые уже фактически выдали сотруднику в этом периоде. Сюда попадают «Аванс», «Фактическая выплата» и другие операции с типом «Выплата». Начисления, премии, корректировки и удержания сюда не входят.",
formula: "Выплачено = Аванс + Фактическая выплата + Σ прочих операций типа «Выплата»",
logic: "Деньги, которые уже фактически выдали сотруднику в этом периоде. Сюда попадают «Аванс», «Фактическая выплата» и другие операции с типом «Уже выплачено». Начисления, премии, корректировки и удержания сюда не входят.",
formula: "Выплачено = Аванс + Фактическая выплата + Σ прочих операций типа «Уже выплачено»",
manual: "Сам итог не редактируется. Введите сумму в ячейке «Аванс» или «Фактическая выплата» — она уменьшит остаток к выплате.",
},
payable: {
@ -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,