Add position creation to user modal and secret skill admin tools
This commit is contained in:
parent
c0185913e6
commit
b6a6b8210e
@ -1,5 +1,7 @@
|
||||
# backend/employees/admin.py
|
||||
|
||||
from io import StringIO
|
||||
|
||||
from django import forms
|
||||
from django.contrib import admin, messages
|
||||
from django.contrib.auth.admin import GroupAdmin as DefaultGroupAdmin
|
||||
@ -9,6 +11,13 @@ from django.contrib.auth.forms import (
|
||||
)
|
||||
from django.contrib.auth.models import Group, Permission
|
||||
from django.contrib.auth.admin import UserAdmin as DjangoUserAdmin
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.core.management import call_command
|
||||
from django.core.management.base import CommandError
|
||||
from django.template.response import TemplateResponse
|
||||
from django.urls import path, reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.html import format_html
|
||||
|
||||
from simple_history.admin import SimpleHistoryAdmin
|
||||
|
||||
@ -19,6 +28,7 @@ from .models import (
|
||||
DepartmentRole,
|
||||
EmployeeDepartment,
|
||||
EmployeeSkillProgress,
|
||||
SecretSkillSettings,
|
||||
Position,
|
||||
Skill,
|
||||
SkillExperienceEvent,
|
||||
@ -26,6 +36,151 @@ from .models import (
|
||||
)
|
||||
|
||||
|
||||
class SecretSkillSettingsAdminForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = SecretSkillSettings
|
||||
fields = ("calculation_start_date",)
|
||||
|
||||
def clean_calculation_start_date(self):
|
||||
value = self.cleaned_data["calculation_start_date"]
|
||||
if value > timezone.localdate():
|
||||
raise forms.ValidationError(
|
||||
"Дата начала расчёта не может быть из будущего."
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
class SecretSkillRecalculateAdminForm(forms.Form):
|
||||
employee = forms.ModelChoiceField(
|
||||
label="Сотрудник",
|
||||
queryset=Employee.objects.none(),
|
||||
required=False,
|
||||
help_text="Не выбирайте сотрудника, чтобы проверить или пересчитать всех.",
|
||||
)
|
||||
dry_run = forms.BooleanField(
|
||||
label="Сухой прогон без сохранения",
|
||||
required=False,
|
||||
initial=True,
|
||||
)
|
||||
confirm = forms.BooleanField(
|
||||
label="Подтверждаю реальный пересчёт и сохранение изменений",
|
||||
required=False,
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields["employee"].queryset = Employee.objects.order_by(
|
||||
"last_name",
|
||||
"first_name",
|
||||
"id",
|
||||
)
|
||||
|
||||
def clean(self):
|
||||
cleaned_data = super().clean()
|
||||
if not cleaned_data.get("dry_run") and not cleaned_data.get("confirm"):
|
||||
raise forms.ValidationError(
|
||||
"Для реального пересчёта подтвердите сохранение изменений."
|
||||
)
|
||||
return cleaned_data
|
||||
|
||||
|
||||
class SecretSkillReportAdminForm(forms.Form):
|
||||
top = forms.IntegerField(
|
||||
label="Количество сотрудников в рейтинге",
|
||||
min_value=1,
|
||||
max_value=100,
|
||||
initial=3,
|
||||
)
|
||||
|
||||
|
||||
@admin.register(SecretSkillSettings)
|
||||
class SecretSkillSettingsAdmin(admin.ModelAdmin):
|
||||
"""Единая дата начала расчёта всех секретных навыков."""
|
||||
|
||||
form = SecretSkillSettingsAdminForm
|
||||
list_display = ("calculation_start_date", "updated_at", "tools_link")
|
||||
fields = ("calculation_start_date", "updated_at", "tools_link")
|
||||
readonly_fields = ("updated_at", "tools_link")
|
||||
|
||||
def get_urls(self):
|
||||
custom_urls = [
|
||||
path(
|
||||
"tools/",
|
||||
self.admin_site.admin_view(self.tools_view),
|
||||
name="employees_secretskillsettings_tools",
|
||||
)
|
||||
]
|
||||
return custom_urls + super().get_urls()
|
||||
|
||||
@admin.display(description="Операции")
|
||||
def tools_link(self, obj=None):
|
||||
url = reverse("admin:employees_secretskillsettings_tools")
|
||||
return format_html('<a class="button" href="{}">Открыть инструменты</a>', url)
|
||||
|
||||
def tools_view(self, request):
|
||||
if not self.has_change_permission(request):
|
||||
raise PermissionDenied
|
||||
|
||||
operation = request.POST.get("operation")
|
||||
recalculate_form = SecretSkillRecalculateAdminForm(
|
||||
request.POST if operation == "recalculate" else None,
|
||||
prefix="recalculate",
|
||||
)
|
||||
report_form = SecretSkillReportAdminForm(
|
||||
request.POST if operation == "report" else None,
|
||||
prefix="report",
|
||||
)
|
||||
output = ""
|
||||
|
||||
try:
|
||||
if operation == "recalculate" and recalculate_form.is_valid():
|
||||
stream = StringIO()
|
||||
employee = recalculate_form.cleaned_data["employee"]
|
||||
command_options = {
|
||||
"dry_run": recalculate_form.cleaned_data["dry_run"],
|
||||
"stdout": stream,
|
||||
}
|
||||
if employee is not None:
|
||||
command_options["employee_id"] = employee.pk
|
||||
call_command("recalculate_secret_skills", **command_options)
|
||||
output = stream.getvalue()
|
||||
messages.success(request, "Пересчёт завершён.")
|
||||
elif operation == "report" and report_form.is_valid():
|
||||
stream = StringIO()
|
||||
call_command(
|
||||
"secret_skills_report",
|
||||
top=report_form.cleaned_data["top"],
|
||||
stdout=stream,
|
||||
)
|
||||
output = stream.getvalue()
|
||||
messages.success(request, "Отчёт сформирован.")
|
||||
except CommandError as error:
|
||||
messages.error(request, str(error))
|
||||
|
||||
context = {
|
||||
**self.admin_site.each_context(request),
|
||||
"opts": self.model._meta,
|
||||
"title": "Инструменты секретных навыков",
|
||||
"settings_object": SecretSkillSettings.get_solo(),
|
||||
"recalculate_form": recalculate_form,
|
||||
"report_form": report_form,
|
||||
"output": output,
|
||||
}
|
||||
return TemplateResponse(
|
||||
request,
|
||||
"admin/employees/secretskillsettings/tools.html",
|
||||
context,
|
||||
)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
if SecretSkillSettings.objects.exists():
|
||||
return False
|
||||
return super().has_add_permission(request)
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
return False
|
||||
|
||||
|
||||
def _resolve_ldap_user_for_employee(employee):
|
||||
"""Возвращает наиболее вероятную LDAP-модель сотрудника.
|
||||
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
{% extends "admin/base_site.html" %}
|
||||
{% load admin_urls %}
|
||||
|
||||
{% block breadcrumbs %}
|
||||
<div class="breadcrumbs">
|
||||
<a href="{% url 'admin:index' %}">Главная</a>
|
||||
› <a href="{% url 'admin:app_list' app_label=opts.app_label %}">{{ opts.app_config.verbose_name }}</a>
|
||||
› <a href="{% url opts|admin_urlname:'changelist' %}">{{ opts.verbose_name_plural|capfirst }}</a>
|
||||
› Инструменты
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div id="content-main">
|
||||
<p>
|
||||
Общая дата начала расчёта:
|
||||
<strong>{{ settings_object.calculation_start_date|date:"d.m.Y" }}</strong>.
|
||||
<a href="{% url opts|admin_urlname:'change' settings_object.pk|admin_urlquote %}">Изменить дату</a>
|
||||
</p>
|
||||
|
||||
<div class="module" style="margin-bottom: 24px; padding: 16px;">
|
||||
<h2 style="margin: -16px -16px 16px; padding: 8px 16px;">Отчёт</h2>
|
||||
<p>
|
||||
Показывает лидеров по каждому секретному навыку, а для ещё не полученных
|
||||
навыков — сотрудников, которые ближе всего к условиям получения.
|
||||
</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="operation" value="report">
|
||||
{{ report_form.as_div }}
|
||||
<div class="submit-row" style="padding: 12px 0 0;">
|
||||
<input type="submit" class="default" value="Сформировать отчёт">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="module" style="margin-bottom: 24px; padding: 16px;">
|
||||
<h2 style="margin: -16px -16px 16px; padding: 8px 16px;">Пересчёт</h2>
|
||||
<p>
|
||||
Сухой прогон только показывает будущие изменения. Реальный пересчёт
|
||||
обновляет навыки и ранги в базе данных.
|
||||
</p>
|
||||
<form method="post">
|
||||
{% csrf_token %}
|
||||
<input type="hidden" name="operation" value="recalculate">
|
||||
{{ recalculate_form.as_div }}
|
||||
<div class="submit-row" style="padding: 12px 0 0;">
|
||||
<input type="submit" class="default" value="Запустить пересчёт">
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if output %}
|
||||
<div class="module" style="padding: 16px;">
|
||||
<h2 style="margin: -16px -16px 16px; padding: 8px 16px;">Результат</h2>
|
||||
<pre style="margin: 0; overflow: auto; max-height: 60vh; white-space: pre-wrap;">{{ output }}</pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
140
backend/tests/employees/test_secret_skill_settings_admin.py
Normal file
140
backend/tests/employees/test_secret_skill_settings_admin.py
Normal file
@ -0,0 +1,140 @@
|
||||
from datetime import timedelta
|
||||
from io import StringIO
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
|
||||
from employees.models import SecretSkillSettings
|
||||
|
||||
pytestmark = pytest.mark.django_db
|
||||
|
||||
|
||||
def test_admin_can_change_secret_skills_start_date(client, user_factory):
|
||||
administrator = user_factory(staff=True, superuser=True)
|
||||
settings = SecretSkillSettings.get_solo()
|
||||
target = timezone.localdate() - timedelta(days=90)
|
||||
client.force_login(administrator)
|
||||
|
||||
response = client.post(
|
||||
reverse(
|
||||
"admin:employees_secretskillsettings_change",
|
||||
args=[settings.pk],
|
||||
),
|
||||
{"calculation_start_date": target.isoformat()},
|
||||
)
|
||||
|
||||
assert response.status_code == 302
|
||||
settings.refresh_from_db()
|
||||
assert settings.calculation_start_date == target
|
||||
|
||||
|
||||
def test_admin_does_not_allow_duplicate_or_delete(rf):
|
||||
from django.contrib import admin
|
||||
|
||||
from employees.admin import SecretSkillSettingsAdmin
|
||||
|
||||
settings = SecretSkillSettings.get_solo()
|
||||
model_admin = SecretSkillSettingsAdmin(SecretSkillSettings, admin.site)
|
||||
request = rf.get("/admin/")
|
||||
|
||||
assert model_admin.has_add_permission(request) is False
|
||||
assert model_admin.has_delete_permission(request, settings) is False
|
||||
|
||||
|
||||
def test_admin_secret_skill_tools_page(client, user_factory):
|
||||
administrator = user_factory(staff=True, superuser=True)
|
||||
settings = SecretSkillSettings.get_solo()
|
||||
client.force_login(administrator)
|
||||
|
||||
response = client.get(reverse("admin:employees_secretskillsettings_tools"))
|
||||
|
||||
assert response.status_code == 200
|
||||
content = response.content.decode()
|
||||
assert "Отчёт" in content
|
||||
assert "Пересчёт" in content
|
||||
assert settings.calculation_start_date.strftime("%d.%m.%Y") in content
|
||||
|
||||
|
||||
def test_admin_runs_secret_skill_report(client, user_factory):
|
||||
administrator = user_factory(staff=True, superuser=True)
|
||||
client.force_login(administrator)
|
||||
|
||||
def fake_call(command_name, **options):
|
||||
assert command_name == "secret_skills_report"
|
||||
assert options["top"] == 5
|
||||
assert isinstance(options["stdout"], StringIO)
|
||||
options["stdout"].write("Тестовый отчёт")
|
||||
|
||||
with patch("employees.admin.call_command", side_effect=fake_call) as command:
|
||||
response = client.post(
|
||||
reverse("admin:employees_secretskillsettings_tools"),
|
||||
{"operation": "report", "report-top": "5"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert command.call_count == 1
|
||||
assert "Тестовый отчёт" in response.content.decode()
|
||||
|
||||
|
||||
def test_admin_runs_dry_recalculation_for_one_employee(client, user_factory):
|
||||
administrator = user_factory(staff=True, superuser=True)
|
||||
employee = user_factory()
|
||||
client.force_login(administrator)
|
||||
|
||||
def fake_call(command_name, **options):
|
||||
assert command_name == "recalculate_secret_skills"
|
||||
assert options["employee_id"] == employee.pk
|
||||
assert options["dry_run"] is True
|
||||
options["stdout"].write("Сухой прогон")
|
||||
|
||||
with patch("employees.admin.call_command", side_effect=fake_call) as command:
|
||||
response = client.post(
|
||||
reverse("admin:employees_secretskillsettings_tools"),
|
||||
{
|
||||
"operation": "recalculate",
|
||||
"recalculate-employee": str(employee.pk),
|
||||
"recalculate-dry_run": "on",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert command.call_count == 1
|
||||
assert "Сухой прогон" in response.content.decode()
|
||||
|
||||
|
||||
def test_admin_requires_confirmation_for_real_recalculation(client, user_factory):
|
||||
administrator = user_factory(staff=True, superuser=True)
|
||||
client.force_login(administrator)
|
||||
|
||||
with patch("employees.admin.call_command") as command:
|
||||
response = client.post(
|
||||
reverse("admin:employees_secretskillsettings_tools"),
|
||||
{"operation": "recalculate"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert command.call_count == 0
|
||||
assert "подтвердите сохранение изменений" in response.content.decode()
|
||||
|
||||
|
||||
def test_admin_runs_confirmed_recalculation_for_all(client, user_factory):
|
||||
administrator = user_factory(staff=True, superuser=True)
|
||||
client.force_login(administrator)
|
||||
|
||||
with patch("employees.admin.call_command") as command:
|
||||
response = client.post(
|
||||
reverse("admin:employees_secretskillsettings_tools"),
|
||||
{
|
||||
"operation": "recalculate",
|
||||
"recalculate-confirm": "on",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert command.call_count == 1
|
||||
args, options = command.call_args
|
||||
assert args == ("recalculate_secret_skills",)
|
||||
assert options["dry_run"] is False
|
||||
assert "employee_id" not in options
|
||||
@ -75,6 +75,7 @@ export default function UserDetailPage() {
|
||||
handleCloseActionModal,
|
||||
handleCloseEditModal,
|
||||
handleClosePositionModal,
|
||||
handleCreatePosition,
|
||||
handleCopyToClipboard,
|
||||
handleDeleteAction,
|
||||
handleEditAction,
|
||||
@ -616,10 +617,12 @@ export default function UserDetailPage() {
|
||||
/>
|
||||
|
||||
<ChangeUserPositionModal
|
||||
key={isPositionModalOpen ? "position-modal-open" : "position-modal-closed"}
|
||||
actionLoading={actionLoading}
|
||||
error={positionsError}
|
||||
isOpen={isPositionModalOpen}
|
||||
onClose={handleClosePositionModal}
|
||||
onCreatePosition={handleCreatePosition}
|
||||
onPositionChange={setPositionValue}
|
||||
onSave={handleSavePosition}
|
||||
person={person}
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Plus } from "lucide-react";
|
||||
import { Modal } from "@/components/ui";
|
||||
import type { Position, User } from "@/types/api";
|
||||
|
||||
type ChangeUserPositionModalProps = {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onCreatePosition: (name: string, description: string) => Promise<boolean>;
|
||||
onSave: () => void;
|
||||
onPositionChange: (value: string) => void;
|
||||
positions: Position[];
|
||||
@ -19,6 +22,7 @@ type ChangeUserPositionModalProps = {
|
||||
export default function ChangeUserPositionModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
onCreatePosition,
|
||||
onSave,
|
||||
onPositionChange,
|
||||
positions,
|
||||
@ -29,13 +33,30 @@ export default function ChangeUserPositionModal({
|
||||
error,
|
||||
}: ChangeUserPositionModalProps) {
|
||||
const isSaving = actionLoading === "position";
|
||||
const isCreating = actionLoading === "position-create";
|
||||
const isBusy = isSaving || isCreating;
|
||||
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
||||
const [newPositionName, setNewPositionName] = useState("");
|
||||
const [newPositionDescription, setNewPositionDescription] = useState("");
|
||||
|
||||
const handleCreatePosition = async () => {
|
||||
const created = await onCreatePosition(
|
||||
newPositionName,
|
||||
newPositionDescription,
|
||||
);
|
||||
if (!created) return;
|
||||
|
||||
setNewPositionName("");
|
||||
setNewPositionDescription("");
|
||||
setIsCreateOpen(false);
|
||||
};
|
||||
|
||||
const footer = (
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isSaving}
|
||||
disabled={isBusy}
|
||||
className="app-action-secondary flex-1 rounded-lg px-4 py-2.5 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
Отмена
|
||||
@ -43,7 +64,7 @@ export default function ChangeUserPositionModal({
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={isSaving || positionsLoading}
|
||||
disabled={isBusy || positionsLoading}
|
||||
className="app-action-primary flex-1 rounded-lg px-4 py-2.5 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{isSaving ? "Сохранение..." : "Сохранить"}
|
||||
@ -57,7 +78,7 @@ export default function ChangeUserPositionModal({
|
||||
onClose={onClose}
|
||||
title="Изменить должность"
|
||||
size="sm"
|
||||
closeOnEsc={!isSaving}
|
||||
closeOnEsc={!isBusy}
|
||||
footer={footer}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
@ -80,7 +101,7 @@ export default function ChangeUserPositionModal({
|
||||
value={positionValue}
|
||||
onChange={(event) => onPositionChange(event.target.value)}
|
||||
className="app-select w-full rounded-lg px-4 py-2.5 text-sm"
|
||||
disabled={positionsLoading || isSaving}
|
||||
disabled={positionsLoading || isBusy}
|
||||
>
|
||||
<option value="">Без должности</option>
|
||||
{positions.map((position) => (
|
||||
@ -99,6 +120,66 @@ export default function ChangeUserPositionModal({
|
||||
<p className="mt-3 text-sm text-[var(--danger-foreground)]">{error}</p>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section className="app-surface-muted rounded-xl p-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCreateOpen((current) => !current)}
|
||||
disabled={isBusy}
|
||||
className="flex w-full items-center justify-between gap-3 text-left text-sm font-medium text-[var(--foreground)] disabled:opacity-50"
|
||||
aria-expanded={isCreateOpen}
|
||||
>
|
||||
<span>Добавить новую должность</span>
|
||||
<Plus
|
||||
size={18}
|
||||
className={`app-text-muted shrink-0 transition-transform ${isCreateOpen ? "rotate-45" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isCreateOpen ? (
|
||||
<div className="mt-4 space-y-3 border-t border-[var(--border)] pt-4">
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-medium text-[var(--foreground)]">
|
||||
Название
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
value={newPositionName}
|
||||
onChange={(event) => setNewPositionName(event.target.value)}
|
||||
placeholder="Например, Системный аналитик"
|
||||
maxLength={255}
|
||||
autoFocus
|
||||
disabled={isBusy}
|
||||
className="app-input w-full rounded-lg px-4 py-2.5 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="mb-2 block text-sm font-medium text-[var(--foreground)]">
|
||||
Описание
|
||||
<span className="app-text-muted ml-1 font-normal">(необязательно)</span>
|
||||
</span>
|
||||
<textarea
|
||||
value={newPositionDescription}
|
||||
onChange={(event) => setNewPositionDescription(event.target.value)}
|
||||
placeholder="Кратко опишите обязанности"
|
||||
rows={3}
|
||||
disabled={isBusy}
|
||||
className="app-input w-full resize-y rounded-lg px-4 py-2.5 text-sm"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCreatePosition()}
|
||||
disabled={isBusy || !newPositionName.trim()}
|
||||
className="app-action-secondary w-full rounded-lg px-4 py-2.5 text-sm font-medium disabled:opacity-50"
|
||||
>
|
||||
{isCreating ? "Создание..." : "Создать и выбрать"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@ -337,6 +337,40 @@ export function useUserDetailPage(userId: number, currentUser: User | null) {
|
||||
}
|
||||
}, [handleClosePositionModal, person, positionValue, refreshPerson, userId]);
|
||||
|
||||
const handleCreatePosition = useCallback(async (
|
||||
name: string,
|
||||
description: string,
|
||||
): Promise<boolean> => {
|
||||
const trimmedName = name.trim();
|
||||
if (!trimmedName) {
|
||||
setPositionsError("Укажите название должности");
|
||||
return false;
|
||||
}
|
||||
|
||||
setActionLoading("position-create");
|
||||
setPositionsError(null);
|
||||
try {
|
||||
const createdPosition = await apiClient.createPosition({
|
||||
name: trimmedName,
|
||||
description: description.trim() || undefined,
|
||||
}) as Position;
|
||||
|
||||
setPositions((current) => (
|
||||
[...current.filter((position) => position.id !== createdPosition.id), createdPosition]
|
||||
.sort((left, right) => left.name.localeCompare(right.name, "ru"))
|
||||
));
|
||||
setPositionValue(String(createdPosition.id));
|
||||
return true;
|
||||
} catch (createError) {
|
||||
setPositionsError(
|
||||
getErrorMessage(createError, "Не удалось создать должность"),
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
setActionLoading(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const canEdit = Boolean(currentUser && (
|
||||
currentUser.auth?.is_staff ||
|
||||
currentUser.auth?.is_superuser ||
|
||||
@ -372,6 +406,7 @@ export function useUserDetailPage(userId: number, currentUser: User | null) {
|
||||
handleCloseActionModal,
|
||||
handleCloseEditModal,
|
||||
handleClosePositionModal,
|
||||
handleCreatePosition,
|
||||
handleCopyToClipboard: copyToClipboard,
|
||||
handleDeleteAction,
|
||||
handleEditAction,
|
||||
|
||||
@ -28,6 +28,11 @@ export function createEmployeesApi(request: RequestFn) {
|
||||
},
|
||||
getEmployee: (id: number | string) => request(`/api/v1/employees/${id}/`),
|
||||
getPositions: () => request('/api/v1/positions/'),
|
||||
createPosition: (data: { name: string; description?: string }) =>
|
||||
request('/api/v1/positions/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
addEmployeeSkill: (
|
||||
id: number | string,
|
||||
data: { skill_id?: number; name?: string },
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user