feat(finance): configure payroll management access
This commit is contained in:
parent
7a0270fb32
commit
47d48c45f4
@ -407,6 +407,8 @@ class EmployeeSerializer(serializers.ModelSerializer):
|
||||
u = req.user
|
||||
perms = sorted(u.get_all_permissions())
|
||||
|
||||
from finance.payroll.access import can_access_payroll_management
|
||||
|
||||
by_app = defaultdict(list)
|
||||
for p in perms:
|
||||
app, code = p.split(".", 1)
|
||||
@ -418,6 +420,7 @@ class EmployeeSerializer(serializers.ModelSerializer):
|
||||
"email": u.email or "",
|
||||
"is_staff": u.is_staff,
|
||||
"is_superuser": u.is_superuser,
|
||||
"can_manage_payroll": can_access_payroll_management(u),
|
||||
"groups": list(u.groups.values_list("name", flat=True)),
|
||||
"permissions": perms,
|
||||
"permissions_by_app": perms_by_app,
|
||||
|
||||
@ -32,6 +32,7 @@ from finance.enums import (
|
||||
)
|
||||
from finance.models import (
|
||||
EmployeePayRate,
|
||||
FinanceSettings,
|
||||
PayrollAuditEvent,
|
||||
PayrollCarryover,
|
||||
PayrollCellCommentThread,
|
||||
@ -53,7 +54,11 @@ from finance.payroll.carryovers import (
|
||||
create_carryover,
|
||||
materialize_pending_carryovers,
|
||||
)
|
||||
from finance.payroll.access import has_payroll_permission, has_simple_admin_access
|
||||
from finance.payroll.access import (
|
||||
can_access_payroll_management,
|
||||
has_payroll_permission,
|
||||
has_simple_admin_access,
|
||||
)
|
||||
from finance.payroll.exceptions import PayrollOperationError, PayrollPermissionDenied
|
||||
from finance.payroll.services import (
|
||||
approve_input_line,
|
||||
@ -354,6 +359,8 @@ class PayrollAdminAPIView(NoStorePayrollResponseMixin, APIView):
|
||||
|
||||
def initial(self, request, *args, **kwargs):
|
||||
super().initial(request, *args, **kwargs)
|
||||
if not can_access_payroll_management(request.user):
|
||||
_permission_error("finance.payroll_management_access")
|
||||
if not any(
|
||||
has_payroll_permission(request.user, code)
|
||||
for code in self.allowed_permissions
|
||||
@ -400,6 +407,7 @@ class PayrollAdminWorkspaceView(PayrollAdminAPIView):
|
||||
"""One bounded bootstrap response for the native payroll screen."""
|
||||
|
||||
def get(self, request):
|
||||
finance_settings = FinanceSettings.get_solo()
|
||||
permissions_payload = {
|
||||
key: has_payroll_permission(request.user, permission)
|
||||
for key, permission in PAYROLL_PERMISSIONS.items()
|
||||
@ -671,6 +679,14 @@ class PayrollAdminWorkspaceView(PayrollAdminAPIView):
|
||||
return Response(
|
||||
{
|
||||
"permissions": permissions_payload,
|
||||
"access_settings": {
|
||||
"restricted": finance_settings.management_access_is_restricted(),
|
||||
"management_user_count": finance_settings.management_users.count(),
|
||||
"can_configure": (
|
||||
request.user.is_superuser
|
||||
or request.user.has_perm("finance.change_financesettings")
|
||||
),
|
||||
},
|
||||
"employees": PayrollEmployeeMiniSerializer(employees, many=True).data,
|
||||
"rate_employees": PayrollEmployeeMiniSerializer(
|
||||
rate_employees,
|
||||
@ -705,6 +721,77 @@ class PayrollAdminWorkspaceView(PayrollAdminAPIView):
|
||||
)
|
||||
|
||||
|
||||
class PayrollManagementAccessSettingsView(NoStorePayrollResponseMixin, APIView):
|
||||
"""Read and update the allow-list shown in the native finance workspace."""
|
||||
|
||||
permission_classes = [permissions.IsAuthenticated]
|
||||
|
||||
def initial(self, request, *args, **kwargs):
|
||||
super().initial(request, *args, **kwargs)
|
||||
if not (
|
||||
request.user.is_superuser
|
||||
or request.user.has_perm("finance.change_financesettings")
|
||||
):
|
||||
_permission_error("finance.change_financesettings")
|
||||
|
||||
def _payload(self):
|
||||
from ..employees.serializers import EmployeeBriefSerializer
|
||||
|
||||
finance_settings = FinanceSettings.get_solo()
|
||||
selected_ids = list(
|
||||
finance_settings.management_users.values_list("id", flat=True)
|
||||
)
|
||||
candidates = Employee.objects.filter(
|
||||
Q(is_active=True) & (Q(is_staff=True) | Q(is_superuser=True))
|
||||
| Q(id__in=selected_ids)
|
||||
).order_by("last_name", "first_name", "id")
|
||||
return {
|
||||
"restricted": bool(selected_ids),
|
||||
"management_user_ids": selected_ids,
|
||||
"management_user_count": len(selected_ids),
|
||||
"candidates": EmployeeBriefSerializer(candidates, many=True).data,
|
||||
}
|
||||
|
||||
def get(self, request):
|
||||
return Response(self._payload())
|
||||
|
||||
def patch(self, request):
|
||||
raw_ids = request.data.get("management_user_ids")
|
||||
if not isinstance(raw_ids, list):
|
||||
raise ValidationError(
|
||||
{"management_user_ids": "Передайте список администраторов."}
|
||||
)
|
||||
try:
|
||||
requested_ids = {int(user_id) for user_id in raw_ids}
|
||||
except (TypeError, ValueError):
|
||||
raise ValidationError(
|
||||
{"management_user_ids": "Идентификаторы должны быть числами."}
|
||||
)
|
||||
if any(user_id <= 0 for user_id in requested_ids):
|
||||
raise ValidationError(
|
||||
{"management_user_ids": "Некорректный идентификатор пользователя."}
|
||||
)
|
||||
|
||||
eligible_users = Employee.objects.filter(
|
||||
id__in=requested_ids,
|
||||
is_active=True,
|
||||
).filter(Q(is_staff=True) | Q(is_superuser=True))
|
||||
eligible_ids = set(eligible_users.values_list("id", flat=True))
|
||||
invalid_ids = requested_ids - eligible_ids
|
||||
if invalid_ids:
|
||||
raise ValidationError(
|
||||
{
|
||||
"management_user_ids": (
|
||||
"Выбирать можно только активных администраторов."
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
finance_settings = FinanceSettings.get_solo()
|
||||
finance_settings.management_users.set(eligible_users)
|
||||
return Response(self._payload())
|
||||
|
||||
|
||||
class PayrollAdminEmployeeListView(PayrollAdminAPIView):
|
||||
def get(self, request):
|
||||
employees = (
|
||||
|
||||
@ -23,6 +23,7 @@ from .admin_views import (
|
||||
PayrollAdminPeriodDetailView,
|
||||
PayrollAdminPeriodListCreateView,
|
||||
PayrollAdminWorkspaceView,
|
||||
PayrollManagementAccessSettingsView,
|
||||
PayrollAttendanceWorkRecordsView,
|
||||
PayrollInputLineApproveView,
|
||||
PayrollInputLineDetailView,
|
||||
@ -50,6 +51,11 @@ from .admin_views import (
|
||||
app_name = "finance-payroll"
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"payroll/management-access/",
|
||||
PayrollManagementAccessSettingsView.as_view(),
|
||||
name="management-access-settings",
|
||||
),
|
||||
path(
|
||||
"payroll/admin/workspace/",
|
||||
PayrollAdminWorkspaceView.as_view(),
|
||||
|
||||
@ -10,6 +10,7 @@ from django.template.response import TemplateResponse
|
||||
from .enums import ApprovalStatus
|
||||
from .models import (
|
||||
EmployeePayRate,
|
||||
FinanceSettings,
|
||||
PayrollAuditEvent,
|
||||
PayrollComponent,
|
||||
PayrollDailyWorkEntry,
|
||||
@ -36,6 +37,29 @@ from .payroll.services import (
|
||||
)
|
||||
|
||||
|
||||
@admin.register(FinanceSettings)
|
||||
class FinanceSettingsAdmin(admin.ModelAdmin):
|
||||
"""Singleton allow-list for the native finance management workspace."""
|
||||
|
||||
list_display = ("name", "management_users_count", "updated_at")
|
||||
fields = ("name", "management_users", "updated_at")
|
||||
readonly_fields = ("updated_at",)
|
||||
autocomplete_fields = ("management_users",)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
if FinanceSettings.objects.exists():
|
||||
return False
|
||||
return super().has_add_permission(request)
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return request.user.is_superuser
|
||||
|
||||
@admin.display(description="Доступ к управлению")
|
||||
def management_users_count(self, obj):
|
||||
count = obj.management_users.count()
|
||||
return count or "Все администраторы"
|
||||
|
||||
|
||||
def payroll_reason_action_response(
|
||||
model_admin,
|
||||
request,
|
||||
|
||||
55
backend/finance/migrations/0018_financesettings.py
Normal file
55
backend/finance/migrations/0018_financesettings.py
Normal file
@ -0,0 +1,55 @@
|
||||
# Generated by Django 5.2.4 on 2026-08-10
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("finance", "0017_payrollcarryover"),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="FinanceSettings",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"name",
|
||||
models.CharField(
|
||||
default="Основные настройки",
|
||||
max_length=100,
|
||||
verbose_name="Название",
|
||||
),
|
||||
),
|
||||
("updated_at", models.DateTimeField(auto_now=True, verbose_name="Обновлено")),
|
||||
(
|
||||
"management_users",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text=(
|
||||
"Если список пуст, блок доступен всем администраторам и сотрудникам "
|
||||
"с финансовыми правами. Заполните список, чтобы ограничить доступ."
|
||||
),
|
||||
related_name="finance_management_settings",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Доступ к блоку «Управление»",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Настройки финансов",
|
||||
"verbose_name_plural": "Настройки финансов",
|
||||
},
|
||||
),
|
||||
]
|
||||
@ -41,6 +41,49 @@ payroll_cell_column_validator = RegexValidator(
|
||||
DEFAULT_DAILY_TARGET_POINTS = Decimal("5")
|
||||
|
||||
|
||||
class FinanceSettings(models.Model):
|
||||
"""Global access settings for the native finance management workspace."""
|
||||
|
||||
name = models.CharField(
|
||||
"Название",
|
||||
max_length=100,
|
||||
default="Основные настройки",
|
||||
)
|
||||
management_users = models.ManyToManyField(
|
||||
settings.AUTH_USER_MODEL,
|
||||
blank=True,
|
||||
related_name="finance_management_settings",
|
||||
verbose_name="Доступ к блоку «Управление»",
|
||||
help_text=(
|
||||
"Если список пуст, блок доступен всем администраторам и сотрудникам "
|
||||
"с финансовыми правами. Заполните список, чтобы ограничить доступ."
|
||||
),
|
||||
)
|
||||
updated_at = models.DateTimeField("Обновлено", auto_now=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Настройки финансов"
|
||||
verbose_name_plural = "Настройки финансов"
|
||||
|
||||
def __str__(self):
|
||||
return self.name
|
||||
|
||||
@classmethod
|
||||
def get_solo(cls):
|
||||
instance = cls.objects.first()
|
||||
return instance if instance else cls.objects.create()
|
||||
|
||||
def management_access_is_restricted(self) -> bool:
|
||||
return self.management_users.exists()
|
||||
|
||||
def allows_management_user(self, user) -> bool:
|
||||
if user is None or not getattr(user, "is_authenticated", False):
|
||||
return False
|
||||
if not self.management_access_is_restricted():
|
||||
return True
|
||||
return self.management_users.filter(pk=user.pk, is_active=True).exists()
|
||||
|
||||
|
||||
class PayrollPeriod(models.Model):
|
||||
"""A calculation and publication period controlled by one active run."""
|
||||
|
||||
|
||||
@ -10,6 +10,14 @@ from __future__ import annotations
|
||||
from .config import simple_admin_access_enabled
|
||||
|
||||
SELF_APPROVAL_OVERRIDE_PERMISSION = "finance.override_payroll_approval"
|
||||
PAYROLL_MANAGEMENT_PERMISSIONS = (
|
||||
"finance.manage_payroll_inputs",
|
||||
"finance.approve_payroll_inputs",
|
||||
"finance.calculate_payroll",
|
||||
"finance.approve_payroll",
|
||||
"finance.publish_payroll",
|
||||
"finance.view_all_payroll",
|
||||
)
|
||||
|
||||
|
||||
def has_simple_admin_access(user) -> bool:
|
||||
@ -34,6 +42,24 @@ def has_payroll_permission(user, permission: str) -> bool:
|
||||
return user.has_perm(permission)
|
||||
|
||||
|
||||
def can_access_payroll_management(user) -> bool:
|
||||
"""Check the global allow-list and the user's underlying payroll access."""
|
||||
|
||||
if user is None or not getattr(user, "is_authenticated", False):
|
||||
return False
|
||||
if not getattr(user, "is_active", False):
|
||||
return False
|
||||
|
||||
# Imported lazily so model imports never depend on the payroll adapter.
|
||||
from finance.models import FinanceSettings
|
||||
|
||||
if not FinanceSettings.get_solo().allows_management_user(user):
|
||||
return False
|
||||
return has_simple_admin_access(user) or any(
|
||||
user.has_perm(permission) for permission in PAYROLL_MANAGEMENT_PERMISSIONS
|
||||
)
|
||||
|
||||
|
||||
def can_self_approve_payroll(user) -> bool:
|
||||
"""Allow admins to approve directly while retaining the old override role."""
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ from finance.admin import (
|
||||
)
|
||||
from finance.models import (
|
||||
EmployeePayRate,
|
||||
FinanceSettings,
|
||||
PayrollAuditEvent,
|
||||
PayrollDailyWorkEntry,
|
||||
PayrollPeriod,
|
||||
@ -208,6 +209,109 @@ def test_simple_mode_staff_can_open_and_edit_any_payroll_draft(
|
||||
assert "amount" not in model_admin.get_readonly_fields(request, rate)
|
||||
|
||||
|
||||
def test_finance_management_allow_list_restricts_native_admin_api(
|
||||
settings,
|
||||
user_factory,
|
||||
api_client,
|
||||
):
|
||||
settings.FINANCE_PAYROLL = {"SIMPLE_ADMIN_ACCESS": True}
|
||||
allowed_admin = user_factory(email="allowed.finance@example.test", staff=True)
|
||||
denied_admin = user_factory(email="denied.finance@example.test", staff=True)
|
||||
FinanceSettings.get_solo().management_users.set([allowed_admin])
|
||||
|
||||
workspace_url = reverse("api:v1:finance-payroll:admin-workspace")
|
||||
|
||||
api_client.force_authenticate(user=denied_admin)
|
||||
denied_response = api_client.get(workspace_url)
|
||||
assert denied_response.status_code == 403
|
||||
|
||||
api_client.force_authenticate(user=allowed_admin)
|
||||
allowed_response = api_client.get(workspace_url)
|
||||
assert allowed_response.status_code == 200
|
||||
assert allowed_response.data["access_settings"] == {
|
||||
"restricted": True,
|
||||
"management_user_count": 1,
|
||||
"can_configure": False,
|
||||
}
|
||||
|
||||
|
||||
def test_empty_finance_management_allow_list_keeps_all_admins_enabled(
|
||||
settings,
|
||||
user_factory,
|
||||
):
|
||||
from finance.payroll.access import can_access_payroll_management
|
||||
|
||||
settings.FINANCE_PAYROLL = {"SIMPLE_ADMIN_ACCESS": True}
|
||||
administrator = user_factory(email="all.finance@example.test", staff=True)
|
||||
|
||||
assert FinanceSettings.get_solo().management_users.count() == 0
|
||||
assert can_access_payroll_management(administrator) is True
|
||||
|
||||
|
||||
def test_superuser_can_update_finance_management_allow_list_from_api(
|
||||
settings,
|
||||
user_factory,
|
||||
api_client,
|
||||
):
|
||||
settings.FINANCE_PAYROLL = {"SIMPLE_ADMIN_ACCESS": True}
|
||||
superuser = user_factory(
|
||||
email="settings.finance@example.test",
|
||||
staff=True,
|
||||
superuser=True,
|
||||
)
|
||||
selected_admin = user_factory(
|
||||
email="selected.finance@example.test",
|
||||
staff=True,
|
||||
)
|
||||
regular_employee = user_factory(email="regular.finance@example.test")
|
||||
settings_url = reverse("api:v1:finance-payroll:management-access-settings")
|
||||
api_client.force_authenticate(user=superuser)
|
||||
|
||||
response = api_client.patch(
|
||||
settings_url,
|
||||
{"management_user_ids": [selected_admin.id]},
|
||||
format="json",
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data["restricted"] is True
|
||||
assert response.data["management_user_ids"] == [selected_admin.id]
|
||||
assert FinanceSettings.get_solo().management_users.filter(
|
||||
id=selected_admin.id
|
||||
).exists()
|
||||
|
||||
invalid_response = api_client.patch(
|
||||
settings_url,
|
||||
{"management_user_ids": [regular_employee.id]},
|
||||
format="json",
|
||||
)
|
||||
assert invalid_response.status_code == 400
|
||||
|
||||
unrestricted_response = api_client.patch(
|
||||
settings_url,
|
||||
{"management_user_ids": []},
|
||||
format="json",
|
||||
)
|
||||
assert unrestricted_response.status_code == 200
|
||||
assert unrestricted_response.data["restricted"] is False
|
||||
|
||||
|
||||
def test_regular_payroll_admin_cannot_change_management_allow_list(
|
||||
settings,
|
||||
user_factory,
|
||||
api_client,
|
||||
):
|
||||
settings.FINANCE_PAYROLL = {"SIMPLE_ADMIN_ACCESS": True}
|
||||
administrator = user_factory(email="readonly.finance@example.test", staff=True)
|
||||
api_client.force_authenticate(user=administrator)
|
||||
|
||||
response = api_client.get(
|
||||
reverse("api:v1:finance-payroll:management-access-settings")
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_standard_model_permission_does_not_expose_all_statements(user_factory):
|
||||
staff = user_factory(email="generic.admin@example.test", staff=True)
|
||||
grant(staff, "view_payrollstatement")
|
||||
|
||||
@ -166,11 +166,15 @@ export default function FinancesPage() {
|
||||
Управление
|
||||
</button>
|
||||
) : null}
|
||||
{activeTab === "management" && showPayrollAdmin ? (
|
||||
<div id="finance-management-access" className="ml-auto" />
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{activeTab === "management" && showPayrollAdmin ? (
|
||||
<PayrollAdminWorkspace
|
||||
accessTargetId="finance-management-access"
|
||||
embedded
|
||||
actionsTargetId="finance-management-actions"
|
||||
desktopWideMode={desktopWideMode}
|
||||
|
||||
@ -39,6 +39,7 @@ import {
|
||||
} from "@/components/finance/admin/PayrollAdminModals";
|
||||
import { PayrollAttendanceWorkModal } from "@/components/finance/admin/PayrollAttendanceWorkModal";
|
||||
import { PayrollCarryoversModal } from "@/components/finance/admin/PayrollCarryoversModal";
|
||||
import { PayrollManagementAccessModal } from "@/components/finance/admin/PayrollManagementAccessModal";
|
||||
import { PayrollWorkbookImportModal } from "@/components/finance/admin/PayrollWorkbookImportModal";
|
||||
import {
|
||||
PayrollApprovalQueue,
|
||||
@ -378,6 +379,7 @@ function RunCard({ run, current = false }: { run: PayrollAdminRun; current?: boo
|
||||
}
|
||||
|
||||
type PayrollAdminWorkspaceProps = {
|
||||
accessTargetId?: string;
|
||||
actionsTargetId?: string;
|
||||
desktopWideMode?: boolean;
|
||||
embedded?: boolean;
|
||||
@ -386,13 +388,14 @@ type PayrollAdminWorkspaceProps = {
|
||||
};
|
||||
|
||||
export function PayrollAdminWorkspace({
|
||||
accessTargetId,
|
||||
actionsTargetId,
|
||||
desktopWideMode = false,
|
||||
embedded = false,
|
||||
headerTargetId,
|
||||
onDesktopWideModeChange,
|
||||
}: PayrollAdminWorkspaceProps) {
|
||||
const { user } = useUser();
|
||||
const { user, refreshUser } = useUser();
|
||||
const [workspace, setWorkspace] = useState<WorkspacePayload | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [loadError, setLoadError] = useState<string | null>(null);
|
||||
@ -424,6 +427,7 @@ export function PayrollAdminWorkspace({
|
||||
const [inputModalOpen, setInputModalOpen] = useState(false);
|
||||
const [editingInput, setEditingInput] = useState<PayrollAdminInputLine | null>(null);
|
||||
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
|
||||
const [accessModalOpen, setAccessModalOpen] = useState(false);
|
||||
const createdPeriodIdRef = useRef<number | null>(null);
|
||||
|
||||
const selectedPeriod = workspace?.selected_period || null;
|
||||
@ -925,12 +929,37 @@ export function PayrollAdminWorkspace({
|
||||
{permissions.manage_inputs ? <button type="button" className="app-action-primary inline-flex items-center justify-center gap-2 rounded-lg px-3 py-2.5 text-sm font-semibold" onClick={() => { setEditingPeriod(null); setPeriodModalOpen(true); }}><Plus size={16} /> Новый период</button> : null}
|
||||
</div>
|
||||
);
|
||||
const accessLabel = workspace.access_settings.restricted
|
||||
? `Доступ: выбрано ${workspace.access_settings.management_user_count}`
|
||||
: "Доступ: все администраторы";
|
||||
const managementAccess = workspace.access_settings.can_configure ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setAccessModalOpen(true)}
|
||||
className="app-action-ghost inline-flex h-8 items-center gap-1.5 rounded-lg px-2.5 text-xs font-medium"
|
||||
title="Настроить доступ к управлению финансами"
|
||||
>
|
||||
<ShieldCheck size={14} />
|
||||
{accessLabel}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="app-text-muted inline-flex h-8 items-center gap-1.5 px-2.5 text-xs"
|
||||
title="Доступ к управлению финансами"
|
||||
>
|
||||
<ShieldCheck size={14} />
|
||||
{accessLabel}
|
||||
</span>
|
||||
);
|
||||
const headerTarget = headerTargetId && typeof document !== "undefined"
|
||||
? document.getElementById(headerTargetId)
|
||||
: null;
|
||||
const actionsTarget = actionsTargetId && typeof document !== "undefined"
|
||||
? document.getElementById(actionsTargetId)
|
||||
: null;
|
||||
const accessTarget = accessTargetId && typeof document !== "undefined"
|
||||
? document.getElementById(accessTargetId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4 lg:min-h-0 lg:flex-1 lg:overflow-y-auto lg:pr-1">
|
||||
@ -938,10 +967,12 @@ export function PayrollAdminWorkspace({
|
||||
<>
|
||||
{headerTarget ? createPortal(managementInfo, headerTarget) : null}
|
||||
{actionsTarget ? createPortal(managementActions, actionsTarget) : null}
|
||||
{accessTarget ? createPortal(managementAccess, accessTarget) : null}
|
||||
</>
|
||||
) : (
|
||||
<section className="app-surface rounded-2xl p-4 sm:p-5">
|
||||
<div className="flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">{managementInfo}{managementActions}</div>
|
||||
<div className="mt-3 flex justify-end">{managementAccess}</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
@ -1148,6 +1179,24 @@ export function PayrollAdminWorkspace({
|
||||
)}
|
||||
|
||||
{periodModalOpen ? <PayrollPeriodFormModal isOpen period={editingPeriod} periods={workspace.periods} onClose={() => setPeriodModalOpen(false)} onSubmit={savePeriod} onSaved={async () => { setPeriodModalOpen(false); const createdPeriodId = createdPeriodIdRef.current; createdPeriodIdRef.current = null; if (createdPeriodId) { setNotice("Период создан и выбран."); await loadWorkspace(createdPeriodId, true); setTableVersion((value) => value + 1); } else { await refreshView("Параметры периода сохранены."); } }} onStale={handleStale} /> : null}
|
||||
{workspace.access_settings.can_configure ? (
|
||||
<PayrollManagementAccessModal
|
||||
isOpen={accessModalOpen}
|
||||
onClose={() => setAccessModalOpen(false)}
|
||||
onSaved={async (accessSettings) => {
|
||||
setWorkspace((current) => current ? {
|
||||
...current,
|
||||
access_settings: {
|
||||
...current.access_settings,
|
||||
restricted: accessSettings.restricted,
|
||||
management_user_count: accessSettings.management_user_count,
|
||||
},
|
||||
} : current);
|
||||
setNotice("Настройки доступа сохранены.");
|
||||
await refreshUser();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{selectedPeriod && rateModalOpen ? <PayrollRateFormModal isOpen period={selectedPeriod} employees={workspace.rate_employees} rate={editingRate} onClose={() => setRateModalOpen(false)} onSubmit={saveRate} onSaved={async () => { setRateModalOpen(false); await refreshView("Черновик ставки сохранён."); }} onStale={handleStale} /> : null}
|
||||
{selectedPeriod && bulkPayRateModalOpen ? <PayrollBulkPayRateModal isOpen period={selectedPeriod} employees={workspace.rate_employees} rates={rates} onClose={() => setBulkPayRateModalOpen(false)} onSubmit={(payload) => apiClient.bulkCreatePayrollAdminPayRates(selectedPeriod.id, payload)} onSaved={async (result: PayrollBulkPayRateResult) => { setBulkPayRateModalOpen(false); const changed = result.summary.created_drafts + result.summary.updated_drafts + result.summary.created_revisions; const details = [result.summary.unchanged ? `без изменений: ${result.summary.unchanged}` : "", result.summary.skipped ? `пропущено: ${result.summary.skipped}` : ""].filter(Boolean).join(", "); await refreshView(`Ставка сохранена для ${changed} сотрудников${details ? ` (${details})` : ""}.`); }} onStale={handleStale} /> : null}
|
||||
{selectedPeriod && bulkPointRateModalOpen ? <PayrollBulkPointRateModal isOpen period={selectedPeriod} rates={rates} onClose={() => setBulkPointRateModalOpen(false)} onSubmit={(payload) => apiClient.bulkSetPayrollAdminPointRate(selectedPeriod.id, payload)} onSaved={async (result: PayrollBulkPointRateResult) => { setBulkPointRateModalOpen(false); const changed = result.summary.updated_drafts + result.summary.created_revisions; const details = [result.summary.unchanged ? `без изменений: ${result.summary.unchanged}` : "", result.summary.skipped ? `пропущено: ${result.summary.skipped}` : ""].filter(Boolean).join(", "); await refreshView(`Цена балла сверх нормы сохранена для ${changed} ставок${details ? ` (${details})` : ""}.`); }} onStale={handleStale} /> : null}
|
||||
|
||||
@ -0,0 +1,222 @@
|
||||
"use client";
|
||||
|
||||
import { Check, Loader2, Search, ShieldCheck, UsersRound } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
import { RequestAvatar } from "@/components/requests/RequestAvatar";
|
||||
import { Modal } from "@/components/ui";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import type {
|
||||
PayrollManagementAccessCandidate,
|
||||
PayrollManagementAccessSettings,
|
||||
} from "@/lib/api/finance";
|
||||
|
||||
type AccessMode = "all" | "selected";
|
||||
|
||||
type PayrollManagementAccessModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSaved: (settings: PayrollManagementAccessSettings) => void | Promise<void>;
|
||||
};
|
||||
|
||||
function initials(candidate: PayrollManagementAccessCandidate): string {
|
||||
const parts = [candidate.first_name, candidate.last_name].filter(Boolean);
|
||||
if (parts.length) return parts.map((part) => part[0]).join("").slice(0, 2).toUpperCase();
|
||||
return candidate.email.slice(0, 2).toUpperCase() || "A";
|
||||
}
|
||||
|
||||
export function PayrollManagementAccessModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSaved,
|
||||
}: PayrollManagementAccessModalProps) {
|
||||
const [settings, setSettings] = useState<PayrollManagementAccessSettings | null>(null);
|
||||
const [mode, setMode] = useState<AccessMode>("all");
|
||||
const [selectedIds, setSelectedIds] = useState<number[]>([]);
|
||||
const [search, setSearch] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSearch("");
|
||||
void apiClient.getPayrollManagementAccessSettings()
|
||||
.then((payload) => {
|
||||
if (cancelled) return;
|
||||
setSettings(payload);
|
||||
setMode(payload.restricted ? "selected" : "all");
|
||||
setSelectedIds(payload.management_user_ids);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError("Не удалось загрузить настройки доступа.");
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, [isOpen]);
|
||||
|
||||
const filteredCandidates = useMemo(() => {
|
||||
const needle = search.trim().toLocaleLowerCase("ru-RU");
|
||||
if (!needle) return settings?.candidates || [];
|
||||
return (settings?.candidates || []).filter((candidate) => (
|
||||
`${candidate.display_name} ${candidate.email}`.toLocaleLowerCase("ru-RU").includes(needle)
|
||||
));
|
||||
}, [search, settings?.candidates]);
|
||||
|
||||
const toggleCandidate = (candidateId: number) => {
|
||||
setSelectedIds((current) => (
|
||||
current.includes(candidateId)
|
||||
? current.filter((id) => id !== candidateId)
|
||||
: [...current, candidateId]
|
||||
));
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
if (mode === "selected" && selectedIds.length === 0) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const payload = await apiClient.updatePayrollManagementAccessSettings(
|
||||
mode === "all" ? [] : selectedIds,
|
||||
);
|
||||
await onSaved(payload);
|
||||
onClose();
|
||||
} catch {
|
||||
setError("Не удалось сохранить настройки доступа.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
title="Доступ к управлению финансами"
|
||||
size="sm"
|
||||
closeOnClickOutside
|
||||
footer={(
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="app-action-secondary rounded-lg px-4 py-2 text-sm font-medium"
|
||||
disabled={saving}
|
||||
>
|
||||
Отмена
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void save()}
|
||||
className="app-action-primary inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={loading || saving || (mode === "selected" && selectedIds.length === 0)}
|
||||
>
|
||||
{saving ? <Loader2 className="animate-spin" size={15} /> : <Check size={15} />}
|
||||
Сохранить
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{loading ? (
|
||||
<div className="flex min-h-52 items-center justify-center">
|
||||
<Loader2 className="app-accent-text animate-spin" size={24} />
|
||||
</div>
|
||||
) : error && !settings ? (
|
||||
<div className="app-feedback-danger rounded-xl px-3 py-3 text-sm">{error}</div>
|
||||
) : settings ? (
|
||||
<div className="space-y-4">
|
||||
<p className="app-text-muted text-sm leading-relaxed">
|
||||
Ограничение действует и для вкладки, и для прямых запросов к финансовому API.
|
||||
</p>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("all")}
|
||||
className={`flex items-center gap-3 rounded-xl border p-3 text-left transition ${mode === "all" ? "app-selected border-[var(--accent-primary)]" : "border-[var(--border-subtle)] hover:bg-[var(--surface-secondary)]"}`}
|
||||
>
|
||||
<span className="app-surface-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<UsersRound size={17} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium text-[var(--foreground)]">Все администраторы</span>
|
||||
<span className="app-text-muted mt-0.5 block text-xs">Поведение по умолчанию</span>
|
||||
</span>
|
||||
{mode === "all" ? <Check className="app-accent-text" size={17} /> : null}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMode("selected")}
|
||||
className={`flex items-center gap-3 rounded-xl border p-3 text-left transition ${mode === "selected" ? "app-selected border-[var(--accent-primary)]" : "border-[var(--border-subtle)] hover:bg-[var(--surface-secondary)]"}`}
|
||||
>
|
||||
<span className="app-surface-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-lg">
|
||||
<ShieldCheck size={17} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block text-sm font-medium text-[var(--foreground)]">Только выбранные</span>
|
||||
<span className="app-text-muted mt-0.5 block text-xs">Сейчас выбрано: {selectedIds.length}</span>
|
||||
</span>
|
||||
{mode === "selected" ? <Check className="app-accent-text" size={17} /> : null}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mode === "selected" ? (
|
||||
<div className="space-y-2">
|
||||
<label className="relative block">
|
||||
<Search className="app-text-muted pointer-events-none absolute left-3 top-1/2 -translate-y-1/2" size={14} />
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="app-input w-full rounded-lg py-2 pl-9 pr-3 text-sm"
|
||||
placeholder="Найти администратора"
|
||||
aria-label="Поиск администраторов"
|
||||
/>
|
||||
</label>
|
||||
<div className="app-surface-muted max-h-64 space-y-1 overflow-y-auto rounded-xl border border-[var(--border-subtle)] p-2">
|
||||
{filteredCandidates.length ? filteredCandidates.map((candidate) => {
|
||||
const selected = selectedIds.includes(candidate.id);
|
||||
return (
|
||||
<label
|
||||
key={candidate.id}
|
||||
className={`flex cursor-pointer items-center gap-2 rounded-lg px-2 py-2 transition ${selected ? "app-selected" : "hover:bg-[var(--surface-elevated)]"}`}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected}
|
||||
onChange={() => toggleCandidate(candidate.id)}
|
||||
className="h-4 w-4 shrink-0"
|
||||
/>
|
||||
<RequestAvatar
|
||||
alt={candidate.display_name}
|
||||
fallback={initials(candidate)}
|
||||
src={candidate.avatar}
|
||||
size="sm"
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-xs font-medium text-[var(--foreground)]">{candidate.display_name}</span>
|
||||
<span className="app-text-muted block truncate text-[10px]">{candidate.email}</span>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
}) : (
|
||||
<p className="app-text-muted px-2 py-6 text-center text-xs">Администраторы не найдены</p>
|
||||
)}
|
||||
</div>
|
||||
{selectedIds.length === 0 ? (
|
||||
<p className="app-feedback-warning rounded-lg px-3 py-2 text-xs">Выберите хотя бы одного администратора.</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? <div className="app-feedback-danger rounded-lg px-3 py-2 text-xs">{error}</div> : null}
|
||||
</div>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@ -140,6 +140,23 @@ export type PayrollAdminEmployee = PayrollAdminUserRef & {
|
||||
department?: string | null;
|
||||
};
|
||||
|
||||
export type PayrollManagementAccessCandidate = {
|
||||
id: number;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
patronymic: string;
|
||||
email: string;
|
||||
display_name: string;
|
||||
avatar: string | null;
|
||||
};
|
||||
|
||||
export type PayrollManagementAccessSettings = {
|
||||
restricted: boolean;
|
||||
management_user_ids: number[];
|
||||
management_user_count: number;
|
||||
candidates: PayrollManagementAccessCandidate[];
|
||||
};
|
||||
|
||||
export type PayrollAdminPeriod = PayrollPeriodSummary & {
|
||||
id: number;
|
||||
currency: string;
|
||||
@ -198,6 +215,11 @@ export type PayrollCalculationBlocker = {
|
||||
|
||||
export type PayrollAdminWorkspace = {
|
||||
permissions: PayrollAdminPermissions;
|
||||
access_settings: {
|
||||
restricted: boolean;
|
||||
management_user_count: number;
|
||||
can_configure: boolean;
|
||||
};
|
||||
employees: PayrollAdminEmployee[];
|
||||
rate_employees: PayrollAdminEmployee[];
|
||||
components: PayrollComponent[];
|
||||
@ -844,6 +866,15 @@ export function createFinanceApi(request: RequestFn) {
|
||||
"/api/v1/finance/payroll/me/work-records/",
|
||||
jsonOptions("POST", payload),
|
||||
),
|
||||
getPayrollManagementAccessSettings: (): Promise<PayrollManagementAccessSettings> =>
|
||||
request("/api/v1/finance/payroll/management-access/"),
|
||||
updatePayrollManagementAccessSettings: (
|
||||
managementUserIds: number[],
|
||||
): Promise<PayrollManagementAccessSettings> =>
|
||||
request(
|
||||
"/api/v1/finance/payroll/management-access/",
|
||||
jsonOptions("PATCH", { management_user_ids: managementUserIds }),
|
||||
),
|
||||
getPayrollAdminWorkspace: (periodId?: number): Promise<PayrollAdminWorkspace> =>
|
||||
request(`${adminPrefix}/workspace/${buildQuery({ period_id: periodId })}`),
|
||||
getPayrollAdminPeriods: (): Promise<PayrollAdminListPayload<PayrollAdminPeriod>> =>
|
||||
|
||||
@ -26,6 +26,17 @@ test("staff administrators can open payroll management without extra roles", ()
|
||||
);
|
||||
});
|
||||
|
||||
test("server-side finance allow-list overrides the legacy staff shortcut", () => {
|
||||
assert.equal(
|
||||
canOpenPayrollAdmin({ auth: { is_staff: true, can_manage_payroll: false } }),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
canOpenPayrollAdmin({ auth: { is_staff: false, can_manage_payroll: true } }),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("custom payroll roles do not need Django staff access", () => {
|
||||
assert.equal(
|
||||
canOpenPayrollAdmin({
|
||||
|
||||
@ -13,6 +13,9 @@ const PAYROLL_ADMIN_PERMISSIONS = [
|
||||
export function canOpenPayrollAdmin(user?: User | null): boolean {
|
||||
const auth = user?.auth;
|
||||
if (!auth) return false;
|
||||
if (typeof auth.can_manage_payroll === "boolean") {
|
||||
return auth.can_manage_payroll;
|
||||
}
|
||||
// TODO(payroll-access-hardening): when SIMPLE_ADMIN_ACCESS is disabled on
|
||||
// the backend, remove this staff shortcut in the same deployment and use
|
||||
// the granular Finance permissions below again.
|
||||
|
||||
@ -101,6 +101,7 @@ export interface User {
|
||||
email?: string;
|
||||
is_staff?: boolean;
|
||||
is_superuser?: boolean;
|
||||
can_manage_payroll?: boolean;
|
||||
groups?: string[];
|
||||
permissions?: string[];
|
||||
permissions_by_app?: Record<string, string[]>;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user