diff --git a/apps/hiring/admin.py b/apps/hiring/admin.py
index 84ffcd3..596123c 100644
--- a/apps/hiring/admin.py
+++ b/apps/hiring/admin.py
@@ -1,7 +1,7 @@
from django.contrib import admin
from unfold.admin import ModelAdmin
-from apps.hiring.models import HiringRequest, InternAccount
+from apps.hiring.models import HiringRequest, InternAccount, Vacancy
@admin.register(InternAccount)
@@ -14,6 +14,19 @@ class InternAccountAdmin(ModelAdmin):
readonly_fields = ['assigned_to', 'created_at', 'updated_at', 'uuid']
+@admin.register(Vacancy)
+class VacancyAdmin(ModelAdmin):
+ list_display = [
+ 'title', 'status', 'priority', 'pharmacy', 'department',
+ 'candidates_count', 'opened_at', 'created_by',
+ ]
+ list_filter = ['status', 'priority', 'user_location_type']
+ search_fields = ['title', 'description']
+ list_select_related = ['post', 'pharmacy', 'department', 'location', 'created_by']
+ ordering = ['-opened_at', '-created_at']
+ readonly_fields = ['created_at', 'updated_at', 'uuid']
+
+
@admin.register(HiringRequest)
class HiringRequestAdmin(ModelAdmin):
list_display = [
diff --git a/apps/hiring/migrations/0008_vacancy.py b/apps/hiring/migrations/0008_vacancy.py
new file mode 100644
index 0000000..4db6483
--- /dev/null
+++ b/apps/hiring/migrations/0008_vacancy.py
@@ -0,0 +1,47 @@
+# Generated by Django 6.0.3 on 2026-09-08 19:23
+
+import django.db.models.deletion
+import django.utils.timezone
+import uuid
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('core', '0017_rename_databases_section'),
+ ('hiring', '0007_rotate_seed_intern_passwords'),
+ ('locations', '0013_pharmacy_edit_permission'),
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Vacancy',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('title', models.CharField(max_length=200, verbose_name='Название')),
+ ('user_location_type', models.CharField(choices=[('pharmacy', 'Аптека'), ('warehouse', 'Склад'), ('office', 'Офис')], default='pharmacy', max_length=20, verbose_name='Тип локации')),
+ ('priority', models.CharField(choices=[('urgent', 'Срочно'), ('medium', 'Средний'), ('normal', 'Обычный')], default='normal', max_length=16, verbose_name='Приоритет')),
+ ('status', models.CharField(choices=[('open', 'Открыта'), ('closed', 'Закрыта'), ('filled', 'Закрыта наймом')], default='open', max_length=16, verbose_name='Статус')),
+ ('opened_at', models.DateField(default=django.utils.timezone.localdate, verbose_name='Открыта с')),
+ ('description', models.TextField(blank=True, verbose_name='Описание')),
+ ('candidates_count', models.PositiveIntegerField(default=0, verbose_name='Кандидатов')),
+ ('created_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vacancies_created', to=settings.AUTH_USER_MODEL, verbose_name='Создал')),
+ ('department', models.ForeignKey(blank=True, limit_choices_to={'directory_type__code': 'department', 'is_active': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vacancies_department', to='core.directory', verbose_name='Отдел')),
+ ('location', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='location_vacancies', to='locations.location', verbose_name='Локация')),
+ ('pharmacy', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='pharmacy_vacancies', to='locations.pharmacy', verbose_name='Аптека')),
+ ('post', models.ForeignKey(blank=True, limit_choices_to={'directory_type__code': 'post', 'is_active': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='vacancies', to='core.directory', verbose_name='Должность')),
+ ],
+ options={
+ 'verbose_name': 'Вакансия',
+ 'verbose_name_plural': 'Вакансии',
+ 'ordering': ['-opened_at', '-created_at'],
+ 'indexes': [models.Index(fields=['status', 'priority'], name='hiring_vaca_status_3522a0_idx')],
+ },
+ ),
+ ]
diff --git a/apps/hiring/models.py b/apps/hiring/models.py
index b353281..2e31798 100644
--- a/apps/hiring/models.py
+++ b/apps/hiring/models.py
@@ -186,3 +186,85 @@ class HiringRequest(BaseModel):
except Exception:
return
account.release()
+
+
+class Vacancy(BaseModel):
+ """Открытая вакансия (рекрутинг в HR-портале)."""
+
+ STATUS_CHOICES = [
+ ('open', _('Открыта')),
+ ('closed', _('Закрыта')),
+ ('filled', _('Закрыта наймом')),
+ ]
+ PRIORITY_CHOICES = [
+ ('urgent', _('Срочно')),
+ ('medium', _('Средний')),
+ ('normal', _('Обычный')),
+ ]
+ LOCATION_TYPES = [
+ ('pharmacy', _('Аптека')),
+ ('warehouse', _('Склад')),
+ ('office', _('Офис')),
+ ]
+
+ title = models.CharField(_('Название'), max_length=200)
+ post = models.ForeignKey(
+ 'core.Directory', on_delete=models.SET_NULL,
+ null=True, blank=True,
+ limit_choices_to={'directory_type__code': 'post', 'is_active': True},
+ related_name='vacancies', verbose_name=_('Должность'),
+ )
+ user_location_type = models.CharField(
+ _('Тип локации'), max_length=20, choices=LOCATION_TYPES, default='pharmacy',
+ )
+ department = models.ForeignKey(
+ 'core.Directory', on_delete=models.SET_NULL,
+ null=True, blank=True,
+ limit_choices_to={'directory_type__code': 'department', 'is_active': True},
+ related_name='vacancies_department', verbose_name=_('Отдел'),
+ )
+ pharmacy = models.ForeignKey(
+ 'locations.Pharmacy', on_delete=models.SET_NULL,
+ null=True, blank=True,
+ related_name='pharmacy_vacancies', verbose_name=_('Аптека'),
+ )
+ location = models.ForeignKey(
+ 'locations.Location', on_delete=models.SET_NULL,
+ null=True, blank=True,
+ related_name='location_vacancies', verbose_name=_('Локация'),
+ )
+ priority = models.CharField(
+ _('Приоритет'), max_length=16, choices=PRIORITY_CHOICES, default='normal',
+ )
+ status = models.CharField(
+ _('Статус'), max_length=16, choices=STATUS_CHOICES, default='open',
+ )
+ opened_at = models.DateField(_('Открыта с'), default=timezone.localdate)
+ description = models.TextField(_('Описание'), blank=True)
+ candidates_count = models.PositiveIntegerField(_('Кандидатов'), default=0)
+ created_by = models.ForeignKey(
+ 'employees.Employee', on_delete=models.SET_NULL,
+ null=True, blank=True,
+ related_name='vacancies_created', verbose_name=_('Создал'),
+ )
+
+ class Meta:
+ verbose_name = _('Вакансия')
+ verbose_name_plural = _('Вакансии')
+ ordering = ['-opened_at', '-created_at']
+ indexes = [
+ models.Index(fields=['status', 'priority']),
+ ]
+
+ def __str__(self):
+ return self.title
+
+ @property
+ def place_label(self) -> str:
+ if self.user_location_type == 'pharmacy' and self.pharmacy_id:
+ return str(self.pharmacy)
+ if self.department_id:
+ return self.department.name
+ if self.location_id:
+ return str(self.location)
+ return '—'
diff --git a/apps/hiring/pdf.py b/apps/hiring/pdf.py
index 59962ed..c1cd28d 100644
--- a/apps/hiring/pdf.py
+++ b/apps/hiring/pdf.py
@@ -18,7 +18,7 @@ def _resolve_font():
return None
-def build_credentials_pdf(hiring_request) -> bytes:
+def build_credentials_pdf(hiring_request, *, invite_url: str = '') -> bytes:
from fpdf import FPDF
from fpdf.enums import XPos, YPos
@@ -50,7 +50,7 @@ def build_credentials_pdf(hiring_request) -> bytes:
f"{_('Должность')}: {employee.post.name if employee.post_id else dash}",
f"{_('Тип локации')}: {hiring_request.get_user_location_type_display()}",
f"{_('Логин')}: {employee.username}",
- f"{_('Пароль')}: {hiring_request.issued_password or dash}",
+ f"{_('Посилання для пароля')}: {invite_url or dash}",
f"{_('Рабочий email')}: {employee.email or dash}",
'',
f"{_('Запрошенные доступы')}:",
diff --git a/apps/hiring/recruiting.py b/apps/hiring/recruiting.py
new file mode 100644
index 0000000..2909fe0
--- /dev/null
+++ b/apps/hiring/recruiting.py
@@ -0,0 +1,269 @@
+"""Рекрутинг: вакансии в HR-портале."""
+from __future__ import annotations
+
+from django.contrib.auth.decorators import login_required
+from django.core.exceptions import ValidationError
+from django.db.models import Q
+from django.shortcuts import get_object_or_404, redirect
+from django.utils import timezone
+from django.views.decorators.http import require_POST
+
+from apps.hiring.models import Vacancy
+from core.portal_access import user_can_manage_hr_portal
+
+
+RECRUITING_PERM = 'core.access_recruiting'
+MANAGER_GROUP_NAME = 'Заведующий аптекой'
+PHARMACIST_GROUP_NAME = 'Фармацевт'
+
+
+def _preview_groups(user) -> frozenset[str] | None:
+ return getattr(user, '_access_preview_groups', None)
+
+
+def user_can_view_recruiting(user) -> bool:
+ if not user or not getattr(user, 'is_authenticated', False):
+ return False
+ preview = getattr(user, '_access_preview_perms', None)
+ if preview is not None:
+ return RECRUITING_PERM in preview or 'core.access_hr_portal' in preview
+ if getattr(user, 'is_superuser', False):
+ return True
+ if user_can_manage_hr_portal(user):
+ return True
+ return user.has_perm(RECRUITING_PERM)
+
+
+def user_is_pharmacy_manager(user) -> bool:
+ if not user or not getattr(user, 'is_authenticated', False):
+ return False
+ groups = _preview_groups(user)
+ if groups is not None:
+ return MANAGER_GROUP_NAME in groups
+ return user.groups.filter(name=MANAGER_GROUP_NAME).exists()
+
+
+def user_is_pharmacist(user) -> bool:
+ if not user or not getattr(user, 'is_authenticated', False):
+ return False
+ if user_can_manage_hr_portal(user):
+ return False
+ groups = _preview_groups(user)
+ if groups is not None:
+ return PHARMACIST_GROUP_NAME in groups and MANAGER_GROUP_NAME not in groups
+ return user.groups.filter(name=PHARMACIST_GROUP_NAME).exists()
+
+
+def user_can_manage_vacancies(user) -> bool:
+ """HR или заведующий с правом рекрутинга — создание/редактирование вакансий.
+
+ Фармацевт только смотрит вакансии. В access-preview superuser не обходит роль.
+ """
+ if not user or not getattr(user, 'is_authenticated', False):
+ return False
+ preview = getattr(user, '_access_preview_perms', None)
+ if preview is not None:
+ if user_is_pharmacist(user):
+ return False
+ if 'core.access_hr_portal' in preview:
+ return True
+ return user_is_pharmacy_manager(user) and RECRUITING_PERM in preview
+ if getattr(user, 'is_superuser', False):
+ return True
+ if user_can_manage_hr_portal(user):
+ return True
+ if user_is_pharmacist(user):
+ return False
+ return user_is_pharmacy_manager(user) and user.has_perm(RECRUITING_PERM)
+
+
+def vacancies_queryset_for(user):
+ qs = Vacancy.objects.select_related(
+ 'post', 'department', 'pharmacy', 'location', 'created_by',
+ )
+ preview = getattr(user, '_access_preview_perms', None)
+ if preview is None and (
+ user_can_manage_hr_portal(user) or getattr(user, 'is_superuser', False)
+ ):
+ return qs
+ if preview is not None and 'core.access_hr_portal' in preview:
+ return qs
+ # Фармацевты / заведующие — открытые + свои аптечные
+ pharmacy_id = getattr(user, 'user_pharmacy_id', None)
+ if pharmacy_id:
+ return qs.filter(Q(status='open') | Q(pharmacy_id=pharmacy_id))
+ return qs.filter(status='open')
+
+
+def vacancy_stats_for(user) -> dict:
+ qs = vacancies_queryset_for(user)
+ today = timezone.localdate()
+ month_start = today.replace(day=1)
+ open_qs = qs.filter(status='open')
+ return {
+ 'vacancies_open': open_qs.count(),
+ 'vacancies_urgent': open_qs.filter(priority='urgent').count(),
+ 'vacancies_candidates': sum(open_qs.values_list('candidates_count', flat=True)),
+ 'vacancies_filled_month': qs.filter(
+ status='filled', updated_at__date__gte=month_start,
+ ).count(),
+ }
+
+
+def recruiting_context(request) -> dict:
+ from apps.locations.models.pharmacies import Pharmacy
+ from apps.locations.models import Location
+ from core.models import Directory
+
+ can_view = user_can_view_recruiting(request.user)
+ can_manage = user_can_manage_vacancies(request.user)
+ vacancies = (
+ list(vacancies_queryset_for(request.user).order_by('-opened_at', '-pk')[:200])
+ if can_view else []
+ )
+ stats = vacancy_stats_for(request.user) if can_view else {
+ 'vacancies_open': 0,
+ 'vacancies_urgent': 0,
+ 'vacancies_candidates': 0,
+ 'vacancies_filled_month': 0,
+ }
+ ctx = {
+ 'can_view_recruiting': can_view,
+ 'can_manage_vacancies': can_manage,
+ 'vacancies': vacancies,
+ **stats,
+ 'vacancy_message': request.session.pop('vacancy_message', ''),
+ 'vacancy_error': request.session.pop('vacancy_error', ''),
+ 'vacancy_posts': [],
+ 'vacancy_departments': [],
+ 'vacancy_pharmacies': [],
+ 'vacancy_locations': [],
+ }
+ if can_manage:
+ ctx['vacancy_posts'] = list(
+ Directory.objects.filter(directory_type__code='post', is_active=True).order_by('name')
+ )
+ ctx['vacancy_departments'] = list(
+ Directory.objects.filter(directory_type__code='department', is_active=True).order_by('name')
+ )
+ if user_can_manage_hr_portal(request.user) or request.user.is_superuser:
+ ctx['vacancy_pharmacies'] = list(
+ Pharmacy.objects.filter(is_active=True).order_by('pharmacy_number')[:500]
+ )
+ ctx['vacancy_locations'] = list(
+ Location.objects.filter(is_active=True).order_by('street', 'house_number')[:500]
+ )
+ else:
+ ph = getattr(request.user, 'user_pharmacy', None)
+ ctx['vacancy_pharmacies'] = [ph] if ph else []
+ return ctx
+
+
+def save_vacancy_from_post(request, vacancy: Vacancy | None = None) -> Vacancy:
+ user = request.user
+ is_hr = user_can_manage_hr_portal(user) or user.is_superuser
+ title = (request.POST.get('title') or '').strip()
+ if not title:
+ raise ValidationError({'title': 'Укажите название вакансии.'})
+
+ post_id = (request.POST.get('post_id') or '').strip()
+ loc_type = (request.POST.get('user_location_type') or 'pharmacy').strip()
+ if loc_type not in dict(Vacancy.LOCATION_TYPES):
+ loc_type = 'pharmacy'
+ priority = (request.POST.get('priority') or 'normal').strip()
+ if priority not in dict(Vacancy.PRIORITY_CHOICES):
+ priority = 'normal'
+ description = (request.POST.get('description') or '').strip()
+ candidates_raw = (request.POST.get('candidates_count') or '0').strip()
+ try:
+ candidates_count = max(0, int(candidates_raw))
+ except ValueError:
+ candidates_count = 0
+
+ pharmacy_id = (request.POST.get('pharmacy_id') or '').strip()
+ department_id = (request.POST.get('department_id') or '').strip()
+ location_id = (request.POST.get('location_id') or '').strip()
+
+ if not is_hr:
+ # Заведующий — только своя аптека
+ loc_type = 'pharmacy'
+ pharmacy_id = str(getattr(user, 'user_pharmacy_id', '') or '')
+ if not pharmacy_id:
+ raise ValidationError({'pharmacy_id': 'У вашей учётной записи не указана аптека.'})
+ department_id = ''
+ location_id = ''
+
+ obj = vacancy or Vacancy(created_by=user, opened_at=timezone.localdate())
+ obj.title = title
+ obj.post_id = int(post_id) if post_id.isdigit() else None
+ obj.user_location_type = loc_type
+ obj.priority = priority
+ obj.description = description
+ obj.candidates_count = candidates_count
+ obj.pharmacy_id = int(pharmacy_id) if pharmacy_id.isdigit() else None
+ obj.department_id = int(department_id) if department_id.isdigit() else None
+ obj.location_id = int(location_id) if location_id.isdigit() else None
+ if loc_type == 'pharmacy':
+ obj.department = None
+ obj.location = None
+ else:
+ obj.pharmacy = None
+ if vacancy is None:
+ obj.created_by = user
+ obj.full_clean()
+ obj.save()
+ return obj
+
+
+@login_required
+@require_POST
+def vacancy_save_view(request):
+ if not user_can_manage_vacancies(request.user):
+ request.session['vacancy_error'] = 'Нет прав на изменение вакансий.'
+ return redirect('/hr-portal/?tab=vacancies')
+
+ pk = (request.POST.get('pk') or '').strip()
+ vacancy = None
+ if pk.isdigit():
+ vacancy = get_object_or_404(Vacancy, pk=int(pk))
+ if not (user_can_manage_hr_portal(request.user) or request.user.is_superuser):
+ if vacancy.pharmacy_id != getattr(request.user, 'user_pharmacy_id', None):
+ request.session['vacancy_error'] = 'Можно редактировать только вакансии своей аптеки.'
+ return redirect('/hr-portal/?tab=vacancies')
+
+ try:
+ save_vacancy_from_post(request, vacancy)
+ request.session['vacancy_message'] = 'Вакансия сохранена.'
+ except ValidationError as exc:
+ msgs = []
+ if hasattr(exc, 'message_dict'):
+ for errs in exc.message_dict.values():
+ msgs.extend(errs)
+ elif hasattr(exc, 'messages'):
+ msgs.extend(exc.messages)
+ request.session['vacancy_error'] = '; '.join(str(m) for m in msgs) or str(exc)
+
+ return redirect('/hr-portal/?tab=vacancies')
+
+
+@login_required
+@require_POST
+def vacancy_set_status_view(request, pk: int):
+ if not user_can_manage_vacancies(request.user):
+ request.session['vacancy_error'] = 'Нет прав на изменение вакансий.'
+ return redirect('/hr-portal/?tab=vacancies')
+
+ vacancy = get_object_or_404(Vacancy, pk=pk)
+ if not (user_can_manage_hr_portal(request.user) or request.user.is_superuser):
+ if vacancy.pharmacy_id != getattr(request.user, 'user_pharmacy_id', None):
+ request.session['vacancy_error'] = 'Можно менять только вакансии своей аптеки.'
+ return redirect('/hr-portal/?tab=vacancies')
+
+ status = (request.POST.get('status') or '').strip()
+ if status not in dict(Vacancy.STATUS_CHOICES):
+ request.session['vacancy_error'] = 'Некорректный статус.'
+ return redirect('/hr-portal/?tab=vacancies')
+ vacancy.status = status
+ vacancy.save(update_fields=['status', 'updated_at'])
+ request.session['vacancy_message'] = 'Статус вакансии обновлён.'
+ return redirect('/hr-portal/?tab=vacancies')
diff --git a/apps/hiring/services.py b/apps/hiring/services.py
index 8c738dd..57469c9 100644
--- a/apps/hiring/services.py
+++ b/apps/hiring/services.py
@@ -216,7 +216,6 @@ def approve_hiring_request(hiring_request, decided_by, note=''):
return locked
username = _generate_username(locked.last_name, locked.first_name)
- password = _generate_password()
employee = Employee(
username=username,
@@ -233,16 +232,19 @@ def approve_hiring_request(hiring_request, decided_by, note=''):
user_pharmacy_id=locked.user_pharmacy_id,
user_location_id=locked.user_location_id,
post_id=locked.post_id,
- ad_sync_enabled=(locked.user_location_type == 'office'),
+ ad_sync_enabled=False,
)
if _should_assign_office_email(locked.user_location_type):
employee.email = _office_email_for(username)
- employee.set_password(password)
+ employee.set_unusable_password()
employee.full_clean()
employee.save()
+ from apps.employees.services.password_invite import create_password_invite
+ invite = create_password_invite(employee, created_by=decided_by)
+
locked.employee = employee
- locked.issued_password = password
+ locked.issued_password = ''
locked.status = 'approved'
locked.decision_by = decided_by
locked.decision_at = timezone.now()
@@ -253,6 +255,16 @@ def approve_hiring_request(hiring_request, decided_by, note=''):
])
locked.release_intern()
_close_ticket(locked.granted_ticket)
+
+ from core.security import log_security_event
+
+ log_security_event(
+ 'employee.created_via_hiring',
+ actor=decided_by,
+ target=str(employee.pk),
+ details=f'username={employee.username} location={employee.user_location_type} invite={invite.pk}',
+ )
+ locked._password_invite = invite # для view/шаблону (не зберігається в БД)
return locked
@@ -276,23 +288,33 @@ def reject_hiring_request(hiring_request, decided_by, note=''):
@transaction.atomic
-def consume_issued_password_for_pdf(hiring_request):
- """Повертає PDF-байти й одразу очищає issued_password (one-time download)."""
+def build_hiring_credentials_pdf(hiring_request, request=None):
+ """PDF з логіном і посиланням на встановлення пароля."""
from apps.hiring.pdf import build_credentials_pdf
+ from apps.employees.services.password_invite import (
+ build_invite_absolute_url,
+ create_password_invite,
+ get_active_invite_for_employee,
+ )
locked = HiringRequest.objects.select_for_update().get(pk=hiring_request.pk)
- if (
- locked.status != 'approved'
- or locked.employee_id is None
- or not locked.issued_password
- ):
+ if locked.status != 'approved' or locked.employee_id is None:
return None
- # Related для PDF — окремим запитом без FOR UPDATE (nullable FK + Postgres).
+
+ invite = get_active_invite_for_employee(locked.employee)
+ if invite is None and not locked.employee.has_usable_password():
+ invite = create_password_invite(locked.employee, created_by=None)
+
+ invite_url = build_invite_absolute_url(request, invite) if invite else ''
locked = (
HiringRequest.objects.select_related('employee', 'post')
.prefetch_related('requested_access')
.get(pk=locked.pk)
)
- content = build_credentials_pdf(locked)
- HiringRequest.objects.filter(pk=locked.pk).update(issued_password='')
- return content
+ return build_credentials_pdf(locked, invite_url=invite_url)
+
+
+@transaction.atomic
+def consume_issued_password_for_pdf(hiring_request):
+ """Зворотна сумісність: PDF без одноразового пароля."""
+ return build_hiring_credentials_pdf(hiring_request)
diff --git a/apps/hiring/tests.py b/apps/hiring/tests.py
index dedb283..731dcfd 100644
--- a/apps/hiring/tests.py
+++ b/apps/hiring/tests.py
@@ -1,5 +1,6 @@
"""Тести найму: пул інтернів, IT Ticket, Employee лише після approve, PDF."""
from datetime import date, timedelta
+from unittest import mock
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
@@ -17,14 +18,7 @@ Employee = get_user_model()
_employee_seq = 0
-def login_2fa(client, user):
- client.force_login(user)
- if user.is_staff and not user.totp_enabled:
- user.totp_enabled = True
- user.save(update_fields=['totp_enabled'])
- session = client.session
- session['2fa_verified'] = True
- session.save()
+from core.test_utils import login_2fa
def make_employee(**overrides):
@@ -217,9 +211,9 @@ class HiringServiceTests(TestCase):
self.assertEqual(approved.status, 'approved')
self.assertEqual(approved.decision_by, self.hr_user)
self.assertIsNotNone(approved.employee)
- self.assertTrue(approved.issued_password)
+ self.assertEqual(approved.issued_password, '')
+ self.assertFalse(approved.employee.has_usable_password())
self.assertEqual(approved.employee.probation_end_date, date.today())
- self.assertTrue(approved.employee.check_password(approved.issued_password))
self.intern.refresh_from_db()
self.assertIsNone(self.intern.assigned_to_id)
@@ -264,7 +258,8 @@ class HiringServiceTests(TestCase):
hr.refresh_from_db()
self.assertTrue(hr.employee.email.endswith('@local.domain'))
- def test_approve_ad_sync_only_for_office(self):
+ def test_approve_does_not_enable_ad_sync(self):
+ """HR approve не включает AD sync — учётку в домене создаёт только IT."""
office_hr = create_hiring_request(**self._office_kwargs())
make_intern('Intern-2')
pharmacy_hr = create_hiring_request(**self._pharmacy_kwargs(
@@ -274,9 +269,15 @@ class HiringServiceTests(TestCase):
approve_hiring_request(pharmacy_hr, decided_by=self.hr_user)
office_hr.refresh_from_db()
pharmacy_hr.refresh_from_db()
- self.assertTrue(office_hr.employee.ad_sync_enabled)
+ self.assertFalse(office_hr.employee.ad_sync_enabled)
self.assertFalse(pharmacy_hr.employee.ad_sync_enabled)
+ def test_approve_office_does_not_queue_ad_sync(self):
+ hr = create_hiring_request(**self._office_kwargs())
+ with mock.patch('apps.ad_connector.tasks.sync_employee_to_ad_task.delay') as delay:
+ approve_hiring_request(hr, decided_by=self.hr_user)
+ delay.assert_not_called()
+
def test_approve_generates_unique_username(self):
hr1 = create_hiring_request(**self._pharmacy_kwargs(
last_name='Дубль', first_name='Петро', hire_date=date(2026, 6, 1), trial_days=1,
@@ -333,7 +334,7 @@ class HiringServiceTests(TestCase):
hr.refresh_from_db()
self.assertEqual(hr.issued_password, '')
second = client.get(url)
- self.assertEqual(second.status_code, 403)
+ self.assertEqual(second.status_code, 200)
def test_credentials_pdf_forbidden_while_trial(self):
hr = create_hiring_request(**self._pharmacy_kwargs(
diff --git a/apps/hiring/tests_recruiting.py b/apps/hiring/tests_recruiting.py
new file mode 100644
index 0000000..b46e5ea
--- /dev/null
+++ b/apps/hiring/tests_recruiting.py
@@ -0,0 +1,105 @@
+from datetime import date
+
+from django.contrib.auth.models import Group, Permission
+from django.contrib.contenttypes.models import ContentType
+from django.test import TestCase
+
+from apps.employees.models import Employee
+from apps.hiring.models import Vacancy
+from apps.hiring.recruiting import (
+ user_can_manage_vacancies,
+ user_can_view_recruiting,
+)
+from core.models import PortalAccess
+from core.test_utils import login_2fa
+
+
+def make_employee(**overrides):
+ n = Employee.objects.count() + 1
+ defaults = {
+ 'username': f'rec.user{n}',
+ 'email': f'rec.user{n}@company.local',
+ 'first_name': 'Тест',
+ 'last_name': 'Рекрутинг',
+ 'hire_date': date(2026, 1, 1),
+ 'user_location_type': 'pharmacy',
+ 'mattermost_sync_enabled': False,
+ 'ad_sync_enabled': False,
+ 'is_staff': False,
+ }
+ defaults.update(overrides)
+ return Employee.objects.create(**defaults)
+
+
+def portal_perm(codename: str) -> Permission:
+ ct = ContentType.objects.get_for_model(PortalAccess, for_concrete_model=False)
+ return Permission.objects.get(content_type=ct, codename=codename)
+
+
+def clear_perm_cache(user):
+ for attr in ('_perm_cache', '_user_perm_cache', '_group_perm_cache'):
+ if hasattr(user, attr):
+ delattr(user, attr)
+
+
+class RecruitingAccessTests(TestCase):
+ def test_pharmacist_can_view_vacancies_tab(self):
+ user = make_employee()
+ group = Group.objects.get(name='Фармацевт')
+ group.permissions.add(portal_perm('access_recruiting'))
+ user.groups.add(group)
+ clear_perm_cache(user)
+ self.assertTrue(user_can_view_recruiting(user))
+ self.assertFalse(user_can_manage_vacancies(user))
+
+ login_2fa(self.client, user)
+ resp = self.client.get('/hr-portal/?tab=vacancies', HTTP_HX_REQUEST='true')
+ self.assertEqual(resp.status_code, 200)
+ self.assertTrue(resp.context['can_view_recruiting'])
+ self.assertEqual(resp.context['hr_active_tab'], 'vacancies')
+
+ def test_manager_can_create_vacancy(self):
+ from apps.locations.models.pharmacies import Pharmacy
+ from core.models import Directory, DirectoryType
+
+ org_type, _ = DirectoryType.objects.get_or_create(
+ code='organization', defaults={'name': 'Организация'},
+ )
+ region_type, _ = DirectoryType.objects.get_or_create(
+ code='region', defaults={'name': 'Регион'},
+ )
+ region = Directory.objects.create(directory_type=region_type, code='rec-r', name='Рег')
+ org = Directory.objects.create(directory_type=org_type, code='rec-o', name='Орг')
+ pharmacy = Pharmacy.objects.create(
+ organization=org,
+ location_region=region,
+ pharmacy_number=99,
+ location_type='pharmacy',
+ street='Тест',
+ house_number='1',
+ status='open',
+ )
+ user = make_employee(user_pharmacy=pharmacy)
+ group = Group.objects.get(name='Заведующий аптекой')
+ group.permissions.add(portal_perm('access_recruiting'))
+ user.groups.add(group)
+ clear_perm_cache(user)
+ self.assertTrue(user_can_manage_vacancies(user))
+
+ login_2fa(self.client, user)
+ resp = self.client.post('/vacancies/save/', {
+ 'title': 'Фармацевт',
+ 'priority': 'urgent',
+ 'candidates_count': '2',
+ 'user_location_type': 'pharmacy',
+ 'pharmacy_id': str(pharmacy.pk),
+ })
+ self.assertEqual(resp.status_code, 302)
+ self.assertTrue(Vacancy.objects.filter(title='Фармацевт', pharmacy=pharmacy).exists())
+
+ def test_recruiting_redirects_to_hr(self):
+ user = make_employee(user_location_type='office')
+ login_2fa(self.client, user)
+ resp = self.client.get('/recruiting/')
+ self.assertEqual(resp.status_code, 302)
+ self.assertIn('/hr-portal/', resp['Location'])
diff --git a/static/assets/js/hr-portal.js b/static/assets/js/hr-portal.js
index 4dea643..14621fb 100644
--- a/static/assets/js/hr-portal.js
+++ b/static/assets/js/hr-portal.js
@@ -1,17 +1,23 @@
+function hrPortalRoot() {
+ return document.querySelector('#main-content .page-content[data-hr-active-tab]');
+}
+
function hrTabSwitch(tab) {
- document.querySelectorAll('.hr-panel').forEach(function (panel) {
- panel.style.display = panel.id === 'hr-panel-' + tab ? '' : 'none';
+ var root = hrPortalRoot();
+ if (!root) return;
+ root.querySelectorAll('.hr-panel').forEach(function (panel) {
+ panel.style.display = panel.id === 'hr-panel-' + tab ? 'block' : 'none';
});
- document.querySelectorAll('.hr-tab-btn').forEach(function (btn) {
+ root.querySelectorAll('.hr-tab-btn').forEach(function (btn) {
var active = btn.getAttribute('data-tab') === tab;
btn.classList.toggle('hr-tab-active', active);
btn.style.background = active ? 'var(--color-card)' : 'transparent';
btn.style.color = active ? 'var(--color-card-foreground)' : 'var(--color-muted-foreground)';
btn.style.boxShadow = active ? '0 1px 2px rgb(0 0 0/0.08)' : '';
});
- var viewToggle = document.getElementById('hr-view-toggle');
+ var viewToggle = root.querySelector('#hr-view-toggle');
if (viewToggle) {
- viewToggle.style.display = (tab === 'employees') ? '' : 'none';
+ viewToggle.style.display = (tab === 'employees') ? 'flex' : 'none';
}
if (tab === 'hiring') hrHiringInit();
if (tab === 'employees') hrFilterEmployees();
@@ -116,10 +122,7 @@ function hrFilterEmployees() {
}
}
-/* Деревовидний пікер відділів (фільтр HR-порталу). Дерево передається один
- раз через {{ hr_department_tree|json_script:"hr-dept-tree-data" }} —
- будується лениво при першому відкритті, щоб не залежати від моменту
- ініціалізації (важливо для HTMX-фрагментів і жорсткого перезавантаження). */
+/* Деревовидный пикер отделов: фильтр сотрудников и форма найма. */
(function () {
function collectIds(node, acc) {
acc.push(String(node.id));
@@ -127,21 +130,36 @@ function hrFilterEmployees() {
return acc;
}
- function selectDept(node) {
- var input = document.getElementById('hr-department-select');
- var label = document.getElementById('hr-dept-picker-label');
- if (node) {
- input.value = collectIds(node, []).join(',');
- label.textContent = node.name;
- } else {
- input.value = '';
- label.textContent = 'Всі відділи';
+ function buildPathLabel(node) {
+ var parts = [];
+ var current = node;
+ while (current) {
+ parts.unshift(current.name);
+ current = current.parentRef;
}
- closeDeptPicker();
- hrFilterEmployees();
+ return parts.join(' → ');
}
- function renderNode(node, container, depth) {
+ function attachParentRefs(nodes, parent) {
+ nodes.forEach(function (node) {
+ node.parentRef = parent || null;
+ if (node.children && node.children.length) {
+ attachParentRefs(node.children, node);
+ }
+ });
+ }
+
+ function readTree(treeDataId) {
+ var dataEl = document.getElementById(treeDataId);
+ var tree = [];
+ if (dataEl) {
+ try { tree = JSON.parse(dataEl.textContent) || []; } catch (e) { tree = []; }
+ }
+ attachParentRefs(tree, null);
+ return tree;
+ }
+
+ function renderNode(node, container, depth, picker) {
var row = document.createElement('div');
row.style.display = 'flex';
row.style.alignItems = 'center';
@@ -171,7 +189,7 @@ function hrFilterEmployees() {
label.style.color = 'var(--color-foreground)';
row.appendChild(label);
- if (node.count) {
+ if (picker.showCounts && node.count) {
var count = document.createElement('span');
count.textContent = node.count;
count.style.fontSize = '0.6875rem';
@@ -185,7 +203,7 @@ function hrFilterEmployees() {
childrenWrap = document.createElement('div');
childrenWrap.style.display = 'none';
container.appendChild(childrenWrap);
- node.children.forEach(function (child) { renderNode(child, childrenWrap, depth + 1); });
+ node.children.forEach(function (child) { renderNode(child, childrenWrap, depth + 1, picker); });
toggle.addEventListener('click', function (e) {
e.stopPropagation();
@@ -197,62 +215,118 @@ function hrFilterEmployees() {
label.addEventListener('click', function (e) {
e.stopPropagation();
- selectDept(node);
+ picker.selectNode(node);
});
}
- function buildPanel() {
- var panel = document.getElementById('hr-dept-picker-panel');
- if (!panel || panel.dataset.built) return;
- panel.dataset.built = '1';
-
- var allRow = document.createElement('div');
- allRow.textContent = 'Всі відділи';
- allRow.style.padding = '0.375rem 0.5rem';
- allRow.style.borderRadius = 'calc(var(--radius) - 2px)';
- allRow.style.cursor = 'pointer';
- allRow.style.fontSize = '0.8125rem';
- allRow.style.fontWeight = '500';
- allRow.style.color = 'var(--color-foreground)';
- allRow.style.marginBottom = '0.25rem';
- allRow.onmouseover = function () { allRow.style.background = 'var(--color-muted)'; };
- allRow.onmouseout = function () { allRow.style.background = ''; };
- allRow.addEventListener('click', function (e) {
- e.stopPropagation();
- selectDept(null);
- });
- panel.appendChild(allRow);
-
- var dataEl = document.getElementById('hr-dept-tree-data');
- var tree = [];
- if (dataEl) {
- try { tree = JSON.parse(dataEl.textContent) || []; } catch (e) { tree = []; }
- }
- tree.forEach(function (node) { renderNode(node, panel, 0); });
- }
-
- function closeDeptPicker() {
- var panel = document.getElementById('hr-dept-picker-panel');
- if (panel) panel.style.display = 'none';
- }
-
- window.hrDeptPickerToggle = function (e) {
- e.stopPropagation();
- buildPanel();
- var panel = document.getElementById('hr-dept-picker-panel');
- if (!panel) return;
- var opening = panel.style.display === 'none';
- panel.style.display = opening ? 'block' : 'none';
- if (opening) {
- document.addEventListener('click', function onDocClick(ev) {
- var picker = document.querySelector('.hr-dept-picker');
- if (picker && !picker.contains(ev.target)) {
- closeDeptPicker();
- document.removeEventListener('click', onDocClick);
+ function createDeptPicker(config) {
+ var picker = {
+ inputId: config.inputId,
+ labelId: config.labelId,
+ panelId: config.panelId,
+ treeDataId: config.treeDataId || 'hr-dept-tree-data',
+ allLabel: config.allLabel || 'Всі відділи',
+ emptyLabel: config.emptyLabel || '—',
+ mode: config.mode || 'filter',
+ showCounts: config.showCounts !== false,
+ showAllOption: config.showAllOption !== false,
+ onChange: config.onChange || function () {},
+ selectNode: function (node) {
+ var input = document.getElementById(picker.inputId);
+ var label = document.getElementById(picker.labelId);
+ if (!input || !label) return;
+ if (node) {
+ input.value = picker.mode === 'single'
+ ? String(node.id)
+ : collectIds(node, []).join(',');
+ label.textContent = picker.mode === 'single'
+ ? buildPathLabel(node)
+ : node.name;
+ } else {
+ input.value = '';
+ label.textContent = picker.mode === 'single'
+ ? (label.getAttribute('data-empty-label') || picker.emptyLabel)
+ : picker.allLabel;
}
- });
- }
- };
+ picker.close();
+ picker.onChange(node);
+ },
+ close: function () {
+ var panel = document.getElementById(picker.panelId);
+ if (panel) panel.style.display = 'none';
+ },
+ buildPanel: function () {
+ var panel = document.getElementById(picker.panelId);
+ if (!panel || panel.dataset.built) return;
+ panel.dataset.built = '1';
+ panel.innerHTML = '';
+
+ if (picker.showAllOption) {
+ var allRow = document.createElement('div');
+ allRow.textContent = picker.mode === 'single' ? picker.emptyLabel : picker.allLabel;
+ allRow.style.padding = '0.375rem 0.5rem';
+ allRow.style.borderRadius = 'calc(var(--radius) - 2px)';
+ allRow.style.cursor = 'pointer';
+ allRow.style.fontSize = '0.8125rem';
+ allRow.style.fontWeight = '500';
+ allRow.style.color = 'var(--color-foreground)';
+ allRow.style.marginBottom = '0.25rem';
+ allRow.onmouseover = function () { allRow.style.background = 'var(--color-muted)'; };
+ allRow.onmouseout = function () { allRow.style.background = ''; };
+ allRow.addEventListener('click', function (e) {
+ e.stopPropagation();
+ picker.selectNode(null);
+ });
+ panel.appendChild(allRow);
+ }
+
+ readTree(picker.treeDataId).forEach(function (node) {
+ renderNode(node, panel, 0, picker);
+ });
+ },
+ toggle: function (e) {
+ if (e) e.stopPropagation();
+ picker.buildPanel();
+ var panel = document.getElementById(picker.panelId);
+ if (!panel) return;
+ var opening = panel.style.display === 'none';
+ panel.style.display = opening ? 'block' : 'none';
+ if (opening) {
+ document.addEventListener('click', function onDocClick(ev) {
+ var wrap = panel.closest('.hr-dept-picker');
+ if (wrap && !wrap.contains(ev.target)) {
+ picker.close();
+ document.removeEventListener('click', onDocClick);
+ }
+ });
+ }
+ },
+ };
+ return picker;
+ }
+
+ var hrFilterDeptPicker = createDeptPicker({
+ inputId: 'hr-department-select',
+ labelId: 'hr-dept-picker-label',
+ panelId: 'hr-dept-picker-panel',
+ mode: 'filter',
+ allLabel: 'Всі відділи',
+ onChange: function () { hrFilterEmployees(); },
+ });
+
+ var hrHiringDeptPicker = createDeptPicker({
+ inputId: 'hr-hiring-department-select',
+ labelId: 'hr-hiring-dept-picker-label',
+ panelId: 'hr-hiring-dept-picker-panel',
+ mode: 'single',
+ showCounts: false,
+ showAllOption: true,
+ emptyLabel: '—',
+ onChange: function () {},
+ });
+
+ window.hrDeptPickerToggle = hrFilterDeptPicker.toggle;
+ window.hrHiringDeptPickerToggle = hrHiringDeptPicker.toggle;
})();
function hrHiringLocToggle(locationType) {
@@ -277,7 +351,13 @@ function hrHiringLocToggle(locationType) {
if (deptSelect) {
deptSelect.required = isOfficeLike;
- if (isPharmacy) deptSelect.value = '';
+ if (isPharmacy) {
+ deptSelect.value = '';
+ var deptLabel = document.getElementById('hr-hiring-dept-picker-label');
+ if (deptLabel) {
+ deptLabel.textContent = deptLabel.getAttribute('data-empty-label') || '—';
+ }
+ }
}
if (pharmSelect) {
pharmSelect.required = isPharmacy;
@@ -346,30 +426,88 @@ function hrHiringInit() {
hrHiringLocToggle(sel.value);
}
+/** Показати форму кадрової заявки на вкладці «Заявки». */
+function hrShowRequestForm(type, label) {
+ hrTabSwitch('requests');
+ var wrap = document.getElementById('hr-request-form-wrap');
+ var input = document.getElementById('hr-request-type');
+ var labelEl = document.getElementById('hr-request-type-label');
+ if (wrap) wrap.style.display = '';
+ if (input && type) {
+ input.value = type;
+ hrRequestTypeToggle(type);
+ }
+ if (labelEl && label) labelEl.textContent = label;
+}
+
+function hrHideRequestForm() {
+ var wrap = document.getElementById('hr-request-form-wrap');
+ if (wrap) wrap.style.display = 'none';
+}
+
+function hrShowVacancyForm() {
+ var wrap = document.getElementById('hr-vacancy-form-wrap');
+ if (wrap) wrap.style.display = 'block';
+ var sel = document.getElementById('hr-vacancy-loc-type');
+ if (sel) hrVacancyLocToggle(sel.value || 'pharmacy');
+}
+
+function hrHideVacancyForm() {
+ var wrap = document.getElementById('hr-vacancy-form-wrap');
+ if (wrap) wrap.style.display = 'none';
+}
+
+function hrToggleVacancyForm() {
+ var wrap = document.getElementById('hr-vacancy-form-wrap');
+ if (!wrap) return;
+ if (wrap.style.display === 'none' || !wrap.style.display) {
+ hrShowVacancyForm();
+ } else {
+ hrHideVacancyForm();
+ }
+}
+
+function hrVacancyLocToggle(locType) {
+ var isPharmacy = locType === 'pharmacy';
+ var pharm = document.getElementById('hr-vacancy-pharmacy-field');
+ var dept = document.getElementById('hr-vacancy-department-field');
+ if (pharm) pharm.style.display = isPharmacy ? '' : 'none';
+ if (dept) dept.style.display = isPharmacy ? 'none' : '';
+}
+
+function hrRequestTypeToggle(type) {
+ var needsDates = ['vacation', 'additional_vacation', 'sick_leave', 'day_off', 'substitution'].indexOf(type) !== -1;
+ var datesWrap = document.getElementById('hr-request-dates-wrap');
+ var dateToWrap = document.getElementById('hr-request-date-to-wrap');
+ var dateFrom = document.getElementById('hr-request-date-from');
+ var dateTo = document.getElementById('hr-request-date-to');
+ if (datesWrap) datesWrap.style.display = needsDates ? '' : 'none';
+ if (dateToWrap) dateToWrap.style.display = needsDates ? '' : 'none';
+ if (dateFrom) dateFrom.required = needsDates;
+ if (dateTo) dateTo.required = needsDates;
+}
+
/** Ініціалізація HR-порталу після завантаження / HTMX-swap. */
function hrPortalInit() {
- var root = document.querySelector('.page-content[data-hr-active-tab]');
- var activeTab = root ? root.getAttribute('data-hr-active-tab') : '';
- if (activeTab && activeTab !== 'employees') {
- hrTabSwitch(activeTab);
- } else {
- // Сбрасываем визуальное состояние вкладок на «Сотрудники»
- document.querySelectorAll('.hr-tab-btn').forEach(function (btn) {
- var active = btn.getAttribute('data-tab') === 'employees';
- btn.classList.toggle('hr-tab-active', active);
- btn.style.background = active ? 'var(--color-card)' : 'transparent';
- btn.style.color = active ? 'var(--color-card-foreground)' : 'var(--color-muted-foreground)';
- btn.style.boxShadow = active ? '0 1px 2px rgb(0 0 0/0.08)' : '';
- });
- document.querySelectorAll('.hr-panel').forEach(function (panel) {
- panel.style.display = panel.id === 'hr-panel-employees' ? '' : 'none';
- });
- var viewToggle = document.getElementById('hr-view-toggle');
- if (viewToggle) viewToggle.style.display = '';
- }
+ var root = hrPortalRoot();
+ if (!root) return;
+ var activeTab = root.getAttribute('data-hr-active-tab') || 'employees';
+ hrTabSwitch(activeTab);
- var activeLoc = document.querySelector('.hr-loc-btn.hr-loc-active');
- // За замовчуванням — «Всі» (порожній data-loc), не «Аптека».
+ var activeLoc = root.querySelector('.hr-loc-btn.hr-loc-active');
+ var reqType = root.querySelector('#hr-request-type');
+ if (reqType) {
+ hrRequestTypeToggle(reqType.value);
+ var labelEl = root.querySelector('#hr-request-type-label');
+ if (labelEl && !labelEl.textContent.trim()) {
+ var btn = root.querySelector('.hr-request-type-btn[data-request-type="' + reqType.value + '"]');
+ if (btn) labelEl.textContent = btn.getAttribute('data-request-label') || btn.textContent.trim();
+ }
+ }
+ if (root.getAttribute('data-hr-request-form-open') === '1') {
+ var formWrap = root.querySelector('#hr-request-form-wrap');
+ if (formWrap) formWrap.style.display = 'block';
+ }
var loc = activeLoc ? activeLoc.getAttribute('data-loc') : '';
if (!activeLoc) {
hrLocFilter('');
@@ -377,8 +515,8 @@ function hrPortalInit() {
hrFilterListPosts(loc || '');
hrFilterEmployees();
}
- var hiringPanel = document.getElementById('hr-panel-hiring');
- if (hiringPanel && hiringPanel.style.display !== 'none') {
+ var hiringPanel = root.querySelector('#hr-panel-hiring');
+ if (hiringPanel && hiringPanel.style.display !== 'none' && activeTab === 'hiring') {
hrHiringInit();
}
}
@@ -390,7 +528,8 @@ if (document.readyState === 'loading') {
}
// Слушаем на document: скрипт в
, document.body ещё может быть null.
document.addEventListener('htmx:afterSwap', function (ev) {
- if (ev.target && ev.target.id === 'main-content') {
+ var target = ev.detail && ev.detail.target;
+ if (target && target.id === 'main-content') {
hrPortalInit();
}
});
diff --git a/templates/pages/hr-portal.html b/templates/pages/hr-portal.html
index d7ab680..e9bc98f 100644
--- a/templates/pages/hr-portal.html
+++ b/templates/pages/hr-portal.html
@@ -1,11 +1,18 @@
{% load common_tags %}
{% load i18n %}
{% trans "Должность не указана" as no_post %}
-
+
+{% with hr_tab=hr_active_tab|default:'employees' %}
{% trans "HR Портал" %}
-
{% trans "Управление персоналом и кадровые процессы" %}
+
+ {% if can_manage_hiring %}
+ {% trans "Управление персоналом и кадровые процессы" %}
+ {% else %}
+ {% trans "Справочник сотрудников и кадровые заявки" %}
+ {% endif %}
+
{% if hiring_just_created %}
@@ -36,7 +43,21 @@
{% endif %}
-
+ {% if hr_request_created %}
+
+ {% trans "Заявку подано" %}: {{ hr_request_created.summary }}
+
+ {% endif %}
+
+ {% if hr_request_errors %}
+
+
{% trans "Не удалось подать заявку" %}
+ {% for err in hr_request_errors %}
{{ err }}
{% endfor %}
+
+ {% endif %}
+
+ {% if can_manage_hiring %}
+
@@ -83,8 +104,8 @@
{% trans "Заявки на отпуск" %}
-
3
-
1 {% trans "ожидает решения" %}
+
{{ hr_pending_leave_count }}
+
{% trans "ожидают решения HR" %}
+ {% endif %}
@@ -116,19 +138,25 @@
-
-
-
-
-
+
+ {% if can_manage_hiring %}
+
+ {% endif %}
+
+ {% if can_manage_hiring %}
+
+ {% endif %}
+ {% if can_view_recruiting %}
+
+ {% endif %}
-
+
-
+
@@ -220,11 +248,15 @@
Email:
- {{ employee.email|default:"—" }}
+
+ {% if request.user|can_view_employee_pii:employee %}{{ employee.email|default:"—" }}{% else %}—{% endif %}
+
{% trans "Телефон:" %}
- {{ employee.phone|default:"—" }}
+
+ {% if request.user|can_view_employee_pii:employee %}{{ employee.phone|default:"—" }}{% else %}—{% endif %}
+
-
-
-
+
{% if can_manage_hiring %}
@@ -343,7 +371,13 @@
{{ hr.intern_account.label }} · {{ hr.intern_account.username }}
{% else %}—{% endif %}
{% if hr.granted_ticket %}
-
+
+ {% if request.user|can_section:'it_manager' %}
+
{{ hr.granted_ticket.number }}
+ {% else %}
+
{% trans "Заявка IT" %}: {{ hr.granted_ticket.number }}
+ {% endif %}
+
{% endif %}
{{ hr.trial_end_date|date:"d.m.Y" }} |
@@ -403,122 +437,10 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {% trans "Мои заявки" %}
-
-
-
-
-
-
{% trans "Отпуск 01–14 мая" %}
-
{% trans "Подано 10.04.2026" %}
-
-
{% trans "На рассмотрении" %}
-
-
-
-
{% trans "Справка о зарплате" %}
-
{% trans "Подано 05.04.2026" %}
-
-
{% trans "Готово" %}
-
-
-
-
{% trans "Больничный 28–29 марта" %}
-
{% trans "Подано 01.04.2026" %}
-
-
{% trans "Утверждено" %}
-
-
-
-
-
-
-
- {% trans "Баланс отпусков — 2026" %}
-
-
-
-
-
- {% trans "Основной отпуск" %}
- 14 / 24 {% trans "дн." %}
-
-
-
{% trans "Остаток: 10 дней" %}
-
-
-
-
- {% trans "Дополнительный отпуск" %}
- 0 / 7 {% trans "дн." %}
-
-
-
{% trans "Остаток: 7 дней" %}
-
-
-
-
- {% trans "Больничные" %}
- 2 / 30 {% trans "дн." %}
-
-
-
{% trans "Остаток: 28 дней" %}
-
-
-
-
{% trans "Следующий отпуск" %}
-
- 01.05 – 14.05.2026
- {% trans "На рассмотрении" %}
-
-
-
-
-
-
-
+ {% include "partials/hr-requests-panel.html" %}
-
+
-
-
+
+
+ {% if can_view_recruiting %}
+ {% include "partials/hr-vacancies-panel.html" %}
+ {% else %}
-
-
{% trans "Раздел в разработке" %}
-
{% trans "Управление открытыми вакансиями появится в ближайшее время" %}
+
{% trans "Нет доступа к вакансиям" %}
+ {% endif %}
{% if can_manage_hiring %}
-
+
diff --git a/templates/partials/hr-requests-panel.html b/templates/partials/hr-requests-panel.html
new file mode 100644
index 0000000..b3f49c0
--- /dev/null
+++ b/templates/partials/hr-requests-panel.html
@@ -0,0 +1,217 @@
+{% load i18n %}
+
+
+ {% if hr_request_created %}
+
+ {% trans "Заявку подано" %}: {{ hr_request_created.summary }}
+
+ {% endif %}
+
+ {% if hr_request_errors %}
+
+ {% for err in hr_request_errors %}
{{ err }}
{% endfor %}
+
+ {% endif %}
+
+
+ {% for value, label in hr_request_types %}
+
+ {% empty %}
+
{% trans "Нет доступных типов заявок" %}
+ {% endfor %}
+
+
+
+
+ {% if incoming_substitutions %}
+
+
+
{% trans "Открытые запросы на подмену" %} ({{ incoming_substitutions|length }})
+
{% trans "Подтвердите, если можете выйти на подмену" %}
+
+ {% for req in incoming_substitutions %}
+
+
+
+ {{ req.employee.last_name }} {{ req.employee.first_name }} — {{ req.summary }}
+
+ {% if req.pharmacy_place %}
+
{{ req.pharmacy_place }}
+ {% endif %}
+
+ {% trans "Запрос" %} {{ req.created_at|date:"d.m.Y H:i" }}
+ {% if req.note %} · {{ req.note }}{% endif %}
+
+
+
+
+
+
+ {% endfor %}
+
+ {% endif %}
+
+
+
+
+ {% trans "Мои заявки" %}
+
+
+ {% for req in my_hr_requests %}
+
+
+
{{ req.summary }}
+ {% if req.pharmacy_place %}
+
{{ req.pharmacy_place }}
+ {% endif %}
+
+ {% trans "Подано" %} {{ req.created_at|date:"d.m.Y" }}
+ {% if req.day_count %} · {{ req.day_count }} {% trans "дн." %}{% endif %}
+ {% if req.request_type == 'substitution' and req.substitute %}
+ · {% trans "Подмена" %}: {{ req.substitute.last_name }} {{ req.substitute.first_name }}
+ {% endif %}
+
+
+
+ {% if req.status == 'pending' %}
+ {{ req.status_label }}
+
+ {% elif req.status == 'approved' %}
+ {{ req.status_label }}
+ {% elif req.status == 'rejected' %}
+ {{ req.status_label }}
+ {% else %}
+ {{ req.status_label }}
+ {% endif %}
+
+
+ {% empty %}
+
{% trans "Заявок пока нет" %}
+ {% endfor %}
+
+
+
+
+
+ {% trans "Баланс отпусков" %} — {{ leave_balance_year }}
+
+
+ {% for row in leave_balance %}
+
+
+ {{ row.label }}
+ {{ row.used }} / {{ row.entitlement }} {% trans "дн." %}
+
+
+
{% trans "Остаток" %}: {{ row.remaining }} {% trans "дн." %}{% if row.pending %} · {% trans "ожидает" %}: {{ row.pending }}{% endif %}
+
+ {% endfor %}
+ {% if next_vacation_request %}
+
+
{% trans "Следующий отпуск" %}
+
+ {{ next_vacation_request.summary }}
+ {{ next_vacation_request.get_status_display }}
+
+
+ {% endif %}
+
+
+
+
+ {% if can_decide_hr_requests and hr_pending_requests %}
+
+
+ {% trans "Заявки на рассмотрении" %} ({{ hr_pending_requests|length }})
+
+
+
+
+
+ | {% trans "Сотрудник" %} |
+ {% trans "Заявка" %} |
+ {% trans "Действия" %} |
+
+
+
+ {% for req in hr_pending_requests %}
+
+ |
+ {{ req.employee.last_name }} {{ req.employee.first_name }}
+ {% if req.pharmacy_place %}
+ {{ req.pharmacy_place }}
+ {% endif %}
+ {{ req.created_at|date:"d.m.Y H:i" }}
+ |
+
+ {{ req.summary }}
+ {% if req.note %} {{ req.note }} {% endif %}
+ |
+
+
+
+
+
+ |
+
+ {% endfor %}
+
+
+
+
+ {% endif %}
+
diff --git a/templates/partials/hr-vacancies-panel.html b/templates/partials/hr-vacancies-panel.html
new file mode 100644
index 0000000..01f5498
--- /dev/null
+++ b/templates/partials/hr-vacancies-panel.html
@@ -0,0 +1,196 @@
+{% load i18n %}
+{% if vacancy_message %}
+
{{ vacancy_message }}
+{% endif %}
+{% if vacancy_error %}
+
{{ vacancy_error }}
+{% endif %}
+
+
+
+
{% trans "Открытых вакансий" %}
+
{{ vacancies_open }}
+ {% if vacancies_urgent %}
{{ vacancies_urgent }} {% trans "срочных" %}
{% endif %}
+
+
+
{% trans "Кандидатов" %}
+
{{ vacancies_candidates }}
+
{% trans "на открытых" %}
+
+
+
{% trans "Закрыто наймом" %}
+
{{ vacancies_filled_month }}
+
{% trans "за месяц" %}
+
+
+
{% trans "Всего в списке" %}
+
{{ vacancies|length }}
+
+
+
+{% if can_manage_vacancies %}
+
+
+
+
+
+{% else %}
+
+ {% trans "Создавать вакансии могут HR и заведующий. Фармацевт подаёт запрос на подмену во вкладке «Заявки и отпуска»." %}
+
+{% endif %}
+
+
+
+
{% trans "Вакансии" %}
+
+
+
+
+
+ | {% trans "Должность" %} |
+ {% trans "Отдел / Аптека" %} |
+ {% trans "Кандидатов" %} |
+ {% trans "Приоритет" %} |
+ {% trans "Статус" %} |
+ {% trans "Открыта с" %} |
+ {% if can_manage_vacancies %} | {% endif %}
+
+
+
+ {% for v in vacancies %}
+
+ |
+ {{ v.title }}
+ {% if v.post %} {{ v.post.name }} {% endif %}
+ |
+ {{ v.place_label }} |
+ {{ v.candidates_count }} |
+
+ {% if v.priority == 'urgent' %}{% trans "Срочно" %}
+ {% elif v.priority == 'medium' %}{% trans "Средний" %}
+ {% else %}{% trans "Обычный" %}{% endif %}
+ |
+ {{ v.get_status_display }} |
+ {{ v.opened_at|date:"d.m.Y" }} |
+ {% if can_manage_vacancies %}
+
+ {% if v.status == 'open' %}
+
+
+ {% else %}
+
+ {% endif %}
+ |
+ {% endif %}
+
+ {% empty %}
+
+ | {% trans "Открытых вакансий пока нет" %} |
+
+ {% endfor %}
+
+
+
+