feat(finance): configure payroll management access

This commit is contained in:
lizerk 2026-08-10 10:32:37 +03:00
parent 7a0270fb32
commit 47d48c45f4
15 changed files with 671 additions and 2 deletions

View File

@ -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,

View File

@ -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 = (

View File

@ -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(),

View File

@ -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,

View 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": "Настройки финансов",
},
),
]

View File

@ -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."""

View File

@ -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."""

View File

@ -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")

View File

@ -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}

View File

@ -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}

View File

@ -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>
);
}

View File

@ -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>> =>

View File

@ -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({

View File

@ -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.

View File

@ -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[]>;