Merge develop into master
All checks were successful
Deploy production / deploy (push) Successful in 9m12s
All checks were successful
Deploy production / deploy (push) Successful in 9m12s
This commit is contained in:
commit
ed95c1e979
@ -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(),
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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="Тип",
|
||||
),
|
||||
),
|
||||
]
|
||||
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."""
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) => {
|
||||
|
||||
@ -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" ? (
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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