diff --git a/acore/settings/base.py b/acore/settings/base.py index 8c33efa..1ce32b8 100644 --- a/acore/settings/base.py +++ b/acore/settings/base.py @@ -77,6 +77,7 @@ INSTALLED_APPS = [ 'apps.tasks', 'apps.hiring', 'apps.utilities', + 'apps.rro', 'apps.ad_connector', 'apps.mail_connector', 'apps.mailparser', diff --git a/acore/urls.py b/acore/urls.py index 8971b6c..aa37f78 100644 --- a/acore/urls.py +++ b/acore/urls.py @@ -34,6 +34,7 @@ urlpatterns = [ path('', include('apps.it_manager.urls')), path('', include('apps.mattermost_connector.urls')), path('', include('apps.utilities.urls')), + path('', include('apps.rro.urls')), path('', include('apps.nextcloud_connector.urls')), path('tinymce/', include('tinymce.urls')), path('', views.index_view, name='index'), @@ -48,6 +49,7 @@ urlpatterns = [ path('employees//', views.employee_detail_view, name='employee_detail'), path('pharmacies/', views.pharmacies_view, name='pharmacies'), path('dislocation/', views.dislocation_view, name='dislocation'), + path('dislocation/columns/', views.dislocation_columns_view, name='dislocation_columns'), path('calendar/', views.calendar_view, name='calendar'), path('timetrack/', views.timetrack_view, name='timetrack'), path('reports/', views.reports_view, name='reports'), @@ -65,6 +67,7 @@ urlpatterns = [ path('hiring/', views.hiring_create_view, name='hiring_create'), path('hiring//approve/', views.hiring_approve_view, name='hiring_approve'), path('hiring//reject/', views.hiring_reject_view, name='hiring_reject'), + path('hiring//credentials.pdf/', views.hiring_credentials_pdf_view, name='hiring_credentials_pdf'), path('help/', views.help_view, name='help'), path('it-support/', views.it_support_view, name='it_support'), path('directory/quick-add/', views.directory_quick_add_view, name='directory_quick_add'), diff --git a/apps/ad_connector/admin.py b/apps/ad_connector/admin.py index 4f56804..580c0ba 100644 --- a/apps/ad_connector/admin.py +++ b/apps/ad_connector/admin.py @@ -10,6 +10,8 @@ class ADSyncLogAdmin(ModelAdmin): list_filter = ('action', 'status') search_fields = ('employee__username', 'employee__last_name', 'employee__first_name', 'message') date_hierarchy = 'created_at' + # Журнал только для просмотра — без массового удаления из changelist. + actions = None def has_add_permission(self, request): return False @@ -18,4 +20,9 @@ class ADSyncLogAdmin(ModelAdmin): return False def has_delete_permission(self, request, obj=None): - return False + # Нельзя False для всех: Django get_deleted_objects() зовёт этот метод + # при удалении Employee с CASCADE на ADSyncLog и блокирует удаление + # сообщением «нет прав на Запись синхронизации AD». + # Разрешаем CASCADE тем, кто может удалять сотрудников; UI журнала + # без delete_selected (actions = None). + return request.user.has_perm('employees.delete_employee') diff --git a/apps/ad_connector/views.py b/apps/ad_connector/views.py index d8c5c60..9c42b7b 100644 --- a/apps/ad_connector/views.py +++ b/apps/ad_connector/views.py @@ -1,7 +1,7 @@ import logging -from django.contrib.admin.views.decorators import staff_member_required from django.contrib.auth import get_user_model +from django.contrib.auth.decorators import login_required from django.shortcuts import get_object_or_404 from django.utils import timezone @@ -9,6 +9,7 @@ from apps.ad_connector.ad_service import ADConfigError, ADService from apps.ad_connector.services.sync_service import relocate_ou_references from apps.locations.models import Location from core.models import Directory +from core.portal_access import section_required from core.utils.htmx import render_page from core.utils.pagination import paginate @@ -68,7 +69,8 @@ def _find_suggestion(employee, ad_users, linked_dns): return None, None -@staff_member_required +@login_required +@section_required('ad_mapping') def ad_mapping_view(request): """Страница сопоставления офисных сотрудников с учётными записями AD""" if request.method == 'POST': @@ -177,7 +179,8 @@ def _handle_ou_action(request): service.unbind() -@staff_member_required +@login_required +@section_required('ad_mapping') def ad_ou_manage_view(request): """Управление организационными юнитами (OU) в Active Directory и их привязкой к отделам""" error, notice = None, None @@ -257,7 +260,8 @@ def _import_context(request): } -@staff_member_required +@login_required +@section_required('ad_mapping') def ad_import_view(request): """Импорт учётных записей AD, отсутствующих среди сотрудников, как новых офисных сотрудников. Ничего не создаётся, пока администратор явно не отметит и не подтвердит конкретные записи.""" diff --git a/apps/employees/admin.py b/apps/employees/admin.py index 4cd1e6c..3c11921 100644 --- a/apps/employees/admin.py +++ b/apps/employees/admin.py @@ -1,14 +1,22 @@ from django import forms from django.contrib import admin +from django.contrib.auth.models import Permission +from django.contrib.contenttypes.models import ContentType from django.utils.html import format_html from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from unfold.admin import ModelAdmin, TabularInline -from core.models import Directory -from .models import Employee, PermissionGroup +from core.models import Directory, PortalAccess +from .models import Employee, PermissionGroup, EmployeeDefaults, EmployeePermissionOverride from apps.visit_manager.models import WorkSchedule, AttendanceLog + +def _portal_permissions_qs(): + ct = ContentType.objects.get_for_model(PortalAccess, for_concrete_model=False) + return Permission.objects.filter(content_type=ct, codename__startswith='access_').order_by('codename') + + class HierarchicalDepartmentChoiceField(forms.ModelChoiceField): """Поле выбора отдела с отступами по иерархии в выпадающем списке.""" @@ -78,6 +86,20 @@ class WorkScheduleInline(TabularInline): return False +class EmployeePermissionOverrideInline(TabularInline): + model = EmployeePermissionOverride + extra = 0 + fields = ('permission', 'override_type', 'valid_from', 'valid_until', 'is_active', 'comment') + autocomplete_fields = [] + verbose_name = _('Доп. право на раздел портала') + verbose_name_plural = _('Доп. права на разделы портала') + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if db_field.name == 'permission': + kwargs['queryset'] = _portal_permissions_qs() + return super().formfield_for_foreignkey(db_field, request, **kwargs) + + class AttendanceLogInline(TabularInline): model = AttendanceLog fk_name = 'employee' @@ -98,7 +120,7 @@ class AttendanceLogInline(TabularInline): @admin.register(Employee) class EmployeeAdmin(ModelAdmin): form = EmployeeAdminForm - inlines = [WorkScheduleInline, AttendanceLogInline] + inlines = [EmployeePermissionOverrideInline, WorkScheduleInline, AttendanceLogInline] class Media: css = {'all': ('users/css/employee_admin.css',)} @@ -256,4 +278,111 @@ class EmployeeAdmin(ModelAdmin): ) except Exception: return obj.department.name or '—' - department_display.short_description = _('Отдел') \ No newline at end of file + department_display.short_description = _('Отдел') + +@admin.register(EmployeeDefaults) +class EmployeeDefaultsAdmin(ModelAdmin): + """Единственная запись настроек по умолчанию для сотрудников.""" + + fieldsets = ( + (_('Офисные сотрудники'), { + 'fields': ('office_email_domain',), + 'description': _( + 'При утверждении заявки на найм офисному сотруднику ' + 'назначается рабочий email: логин@домен.' + ), + }), + ) + + def has_add_permission(self, request): + return not EmployeeDefaults.objects.exists() + + def has_delete_permission(self, request, obj=None): + return False + + +class PermissionGroupAdminForm(forms.ModelForm): + portal_permissions = forms.ModelMultipleChoiceField( + label=_('Разделы портала'), + queryset=Permission.objects.none(), + required=False, + widget=admin.widgets.FilteredSelectMultiple(_('разделы'), is_stacked=False), + help_text=_('Базовый набор доступов к разделам для этой роли. Доп. права сотруднику — через overrides.'), + ) + + class Meta: + model = PermissionGroup + fields = ('group', 'description', 'is_active') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['portal_permissions'].queryset = _portal_permissions_qs() + if self.instance and self.instance.pk and self.instance.group_id: + portal_pks = set(_portal_permissions_qs().values_list('pk', flat=True)) + self.fields['portal_permissions'].initial = [ + p for p in self.instance.group.permissions.all() if p.pk in portal_pks + ] + + def save_portal_permissions(self, obj): + portal_qs = list(_portal_permissions_qs()) + selected = self.cleaned_data.get('portal_permissions') + if selected is None: + return + group = obj.group + group.permissions.remove(*portal_qs) + group.permissions.add(*selected) + + +@admin.register(PermissionGroup) +class PermissionGroupAdmin(ModelAdmin): + form = PermissionGroupAdminForm + list_display = ('group', 'is_active', 'description') + list_filter = ('is_active',) + search_fields = ('group__name', 'description') + + fieldsets = ( + (None, { + 'fields': ('group', 'description', 'is_active', 'portal_permissions'), + }), + ) + + def save_model(self, request, obj, form, change): + super().save_model(request, obj, form, change) + form.save_portal_permissions(obj) + +@admin.register(EmployeePermissionOverride) +class EmployeePermissionOverrideAdmin(ModelAdmin): + list_display = ( + 'employee', 'permission', 'override_type', + 'valid_from', 'valid_until', 'is_active', + ) + list_filter = ('override_type', 'is_active') + search_fields = ( + 'employee__username', 'employee__last_name', 'employee__first_name', + 'permission__codename', 'comment', + ) + list_select_related = ('employee', 'permission') + autocomplete_fields = ('employee',) + readonly_fields = ('created_at', 'updated_at', 'uuid') + + fieldsets = ( + (None, { + 'fields': ('employee', 'permission', 'override_type', 'is_active'), + 'description': _( + 'grant — выдать раздел сверх группы; ' + 'revoke — запретить раздел даже если право есть в группе.' + ), + }), + (_('Срок действия'), { + 'fields': ('valid_from', 'valid_until', 'comment'), + }), + (_('Служебное'), { + 'classes': ('collapse',), + 'fields': ('uuid', 'created_at', 'updated_at'), + }), + ) + + def formfield_for_foreignkey(self, db_field, request, **kwargs): + if db_field.name == 'permission': + kwargs['queryset'] = _portal_permissions_qs() + return super().formfield_for_foreignkey(db_field, request, **kwargs) diff --git a/apps/employees/migrations/0012_employee_defaults.py b/apps/employees/migrations/0012_employee_defaults.py new file mode 100644 index 0000000..0e321c5 --- /dev/null +++ b/apps/employees/migrations/0012_employee_defaults.py @@ -0,0 +1,38 @@ +# Generated by Django 6.0.3 on 2026-07-28 19:38 + +from django.db import migrations, models + + +def seed_defaults(apps, schema_editor): + EmployeeDefaults = apps.get_model('employees', 'EmployeeDefaults') + EmployeeDefaults.objects.get_or_create( + pk=1, + defaults={'office_email_domain': 'aks-apteka.com.ua'}, + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('employees', '0011_employee_totp_backup_codes_employee_totp_enabled_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='EmployeeDefaults', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('office_email_domain', models.CharField( + default='aks-apteka.com.ua', + help_text='При утверждении заявки на найм офисному сотруднику назначается email вида логин@этот-домен (вместо @local.domain).', + max_length=255, + verbose_name='Домен email для офисных сотрудников', + )), + ], + options={ + 'verbose_name': 'Настройки сотрудников по умолчанию', + 'verbose_name_plural': 'Настройки сотрудников по умолчанию', + }, + ), + migrations.RunPython(seed_defaults, migrations.RunPython.noop), + ] diff --git a/apps/employees/migrations/0013_dislocation_column_presets.py b/apps/employees/migrations/0013_dislocation_column_presets.py new file mode 100644 index 0000000..777c96a --- /dev/null +++ b/apps/employees/migrations/0013_dislocation_column_presets.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-07-28 21:08 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('employees', '0012_employee_defaults'), + ] + + operations = [ + migrations.AddField( + model_name='employee', + name='dislocation_columns', + field=models.JSONField(blank=True, help_text='Персональный набор колонок таблицы /dislocation/. Пусто — пресет отдела или системный «Стандарт».', null=True, verbose_name='Колонки дислокации'), + ), + ] diff --git a/apps/employees/models/__init__.py b/apps/employees/models/__init__.py index e088ed0..0443f65 100644 --- a/apps/employees/models/__init__.py +++ b/apps/employees/models/__init__.py @@ -1,2 +1,3 @@ from .base import Employee, EmployeeManager +from .defaults import EmployeeDefaults from .permissions import PermissionGroup, EmployeePermissionOverride diff --git a/apps/employees/models/base.py b/apps/employees/models/base.py index 5ffc9ec..e62abfb 100644 --- a/apps/employees/models/base.py +++ b/apps/employees/models/base.py @@ -173,6 +173,15 @@ class Employee(AbstractBaseUser, PermissionsMixin, BaseModel): help_text=_('Сколько строк показывать в таблицах портала по умолчанию'), ) + dislocation_columns = models.JSONField( + _('Колонки дислокации'), + null=True, blank=True, + help_text=_( + 'Персональный набор колонок таблицы /dislocation/. ' + 'Пусто — пресет отдела или системный «Стандарт».' + ), + ) + last_seen = models.DateTimeField( _('Был онлайн'), null=True, blank=True, help_text=_('Обновляется автоматически при активности в портале (не в AD/Mattermost)'), diff --git a/apps/employees/models/defaults.py b/apps/employees/models/defaults.py new file mode 100644 index 0000000..1c8aa6e --- /dev/null +++ b/apps/employees/models/defaults.py @@ -0,0 +1,43 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class EmployeeDefaults(models.Model): + """Синглтон: значения по умолчанию для сотрудников (настраивается в админке). + + Сейчас используется при утверждении заявки на найм офисного сотрудника — + рабочий email становится «логин@office_email_domain». + """ + + office_email_domain = models.CharField( + _('Домен email для офисных сотрудников'), + max_length=255, + default='aks-apteka.com.ua', + help_text=_( + 'При утверждении заявки на найм офисному сотруднику ' + 'назначается email вида логин@этот-домен (вместо @local.domain).' + ), + ) + + class Meta: + verbose_name = _('Настройки сотрудников по умолчанию') + verbose_name_plural = _('Настройки сотрудников по умолчанию') + + def __str__(self): + return str(_('Настройки сотрудников по умолчанию')) + + def save(self, *args, **kwargs): + self.pk = 1 + super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + pass # синглтон нельзя удалить + + @classmethod + def load(cls): + obj, _ = cls.objects.get_or_create(pk=1) + return obj + + @property + def normalized_office_domain(self) -> str: + return (self.office_email_domain or 'aks-apteka.com.ua').strip().lstrip('@').lower() diff --git a/apps/hiring/admin.py b/apps/hiring/admin.py index a01a92d..84ffcd3 100644 --- a/apps/hiring/admin.py +++ b/apps/hiring/admin.py @@ -1,14 +1,31 @@ from django.contrib import admin from unfold.admin import ModelAdmin -from apps.hiring.models import HiringRequest +from apps.hiring.models import HiringRequest, InternAccount + + +@admin.register(InternAccount) +class InternAccountAdmin(ModelAdmin): + list_display = ['label', 'username', 'is_active', 'assigned_to', 'updated_at'] + list_filter = ['is_active'] + search_fields = ['label', 'username'] + list_select_related = ['assigned_to'] + ordering = ['label'] + readonly_fields = ['assigned_to', 'created_at', 'updated_at', 'uuid'] @admin.register(HiringRequest) class HiringRequestAdmin(ModelAdmin): - list_display = ['last_name', 'first_name', 'status', 'hire_date', 'trial_days', 'requested_by', 'created_at'] - list_filter = ['status', 'user_location_type'] + list_display = [ + 'last_name', 'first_name', 'status', 'hire_date', 'trial_days', + 'requested_by', 'granted_ticket', 'created_at', + ] + list_filter = ['status', 'user_location_type'] search_fields = ['last_name', 'first_name', 'middle_name', 'candidate_email'] - list_select_related = ['requested_by', 'decision_by', 'employee', 'post', 'department'] + list_select_related = [ + 'requested_by', 'decision_by', 'employee', 'post', 'department', + 'user_pharmacy', 'user_location', 'granted_ticket', + ] filter_horizontal = ['requested_access'] ordering = ['-created_at'] + readonly_fields = ['issued_password', 'created_at', 'updated_at', 'uuid'] diff --git a/apps/hiring/migrations/0004_intern_account_and_user_location.py b/apps/hiring/migrations/0004_intern_account_and_user_location.py new file mode 100644 index 0000000..8b9ef09 --- /dev/null +++ b/apps/hiring/migrations/0004_intern_account_and_user_location.py @@ -0,0 +1,46 @@ +# Generated by Django 6.0.3 on 2026-07-28 20:16 + +import core.models.fields +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('hiring', '0003_hiringrequest_access_task'), + ('locations', '0009_pharmacy_carbonio_password'), + ] + + operations = [ + migrations.AddField( + model_name='hiringrequest', + name='issued_password', + field=core.models.fields.EncryptedCharField(blank=True, max_length=512, verbose_name='Виданий пароль (PDF)'), + ), + migrations.AddField( + model_name='hiringrequest', + name='user_location', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hiring_requests_location', to='locations.location', verbose_name='Локація (офіс/склад)'), + ), + migrations.CreateModel( + name='InternAccount', + 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)), + ('label', models.CharField(max_length=50, unique=True, verbose_name='Мітка')), + ('username', core.models.fields.EncryptedCharField(max_length=512, verbose_name='Логін')), + ('password', core.models.fields.EncryptedCharField(max_length=512, verbose_name='Пароль')), + ('is_active', models.BooleanField(default=True, verbose_name='Активний')), + ('assigned_to', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='intern_account', to='hiring.hiringrequest', verbose_name='Призначено заявці')), + ], + options={ + 'verbose_name': 'Інтерн-обліковий запис', + 'verbose_name_plural': 'Пул інтерн-облікових записів', + 'ordering': ['label'], + }, + ), + ] diff --git a/apps/hiring/migrations/0005_seed_intern_accounts.py b/apps/hiring/migrations/0005_seed_intern_accounts.py new file mode 100644 index 0000000..7cb6e7d --- /dev/null +++ b/apps/hiring/migrations/0005_seed_intern_accounts.py @@ -0,0 +1,36 @@ +"""Seed pool of 5 shared intern accounts for hiring trial period.""" +from django.db import migrations + + +def seed_intern_accounts(apps, schema_editor): + # Concrete model — EncryptedCharField must encrypt on save. + from apps.hiring.models import InternAccount + + for i in range(1, 6): + InternAccount.objects.get_or_create( + label=f'Intern-{i}', + defaults={ + 'username': f'intern{i}', + 'password': f'InternPass{i}!', + 'is_active': True, + }, + ) + + +def unseed_intern_accounts(apps, schema_editor): + InternAccount = apps.get_model('hiring', 'InternAccount') + InternAccount.objects.filter( + label__in=[f'Intern-{i}' for i in range(1, 6)], + assigned_to__isnull=True, + ).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('hiring', '0004_intern_account_and_user_location'), + ] + + operations = [ + migrations.RunPython(seed_intern_accounts, unseed_intern_accounts), + ] diff --git a/apps/hiring/migrations/0006_alter_internaccount_options_and_more.py b/apps/hiring/migrations/0006_alter_internaccount_options_and_more.py new file mode 100644 index 0000000..a5576c7 --- /dev/null +++ b/apps/hiring/migrations/0006_alter_internaccount_options_and_more.py @@ -0,0 +1,140 @@ +# Generated by Django 6.0.3 on 2026-07-29 08:35 + +import core.models.fields +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0008_access_location_types_help'), + ('hiring', '0005_seed_intern_accounts'), + ('locations', '0011_seed_dislocation_column_presets'), + ('tasks', '0005_alter_task_priority_alter_task_status'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterModelOptions( + name='internaccount', + options={'ordering': ['label'], 'verbose_name': 'Интерн-аккаунт', 'verbose_name_plural': 'Пул интерн-аккаунтов'}, + ), + migrations.AlterField( + model_name='hiringrequest', + name='access_note', + field=models.TextField(blank=True, verbose_name='Примечание по доступам'), + ), + migrations.AlterField( + model_name='hiringrequest', + name='access_task', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='tasks.task', verbose_name='Задача на выдачу доступов'), + ), + migrations.AlterField( + model_name='hiringrequest', + name='candidate_email', + field=models.EmailField(blank=True, max_length=254, verbose_name='Личный email кандидата'), + ), + migrations.AlterField( + model_name='hiringrequest', + name='decision_at', + field=models.DateTimeField(blank=True, null=True, verbose_name='Дата решения'), + ), + migrations.AlterField( + model_name='hiringrequest', + name='decision_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hiring_requests_decided', to=settings.AUTH_USER_MODEL, verbose_name='Решение принял'), + ), + migrations.AlterField( + model_name='hiringrequest', + name='decision_note', + field=models.TextField(blank=True, verbose_name='Комментарий к решению'), + ), + migrations.AlterField( + model_name='hiringrequest', + name='department', + field=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='hiring_requests_department', to='core.directory', verbose_name='Отдел'), + ), + migrations.AlterField( + model_name='hiringrequest', + name='employee', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hiring_request', to=settings.AUTH_USER_MODEL, verbose_name='Учётная запись'), + ), + migrations.AlterField( + model_name='hiringrequest', + name='first_name', + field=models.CharField(max_length=150, verbose_name='Имя'), + ), + migrations.AlterField( + model_name='hiringrequest', + name='issued_password', + field=core.models.fields.EncryptedCharField(blank=True, max_length=512, verbose_name='Выданный пароль (PDF)'), + ), + migrations.AlterField( + model_name='hiringrequest', + name='last_name', + field=models.CharField(max_length=150, verbose_name='Фамилия'), + ), + migrations.AlterField( + model_name='hiringrequest', + name='middle_name', + field=models.CharField(blank=True, max_length=150, verbose_name='Отчество'), + ), + migrations.AlterField( + model_name='hiringrequest', + name='post', + field=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='hiring_requests_post', to='core.directory', verbose_name='Должность'), + ), + migrations.AlterField( + model_name='hiringrequest', + name='requested_access', + field=models.ManyToManyField(blank=True, limit_choices_to={'directory_type__code': 'access_system', 'is_active': True}, related_name='hiring_requests_access', to='core.directory', verbose_name='Требуемые доступы'), + ), + migrations.AlterField( + model_name='hiringrequest', + name='requested_by', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='hiring_requests_submitted', to=settings.AUTH_USER_MODEL, verbose_name='Подал HR'), + ), + migrations.AlterField( + model_name='hiringrequest', + name='status', + field=models.CharField(choices=[('trial', 'На испытании'), ('approved', 'Утверждено'), ('rejected', 'Отклонено')], default='trial', max_length=10, verbose_name='Статус'), + ), + migrations.AlterField( + model_name='hiringrequest', + name='trial_days', + field=models.PositiveSmallIntegerField(choices=[(1, '1 дн.'), (2, '2 дн.'), (3, '3 дн.'), (4, '4 дн.'), (5, '5 дн.')], default=3, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(5)], verbose_name='Срок испытания, дней'), + ), + migrations.AlterField( + model_name='hiringrequest', + name='user_location', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='hiring_requests_location', to='locations.location', verbose_name='Локация (офис/склад)'), + ), + migrations.AlterField( + model_name='hiringrequest', + name='user_location_type', + field=models.CharField(choices=[('pharmacy', 'Аптека'), ('warehouse', 'Склад'), ('office', 'Офис')], max_length=20, verbose_name='Тип локации'), + ), + migrations.AlterField( + model_name='internaccount', + name='assigned_to', + field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='intern_account', to='hiring.hiringrequest', verbose_name='Назначено заявке'), + ), + migrations.AlterField( + model_name='internaccount', + name='is_active', + field=models.BooleanField(default=True, verbose_name='Активный'), + ), + migrations.AlterField( + model_name='internaccount', + name='label', + field=models.CharField(max_length=50, unique=True, verbose_name='Метка'), + ), + migrations.AlterField( + model_name='internaccount', + name='username', + field=core.models.fields.EncryptedCharField(max_length=512, verbose_name='Логин'), + ), + ] diff --git a/apps/hiring/models.py b/apps/hiring/models.py index e053092..b353281 100644 --- a/apps/hiring/models.py +++ b/apps/hiring/models.py @@ -1,30 +1,82 @@ from datetime import timedelta +from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.utils import timezone from django.utils.translation import gettext_lazy as _ from core.models import BaseModel +from core.models.fields import EncryptedCharField from core.utils.validators import validate_ukrainian_phone +class InternAccount(BaseModel): + """Спільний інтерн-обліковий запис з пулу (5 слотів). Призначається заявці + на час випробування; після approve/reject звільняється.""" + + label = models.CharField(_('Метка'), max_length=50, unique=True) + username = EncryptedCharField(_('Логин'), max_length=512) + password = EncryptedCharField(_('Пароль'), max_length=512) + is_active = models.BooleanField(_('Активный'), default=True) + assigned_to = models.OneToOneField( + 'hiring.HiringRequest', on_delete=models.SET_NULL, + null=True, blank=True, + related_name='intern_account', verbose_name=_('Назначено заявке'), + ) + + class Meta: + verbose_name = _('Интерн-аккаунт') + verbose_name_plural = _('Пул интерн-аккаунтов') + ordering = ['label'] + + def __str__(self): + status = _('занят') if self.assigned_to_id else _('свободен') + return f'{self.label} ({status})' + + @classmethod + def allocate(cls, hiring_request): + """Виділяє свободен слот під select_for_update (викликати всередині atomic).""" + account = ( + cls.objects.select_for_update() + .filter(is_active=True, assigned_to__isnull=True) + .order_by('label') + .first() + ) + if account is None: + raise ValidationError({ + '__all__': _( + 'Нет свободных интерн-аккаунтов. ' + 'Дождитесь завершения стажировки или добавьте слот в админке.' + ), + }) + account.assigned_to = hiring_request + account.save(update_fields=['assigned_to', 'updated_at']) + return account + + def release(self): + if self.assigned_to_id is None: + return + self.assigned_to = None + self.save(update_fields=['assigned_to', 'updated_at']) + + class HiringRequest(BaseModel): - """Заявка HR на найм кандидата: створення тимчасового (стажерського) - облікового запису та подальше затвердження/відхилення.""" + """Заявка HR на найм кандидата: інтерн-слот + IT Ticket на випробуванні, + персональний Employee — лише після затвердження.""" STATUS_CHOICES = [ - ('trial', _('На випробуванні')), - ('approved', _('Затверджено')), - ('rejected', _('Відхилено')), + ('trial', _('На испытании')), + ('approved', _('Утверждено')), + ('rejected', _('Отклонено')), ] TRIAL_DAYS_CHOICES = [(i, _('%(n)d дн.') % {'n': i}) for i in range(1, 6)] - last_name = models.CharField(_('Прізвище'), max_length=150) - first_name = models.CharField(_('Ім\'я'), max_length=150) - middle_name = models.CharField(_('По батькові'), max_length=150, blank=True) + last_name = models.CharField(_('Фамилия'), max_length=150) + first_name = models.CharField(_('Имя'), max_length=150) + middle_name = models.CharField(_('Отчество'), max_length=150, blank=True) - candidate_email = models.EmailField(_('Особистий email кандидата'), blank=True) + candidate_email = models.EmailField(_('Личный email кандидата'), blank=True) candidate_phone = models.CharField( _('Телефон кандидата'), max_length=20, blank=True, validators=[validate_ukrainian_phone], @@ -33,60 +85,65 @@ class HiringRequest(BaseModel): LOCATION_TYPES = [ ('pharmacy', _('Аптека')), ('warehouse', _('Склад')), - ('office', _('Офіс')), + ('office', _('Офис')), ] user_location_type = models.CharField( - _('Тип локації'), max_length=20, choices=LOCATION_TYPES, + _('Тип локации'), max_length=20, choices=LOCATION_TYPES, ) 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='hiring_requests_department', verbose_name=_('Відділ'), + related_name='hiring_requests_department', verbose_name=_('Отдел'), ) user_pharmacy = models.ForeignKey( 'locations.Pharmacy', on_delete=models.SET_NULL, null=True, blank=True, related_name='hiring_requests', verbose_name=_('Аптека'), ) + user_location = models.ForeignKey( + 'locations.Location', on_delete=models.SET_NULL, + null=True, blank=True, + related_name='hiring_requests_location', verbose_name=_('Локация (офис/склад)'), + ) 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='hiring_requests_post', verbose_name=_('Посада'), + related_name='hiring_requests_post', verbose_name=_('Должность'), ) hire_date = models.DateField(_('Дата виходу'), default=timezone.localdate) trial_days = models.PositiveSmallIntegerField( - _('Термін випробування, днів'), choices=TRIAL_DAYS_CHOICES, default=3, + _('Срок испытания, дней'), choices=TRIAL_DAYS_CHOICES, default=3, validators=[MinValueValidator(1), MaxValueValidator(5)], ) requested_access = models.ManyToManyField( 'core.Directory', blank=True, limit_choices_to={'directory_type__code': 'access_system', 'is_active': True}, - related_name='hiring_requests_access', verbose_name=_('Потрібні доступи'), + related_name='hiring_requests_access', verbose_name=_('Требуемые доступы'), ) - access_note = models.TextField(_('Примітка щодо доступів'), blank=True) + access_note = models.TextField(_('Примечание по доступам'), blank=True) status = models.CharField(_('Статус'), max_length=10, choices=STATUS_CHOICES, default='trial') requested_by = models.ForeignKey( 'employees.Employee', on_delete=models.PROTECT, - related_name='hiring_requests_submitted', verbose_name=_('Подав HR'), + related_name='hiring_requests_submitted', verbose_name=_('Подал HR'), ) employee = models.OneToOneField( 'employees.Employee', on_delete=models.SET_NULL, null=True, blank=True, - related_name='hiring_request', verbose_name=_('Обліковий запис'), + related_name='hiring_request', verbose_name=_('Учётная запись'), ) decision_by = models.ForeignKey( 'employees.Employee', on_delete=models.SET_NULL, null=True, blank=True, - related_name='hiring_requests_decided', verbose_name=_('Рішення прийняв'), + related_name='hiring_requests_decided', verbose_name=_('Решение принял'), ) - decision_at = models.DateTimeField(_('Дата рішення'), null=True, blank=True) - decision_note = models.TextField(_('Коментар до рішення'), blank=True) + decision_at = models.DateTimeField(_('Дата решения'), null=True, blank=True) + decision_note = models.TextField(_('Комментарий к решению'), blank=True) granted_ticket = models.ForeignKey( 'it_manager.Ticket', on_delete=models.SET_NULL, @@ -96,7 +153,12 @@ class HiringRequest(BaseModel): access_task = models.ForeignKey( 'tasks.Task', on_delete=models.SET_NULL, null=True, blank=True, - related_name='+', verbose_name=_('Задача на видачу доступів'), + related_name='+', verbose_name=_('Задача на выдачу доступов'), + ) + + # Пароль персонального акаунта після approve — для одноразового PDF. + issued_password = EncryptedCharField( + _('Выданный пароль (PDF)'), max_length=512, blank=True, ) class Meta: @@ -117,3 +179,10 @@ class HiringRequest(BaseModel): @property def is_overdue(self): return self.status == 'trial' and self.trial_end_date < timezone.localdate() + + def release_intern(self): + try: + account = self.intern_account + except Exception: + return + account.release() diff --git a/apps/hiring/pdf.py b/apps/hiring/pdf.py new file mode 100644 index 0000000..59962ed --- /dev/null +++ b/apps/hiring/pdf.py @@ -0,0 +1,83 @@ +"""PDF з обліковими даними після затвердження кандидата.""" +from pathlib import Path + +from django.http import HttpResponse +from django.utils.translation import gettext as _ + +_FONT_CANDIDATES = ( + Path('/usr/share/fonts/google-noto/NotoSans-Regular.ttf'), + Path('/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf'), + Path('/usr/share/fonts/dejavu/DejaVuSans.ttf'), +) + + +def _resolve_font(): + for path in _FONT_CANDIDATES: + if path.is_file(): + return str(path) + return None + + +def build_credentials_pdf(hiring_request) -> bytes: + from fpdf import FPDF + from fpdf.enums import XPos, YPos + + employee = hiring_request.employee + if employee is None: + raise ValueError('HiringRequest has no employee') + + pdf = FPDF() + pdf.set_auto_page_break(auto=True, margin=15) + pdf.add_page() + pdf.set_margins(20, 20, 20) + + font_path = _resolve_font() + font_name = 'Portal' if font_path else 'Helvetica' + if font_path: + pdf.add_font('Portal', fname=font_path) + pdf.set_font(font_name, size=14) + + pdf.cell( + 0, 10, _('Учётные данные сотрудника'), + new_x=XPos.LMARGIN, new_y=YPos.NEXT, + ) + pdf.ln(4) + pdf.set_font(font_name, size=11) + + dash = '-' + lines = [ + f"{_('ФИО')}: {employee.last_name} {employee.first_name} {employee.middle_name}".strip(), + 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"{_('Рабочий email')}: {employee.email or dash}", + '', + f"{_('Запрошенные доступы')}:", + ] + accesses = list(hiring_request.requested_access.all()) + if accesses: + for access in accesses: + lines.append(f' - {access.name}') + else: + lines.append(f" {_('(не указано)')}") + + for line in lines: + if not line: + pdf.ln(4) + continue + pdf.set_x(pdf.l_margin) + pdf.multi_cell( + pdf.epw, 7, line, + new_x=XPos.LMARGIN, new_y=YPos.NEXT, + ) + + return bytes(pdf.output()) + + +def credentials_pdf_response(hiring_request) -> HttpResponse: + content = build_credentials_pdf(hiring_request) + filename = f'credentials-{hiring_request.employee.username}.pdf' + response = HttpResponse(content, content_type='application/pdf') + response['Content-Disposition'] = f'attachment; filename="{filename}"' + return response diff --git a/apps/hiring/services.py b/apps/hiring/services.py index c5acab1..4930d17 100644 --- a/apps/hiring/services.py +++ b/apps/hiring/services.py @@ -1,13 +1,14 @@ -"""Бізнес-логіка заявок на найм: створення тимчасового акаунта стажера та -подальше затвердження/відхилення. Використовує вже робочу AD/Mattermost -синхронізацію через сигнали Employee.save() — жодного нового коду для -зовнішніх систем тут немає.""" +"""Бізнес-логіка найму: інтерн-слот + IT Ticket на випробуванні; +персональний Employee і PDF — лише після затвердження.""" +import secrets + from django.db import transaction from django.utils import timezone +from django.utils.translation import gettext as _ from pytils.translit import slugify from apps.employees.models.base import Employee -from apps.hiring.models import HiringRequest +from apps.hiring.models import HiringRequest, InternAccount def _generate_username(last_name, first_name): @@ -20,32 +21,155 @@ def _generate_username(last_name, first_name): return username -@transaction.atomic -def create_hiring_request(*, requester, last_name, first_name, middle_name='', - candidate_email='', candidate_phone='', - user_location_type, department_id=None, user_pharmacy_id=None, post_id=None, - hire_date, trial_days, requested_access_ids=None, access_note=''): - """Створює одразу особистий обмежений обліковий запис (Employee) і - заявку (HiringRequest) на випробувальний термін.""" - username = _generate_username(last_name, first_name) +def _generate_password(): + return secrets.token_urlsafe(12) - employee = Employee.objects.create( - username=username, - last_name=last_name, - first_name=first_name, - middle_name=middle_name, - personal_phone=candidate_phone, + +def _coerce_location_fields(user_location_type, department_id, user_pharmacy_id, user_location_id): + """Приводить поля локації до правил Employee.clean(): аптека XOR офіс/склад.""" + if user_location_type == 'pharmacy': + return None, user_pharmacy_id, None + if user_location_type in ('office', 'warehouse'): + return department_id, None, user_location_id + return department_id, user_pharmacy_id, user_location_id + + +def _filter_access_ids_for_location(user_location_type, requested_access_ids): + """Залишає лише системи доступу, дозволені для типу локації. + + Порожній location_types у системи = доступна скрізь (як у посад). + """ + if not requested_access_ids: + return [] + from core.models import Directory + allowed = [] + for access in Directory.objects.filter( + pk__in=requested_access_ids, + directory_type__code='access_system', is_active=True, + ).prefetch_related('location_types'): + types = [lt.code for lt in access.location_types.all()] + if not types or user_location_type in types: + allowed.append(access.pk) + return allowed + + +def _validate_hire_location(*, user_location_type, department_id, user_pharmacy_id, + user_location_id, post_id, hire_date): + """Перевіряє правила локації Employee.clean() без збереження в БД.""" + emp = Employee( + username='__hire_validate__', + last_name='_', + first_name='_', hire_date=hire_date, - probation_end_date=hire_date + timezone.timedelta(days=trial_days), user_location_type=user_location_type, department_id=department_id, user_pharmacy_id=user_pharmacy_id, + user_location_id=user_location_id, post_id=post_id, - # AD-синхронізація стосується лише офісних співробітників — це вже - # чинне правило в apps/ad_connector/signals.py (_is_office), тут лише - # вмикаємо прапорець, без якого сигнал взагалі не спрацює. - ad_sync_enabled=(user_location_type == 'office'), + ) + emp.clean() + + +def _directory(type_code, code): + from core.models import Directory + return Directory.objects.get(directory_type__code=type_code, code=code) + + +def _ticket_location(hiring_request): + if hiring_request.user_location_type == 'pharmacy' and hiring_request.user_pharmacy_id: + return hiring_request.user_pharmacy + return hiring_request.user_location + + +def _build_ticket_description(hiring_request, intern): + loc_label = hiring_request.get_user_location_type_display() + if hiring_request.user_pharmacy_id: + loc_detail = str(hiring_request.user_pharmacy) + elif hiring_request.user_location_id: + loc_detail = str(hiring_request.user_location) + elif hiring_request.department_id: + loc_detail = hiring_request.department.name + else: + loc_detail = '—' + + lines = [ + f"{_('Кандидат')}: {hiring_request.last_name} {hiring_request.first_name} {hiring_request.middle_name}".strip(), + f"{_('Должность')}: {hiring_request.post.name if hiring_request.post_id else '—'}", + f"{_('Тип локации')}: {loc_label}", + f"{_('Локация / отдел')}: {loc_detail}", + f"{_('Дата выхода')}: {hiring_request.hire_date:%d.%m.%Y}", + f"{_('Испытательный срок')}: {hiring_request.trial_days} {_('дн.')}", + f"{_('Интерн-аккаунт')}: {intern.label} / {_('логин')} «{intern.username}»", + '', + f"{_('Требуемые доступы')}:", + ] + accesses = list(hiring_request.requested_access.all()) + if accesses: + for access in accesses: + lines.append(f' • {access.name}') + else: + lines.append(f" {_('(не указано)')}") + if hiring_request.access_note: + lines.append('') + lines.append(f"{_('Примечание')}: {hiring_request.access_note}") + lines.append('') + lines.append(_( + 'Подготовьте рабочее место и выдайте доступы к интерн-аккаунту на время ' + 'испытания. После утверждения HR будет создан персональный аккаунт.' + )) + return '\n'.join(lines) + + +def _create_access_ticket(hiring_request, requester, intern): + from apps.it_manager.models import Ticket + + fio = f'{hiring_request.last_name} {hiring_request.first_name}'.strip() + ticket = Ticket.objects.create( + title=f"{_('Подготовка рабочего места')}: {fio}", + ticket_type=_directory('ticket_type', 'access'), + priority=_directory('ticket_priority', 'high'), + requester=requester, + location=_ticket_location(hiring_request), + description=_build_ticket_description(hiring_request, intern), + ) + hiring_request.granted_ticket = ticket + hiring_request.save(update_fields=['granted_ticket', 'updated_at']) + return ticket + + +def _close_ticket(ticket): + if ticket is None: + return + closed = _directory('ticket_status', 'closed') + if ticket.status_id == closed.pk: + return + ticket.status = closed + ticket.save(update_fields=['status', 'closed_at', 'updated_at']) + + +@transaction.atomic +def create_hiring_request(*, requester, last_name, first_name, middle_name='', + candidate_email='', candidate_phone='', + user_location_type, department_id=None, user_pharmacy_id=None, + user_location_id=None, post_id=None, + hire_date, trial_days, requested_access_ids=None, access_note=''): + """Створює заявку на випробування: виділяє інтерн-слот і IT Ticket. + Персональний Employee ще не створюється. + """ + department_id, user_pharmacy_id, user_location_id = _coerce_location_fields( + user_location_type, department_id, user_pharmacy_id, user_location_id, + ) + _validate_hire_location( + user_location_type=user_location_type, + department_id=department_id, + user_pharmacy_id=user_pharmacy_id, + user_location_id=user_location_id, + post_id=post_id, + hire_date=hire_date, + ) + requested_access_ids = _filter_access_ids_for_location( + user_location_type, requested_access_ids or [], ) hiring_request = HiringRequest.objects.create( @@ -57,72 +181,30 @@ def create_hiring_request(*, requester, last_name, first_name, middle_name='', user_location_type=user_location_type, department_id=department_id, user_pharmacy_id=user_pharmacy_id, + user_location_id=user_location_id, post_id=post_id, hire_date=hire_date, trial_days=trial_days, access_note=access_note, status='trial', requested_by=requester, - employee=employee, ) if requested_access_ids: hiring_request.requested_access.set(requested_access_ids) - _create_access_task(hiring_request, requester) + intern = InternAccount.allocate(hiring_request) + _create_access_ticket(hiring_request, requester, intern) return hiring_request -def _create_access_task(hiring_request, requester): - """Ставить задачу IT-відділу на видачу доступів новому співробітнику - (чек-лист по обраних базах), призначає її IT-групі, рахує строк за - робочим часом і повідомляє постановника.""" - from apps.tasks.models import Task, TaskChecklistItem - from apps.visit_manager.services import add_working_hours - - accesses = list(hiring_request.requested_access.all()) - if not accesses: - return None - - fio = f'{hiring_request.last_name} {hiring_request.first_name}'.strip() - due_dt = add_working_hours(timezone.now(), 24) - - parts = ['Надати новому співробітнику доступ до обраних систем/баз (відмітьте виконане у чек-листі).'] - if hiring_request.post_id: - parts.append(f'Посада: {hiring_request.post.name}.') - if hiring_request.access_note: - parts.append(f'Примітка: {hiring_request.access_note}') - - task = Task.objects.create( - title=f'Доступи для {fio}', - description=' '.join(parts), - creator=requester, - priority='high', - due_date=due_dt.date(), - ) - for i, access in enumerate(accesses): - TaskChecklistItem.objects.create(task=task, label=f'Додати в «{access.name}»', order=i) - - # Призначаємо IT-відділ (co_executors) — це тригерить сповіщення виконавцям (сигнали tasks). - it_staff = list(Employee.objects.filter(groups__name='IT', is_active=True)) - if it_staff: - task.co_executors.add(*it_staff) - - _notify_requester_taken(requester, fio, due_dt) - - hiring_request.access_task = task - hiring_request.save(update_fields=['access_task', 'updated_at']) - return task +def _should_assign_office_email(user_location_type) -> bool: + return user_location_type == 'office' -def _notify_requester_taken(requester, fio, due_dt): - """Повідомити постановника (HR), що заявку взято в роботу і строк — 24 год робочого часу.""" - from apps.mattermost_connector.services.bot_service import send_direct_message - local = timezone.localtime(due_dt) - message = ( - f'Заявку на доступи для {fio} прийнято в роботу IT-відділом. ' - f'Виконаємо протягом 24 годин робочого часу — орієнтовно до {local:%d.%m %H:%M}.' - ) - send_direct_message(requester, message) +def _office_email_for(username: str) -> str: + from apps.employees.models import EmployeeDefaults + domain = EmployeeDefaults.load().normalized_office_domain + return f'{username}@{domain}' @transaction.atomic @@ -130,20 +212,43 @@ def approve_hiring_request(hiring_request, decided_by, note=''): if hiring_request.status != 'trial': return hiring_request - # Доступи видаються задачею IT-відділу ще на етапі створення заявки - # (_create_access_task), тому затвердження лише завершує випробувальний термін. - employee = hiring_request.employee - if employee is not None: - employee.probation_end_date = timezone.localdate() - employee.save(update_fields=['probation_end_date']) + username = _generate_username(hiring_request.last_name, hiring_request.first_name) + password = _generate_password() + employee = Employee( + username=username, + last_name=hiring_request.last_name, + first_name=hiring_request.first_name, + middle_name=hiring_request.middle_name, + personal_phone=hiring_request.candidate_phone or '', + personal_email=hiring_request.candidate_email or '', + is_active=True, + hire_date=hiring_request.hire_date, + probation_end_date=timezone.localdate(), + user_location_type=hiring_request.user_location_type, + department_id=hiring_request.department_id, + user_pharmacy_id=hiring_request.user_pharmacy_id, + user_location_id=hiring_request.user_location_id, + post_id=hiring_request.post_id, + ad_sync_enabled=(hiring_request.user_location_type == 'office'), + ) + if _should_assign_office_email(hiring_request.user_location_type): + employee.email = _office_email_for(username) + employee.set_password(password) + employee.full_clean() + employee.save() + + hiring_request.employee = employee + hiring_request.issued_password = password hiring_request.status = 'approved' hiring_request.decision_by = decided_by hiring_request.decision_at = timezone.now() hiring_request.decision_note = note hiring_request.save(update_fields=[ - 'status', 'decision_by', 'decision_at', 'decision_note', 'updated_at', + 'employee', 'issued_password', 'status', + 'decision_by', 'decision_at', 'decision_note', 'updated_at', ]) + hiring_request.release_intern() return hiring_request @@ -152,11 +257,8 @@ def reject_hiring_request(hiring_request, decided_by, note=''): if hiring_request.status != 'trial': return hiring_request - employee = hiring_request.employee - if employee is not None: - employee.is_active = False - employee.termination_date = timezone.localdate() - employee.save(update_fields=['is_active', 'termination_date']) + hiring_request.release_intern() + _close_ticket(hiring_request.granted_ticket) hiring_request.status = 'rejected' hiring_request.decision_by = decided_by diff --git a/apps/hiring/tests.py b/apps/hiring/tests.py index 39e0b88..2210b96 100644 --- a/apps/hiring/tests.py +++ b/apps/hiring/tests.py @@ -1,21 +1,32 @@ -"""Тести бізнес-логіки заявок на найм: створення тимчасового акаунта, -задача IT на доступи (чек-лист), затвердження і відхилення.""" +"""Тести найму: пул інтернів, IT Ticket, Employee лише після approve, PDF.""" from datetime import date, timedelta from django.contrib.auth import get_user_model -from django.contrib.auth.models import Group -from django.test import TestCase +from django.core.exceptions import ValidationError +from django.test import Client, TestCase +from django.urls import reverse -from core.models import Directory -from apps.hiring.models import HiringRequest +from core.models import Directory, DirectoryType +from apps.hiring.models import HiringRequest, InternAccount from apps.hiring.services import approve_hiring_request, create_hiring_request, reject_hiring_request -from apps.tasks.models import Task +from apps.it_manager.models import Ticket +from apps.locations.models import Location, Pharmacy 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() + + def make_employee(**overrides): global _employee_seq _employee_seq += 1 @@ -34,133 +45,291 @@ def make_employee(**overrides): return Employee.objects.create(**defaults) +def make_intern(label='Intern-1', username=None, password='intern-pass'): + return InternAccount.objects.create( + label=label, + username=username or f'intern.{label.lower().replace("-", "")}', + password=password, + is_active=True, + ) + + class HiringServiceTests(TestCase): + @classmethod + def setUpTestData(cls): + org_type = DirectoryType.objects.create(code='organization', name='Организация') + region_type = DirectoryType.objects.create(code='region', name='Регион') + dept_type = DirectoryType.objects.create(code='department', name='Відділ') + DirectoryType.objects.get_or_create(code='access_system', defaults={'name': 'Системи доступу'}) + + cls.region = Directory.objects.create(directory_type=region_type, code='od', name='Одеса') + cls.org = Directory.objects.create(directory_type=org_type, code='akc', name='АКС') + cls.department = Directory.objects.create(directory_type=dept_type, code='hr', name='HR') + cls.pharmacy = Pharmacy.objects.create( + organization=cls.org, location_region=cls.region, + pharmacy_number=1, location_type='pharmacy', + ) + cls.office = Location.objects.create( + location_type='office', location_region=cls.region, + street='Дерибасівська', house_number='1', + ) + def setUp(self): self.hr_user = make_employee() + # Сид-миграция 0005 наполняет пул Intern-1..5 — очищаем, чтобы тесты сами + # управляли составом стажёров (иначе конфликт лейблов и «свободные» из сида). + InternAccount.objects.all().delete() + self.intern = make_intern('Intern-1') - def test_create_hiring_request_creates_trial_employee(self): - hire_date = date.today() + timedelta(days=1) - hr = create_hiring_request( + def _pharmacy_kwargs(self, **extra): + defaults = dict( requester=self.hr_user, last_name='Кандидат', first_name='Іван', user_location_type='pharmacy', - hire_date=hire_date, + user_pharmacy_id=self.pharmacy.pk, + hire_date=date.today() + timedelta(days=1), trial_days=3, ) + defaults.update(extra) + return defaults + + def _office_kwargs(self, **extra): + defaults = dict( + requester=self.hr_user, + last_name='Офісний', + first_name='Кандидат', + user_location_type='office', + department_id=self.department.pk, + user_location_id=self.office.pk, + hire_date=date.today() + timedelta(days=1), + trial_days=2, + ) + defaults.update(extra) + return defaults + + def test_create_assigns_intern_and_ticket_without_employee(self): + hire_date = date.today() + timedelta(days=1) + hr = create_hiring_request(**self._pharmacy_kwargs(hire_date=hire_date)) self.assertEqual(hr.status, 'trial') - self.assertIsNotNone(hr.employee) - self.assertTrue(hr.employee.is_active) - self.assertEqual(hr.employee.probation_end_date, hire_date + timedelta(days=3)) + self.assertIsNone(hr.employee) + self.assertIsNotNone(hr.granted_ticket) + self.assertEqual(hr.granted_ticket.ticket_type.code, 'access') + self.assertEqual(hr.granted_ticket.priority.code, 'high') + self.assertEqual(hr.intern_account.pk, self.intern.pk) self.assertEqual(hr.trial_end_date, hire_date + timedelta(days=3)) self.assertFalse(hr.is_overdue) + self.assertIsNone(hr.access_task) - def test_ad_sync_enabled_only_for_office_employees(self): - office_hr = create_hiring_request( - requester=self.hr_user, last_name='Офісний', first_name='Кандидат', - user_location_type='office', hire_date=date.today() + timedelta(days=1), trial_days=2, - ) - pharmacy_hr = create_hiring_request( - requester=self.hr_user, last_name='Аптечний', first_name='Кандидат', - user_location_type='pharmacy', hire_date=date.today() + timedelta(days=1), trial_days=2, - ) + def test_no_free_intern_raises(self): + create_hiring_request(**self._pharmacy_kwargs()) + with self.assertRaises(ValidationError) as cm: + create_hiring_request(**self._pharmacy_kwargs( + last_name='Другий', first_name='Кандидат', + )) + self.assertIn('__all__', cm.exception.message_dict) - self.assertTrue(office_hr.employee.ad_sync_enabled) - self.assertFalse(pharmacy_hr.employee.ad_sync_enabled) + def test_pharmacy_without_pharmacy_raises(self): + with self.assertRaises(ValidationError) as cm: + create_hiring_request(**self._pharmacy_kwargs(user_pharmacy_id=None)) + self.assertIn('user_pharmacy', cm.exception.message_dict) + + def test_office_without_department_raises(self): + with self.assertRaises(ValidationError) as cm: + create_hiring_request(**self._office_kwargs(department_id=None)) + self.assertIn('department', cm.exception.message_dict) + + def test_office_without_location_raises(self): + with self.assertRaises(ValidationError) as cm: + create_hiring_request(**self._office_kwargs(user_location_id=None)) + self.assertIn('user_location', cm.exception.message_dict) def test_is_overdue_when_trial_period_passed(self): - hr = create_hiring_request( - requester=self.hr_user, last_name='Прострочений', first_name='Тест', - user_location_type='pharmacy', hire_date=date(2026, 1, 1), trial_days=1, - ) + hr = create_hiring_request(**self._pharmacy_kwargs( + last_name='Прострочений', first_name='Тест', + hire_date=date(2026, 1, 1), trial_days=1, + )) self.assertTrue(hr.is_overdue) - def test_create_hiring_request_generates_unique_username(self): - hr1 = create_hiring_request( - requester=self.hr_user, last_name='Дубль', first_name='Петро', - user_location_type='pharmacy', hire_date=date(2026, 6, 1), trial_days=1, - ) - hr2 = create_hiring_request( - requester=self.hr_user, last_name='Дубль', first_name='Петро', - user_location_type='pharmacy', hire_date=date(2026, 6, 1), trial_days=1, - ) - - self.assertNotEqual(hr1.employee.username, hr2.employee.username) - - # --- задача IT на доступи --- - def _access_ids(self, n=2): ids = list(Directory.objects.filter(directory_type__code='access_system', is_active=True) .values_list('id', flat=True)[:n]) self.assertTrue(ids, 'у тестовій БД немає довідника access_system') return ids - def test_create_with_access_creates_it_task_with_checklist(self): - it_group, _ = Group.objects.get_or_create(name='IT') - it_user = make_employee(username='it.tech', email='it.tech@company.local', last_name='Айтішник') - it_user.groups.add(it_group) - + def test_create_with_access_creates_ticket_not_task(self): access_ids = self._access_ids(2) - hr = create_hiring_request( - requester=self.hr_user, last_name='Доступний', first_name='Кандидат', - user_location_type='pharmacy', hire_date=date.today() + timedelta(days=1), trial_days=3, + hr = create_hiring_request(**self._pharmacy_kwargs( + last_name='Доступний', first_name='Кандидат', requested_access_ids=access_ids, - ) + )) - self.assertIsNotNone(hr.access_task) - task = hr.access_task - self.assertEqual(task.creator, self.hr_user) - self.assertEqual(task.priority, 'high') - self.assertIsNotNone(task.due_date) - self.assertEqual(task.checklist.count(), len(access_ids)) - self.assertIn(it_user, task.co_executors.all()) - # виконавець може відмічати пункти - item = task.checklist.first() - item.set_done(True, user=it_user) - self.assertTrue(task.checklist.get(pk=item.pk).is_done) - self.assertEqual(task.checklist_done, 1) - - def test_create_without_access_creates_no_task(self): - hr = create_hiring_request( - requester=self.hr_user, last_name='Без', first_name='Доступів', - user_location_type='pharmacy', hire_date=date.today() + timedelta(days=1), trial_days=3, - ) self.assertIsNone(hr.access_task) + self.assertIsNotNone(hr.granted_ticket) + self.assertEqual(hr.requested_access.count(), len(access_ids)) + self.assertIn('Доступний', hr.granted_ticket.description) + self.assertEqual(Ticket.objects.filter(pk=hr.granted_ticket_id).count(), 1) - def test_approve_ends_probation_without_ticket(self): - hr = create_hiring_request( - requester=self.hr_user, last_name='Кандидат', first_name='Марія', - user_location_type='pharmacy', hire_date=date(2026, 6, 1), trial_days=5, - ) + def test_create_without_access_still_creates_ticket(self): + hr = create_hiring_request(**self._pharmacy_kwargs( + last_name='Без', first_name='Доступів', + )) + self.assertIsNone(hr.access_task) + self.assertIsNotNone(hr.granted_ticket) + + def test_access_for_office_only_skipped_for_pharmacy(self): + access = Directory.objects.filter( + directory_type__code='access_system', is_active=True, + ).first() + self.assertIsNotNone(access) + loc_type = DirectoryType.objects.get(code='location_type') + office_lt = Directory.objects.get_or_create( + directory_type=loc_type, code='office', + defaults={'name': 'Офіс', 'is_active': True}, + )[0] + access.location_types.set([office_lt]) + + hr = create_hiring_request(**self._pharmacy_kwargs( + last_name='Фільтр', first_name='Доступ', + requested_access_ids=[access.pk], + )) + self.assertEqual(hr.requested_access.count(), 0) + + make_intern('Intern-2') + hr_office = create_hiring_request(**self._office_kwargs( + last_name='Фільтр', first_name='Офіс', + requested_access_ids=[access.pk], + )) + self.assertEqual(list(hr_office.requested_access.values_list('pk', flat=True)), [access.pk]) + + def test_approve_creates_employee_and_frees_intern(self): + hr = create_hiring_request(**self._pharmacy_kwargs( + last_name='Кандидат', first_name='Марія', + hire_date=date(2026, 6, 1), trial_days=5, + )) approved = approve_hiring_request(hr, decided_by=self.hr_user, note='ok') self.assertEqual(approved.status, 'approved') self.assertEqual(approved.decision_by, self.hr_user) - self.assertIsNone(approved.granted_ticket) # доступи — задачею при створенні, не тікетом - approved.employee.refresh_from_db() + self.assertIsNotNone(approved.employee) + self.assertTrue(approved.issued_password) self.assertEqual(approved.employee.probation_end_date, date.today()) + self.assertTrue(approved.employee.check_password(approved.issued_password)) - def test_reject_deactivates_employee(self): - hr = create_hiring_request( - requester=self.hr_user, last_name='Кандидат', first_name='Олег', - user_location_type='pharmacy', hire_date=date(2026, 6, 1), trial_days=2, + self.intern.refresh_from_db() + self.assertIsNone(self.intern.assigned_to_id) + + def test_approve_office_assigns_corporate_email(self): + from apps.employees.models import EmployeeDefaults + EmployeeDefaults.load() + + hr = create_hiring_request(**self._office_kwargs( + last_name='Петренко', first_name='Іван', + )) + approve_hiring_request(hr, decided_by=self.hr_user) + hr.refresh_from_db() + self.assertEqual( + hr.employee.email, + f'{hr.employee.username}@aks-apteka.com.ua', ) + + def test_approve_office_uses_admin_domain_setting(self): + from apps.employees.models import EmployeeDefaults + defaults = EmployeeDefaults.load() + defaults.office_email_domain = 'office.example.com' + defaults.save() + + hr = create_hiring_request(**self._office_kwargs( + last_name='Сидоренко', first_name='Оля', + )) + approve_hiring_request(hr, decided_by=self.hr_user) + hr.refresh_from_db() + self.assertEqual( + hr.employee.email, + f'{hr.employee.username}@office.example.com', + ) + + def test_approve_pharmacy_uses_placeholder_email(self): + hr = create_hiring_request(**self._pharmacy_kwargs( + last_name='Аптечна', first_name='Марія', + )) + approve_hiring_request(hr, decided_by=self.hr_user) + hr.refresh_from_db() + self.assertTrue(hr.employee.email.endswith('@local.domain')) + + def test_approve_ad_sync_only_for_office(self): + office_hr = create_hiring_request(**self._office_kwargs()) + make_intern('Intern-2') + pharmacy_hr = create_hiring_request(**self._pharmacy_kwargs( + last_name='Аптечний', first_name='Кандидат', + )) + approve_hiring_request(office_hr, decided_by=self.hr_user) + 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(pharmacy_hr.employee.ad_sync_enabled) + + 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, + )) + make_intern('Intern-2') + hr2 = create_hiring_request(**self._pharmacy_kwargs( + last_name='Дубль', first_name='Петро', hire_date=date(2026, 6, 1), trial_days=1, + )) + approve_hiring_request(hr1, decided_by=self.hr_user) + approve_hiring_request(hr2, decided_by=self.hr_user) + hr1.refresh_from_db() + hr2.refresh_from_db() + self.assertNotEqual(hr1.employee.username, hr2.employee.username) + + def test_reject_frees_intern_closes_ticket_no_employee(self): + hr = create_hiring_request(**self._pharmacy_kwargs( + last_name='Кандидат', first_name='Олег', + hire_date=date(2026, 6, 1), trial_days=2, + )) + ticket = hr.granted_ticket rejected = reject_hiring_request(hr, decided_by=self.hr_user, note='не пройшов') self.assertEqual(rejected.status, 'rejected') - rejected.employee.refresh_from_db() - self.assertFalse(rejected.employee.is_active) - self.assertEqual(rejected.employee.termination_date, date.today()) + self.assertIsNone(rejected.employee) + self.intern.refresh_from_db() + self.assertIsNone(self.intern.assigned_to_id) + ticket.refresh_from_db() + self.assertEqual(ticket.status.code, 'closed') def test_decision_is_a_noop_once_already_decided(self): - hr = create_hiring_request( - requester=self.hr_user, last_name='Кандидат', first_name='Ліда', - user_location_type='pharmacy', hire_date=date(2026, 6, 1), trial_days=2, - ) + hr = create_hiring_request(**self._pharmacy_kwargs( + last_name='Кандидат', first_name='Ліда', + hire_date=date(2026, 6, 1), trial_days=2, + )) approve_hiring_request(hr, decided_by=self.hr_user) reject_hiring_request(hr, decided_by=self.hr_user) hr.refresh_from_db() - self.assertEqual(hr.status, 'approved') + + def test_credentials_pdf_after_approve(self): + hr = create_hiring_request(**self._pharmacy_kwargs( + last_name='PDF', first_name='Тест', + )) + approve_hiring_request(hr, decided_by=self.hr_user) + + client = Client() + login_2fa(client, self.hr_user) + url = reverse('hiring_credentials_pdf', kwargs={'pk': hr.pk}) + response = client.get(url) + self.assertEqual(response.status_code, 200) + self.assertEqual(response['Content-Type'], 'application/pdf') + self.assertTrue(response.content.startswith(b'%PDF')) + + def test_credentials_pdf_forbidden_while_trial(self): + hr = create_hiring_request(**self._pharmacy_kwargs( + last_name='PDF', first_name='Trial', + )) + client = Client() + login_2fa(client, self.hr_user) + response = client.get(reverse('hiring_credentials_pdf', kwargs={'pk': hr.pk})) + self.assertEqual(response.status_code, 403) diff --git a/apps/it_manager/views.py b/apps/it_manager/views.py index 4623fc7..7256d6b 100644 --- a/apps/it_manager/views.py +++ b/apps/it_manager/views.py @@ -3,7 +3,7 @@ from datetime import datetime from django.contrib.auth import get_user_model from django.contrib.auth.decorators import login_required from django.db import models -from django.db.models import Sum +from django.db.models import Q, Sum from django.http import HttpResponseForbidden from django.shortcuts import get_object_or_404 from django.utils import timezone @@ -22,9 +22,20 @@ Employee = get_user_model() def _is_it_staff(user): - return user.is_superuser or user.groups.filter(name='IT').exists() + from core.portal_access import user_can_access + return user_can_access(user, 'it_manager') +def _it_assignees(): + """Кандидаты в исполнители: группа IT, а также staff/superuser + (если в группу IT ещё никого не добавили — селект не пустой).""" + return ( + Employee.objects.filter(is_active=True) + .filter(Q(groups__name='IT') | Q(is_staff=True) | Q(is_superuser=True)) + .distinct() + .order_by('last_name', 'first_name') + ) + def _directory_choices(type_code): """(code, name)-кортежі активних записів довідника — та сама форма, що й колишні CharField(choices=...), для сумісності з шаблонами.""" @@ -136,7 +147,7 @@ def _it_manager_context(request): 'ticket_statuses': ticket_statuses, 'ticket_priorities': _directory_choices('ticket_priority'), 'ticket_types': _directory_choices('ticket_type'), - 'it_staff': Employee.objects.filter(groups__name='IT', is_active=True).order_by('last_name'), + 'it_staff': _it_assignees(), 'worklogs_page': worklogs_page, 'worklog_types': _directory_choices('worklog_type'), diff --git a/apps/locations/admin.py b/apps/locations/admin.py index cdb4ff0..93b7592 100644 --- a/apps/locations/admin.py +++ b/apps/locations/admin.py @@ -9,7 +9,7 @@ from unfold.admin import ModelAdmin, TabularInline from import_export.admin import ImportExportModelAdmin from unfold.contrib.import_export.forms import ExportForm, ImportForm from core.models import Directory -from .models import Location, Pharmacy, PharmacyPhone, PharmacyHistory +from .models import Location, Pharmacy, PharmacyPhone, PharmacyHistory, DislocationColumnPreset # --------------------------------------------------------------------------- @@ -388,3 +388,13 @@ class PharmacyAdmin(ModelAdmin, ImportExportModelAdmin): return '—' return mark_safe('
'.join(numbers)) phone_numbers_display.short_description = _('Телефоны') + + +@admin.register(DislocationColumnPreset) +class DislocationColumnPresetAdmin(ModelAdmin): + list_display = ['name', 'code', 'is_default', 'department', 'updated_at'] + list_filter = ['is_default'] + search_fields = ['name', 'code'] + list_select_related = ['department'] + ordering = ['name'] + readonly_fields = ['created_at', 'updated_at', 'uuid'] diff --git a/apps/locations/dislocation_columns.py b/apps/locations/dislocation_columns.py new file mode 100644 index 0000000..5e4c9cc --- /dev/null +++ b/apps/locations/dislocation_columns.py @@ -0,0 +1,102 @@ +"""Каталог колонок таблицы дислокации аптек (whitelist). + +Секреты (пароль Carbonio и т.п.) сюда не входят. +Колонка ``pharmacy`` всегда обязательна, но порядок среди колонок свободный. +""" +from __future__ import annotations + +from dataclasses import dataclass + +from django.utils.translation import gettext_lazy as _ + +LOCKED_KEY = 'pharmacy' + +STANDARD_COLUMNS = [ + 'pharmacy', 'organization', 'head', 'staff', 'phone', 'hours', 'status', +] + +HR_COLUMNS = [ + 'pharmacy', 'organization', 'head', 'staff', 'curator', 'manager', +] + +OPS_COLUMNS = [ + 'pharmacy', 'region', 'phone', 'hours', 'status', 'category', +] + + +@dataclass(frozen=True) +class ColumnDef: + key: str + label: str + sort_type: str # 'string' | 'number' + locked: bool = False + + +COLUMN_DEFS: dict[str, ColumnDef] = { + 'pharmacy': ColumnDef('pharmacy', _('Аптека'), 'number', locked=True), + 'organization': ColumnDef('organization', _('Организация'), 'string'), + 'head': ColumnDef('head', _('Заведующий'), 'string'), + 'staff': ColumnDef('staff', _('Персонал'), 'number'), + 'phone': ColumnDef('phone', _('Телефон'), 'string'), + 'hours': ColumnDef('hours', _('Режим работы'), 'string'), + 'status': ColumnDef('status', _('Статус'), 'number'), + 'region': ColumnDef('region', _('Регион'), 'string'), + 'category': ColumnDef('category', _('Категория'), 'string'), + 'curator': ColumnDef('curator', _('Куратор'), 'string'), + 'manager': ColumnDef('manager', _('Ответственный менеджер'), 'string'), + 'license': ColumnDef('license', _('Лицензия'), 'string'), + 'vat': ColumnDef('vat', _('ПДВ'), 'string'), + 'email': ColumnDef('email', _('Email'), 'string'), +} + +ALLOWED_KEYS = frozenset(COLUMN_DEFS) + + +def normalize_columns(keys) -> list[str]: + """Оставляет только известные ключи без дублей; ``pharmacy`` всегда в наборе.""" + seen = set() + result = [] + for key in keys or []: + if key not in ALLOWED_KEYS or key in seen: + continue + seen.add(key) + result.append(key) + if LOCKED_KEY not in result: + result.insert(0, LOCKED_KEY) + return result + + +def column_defs_for(keys: list[str]) -> list[ColumnDef]: + return [COLUMN_DEFS[k] for k in keys if k in COLUMN_DEFS] + + +def all_column_defs() -> list[ColumnDef]: + # pharmacy first in the registry list, then the rest in declaration order + rest = [d for k, d in COLUMN_DEFS.items() if k != LOCKED_KEY] + return [COLUMN_DEFS[LOCKED_KEY], *rest] + + +def resolve_columns_for_user(user) -> list[str]: + """Персональный override → пресет отдела → is_default → STANDARD.""" + custom = getattr(user, 'dislocation_columns', None) + if custom: + return normalize_columns(custom) + + from apps.locations.models import DislocationColumnPreset + + dept_id = getattr(user, 'department_id', None) + if dept_id: + preset = ( + DislocationColumnPreset.objects + .filter(department_id=dept_id) + .order_by('id') + .first() + ) + if preset and preset.columns: + return normalize_columns(preset.columns) + + preset = DislocationColumnPreset.objects.filter(is_default=True).first() + if preset and preset.columns: + return normalize_columns(preset.columns) + + return list(STANDARD_COLUMNS) diff --git a/apps/locations/migrations/0010_dislocation_column_presets.py b/apps/locations/migrations/0010_dislocation_column_presets.py new file mode 100644 index 0000000..0631ee7 --- /dev/null +++ b/apps/locations/migrations/0010_dislocation_column_presets.py @@ -0,0 +1,35 @@ +# Generated by Django 6.0.3 on 2026-07-28 21:08 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0008_access_location_types_help'), + ('locations', '0009_pharmacy_carbonio_password'), + ] + + operations = [ + migrations.CreateModel( + name='DislocationColumnPreset', + 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)), + ('name', models.CharField(max_length=100, verbose_name='Название')), + ('code', models.SlugField(unique=True, verbose_name='Код')), + ('columns', models.JSONField(default=list, help_text='Упорядоченный список ключей из реестра колонок дислокации.', verbose_name='Колонки')), + ('is_default', models.BooleanField(default=False, help_text='Используется, если у сотрудника нет своего набора и нет пресета отдела.', verbose_name='По умолчанию')), + ('department', models.ForeignKey(blank=True, help_text='Если задан — пресет по умолчанию для сотрудников этого отдела.', limit_choices_to={'directory_type__code': 'department', 'is_active': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dislocation_column_presets', to='core.directory', verbose_name='Отдел')), + ], + options={ + 'verbose_name': 'Пресет колонок дислокации', + 'verbose_name_plural': 'Пресеты колонок дислокации', + 'ordering': ['name'], + }, + ), + ] diff --git a/apps/locations/migrations/0011_seed_dislocation_column_presets.py b/apps/locations/migrations/0011_seed_dislocation_column_presets.py new file mode 100644 index 0000000..652bb1c --- /dev/null +++ b/apps/locations/migrations/0011_seed_dislocation_column_presets.py @@ -0,0 +1,52 @@ +from django.db import migrations + +from apps.locations.dislocation_columns import HR_COLUMNS, OPS_COLUMNS, STANDARD_COLUMNS + + +def seed_presets(apps, schema_editor): + DislocationColumnPreset = apps.get_model('locations', 'DislocationColumnPreset') + presets = [ + { + 'code': 'standard', + 'name': 'Стандарт', + 'columns': list(STANDARD_COLUMNS), + 'is_default': True, + }, + { + 'code': 'hr', + 'name': 'HR', + 'columns': list(HR_COLUMNS), + 'is_default': False, + }, + { + 'code': 'ops', + 'name': 'Операции', + 'columns': list(OPS_COLUMNS), + 'is_default': False, + }, + ] + for data in presets: + DislocationColumnPreset.objects.update_or_create( + code=data['code'], + defaults={ + 'name': data['name'], + 'columns': data['columns'], + 'is_default': data['is_default'], + }, + ) + + +def unseed_presets(apps, schema_editor): + DislocationColumnPreset = apps.get_model('locations', 'DislocationColumnPreset') + DislocationColumnPreset.objects.filter(code__in=['standard', 'hr', 'ops']).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('locations', '0010_dislocation_column_presets'), + ] + + operations = [ + migrations.RunPython(seed_presets, unseed_presets), + ] diff --git a/apps/locations/models/__init__.py b/apps/locations/models/__init__.py index 53cb011..d5e5d02 100644 --- a/apps/locations/models/__init__.py +++ b/apps/locations/models/__init__.py @@ -1,2 +1,10 @@ from .base import Location, LocationManager from .pharmacies import Pharmacy, PharmacyManager, PharmacyPhone, PharmacyHistory +from .dislocation import DislocationColumnPreset + +__all__ = [ + 'Location', 'LocationManager', + 'Pharmacy', 'PharmacyManager', 'PharmacyPhone', 'PharmacyHistory', + 'DislocationColumnPreset', +] + diff --git a/apps/locations/models/dislocation.py b/apps/locations/models/dislocation.py new file mode 100644 index 0000000..193d327 --- /dev/null +++ b/apps/locations/models/dislocation.py @@ -0,0 +1,51 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from core.models import BaseModel +from apps.locations.dislocation_columns import STANDARD_COLUMNS, normalize_columns + + +class DislocationColumnPreset(BaseModel): + """Заготовка набора колонок таблицы дислокации. + + ``department`` — задел под преднастройки по отделам (nullable). + """ + + name = models.CharField(_('Название'), max_length=100) + code = models.SlugField(_('Код'), max_length=50, unique=True) + columns = models.JSONField( + _('Колонки'), + default=list, + help_text=_('Упорядоченный список ключей из реестра колонок дислокации.'), + ) + 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='dislocation_column_presets', + verbose_name=_('Отдел'), + help_text=_('Если задан — пресет по умолчанию для сотрудников этого отдела.'), + ) + is_default = models.BooleanField( + _('По умолчанию'), default=False, + help_text=_('Используется, если у сотрудника нет своего набора и нет пресета отдела.'), + ) + + class Meta: + verbose_name = _('Пресет колонок дислокации') + verbose_name_plural = _('Пресеты колонок дислокации') + ordering = ['name'] + + def __str__(self): + return self.name + + def save(self, *args, **kwargs): + self.columns = normalize_columns(self.columns or STANDARD_COLUMNS) + super().save(*args, **kwargs) + if self.is_default: + ( + DislocationColumnPreset.objects + .exclude(pk=self.pk) + .filter(is_default=True) + .update(is_default=False) + ) diff --git a/apps/locations/tests.py b/apps/locations/tests.py index 932e60d..dfed2f4 100644 --- a/apps/locations/tests.py +++ b/apps/locations/tests.py @@ -135,3 +135,127 @@ class CarbonioPasswordSyncTests(TestCase): self.assertEqual(p.carbonio_password, '') # на update пароль не трогаем fake.create_account.assert_not_called() fake.set_account_status.assert_called_once_with('acc-existing', 'active') + + +class DislocationColumnsTests(TestCase): + """Whitelist, резолв пресета default и сохранение preference пользователя.""" + + @classmethod + def setUpTestData(cls): + from datetime import date + from django.contrib.auth import get_user_model + from apps.locations.dislocation_columns import HR_COLUMNS, OPS_COLUMNS, STANDARD_COLUMNS + from apps.locations.models import DislocationColumnPreset + + Employee = get_user_model() + org_type = DirectoryType.objects.create(code='organization', name='Организация') + region_type = DirectoryType.objects.create(code='region', name='Регион') + cls.region = Directory.objects.create(directory_type=region_type, code='kv', name='Киев') + cls.org = Directory.objects.create(directory_type=org_type, code='org', name='ТестОрг') + cls.user = Employee.objects.create( + username='dislo.cols', + email='dislo.cols@company.local', + first_name='Коля', + last_name='Колонки', + hire_date=date(2026, 1, 1), + user_location_type='office', + mattermost_sync_enabled=False, + ad_sync_enabled=False, + ) + DislocationColumnPreset.objects.update_or_create( + code='standard', + defaults={ + 'name': 'Стандарт', + 'columns': list(STANDARD_COLUMNS), + 'is_default': True, + }, + ) + DislocationColumnPreset.objects.update_or_create( + code='hr', + defaults={'name': 'HR', 'columns': list(HR_COLUMNS), 'is_default': False}, + ) + DislocationColumnPreset.objects.update_or_create( + code='ops', + defaults={'name': 'Ops', 'columns': list(OPS_COLUMNS), 'is_default': False}, + ) + Pharmacy.objects.create( + organization=cls.org, location_region=cls.region, + pharmacy_number=101, location_type='pharmacy', street='Тестовая', house_number='1', + ) + + def test_normalize_whitelist_and_locked_pharmacy(self): + from apps.locations.dislocation_columns import normalize_columns + + self.assertEqual( + normalize_columns(['email', 'pharmacy', 'unknown', 'email', 'region']), + ['email', 'pharmacy', 'region'], + ) + self.assertEqual( + normalize_columns(['organization', 'pharmacy', 'phone']), + ['organization', 'pharmacy', 'phone'], + ) + self.assertEqual(normalize_columns([]), ['pharmacy']) + self.assertEqual(normalize_columns(['secret', 'password']), ['pharmacy']) + # pharmacy обязательна, но если её нет в списке — добавляется первой + self.assertEqual( + normalize_columns(['region', 'phone']), + ['pharmacy', 'region', 'phone'], + ) + def test_resolve_uses_default_preset(self): + from apps.locations.dislocation_columns import STANDARD_COLUMNS, resolve_columns_for_user + + self.user.dislocation_columns = None + self.user.save(update_fields=['dislocation_columns']) + self.assertEqual(resolve_columns_for_user(self.user), list(STANDARD_COLUMNS)) + + def test_resolve_uses_custom_preference(self): + from apps.locations.dislocation_columns import resolve_columns_for_user + + self.user.dislocation_columns = ['region', 'phone', 'bogus'] + self.user.save(update_fields=['dislocation_columns']) + self.assertEqual( + resolve_columns_for_user(self.user), + ['pharmacy', 'region', 'phone'], + ) + + def test_post_saves_columns_preference(self): + self.client.force_login(self.user) + session = self.client.session + session['2fa_verified'] = True + session.save() + + resp = self.client.post( + '/dislocation/columns/', + {'columns': ['organization', 'pharmacy', 'staff', 'region', 'email', 'evil']}, + HTTP_HX_REQUEST='true', + ) + self.assertEqual(resp.status_code, 200) + self.user.refresh_from_db() + self.assertEqual( + self.user.dislocation_columns, + ['organization', 'pharmacy', 'staff', 'region', 'email'], + ) + self.assertContains(resp, "disloSortBy('organization'") + self.assertContains(resp, "disloSortBy('region'") + self.assertContains(resp, "disloSortBy('email'") + self.assertNotContains(resp, "disloSortBy('phone'") + + def test_post_preset_code_applies_hr(self): + from apps.locations.dislocation_columns import HR_COLUMNS + + self.client.force_login(self.user) + session = self.client.session + session['2fa_verified'] = True + session.save() + + resp = self.client.post( + '/dislocation/columns/', + {'preset_code': 'hr'}, + HTTP_HX_REQUEST='true', + ) + self.assertEqual(resp.status_code, 200) + self.user.refresh_from_db() + self.assertEqual(self.user.dislocation_columns, list(HR_COLUMNS)) + self.assertContains(resp, "disloSortBy('curator'") + self.assertContains(resp, "disloSortBy('manager'") + self.assertNotContains(resp, "disloSortBy('phone'") diff --git a/apps/rro/__init__.py b/apps/rro/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/rro/admin.py b/apps/rro/admin.py new file mode 100644 index 0000000..fd3a649 --- /dev/null +++ b/apps/rro/admin.py @@ -0,0 +1,39 @@ +from django.contrib import admin +from unfold.admin import ModelAdmin + +from .models import CashRegister + + +@admin.register(CashRegister) +class CashRegisterAdmin(ModelAdmin): + list_display = [ + 'fiscal_number', 'kind', 'pharmacy', 'model_name', + 'vendor', 'status', 'is_primary', 'cert_expires_at', + ] + list_filter = ['kind', 'status', 'is_primary', 'pharmacy__organization', 'vendor'] + search_fields = [ + 'fiscal_number', 'serial_number', 'model_name', + 'pharmacy__pharmacy_number', 'pharmacy__address', 'notes', 'provider', + ] + list_select_related = [ + 'pharmacy', 'pharmacy__organization', 'vendor', 'status', + ] + autocomplete_fields = ['pharmacy'] + readonly_fields = ['created_at', 'updated_at', 'uuid'] + ordering = ['pharmacy__organization__name', 'pharmacy__pharmacy_number'] + + fieldsets = ( + (None, { + 'fields': ( + 'pharmacy', 'kind', 'fiscal_number', 'serial_number', + 'model_name', 'vendor', 'status', 'is_primary', + ), + }), + ('Регистрация', { + 'fields': ('registered_at', 'cert_expires_at', 'provider', 'notes'), + }), + ('Служебное', { + 'classes': ('collapse',), + 'fields': ('uuid', 'created_at', 'updated_at'), + }), + ) diff --git a/apps/rro/apps.py b/apps/rro/apps.py new file mode 100644 index 0000000..ccd089a --- /dev/null +++ b/apps/rro/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class RroConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'apps.rro' + verbose_name = 'РРО / ПРРО' diff --git a/apps/rro/migrations/0001_initial.py b/apps/rro/migrations/0001_initial.py new file mode 100644 index 0000000..cb2dcb9 --- /dev/null +++ b/apps/rro/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 6.0.3 on 2026-08-03 16:12 + +import core.models.directories +import django.db.models.deletion +import functools +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('core', '0008_access_location_types_help'), + ('locations', '0011_seed_dislocation_column_presets'), + ] + + operations = [ + migrations.CreateModel( + name='CashRegister', + 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)), + ('kind', models.CharField(choices=[('rro', 'РРО'), ('prro', 'ПРРО')], default='prro', max_length=10, verbose_name='Тип')), + ('fiscal_number', models.CharField(max_length=64, unique=True, verbose_name='Фискальный номер')), + ('serial_number', models.CharField(blank=True, max_length=64, verbose_name='Заводской / серийный номер')), + ('model_name', models.CharField(blank=True, max_length=120, verbose_name='Модель')), + ('registered_at', models.DateField(blank=True, null=True, verbose_name='Дата регистрации в ГНС')), + ('cert_expires_at', models.DateField(blank=True, help_text='Для ПРРО — срок действия КЭП/сертификата (задел под напоминания).', null=True, verbose_name='Срок сертификата / ключа')), + ('is_primary', models.BooleanField(default=False, verbose_name='Основной на аптеке')), + ('notes', models.TextField(blank=True, verbose_name='Комментарий')), + ('provider', models.CharField(blank=True, help_text='Slug провайдера без интеграции: checkbox, vchasno, …', max_length=50, verbose_name='Провайдер ПРРО')), + ('pharmacy', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='cash_registers', to='locations.pharmacy', verbose_name='Аптека')), + ('status', models.ForeignKey(default=functools.partial(core.models.directories._resolve_directory_default, 'rro_status', 'active'), limit_choices_to={'directory_type__code': 'rro_status', 'is_active': True}, on_delete=django.db.models.deletion.PROTECT, related_name='rro_status_registers', to='core.directory', verbose_name='Статус')), + ('vendor', models.ForeignKey(blank=True, limit_choices_to={'directory_type__code': 'rro_vendor', 'is_active': True}, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='rro_vendor_registers', to='core.directory', verbose_name='Производитель / вендор')), + ], + options={ + 'verbose_name': 'Кассовый аппарат', + 'verbose_name_plural': 'Кассовые аппараты (РРО/ПРРО)', + 'ordering': ['pharmacy__organization__name', 'pharmacy__pharmacy_number', 'fiscal_number'], + 'indexes': [models.Index(fields=['pharmacy', 'status'], name='rro_cashreg_pharmac_66c4f3_idx')], + }, + ), + ] diff --git a/apps/rro/migrations/0002_seed_rro_directories.py b/apps/rro/migrations/0002_seed_rro_directories.py new file mode 100644 index 0000000..bd1d295 --- /dev/null +++ b/apps/rro/migrations/0002_seed_rro_directories.py @@ -0,0 +1,56 @@ +from django.db import migrations + + +RRO_STATUSES = [ + ('active', 'Активный'), + ('reserve', 'Резерв'), + ('repair', 'В ремонте'), + ('decommissioned', 'Списан'), +] + +RRO_VENDORS = [ + ('datecs', 'Datecs'), + ('exellio', 'Exellio'), + ('checkbox', 'Checkbox'), +] + + +def forwards(apps, schema_editor): + DirectoryType = apps.get_model('core', 'DirectoryType') + Directory = apps.get_model('core', 'Directory') + + status_type, _ = DirectoryType.objects.get_or_create( + code='rro_status', + defaults={'name': 'Статус РРО/ПРРО', 'hierarchy_type': 'strict'}, + ) + for code, name in RRO_STATUSES: + Directory.objects.get_or_create( + directory_type=status_type, code=code, + defaults={'name': name, 'is_active': True}, + ) + + vendor_type, _ = DirectoryType.objects.get_or_create( + code='rro_vendor', + defaults={'name': 'Вендор РРО/ПРРО', 'hierarchy_type': 'strict'}, + ) + for code, name in RRO_VENDORS: + Directory.objects.get_or_create( + directory_type=vendor_type, code=code, + defaults={'name': name, 'is_active': True}, + ) + + +def backwards(apps, schema_editor): + DirectoryType = apps.get_model('core', 'DirectoryType') + DirectoryType.objects.filter(code__in=['rro_status', 'rro_vendor']).delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('rro', '0001_initial'), + ] + + operations = [ + migrations.RunPython(forwards, backwards), + ] diff --git a/apps/rro/migrations/__init__.py b/apps/rro/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/rro/models.py b/apps/rro/models.py new file mode 100644 index 0000000..cf1d30f --- /dev/null +++ b/apps/rro/models.py @@ -0,0 +1,79 @@ +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from core.models import BaseModel +from core.models.directories import directory_default + + +class CashRegister(BaseModel): + """Кассовый аппарат (РРО) или программный РРО (ПРРО) на аптеке.""" + + class Kind(models.TextChoices): + RRO = 'rro', _('РРО') + PRRO = 'prro', _('ПРРО') + + pharmacy = models.ForeignKey( + 'locations.Pharmacy', on_delete=models.PROTECT, + related_name='cash_registers', verbose_name=_('Аптека'), + ) + kind = models.CharField( + _('Тип'), max_length=10, choices=Kind.choices, default=Kind.PRRO, + ) + fiscal_number = models.CharField( + _('Фискальный номер'), max_length=64, unique=True, + ) + serial_number = models.CharField( + _('Заводской / серийный номер'), max_length=64, blank=True, + ) + model_name = models.CharField(_('Модель'), max_length=120, blank=True) + vendor = models.ForeignKey( + 'core.Directory', on_delete=models.SET_NULL, + null=True, blank=True, + limit_choices_to={'directory_type__code': 'rro_vendor', 'is_active': True}, + related_name='rro_vendor_registers', verbose_name=_('Производитель / вендор'), + ) + status = models.ForeignKey( + 'core.Directory', on_delete=models.PROTECT, + limit_choices_to={'directory_type__code': 'rro_status', 'is_active': True}, + related_name='rro_status_registers', verbose_name=_('Статус'), + default=directory_default('rro_status', 'active'), + ) + registered_at = models.DateField( + _('Дата регистрации в ГНС'), null=True, blank=True, + ) + cert_expires_at = models.DateField( + _('Срок сертификата / ключа'), null=True, blank=True, + help_text=_('Для ПРРО — срок действия КЭП/сертификата (задел под напоминания).'), + ) + is_primary = models.BooleanField( + _('Основной на аптеке'), default=False, + ) + notes = models.TextField(_('Комментарий'), blank=True) + provider = models.CharField( + _('Провайдер ПРРО'), max_length=50, blank=True, + help_text=_('Slug провайдера без интеграции: checkbox, vchasno, …'), + ) + + class Meta: + verbose_name = _('Кассовый аппарат') + verbose_name_plural = _('Кассовые аппараты (РРО/ПРРО)') + ordering = ['pharmacy__organization__name', 'pharmacy__pharmacy_number', 'fiscal_number'] + indexes = [ + models.Index(fields=['pharmacy', 'status']), + ] + + def __str__(self): + label = self.get_kind_display() + return f'{label} {self.fiscal_number} — {self.pharmacy}' + + def clean(self): + super().clean() + if self.is_primary and self.pharmacy_id: + qs = CashRegister.objects.filter(pharmacy_id=self.pharmacy_id, is_primary=True) + if self.pk: + qs = qs.exclude(pk=self.pk) + if qs.exists(): + raise ValidationError({ + 'is_primary': _('На этой аптеке уже есть основной кассовый аппарат.'), + }) diff --git a/apps/rro/tests.py b/apps/rro/tests.py new file mode 100644 index 0000000..178d176 --- /dev/null +++ b/apps/rro/tests.py @@ -0,0 +1,134 @@ +from datetime import date + +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.db import IntegrityError, transaction +from django.test import TestCase + +from core.models import Directory, DirectoryType +from apps.locations.models import Pharmacy +from apps.rro.models import CashRegister + +Employee = get_user_model() + + +class CashRegisterModelTests(TestCase): + @classmethod + def setUpTestData(cls): + org_type = DirectoryType.objects.create(code='organization', name='Организация') + region_type = DirectoryType.objects.create(code='region', name='Регион') + status_type, _ = DirectoryType.objects.get_or_create( + code='rro_status', defaults={'name': 'Статус РРО/ПРРО', 'hierarchy_type': 'strict'}, + ) + cls.status_active, _ = Directory.objects.get_or_create( + directory_type=status_type, code='active', + defaults={'name': 'Активный', 'is_active': True}, + ) + cls.region = Directory.objects.create(directory_type=region_type, code='od', name='Одесса') + cls.org = Directory.objects.create(directory_type=org_type, code='akc', name='АКС') + cls.pharmacy = Pharmacy.objects.create( + organization=cls.org, location_region=cls.region, + pharmacy_number=42, location_type='pharmacy', + street='Тестовая', house_number='1', + ) + cls.pharmacy2 = Pharmacy.objects.create( + organization=cls.org, location_region=cls.region, + pharmacy_number=43, location_type='pharmacy', + street='Другая', house_number='2', + ) + + def _reg(self, **kw): + defaults = { + 'pharmacy': self.pharmacy, + 'kind': CashRegister.Kind.PRRO, + 'fiscal_number': kw.pop('fiscal_number', f'FN-{CashRegister.objects.count()+1}'), + 'status': self.status_active, + } + defaults.update(kw) + return CashRegister(**defaults) + + def test_seed_statuses_exist(self): + codes = set( + Directory.objects.filter(directory_type__code='rro_status') + .values_list('code', flat=True) + ) + self.assertTrue({'active', 'reserve', 'repair', 'decommissioned'} <= codes) + + def test_unique_fiscal_number(self): + self._reg(fiscal_number='FN-UNIQUE').save() + with self.assertRaises(IntegrityError): + with transaction.atomic(): + self._reg(fiscal_number='FN-UNIQUE', pharmacy=self.pharmacy2).save() + + def test_only_one_primary_per_pharmacy(self): + a = self._reg(fiscal_number='FN-P1', is_primary=True) + a.full_clean() + a.save() + b = self._reg(fiscal_number='FN-P2', is_primary=True) + with self.assertRaises(ValidationError): + b.full_clean() + + def test_primary_ok_on_different_pharmacies(self): + a = self._reg(fiscal_number='FN-A', is_primary=True) + a.full_clean() + a.save() + b = self._reg(fiscal_number='FN-B', pharmacy=self.pharmacy2, is_primary=True) + b.full_clean() + b.save() + + +class CashRegisterPortalTests(TestCase): + @classmethod + def setUpTestData(cls): + org_type = DirectoryType.objects.create(code='organization', name='Организация') + region_type = DirectoryType.objects.create(code='region', name='Регион') + status_type, _ = DirectoryType.objects.get_or_create( + code='rro_status', defaults={'name': 'Статус РРО/ПРРО', 'hierarchy_type': 'strict'}, + ) + cls.status_active, _ = Directory.objects.get_or_create( + directory_type=status_type, code='active', + defaults={'name': 'Активный', 'is_active': True}, + ) + region = Directory.objects.create(directory_type=region_type, code='kv', name='Киев') + org = Directory.objects.create(directory_type=org_type, code='org', name='ТестОрг') + cls.pharmacy = Pharmacy.objects.create( + organization=org, location_region=region, + pharmacy_number=7, location_type='pharmacy', + street='Главная', house_number='7', + ) + CashRegister.objects.create( + pharmacy=cls.pharmacy, kind=CashRegister.Kind.RRO, + fiscal_number='FN-PORTAL-1', status=cls.status_active, + model_name='Datecs FP-700', is_primary=True, + ) + cls.user = Employee.objects.create( + username='rro.staff', + email='rro.staff@company.local', + first_name='РРО', + last_name='Тест', + hire_date=date(2026, 1, 1), + user_location_type='office', + is_staff=True, + mattermost_sync_enabled=False, + ad_sync_enabled=False, + totp_enabled=True, + ) + + def _login(self): + self.client.force_login(self.user) + session = self.client.session + session['2fa_verified'] = True + session.save() + + def test_rro_list_page(self): + self._login() + resp = self.client.get('/rro/', HTTP_HX_REQUEST='true') + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'FN-PORTAL-1') + + def test_pharmacy_detail_rro_tab(self): + self._login() + resp = self.client.get(f'/pharmacies/{self.pharmacy.pk}/', HTTP_HX_REQUEST='true') + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'FN-PORTAL-1') + self.assertContains(resp, 'ph-panel-rro') diff --git a/apps/rro/urls.py b/apps/rro/urls.py new file mode 100644 index 0000000..865bda7 --- /dev/null +++ b/apps/rro/urls.py @@ -0,0 +1,7 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path('rro/', views.rro_list_view, name='rro_list'), +] diff --git a/apps/rro/views.py b/apps/rro/views.py new file mode 100644 index 0000000..e325af7 --- /dev/null +++ b/apps/rro/views.py @@ -0,0 +1,75 @@ +from django.contrib.auth.decorators import login_required +from django.db.models import Q + +from apps.locations.models.pharmacies import Pharmacy +from core.models.directories import Directory +from core.utils.htmx import render_page + +from .models import CashRegister + + +@login_required +def rro_list_view(request): + """Реестр РРО/ПРРО по аптекам. Staff видит все; сотрудник аптеки — свою.""" + is_staff = request.user.is_staff + own_pharmacy = request.user.user_pharmacy + + registers = CashRegister.objects.select_related( + 'pharmacy', 'pharmacy__organization', 'vendor', 'status', + ) + + filter_pharmacy = request.GET.get('pharmacy', '') + filter_org = request.GET.get('organization', '') + filter_kind = request.GET.get('kind', '') + filter_status = request.GET.get('status', '') + q = (request.GET.get('q') or '').strip() + + if is_staff: + if filter_pharmacy: + registers = registers.filter(pharmacy_id=filter_pharmacy) + if filter_org: + registers = registers.filter(pharmacy__organization_id=filter_org) + else: + if own_pharmacy: + registers = registers.filter(pharmacy=own_pharmacy) + else: + registers = registers.none() + + if filter_kind in {CashRegister.Kind.RRO, CashRegister.Kind.PRRO}: + registers = registers.filter(kind=filter_kind) + if filter_status: + registers = registers.filter(status_id=filter_status) + if q: + registers = registers.filter( + Q(fiscal_number__icontains=q) + | Q(serial_number__icontains=q) + | Q(model_name__icontains=q) + | Q(provider__icontains=q) + ) + + statuses = Directory.objects.filter( + directory_type__code='rro_status', is_active=True, + ).order_by('name') + + context = { + 'is_staff': is_staff, + 'registers': list(registers[:500]), + 'statuses': statuses, + 'kind_choices': CashRegister.Kind.choices, + 'filter_pharmacy': filter_pharmacy, + 'filter_org': filter_org, + 'filter_kind': filter_kind, + 'filter_status': filter_status, + 'filter_q': q, + 'all_pharmacies': ( + Pharmacy.objects.filter(is_active=True) + .select_related('organization') + .order_by('organization__name', 'pharmacy_number') + if is_staff else [] + ), + 'organizations': ( + Directory.objects.filter(directory_type__code='organization', is_active=True).order_by('name') + if is_staff else [] + ), + } + return render_page(request, 'rro.html', context) diff --git a/core/admin.py b/core/admin.py index 104c81f..16159d2 100644 --- a/core/admin.py +++ b/core/admin.py @@ -227,22 +227,34 @@ class DirectoryAdmin(ModelAdmin, ImportExportModelAdmin): class AccessSystemAdmin(ModelAdmin): """Отдельный раздел для списка систем/баз доступа (1С, почта, CRM…), которые выбираются чекбоксами в заявке на найм.""" - list_display = ['name', 'code', 'is_active', 'updated_at'] + list_display = ['name', 'code', 'location_types_display', 'is_active', 'updated_at'] list_filter = [ActiveFilter] list_filter_sheet = False list_filter_submit = True search_fields = ['name', 'code'] ordering = ['name'] - fields = ['name', 'code', 'description', 'is_active'] + fields = ['name', 'code', 'description', 'location_types', 'is_active'] + filter_horizontal = ['location_types'] def get_queryset(self, request): - return super().get_queryset(request).filter(directory_type__code='access_system') + return ( + super().get_queryset(request) + .filter(directory_type__code='access_system') + .prefetch_related('location_types') + ) def save_model(self, request, obj, form, change): if not obj.directory_type_id: obj.directory_type = DirectoryType.objects.get(code='access_system') super().save_model(request, obj, form, change) + def location_types_display(self, obj): + types = list(obj.location_types.all()) + if not types: + return mark_safe('все') + return ', '.join(t.name for t in types) + location_types_display.short_description = _('Типы локаций') + @admin.register(ConnectorConfig) class ConnectorConfigAdmin(ModelAdmin): diff --git a/core/migrations/0008_access_location_types_help.py b/core/migrations/0008_access_location_types_help.py new file mode 100644 index 0000000..4b5fbbb --- /dev/null +++ b/core/migrations/0008_access_location_types_help.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-07-28 19:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0007_alter_connectorconfig_connector_type'), + ] + + operations = [ + migrations.AlterField( + model_name='directory', + name='location_types', + field=models.ManyToManyField(blank=True, help_text='Пусто = доступно для всех типов локаций. Для должностей и систем доступа: в каких локациях показывать (офис / аптека / склад).', limit_choices_to={'directory_type__code': 'location_type', 'is_active': True}, related_name='available_posts', to='core.directory', verbose_name='Типы локаций'), + ), + ] diff --git a/core/migrations/0009_portal_access_permissions.py b/core/migrations/0009_portal_access_permissions.py new file mode 100644 index 0000000..681a98c --- /dev/null +++ b/core/migrations/0009_portal_access_permissions.py @@ -0,0 +1,26 @@ +# Generated by Django 6.0.3 on 2026-08-03 16:40 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0008_access_location_types_help'), + ] + + operations = [ + migrations.CreateModel( + name='PortalAccess', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + options={ + 'verbose_name': 'Доступ к разделу портала', + 'verbose_name_plural': 'Доступы к разделам портала', + 'permissions': [('access_hr_portal', 'Доступ к разделу: HR-портал'), ('access_recruiting', 'Доступ к разделу: Рекрутинг'), ('access_structure', 'Доступ к разделу: Структура'), ('access_timetrack', 'Доступ к разделу: Учёт рабочего времени'), ('access_databases', 'Доступ к разделу: Базы данных'), ('access_reports', 'Доступ к разделу: Отчёты'), ('access_purchases', 'Доступ к разделу: Закупки'), ('access_email', 'Доступ к разделу: Почта'), ('access_it_manager', 'Доступ к разделу: IT-отдел'), ('access_ad_mapping', 'Доступ к разделу: Active Directory')], + 'managed': False, + 'default_permissions': (), + }, + ), + ] diff --git a/core/migrations/0010_seed_portal_section_perms.py b/core/migrations/0010_seed_portal_section_perms.py new file mode 100644 index 0000000..28856b7 --- /dev/null +++ b/core/migrations/0010_seed_portal_section_perms.py @@ -0,0 +1,99 @@ +"""Создаёт ContentType/permissions разделов портала, группу HR и раздаёт права.""" +from django.db import migrations + +PORTAL_PERMS = [ + ('access_hr_portal', 'Доступ к разделу: HR-портал'), + ('access_recruiting', 'Доступ к разделу: Рекрутинг'), + ('access_structure', 'Доступ к разделу: Структура'), + ('access_timetrack', 'Доступ к разделу: Учёт рабочего времени'), + ('access_databases', 'Доступ к разделу: Базы данных'), + ('access_reports', 'Доступ к разделу: Отчёты'), + ('access_purchases', 'Доступ к разделу: Закупки'), + ('access_email', 'Доступ к разделу: Почта'), + ('access_it_manager', 'Доступ к разделу: IT-отдел'), + ('access_ad_mapping', 'Доступ к разделу: Active Directory'), +] + +GROUP_PORTAL_PERMS = { + 'Администратор сети': [c for c, _ in PORTAL_PERMS], + 'IT': [ + 'access_it_manager', 'access_ad_mapping', 'access_databases', + ], + 'Офисный сотрудник': [ + 'access_structure', 'access_email', 'access_timetrack', 'access_databases', + 'access_reports', 'access_purchases', + ], + 'HR': [ + 'access_hr_portal', 'access_recruiting', + ], + 'Заведующий аптекой': [ + 'access_timetrack', + ], +} + + +def forwards(apps, schema_editor): + Group = apps.get_model('auth', 'Group') + Permission = apps.get_model('auth', 'Permission') + ContentType = apps.get_model('contenttypes', 'ContentType') + PermissionGroup = apps.get_model('employees', 'PermissionGroup') + + # post_migrate ещё не создал CT/perms для unmanaged PortalAccess — создаём явно + ct, _ = ContentType.objects.get_or_create(app_label='core', model='portalaccess') + perms_by_code = {} + for codename, name in PORTAL_PERMS: + perm, _ = Permission.objects.get_or_create( + content_type=ct, codename=codename, + defaults={'name': name}, + ) + if perm.name != name: + perm.name = name + perm.save(update_fields=['name']) + perms_by_code[codename] = perm + + for group_name, codes in GROUP_PORTAL_PERMS.items(): + group, _ = Group.objects.get_or_create(name=group_name) + PermissionGroup.objects.get_or_create( + group=group, + defaults={'description': f'Группа для роли: {group_name}', 'is_active': True}, + ) + to_add = [perms_by_code[c] for c in codes if c in perms_by_code] + if to_add: + group.permissions.add(*to_add) + + +def backwards(apps, schema_editor): + Group = apps.get_model('auth', 'Group') + Permission = apps.get_model('auth', 'Permission') + ContentType = apps.get_model('contenttypes', 'ContentType') + PermissionGroup = apps.get_model('employees', 'PermissionGroup') + + ct = ContentType.objects.filter(app_label='core', model='portalaccess').first() + if ct is not None: + portal_perms = list( + Permission.objects.filter(content_type=ct, codename__startswith='access_') + ) + for group in Group.objects.filter(name__in=GROUP_PORTAL_PERMS.keys()): + group.permissions.remove(*portal_perms) + + hr = Group.objects.filter(name='HR').first() + if hr is not None: + PermissionGroup.objects.filter(group=hr).delete() + # не удаляем Group HR если уже использовалась — только если создавали мы; + # для backwards удаляем + hr.delete() + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0009_portal_access_permissions'), + ('employees', '0013_dislocation_column_presets'), + ('it_manager', '0007_create_it_group'), + ('auth', '0012_alter_user_first_name_max_length'), + ('contenttypes', '0002_remove_content_type_name'), + ] + + operations = [ + migrations.RunPython(forwards, backwards), + ] diff --git a/core/migrations/0011_ensure_portal_section_perms.py b/core/migrations/0011_ensure_portal_section_perms.py new file mode 100644 index 0000000..a3a5e97 --- /dev/null +++ b/core/migrations/0011_ensure_portal_section_perms.py @@ -0,0 +1,76 @@ +"""Повторный идемпотентный сид: 0010 мог выполниться до появления ContentType.""" +from django.db import migrations + +PORTAL_PERMS = [ + ('access_hr_portal', 'Доступ к разделу: HR-портал'), + ('access_recruiting', 'Доступ к разделу: Рекрутинг'), + ('access_structure', 'Доступ к разделу: Структура'), + ('access_timetrack', 'Доступ к разделу: Учёт рабочего времени'), + ('access_databases', 'Доступ к разделу: Базы данных'), + ('access_reports', 'Доступ к разделу: Отчёты'), + ('access_purchases', 'Доступ к разделу: Закупки'), + ('access_email', 'Доступ к разделу: Почта'), + ('access_it_manager', 'Доступ к разделу: IT-отдел'), + ('access_ad_mapping', 'Доступ к разделу: Active Directory'), +] + +GROUP_PORTAL_PERMS = { + 'Администратор сети': [c for c, _ in PORTAL_PERMS], + 'IT': [ + 'access_it_manager', 'access_ad_mapping', 'access_databases', + ], + 'Офисный сотрудник': [ + 'access_structure', 'access_email', 'access_timetrack', 'access_databases', + 'access_reports', 'access_purchases', + ], + 'HR': [ + 'access_hr_portal', 'access_recruiting', + ], + 'Заведующий аптекой': [ + 'access_timetrack', + ], +} + + +def forwards(apps, schema_editor): + Group = apps.get_model('auth', 'Group') + Permission = apps.get_model('auth', 'Permission') + ContentType = apps.get_model('contenttypes', 'ContentType') + PermissionGroup = apps.get_model('employees', 'PermissionGroup') + + ct, _ = ContentType.objects.get_or_create(app_label='core', model='portalaccess') + perms_by_code = {} + for codename, name in PORTAL_PERMS: + perm, _ = Permission.objects.get_or_create( + content_type=ct, codename=codename, + defaults={'name': name}, + ) + if perm.name != name: + perm.name = name + perm.save(update_fields=['name']) + perms_by_code[codename] = perm + + for group_name, codes in GROUP_PORTAL_PERMS.items(): + group, _ = Group.objects.get_or_create(name=group_name) + PermissionGroup.objects.get_or_create( + group=group, + defaults={'description': f'Группа для роли: {group_name}', 'is_active': True}, + ) + to_add = [perms_by_code[c] for c in codes if c in perms_by_code] + if to_add: + group.permissions.add(*to_add) + + +def backwards(apps, schema_editor): + pass + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0010_seed_portal_section_perms'), + ] + + operations = [ + migrations.RunPython(forwards, backwards), + ] diff --git a/core/models/__init__.py b/core/models/__init__.py index 20b3aaa..2a53d26 100644 --- a/core/models/__init__.py +++ b/core/models/__init__.py @@ -1,3 +1,13 @@ from .base import BaseModel from .directories import DirectoryType, Directory, AccessSystem from .connector_config import ConnectorConfig +from .portal_access import PortalAccess + +__all__ = [ + 'BaseModel', + 'DirectoryType', + 'Directory', + 'AccessSystem', + 'ConnectorConfig', + 'PortalAccess', +] diff --git a/core/models/directories.py b/core/models/directories.py index 035b9a6..a3827f5 100644 --- a/core/models/directories.py +++ b/core/models/directories.py @@ -129,7 +129,11 @@ class Directory(TreeNodeModel, BaseModel): limit_choices_to={'directory_type__code': 'location_type', 'is_active': True}, related_name='available_posts', verbose_name=_('Типы локаций'), - help_text=_('Для должностей: в каких типах локаций доступна эта должность') + help_text=_( + 'Пусто = доступно для всех типов локаций. ' + 'Для должностей и систем доступа: в каких локациях показывать ' + '(офис / аптека / склад).' + ), ) # Active Directory интеграция (для отделов) diff --git a/core/models/portal_access.py b/core/models/portal_access.py new file mode 100644 index 0000000..309ed07 --- /dev/null +++ b/core/models/portal_access.py @@ -0,0 +1,18 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + +from core.portal_sections import portal_permission_codenames + + +class PortalAccess(models.Model): + """Маркер для Django permissions разделов портала (таблица не создаётся).""" + + class Meta: + managed = False + default_permissions = () + permissions = portal_permission_codenames() + verbose_name = _('Доступ к разделу портала') + verbose_name_plural = _('Доступы к разделам портала') + + def __str__(self): + return 'PortalAccess' diff --git a/core/portal_access.py b/core/portal_access.py new file mode 100644 index 0000000..11cf0d6 --- /dev/null +++ b/core/portal_access.py @@ -0,0 +1,51 @@ +"""Проверка доступа к разделам портала + декоратор для views.""" +from __future__ import annotations + +from functools import wraps + +from django.contrib.auth.views import redirect_to_login +from django.http import HttpResponseForbidden + +from core.portal_sections import get_section + + +def user_can_access(user, key: str) -> bool: + """Может ли пользователь открыть раздел портала ``key``. + + - не аутентифицирован → False + - superuser → True + - раздел без perm → True (любой залогиненный) + - is_staff → True (временный мост для офисных админов) + - иначе user.has_perm(section.perm) + """ + if not user or not getattr(user, 'is_authenticated', False): + return False + if getattr(user, 'is_superuser', False): + return True + + section = get_section(key) + if section is None: + return False + if section.perm is None: + return True + if getattr(user, 'is_staff', False): + return True + return user.has_perm(section.perm) + + +def section_required(key: str): + """Декоратор: доступ к view только при user_can_access(key).""" + + def decorator(view_func): + @wraps(view_func) + def _wrapped(request, *args, **kwargs): + user = request.user + if not user.is_authenticated: + return redirect_to_login(request.get_full_path()) + if not user_can_access(user, key): + return HttpResponseForbidden('Нет доступа к этому разделу портала.') + return view_func(request, *args, **kwargs) + + return _wrapped + + return decorator diff --git a/core/portal_sections.py b/core/portal_sections.py new file mode 100644 index 0000000..f08e0c8 --- /dev/null +++ b/core/portal_sections.py @@ -0,0 +1,69 @@ +"""Реестр разделов портала — единый источник для меню и проверки на view.""" +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class PortalSection: + key: str + path: str + label: str + nav: str # main | manage | support | none + perm: str | None = None # 'core.access_' or None = any authenticated + + +# Ограниченные разделы — нужны Django permissions core.access_* +RESTRICTED_KEYS = ( + 'hr_portal', + 'recruiting', + 'structure', + 'timetrack', + 'databases', + 'reports', + 'purchases', + 'email', + 'it_manager', + 'ad_mapping', +) + +PORTAL_SECTIONS: dict[str, PortalSection] = { + 'dashboard': PortalSection('dashboard', '/dashboard/', 'Главная', 'main'), + 'news': PortalSection('news', '/news/', 'Новости', 'main'), + 'employees': PortalSection('employees', '/employees/', 'Сотрудники', 'main'), + 'tasks': PortalSection('tasks', '/tasks/', 'Задачи', 'main'), + 'dislocation': PortalSection('dislocation', '/dislocation/', 'Дислокация', 'main'), + 'documents': PortalSection('documents', '/documents/', 'Документы', 'main'), + 'email': PortalSection('email', '/email/', 'Почта', 'main', 'core.access_email'), + 'chat': PortalSection('chat', '/chat/', 'Чат', 'main'), + 'calendar': PortalSection('calendar', '/calendar/', 'Календарь', 'main'), + 'databases': PortalSection('databases', '/databases/', 'Базы данных', 'main', 'core.access_databases'), + 'reports': PortalSection('reports', '/reports/', 'Отчёты', 'main', 'core.access_reports'), + 'purchases': PortalSection('purchases', '/purchases/', 'Закупки', 'main', 'core.access_purchases'), + 'hr_portal': PortalSection('hr_portal', '/hr-portal/', 'HR-портал', 'main', 'core.access_hr_portal'), + 'settings': PortalSection('settings', '/settings/', 'Настройки', 'main'), + 'profile': PortalSection('profile', '/profile/', 'Профиль', 'none'), + 'structure': PortalSection('structure', '/structure/', 'Структура', 'manage', 'core.access_structure'), + 'pharmacies': PortalSection('pharmacies', '/pharmacies/', 'Аптеки', 'manage'), + 'pharmacy_detail': PortalSection('pharmacy_detail', '/pharmacies/', 'Карточка аптеки', 'none'), + 'rro': PortalSection('rro', '/rro/', 'РРО / ПРРО', 'manage'), + 'utilities': PortalSection('utilities', '/utilities/', 'Коммунальные услуги', 'manage'), + 'recruiting': PortalSection('recruiting', '/recruiting/', 'Рекрутинг', 'manage', 'core.access_recruiting'), + 'timetrack': PortalSection('timetrack', '/timetrack/', 'Учёт рабочего времени', 'manage', 'core.access_timetrack'), + 'help': PortalSection('help', '/help/', 'Помощь', 'support'), + 'it_support': PortalSection('it_support', '/it-support/', 'IT-поддержка', 'support'), + 'it_manager': PortalSection('it_manager', '/it-manager/', 'IT-отдел', 'support', 'core.access_it_manager'), + 'ad_mapping': PortalSection('ad_mapping', '/ad-mapping/', 'Active Directory', 'support', 'core.access_ad_mapping'), +} + + +def get_section(key: str) -> PortalSection | None: + return PORTAL_SECTIONS.get(key) + + +def portal_permission_codenames() -> list[tuple[str, str]]: + """Список (codename, name) для Meta.permissions модели PortalAccess.""" + return [ + (f'access_{key}', f'Доступ к разделу: {PORTAL_SECTIONS[key].label}') + for key in RESTRICTED_KEYS + ] diff --git a/core/templatetags/common_tags.py b/core/templatetags/common_tags.py index 2bd06e9..54d469b 100644 --- a/core/templatetags/common_tags.py +++ b/core/templatetags/common_tags.py @@ -37,7 +37,13 @@ def clean_excerpt(html_text, length=140): @register.filter def is_it_staff(user): - """Чи входить користувач до групи IT (або суперюзер) — доступ до /it-manager/.""" - if not user or not user.is_authenticated: - return False - return user.is_superuser or user.groups.filter(name='IT').exists() + """Доступ к /it-manager/ — через единый реестр разделов портала.""" + from core.portal_access import user_can_access + return user_can_access(user, 'it_manager') + + +@register.filter +def can_section(user, key): + """Видимость пункта меню / доступ к разделу: {% if user|can_section:'hr_portal' %}.""" + from core.portal_access import user_can_access + return user_can_access(user, key) \ No newline at end of file diff --git a/core/tests.py b/core/tests.py index 570e500..1c3cd1a 100644 --- a/core/tests.py +++ b/core/tests.py @@ -55,6 +55,7 @@ class PortalPagesSmokeTests(TestCase): PAGES = [ '/dashboard/', '/employees/', '/pharmacies/', '/structure/', '/dislocation/', + '/rro/', '/timetrack/', '/calendar/', '/reports/', '/documents/', '/recruiting/', '/settings/', '/news/', '/tasks/', '/email/', '/chat/', '/databases/', '/purchases/', '/hr-portal/', diff --git a/core/tests_portal_access.py b/core/tests_portal_access.py new file mode 100644 index 0000000..91fd66e --- /dev/null +++ b/core/tests_portal_access.py @@ -0,0 +1,125 @@ +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, EmployeePermissionOverride +from core.models import PortalAccess +from core.portal_access import user_can_access + + +def make_employee(**overrides): + n = Employee.objects.count() + 1 + defaults = { + 'username': f'portal.user{n}', + 'email': f'portal.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 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() + + +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) + + +class PortalAccessUnitTests(TestCase): + def test_open_section_any_authenticated(self): + user = make_employee() + self.assertTrue(user_can_access(user, 'dashboard')) + self.assertTrue(user_can_access(user, 'rro')) + + def test_restricted_denied_without_perm(self): + user = make_employee() + self.assertFalse(user_can_access(user, 'hr_portal')) + self.assertFalse(user_can_access(user, 'it_manager')) + + def test_staff_bridge(self): + user = make_employee(is_staff=True) + self.assertTrue(user_can_access(user, 'hr_portal')) + self.assertTrue(user_can_access(user, 'databases')) + + def test_hr_group_perm(self): + user = make_employee() + hr = Group.objects.get(name='HR') + user.groups.add(hr) + # clear cached perms + if hasattr(user, '_perm_cache'): + del user._perm_cache + if hasattr(user, '_user_perm_cache'): + del user._user_perm_cache + if hasattr(user, '_group_perm_cache'): + del user._group_perm_cache + self.assertTrue(user_can_access(user, 'hr_portal')) + self.assertTrue(user_can_access(user, 'recruiting')) + self.assertFalse(user_can_access(user, 'it_manager')) + + def test_override_grant(self): + user = make_employee() + EmployeePermissionOverride.objects.create( + employee=user, + permission=portal_perm('access_hr_portal'), + override_type='grant', + ) + self.assertTrue(user_can_access(user, 'hr_portal')) + + def test_override_revoke(self): + user = make_employee() + it = Group.objects.get(name='IT') + user.groups.add(it) + if hasattr(user, '_perm_cache'): + del user._perm_cache + self.assertTrue(user_can_access(user, 'it_manager')) + EmployeePermissionOverride.objects.create( + employee=user, + permission=portal_perm('access_it_manager'), + override_type='revoke', + ) + self.assertFalse(user_can_access(user, 'it_manager')) + + +class PortalAccessViewTests(TestCase): + def test_pharmacist_open_ok_restricted_forbidden(self): + user = make_employee() + login_2fa(self.client, user) + self.assertEqual(self.client.get('/dashboard/', HTTP_HX_REQUEST='true').status_code, 200) + self.assertEqual(self.client.get('/rro/', HTTP_HX_REQUEST='true').status_code, 200) + self.assertEqual(self.client.get('/hr-portal/', HTTP_HX_REQUEST='true').status_code, 403) + self.assertEqual(self.client.get('/it-manager/', HTTP_HX_REQUEST='true').status_code, 403) + self.assertEqual(self.client.get('/databases/', HTTP_HX_REQUEST='true').status_code, 403) + + def test_hr_group_can_open_hr_portal(self): + user = make_employee() + user.groups.add(Group.objects.get(name='HR')) + login_2fa(self.client, user) + self.assertEqual(self.client.get('/hr-portal/', HTTP_HX_REQUEST='true').status_code, 200) + + def test_it_group_can_open_it_manager(self): + user = make_employee() + user.groups.add(Group.objects.get(name='IT')) + login_2fa(self.client, user) + self.assertEqual(self.client.get('/it-manager/', HTTP_HX_REQUEST='true').status_code, 200) + + def test_staff_bridge_views(self): + user = make_employee(is_staff=True, totp_enabled=True) + login_2fa(self.client, user) + self.assertEqual(self.client.get('/databases/', HTTP_HX_REQUEST='true').status_code, 200) + self.assertEqual(self.client.get('/ad-mapping/', HTTP_HX_REQUEST='true').status_code, 200) diff --git a/core/views.py b/core/views.py index b43267a..284ace5 100644 --- a/core/views.py +++ b/core/views.py @@ -12,6 +12,7 @@ from apps.employees.models.base import Employee from apps.locations.models.pharmacies import Pharmacy from core.models.directories import Directory from core.models import ConnectorConfig +from core.portal_access import section_required from core.utils.htmx import render_page from core.utils.pagination import paginate from django.utils.translation import gettext_lazy as _ @@ -210,9 +211,17 @@ def pharmacies_view(request): @login_required def dislocation_view(request): """Дислокація: аптеки на карті/у таблиці разом з їхнім персоналом (замінює вкладку «Аптеки» на /structure/).""" + from apps.locations.dislocation_columns import ( + all_column_defs, column_defs_for, resolve_columns_for_user, + ) + from apps.locations.models import DislocationColumnPreset + pharmacies = ( Pharmacy.objects - .select_related('organization', 'location_region', 'head_pharmacist') + .select_related( + 'organization', 'location_region', 'head_pharmacist', + 'curator', 'responsible_manager', + ) .prefetch_related(Prefetch( 'pharmacy_employees', queryset=Employee.objects.filter(is_active=True).select_related('post'), @@ -257,6 +266,13 @@ def dislocation_view(request): office_departments = [d for d in office_departments if d.staff] office_no_department = by_dept.get(None, []) + visible_keys = resolve_columns_for_user(request.user) + visible_columns = column_defs_for(visible_keys) + visible_set = set(visible_keys) + all_defs = all_column_defs() + panel_column_defs = list(visible_columns) + [d for d in all_defs if d.key not in visible_set] + presets = list(DislocationColumnPreset.objects.order_by('name')) + context = { 'pharmacies': pharmacies, 'active_count': active_count, @@ -268,14 +284,44 @@ def dislocation_view(request): 'office_departments': office_departments, 'office_no_department': office_no_department, 'office_employees_count': len(office_employees), + 'visible_columns': visible_columns, + 'visible_column_keys': visible_keys, + 'all_column_defs': all_defs, + 'panel_column_defs': panel_column_defs, + 'dislocation_presets': presets, + 'dislocation_presets_payload': [ + {'code': p.code, 'columns': list(p.columns or []), 'is_default': p.is_default} + for p in presets + ], + 'dislo_colspan': len(visible_columns) + 1, } return render_page(request, 'dislocation.html', context) + +@login_required +@require_POST +def dislocation_columns_view(request): + """Сохраняет персональный набор колонок дислокации (или применяет пресет).""" + from apps.locations.dislocation_columns import normalize_columns + from apps.locations.models import DislocationColumnPreset + + preset_code = (request.POST.get('preset_code') or '').strip() + if preset_code: + preset = get_object_or_404(DislocationColumnPreset, code=preset_code) + columns = normalize_columns(preset.columns) + else: + columns = normalize_columns(request.POST.getlist('columns')) + + Employee.objects.filter(pk=request.user.pk).update(dislocation_columns=columns) + request.user.dislocation_columns = columns + return dislocation_view(request) + @login_required def calendar_view(request): return render_page(request, 'calendar.html') @login_required +@section_required('timetrack') def timetrack_view(request): """Облік робочого часу: агрегація WorkShift (фактичні зміни з терміналів) по співробітниках за обраний місяць. Дані реальні — приходи/уходи @@ -370,6 +416,7 @@ def timetrack_view(request): return render_page(request, 'timetrack.html', context) @login_required +@section_required('reports') def reports_view(request): return render_page(request, 'reports.html') @@ -381,10 +428,12 @@ def documents_view(request): }) @login_required +@section_required('recruiting') def recruiting_view(request): return render_page(request, 'recruiting.html') @login_required +@section_required('structure') def structure_view(request): """Структура офісу: відділи. Аптеки з їхнім персоналом — на /dislocation/.""" departments = list( @@ -545,24 +594,27 @@ def chat_sync_now_view(request): return render_page(request, 'chat.html', _build_chat_context(request)) -def _static_page_view(template_name): +def _static_page_view(template_name, section=None): """Фабрика простых страниц: HTMX-фрагмент или полный layout""" @login_required def view(request): return render_page(request, template_name) + if section: + return section_required(section)(view) return view -email_view = _static_page_view('email.html') -purchases_view = _static_page_view('purchases.html') +email_view = _static_page_view('email.html', 'email') +purchases_view = _static_page_view('purchases.html', 'purchases') help_view = _static_page_view('help.html') @login_required def pharmacy_detail_view(request, pk): """Аптека целиком: реквизиты, лицензия, адрес/карта, персонал, - IT-обладнання, інтернет-договори, показання лічильників.""" + IT-обладнання, інтернет-договори, РРО/ПРРО, показання лічильників.""" from apps.it_manager.models import Equipment, InternetContract + from apps.rro.models import CashRegister from apps.utilities.models import UtilityReading pharmacy = get_object_or_404( @@ -584,6 +636,11 @@ def pharmacy_detail_view(request, pk): .select_related('provider', 'service_type', 'connection_type', 'status') .order_by('-start_date') ) + cash_registers = list( + CashRegister.objects.filter(pharmacy=pharmacy) + .select_related('vendor', 'status') + .order_by('-is_primary', 'kind', 'fiscal_number') + ) utility_readings = list( UtilityReading.objects.filter(pharmacy=pharmacy) .select_related('utility_type', 'submitted_by') @@ -596,6 +653,7 @@ def pharmacy_detail_view(request, pk): 'phones': list(pharmacy.phones.filter(is_active=True)), 'equipment': equipment, 'internet_contracts': internet_contracts, + 'cash_registers': cash_registers, 'utility_readings': utility_readings, 'can_edit': request.user.is_staff, } @@ -649,11 +707,30 @@ def _hr_portal_context(request): p.id: [lt.code for lt in p.location_types.all()] for p in hr_posts } + # «На испытании»: после перехода на пул интернов кандидаты живут в + # HiringRequest(status=trial); у Employee probation_end_date часто пустой. + employees_on_probation = [ + e for e in employees if e.is_on_probation + ] + trial_hiring_count = HiringRequest.objects.filter(status='trial').count() + intern_total = 0 + intern_free = 0 + try: + from apps.hiring.models import InternAccount + intern_qs = InternAccount.objects.filter(is_active=True) + intern_total = intern_qs.count() + intern_free = intern_qs.filter(assigned_to__isnull=True).count() + except Exception: + pass + context = { 'employees': employees, 'employees_total': employees.count(), 'employees_new': employees.filter(hire_date__gte=month_ago).count(), - 'employees_probation': employees.filter(probation_end_date__gte=today).count(), + 'employees_on_probation': employees_on_probation, + 'employees_probation': trial_hiring_count + len(employees_on_probation), + 'intern_slots_total': intern_total, + 'intern_slots_free': intern_free, 'hr_department_tree': _hr_department_tree(), 'hr_posts': hr_posts, 'hr_post_location_types': hr_post_location_types, @@ -661,17 +738,31 @@ def _hr_portal_context(request): } if request.user.is_staff: + from apps.locations.models import Location + hiring_requests_qs = HiringRequest.objects.select_related( - 'employee', 'post', 'department', 'user_pharmacy', 'requested_by', 'decision_by', + 'employee', 'post', 'department', 'user_pharmacy', 'user_location', + 'requested_by', 'decision_by', 'granted_ticket', 'intern_account', ).order_by('-created_at') + trial_hiring_qs = hiring_requests_qs.filter(status='trial') + hr_access_systems = list( + Directory.objects.filter( + directory_type__code='access_system', is_active=True, + ).prefetch_related('location_types').order_by('name') + ) context.update({ 'hiring_requests': paginate( request, hiring_requests_qs, page_param='hiring_page', size_param='hiring_per_page', ), - 'hr_access_systems': Directory.objects.filter( - directory_type__code='access_system', is_active=True, - ).order_by('name'), + 'trial_hiring_requests': list(trial_hiring_qs), + 'hr_access_systems': hr_access_systems, + 'hr_access_location_types': { + a.id: [lt.code for lt in a.location_types.all()] for a in hr_access_systems + }, 'hr_pharmacies': Pharmacy.objects.filter(is_active=True).order_by('pharmacy_number'), + 'hr_locations': Location.objects.filter( + location_type__in=['office', 'warehouse'], is_active=True, + ).order_by('location_type', 'address'), 'hr_trial_days_choices': range(1, 6), 'hr_departments_flat': Directory.objects.filter( directory_type__code='department', is_active=True, @@ -682,15 +773,16 @@ def _hr_portal_context(request): @login_required +@section_required('hr_portal') def hr_portal_view(request): return render_page(request, 'hr-portal.html', _hr_portal_context(request)) @login_required +@section_required('hr_portal') def hiring_create_view(request): - if not request.user.is_staff: - return HttpResponseForbidden() - + hiring_form_errors = None + hiring_just_created = None if request.method == 'POST': from datetime import datetime from apps.hiring.services import create_hiring_request @@ -715,35 +807,54 @@ def hiring_create_view(request): department_id = request.POST.get('department_id') or None pharmacy_id = request.POST.get('pharmacy_id') or None + location_id = request.POST.get('location_id') or None post_id = request.POST.get('post_id') or None - create_hiring_request( - requester=request.user, - last_name=last_name, - first_name=first_name, - middle_name=middle_name, - candidate_email=request.POST.get('candidate_email', '').strip(), - candidate_phone=request.POST.get('candidate_phone', '').strip(), - user_location_type=location_type, - department_id=department_id, - user_pharmacy_id=pharmacy_id, - post_id=post_id, - hire_date=hire_date, - trial_days=trial_days, - requested_access_ids=request.POST.getlist('requested_access'), - access_note=request.POST.get('access_note', '').strip(), - ) + try: + hiring_just_created = create_hiring_request( + requester=request.user, + last_name=last_name, + first_name=first_name, + middle_name=middle_name, + candidate_email=request.POST.get('candidate_email', '').strip(), + candidate_phone=request.POST.get('candidate_phone', '').strip(), + user_location_type=location_type, + department_id=department_id, + user_pharmacy_id=pharmacy_id, + user_location_id=location_id, + post_id=post_id, + hire_date=hire_date, + trial_days=trial_days, + requested_access_ids=request.POST.getlist('requested_access'), + access_note=request.POST.get('access_note', '').strip(), + ) + except ValidationError as exc: + hiring_form_errors = exc.message_dict if hasattr(exc, 'message_dict') else { + '__all__': list(exc.messages), + } context = _hr_portal_context(request) - context['hr_active_tab'] = 'hiring' + if hiring_form_errors: + context['hr_active_tab'] = 'hiring' + context['hiring_form_errors'] = hiring_form_errors + elif hiring_just_created is not None: + # После успешного создания показываем вкладку «На испытании» + # со свежим списком и баннером с данными интерна. + from apps.hiring.models import HiringRequest + context['hiring_just_created'] = ( + HiringRequest.objects + .select_related('intern_account', 'granted_ticket') + .get(pk=hiring_just_created.pk) + ) + context['hr_active_tab'] = 'probation' + else: + context['hr_active_tab'] = 'hiring' return render_page(request, 'hr-portal.html', context) @login_required +@section_required('hr_portal') def hiring_approve_view(request, pk): - if not request.user.is_staff: - return HttpResponseForbidden() - from apps.hiring.models import HiringRequest from apps.hiring.services import approve_hiring_request @@ -752,15 +863,13 @@ def hiring_approve_view(request, pk): approve_hiring_request(hiring_request, decided_by=request.user, note=request.POST.get('note', '').strip()) context = _hr_portal_context(request) - context['hr_active_tab'] = 'hiring' + context['hr_active_tab'] = 'probation' return render_page(request, 'hr-portal.html', context) @login_required +@section_required('hr_portal') def hiring_reject_view(request, pk): - if not request.user.is_staff: - return HttpResponseForbidden() - from apps.hiring.models import HiringRequest from apps.hiring.services import reject_hiring_request @@ -769,11 +878,27 @@ def hiring_reject_view(request, pk): reject_hiring_request(hiring_request, decided_by=request.user, note=request.POST.get('note', '').strip()) context = _hr_portal_context(request) - context['hr_active_tab'] = 'hiring' + context['hr_active_tab'] = 'probation' return render_page(request, 'hr-portal.html', context) @login_required +@section_required('hr_portal') +def hiring_credentials_pdf_view(request, pk): + from apps.hiring.models import HiringRequest + from apps.hiring.pdf import credentials_pdf_response + + hiring_request = get_object_or_404( + HiringRequest.objects.select_related('employee', 'post'), + pk=pk, + ) + if hiring_request.status != 'approved' or hiring_request.employee_id is None: + return HttpResponseForbidden() + return credentials_pdf_response(hiring_request) + + +@login_required +@section_required('databases') def databases_view(request): """Довідники: типи справочников с количеством записей""" from django.db.models import Count, Max diff --git a/locale/en/LC_MESSAGES/django.mo b/locale/en/LC_MESSAGES/django.mo index d5f4736..2d44db1 100644 Binary files a/locale/en/LC_MESSAGES/django.mo and b/locale/en/LC_MESSAGES/django.mo differ diff --git a/locale/en/LC_MESSAGES/django.po b/locale/en/LC_MESSAGES/django.po index 89f0d61..99e1c79 100644 --- a/locale/en/LC_MESSAGES/django.po +++ b/locale/en/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-28 21:52+0300\n" +"POT-Creation-Date: 2026-07-29 11:36+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,10 +17,10 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: apps/ad_connector/models.py:10 apps/employees/models/base.py:198 +#: apps/ad_connector/models.py:10 apps/employees/models/base.py:207 #: apps/employees/models/permissions.py:47 osc/apps/ad_connector/models.py:10 #: osc/apps/employees/models.py:130 osc/apps/mattermost_connector/models.py:10 -#: templates/pages/employees.html:103 templates/pages/hr-portal.html:215 +#: templates/pages/employees.html:103 templates/pages/hr-portal.html:246 #: templates/pages/timetrack.html:58 msgid "Сотрудник" msgstr "Employee" @@ -29,42 +29,43 @@ msgstr "Employee" #: apps/nextcloud_connector/models.py:34 osc/apps/ad_connector/models.py:13 #: osc/apps/mattermost_connector/models.py:13 msgid "Действие" -msgstr "" +msgstr "Action" #: apps/ad_connector/models.py:15 apps/mail_connector/models.py:17 #: osc/apps/ad_connector/models.py:14 #: osc/apps/mattermost_connector/models.py:14 msgid "Создание" -msgstr "" +msgstr "Creation" #: apps/ad_connector/models.py:16 apps/mail_connector/models.py:18 #: osc/apps/ad_connector/models.py:14 #: osc/apps/mattermost_connector/models.py:14 msgid "Обновление" -msgstr "" +msgstr "Update" #: apps/ad_connector/models.py:17 apps/mail_connector/models.py:19 msgid "Включение" -msgstr "" +msgstr "Enable" #: apps/ad_connector/models.py:18 apps/mail_connector/models.py:20 msgid "Отключение" -msgstr "" +msgstr "Disable" -#: apps/ad_connector/models.py:22 apps/hiring/models.py:72 +#: apps/ad_connector/models.py:22 apps/hiring/models.py:129 #: apps/it_manager/admin.py:168 apps/it_manager/admin.py:263 #: apps/it_manager/admin.py:352 apps/it_manager/admin.py:498 +#: apps/locations/dislocation_columns.py:42 #: apps/locations/models/pharmacies.py:128 apps/mail_connector/models.py:24 #: apps/nextcloud_connector/models.py:35 apps/tasks/models.py:40 #: apps/visit_manager/admin.py:199 core/admin.py:32 #: osc/apps/ad_connector/models.py:17 #: osc/apps/mattermost_connector/models.py:17 -#: templates/pages/dislocation.html:122 templates/pages/employees.html:107 -#: templates/pages/hr-portal.html:535 templates/pages/it-manager.html:237 -#: templates/pages/it-manager.html:273 templates/pages/it-manager.html:350 -#: templates/pages/it-manager.html:403 templates/pages/it-manager.html:696 -#: templates/pages/it-manager.html:724 templates/pages/it-manager.html:973 -#: templates/pages/it-manager.html:1002 templates/pages/pharmacies.html:119 +#: templates/pages/employees.html:107 templates/pages/hr-portal.html:689 +#: templates/pages/it-manager.html:237 templates/pages/it-manager.html:273 +#: templates/pages/it-manager.html:350 templates/pages/it-manager.html:403 +#: templates/pages/it-manager.html:699 templates/pages/it-manager.html:727 +#: templates/pages/it-manager.html:976 templates/pages/it-manager.html:1005 +#: templates/pages/pharmacies.html:119 #: templates/pages/pharmacy-detail.html:197 #: templates/pages/pharmacy-detail.html:247 templates/pages/structure.html:63 msgid "Статус" @@ -74,7 +75,7 @@ msgstr "Status" #: apps/nextcloud_connector/models.py:25 osc/apps/ad_connector/models.py:18 #: osc/apps/mattermost_connector/models.py:18 msgid "Успех" -msgstr "" +msgstr "Success" #: apps/ad_connector/models.py:23 apps/employees/models/base.py:145 #: apps/employees/models/base.py:160 apps/locations/models/pharmacies.py:152 @@ -90,58 +91,60 @@ msgstr "Error" #: apps/nextcloud_connector/models.py:36 osc/apps/ad_connector/models.py:20 #: osc/apps/mattermost_connector/models.py:20 msgid "Сообщение" -msgstr "" +msgstr "Message" #: apps/ad_connector/models.py:28 osc/apps/ad_connector/models.py:23 msgid "Запись синхронизации AD" -msgstr "" +msgstr "AD sync record" #: apps/ad_connector/models.py:29 osc/apps/ad_connector/models.py:24 msgid "Журнал синхронизации AD" -msgstr "" +msgstr "AD sync log" #: apps/employees/admin.py:37 apps/employees/admin.py:259 -#: apps/employees/models/base.py:113 apps/nextcloud_connector/models.py:16 -#: osc/apps/employees/models.py:72 templates/pages/hr-portal.html:446 +#: apps/employees/models/base.py:113 apps/hiring/models.py:97 +#: apps/locations/models/dislocation.py:26 +#: apps/nextcloud_connector/models.py:16 osc/apps/employees/models.py:72 #: templates/pages/structure.html:59 msgid "Отдел" msgstr "Department" #: apps/employees/admin.py:39 msgid "— Выберите отдел —" -msgstr "" +msgstr "— Select department —" #: apps/employees/admin.py:44 apps/locations/admin.py:67 msgid "Выберите из списка. Уровни вложенности отображаются отступами." -msgstr "" +msgstr "Select from the list. Nesting levels are shown with indentation." #: apps/employees/admin.py:49 msgid "— Выберите офис или склад —" -msgstr "" +msgstr "— Select office or warehouse —" #: apps/employees/admin.py:50 apps/employees/models/base.py:107 +#: apps/hiring/models.py:107 msgid "Локация (офис/склад)" -msgstr "" +msgstr "Location (office/warehouse)" #: apps/employees/admin.py:51 apps/employees/models/base.py:108 msgid "Заполняется для location_type=office или warehouse" -msgstr "" +msgstr "Filled for location_type=office or warehouse" -#: apps/employees/admin.py:133 core/admin.py:259 +#: apps/employees/admin.py:133 core/admin.py:271 msgid "Аутентификация" -msgstr "" +msgstr "Authentication" -#: apps/employees/admin.py:137 +#: apps/employees/admin.py:137 apps/hiring/pdf.py:49 msgid "ФИО" -msgstr "" +msgstr "Full name" #: apps/employees/admin.py:141 msgid "Рабочая информация" -msgstr "" +msgstr "Work information" #: apps/employees/admin.py:149 apps/it_manager/admin.py:307 msgid "Даты" -msgstr "" +msgstr "Dates" #: apps/employees/admin.py:153 templates/pages/employee-detail.html:126 msgid "Личные данные" @@ -155,81 +158,96 @@ msgstr "Contacts" #: apps/employees/admin.py:173 msgid "Графік та відвідуваність" -msgstr "" +msgstr "Schedule and attendance" #: apps/employees/admin.py:176 msgid "Графіки роботи та записи відвідуваності відображаються нижче." -msgstr "" +msgstr "Work schedules and attendance records are shown below." #: apps/employees/admin.py:184 msgid "Системный доступ" -msgstr "" +msgstr "System access" #: apps/employees/admin.py:188 msgid "Только для IT-администраторов. is_staff — вход в Django admin." -msgstr "" +msgstr "For IT administrators only. is_staff — access to Django admin." #: apps/employees/admin.py:232 msgid "Предпросмотр" -msgstr "" +msgstr "Preview" #: apps/employees/admin.py:239 msgid "Группы" -msgstr "" +msgstr "Groups" -#: apps/employees/models/base.py:34 core/models/connector_config.py:29 +#: apps/employees/admin.py:266 +#| msgid "Офисных сотрудников" +msgid "Офисные сотрудники" +msgstr "Office employees" + +#: apps/employees/admin.py:269 +msgid "" +"При утверждении заявки на найм офисному сотруднику назначается рабочий " +"email: логин@домен." +msgstr "" +"When a hiring request is approved, an office employee is assigned a work " +"email: login@domain." + +#: apps/employees/models/base.py:34 apps/hiring/models.py:19 +#: apps/hiring/pdf.py:52 core/models/connector_config.py:29 #: osc/apps/employees/models.py:33 msgid "Логин" -msgstr "" +msgstr "Login" #: apps/employees/models/base.py:35 msgid "Уникальный логин для входа в систему" -msgstr "" +msgstr "Unique login for signing in" #: apps/employees/models/base.py:38 osc/apps/employees/models.py:37 msgid "Email адрес" -msgstr "" +msgstr "Email address" #: apps/employees/models/base.py:39 osc/apps/employees/models.py:38 msgid "Рабочий email. Если не указан — генерируется из username@local.domain" -msgstr "" +msgstr "Work email. If not set, generated as username@local.domain" -#: apps/employees/models/base.py:42 osc/apps/employees/models.py:41 +#: apps/employees/models/base.py:42 apps/hiring/models.py:75 +#: osc/apps/employees/models.py:41 msgid "Фамилия" -msgstr "" +msgstr "Last name" -#: apps/employees/models/base.py:43 osc/apps/employees/models.py:42 -#: templates/pages/profile.html:76 +#: apps/employees/models/base.py:43 apps/hiring/models.py:76 +#: osc/apps/employees/models.py:42 templates/pages/profile.html:76 msgid "Имя" msgstr "First name" -#: apps/employees/models/base.py:44 osc/apps/employees/models.py:43 -#: templates/pages/hr-portal.html:430 +#: apps/employees/models/base.py:44 apps/hiring/models.py:77 +#: osc/apps/employees/models.py:43 templates/pages/hr-portal.html:571 msgid "Отчество" -msgstr "Patronymic" +msgstr "Middle name" #: apps/employees/models/base.py:46 osc/apps/employees/models.py:46 msgid "Доступ к панели администратора" -msgstr "" +msgstr "Access to the admin panel" #: apps/employees/models/base.py:47 osc/apps/employees/models.py:47 msgid "Разрешает вход в Django admin. Только для IT-администраторов системы." -msgstr "" +msgstr "Allows access to Django admin. For system IT administrators only." -#: apps/employees/models/base.py:48 core/models/directories.py:29 -#: core/models/directories.py:121 templates/pages/employee-detail.html:45 -#: templates/pages/employees.html:148 templates/pages/hr-portal.html:174 -#: templates/pages/structure.html:89 +#: apps/employees/models/base.py:48 apps/hiring/models.py:21 +#: core/models/directories.py:29 core/models/directories.py:121 +#: templates/pages/employee-detail.html:45 templates/pages/employees.html:148 +#: templates/pages/hr-portal.html:205 templates/pages/structure.html:89 msgid "Активный" msgstr "Active" #: apps/employees/models/base.py:49 osc/apps/employees/models.py:49 msgid "Работает ли сотрудник сейчас" -msgstr "" +msgstr "Whether the employee is currently active" #: apps/employees/models/base.py:50 osc/apps/employees/models.py:50 msgid "Дата создания аккаунта" -msgstr "" +msgstr "Account creation date" #: apps/employees/models/base.py:53 osc/apps/employees/models.py:52 #: templates/pages/employee-detail.html:129 @@ -243,7 +261,7 @@ msgstr "Tax ID" #: apps/employees/models/base.py:58 osc/apps/employees/models.py:55 msgid "Идентификационный номер налогоплательщика" -msgstr "" +msgstr "Taxpayer identification number" #: apps/employees/models/base.py:63 osc/apps/employees/models.py:59 #: templates/pages/employee-detail.html:74 templates/pages/profile.html:57 @@ -252,11 +270,11 @@ msgstr "Work phone" #: apps/employees/models/base.py:67 msgid "Внутрішній номер" -msgstr "" +msgstr "Internal number" #: apps/employees/models/base.py:68 msgid "Короткий внутрішній (додатковий) номер АТС" -msgstr "" +msgstr "Short internal (extension) PBX number" #: apps/employees/models/base.py:71 osc/apps/employees/models.py:60 #: templates/pages/employee-detail.html:83 templates/pages/profile.html:61 @@ -270,11 +288,11 @@ msgstr "Personal email" #: apps/employees/models/base.py:76 osc/apps/employees/models.py:63 msgid "Контакт в экстренных случаях" -msgstr "" +msgstr "Emergency contact" #: apps/employees/models/base.py:77 osc/apps/employees/models.py:65 msgid "Телефон экстренного контакта" -msgstr "" +msgstr "Emergency contact phone" #: apps/employees/models/base.py:79 osc/apps/employees/models.py:67 #: templates/pages/profile.html:84 @@ -282,59 +300,63 @@ msgid "Степень родства" msgstr "Relationship" #: apps/employees/models/base.py:82 apps/employees/models/base.py:101 -#: apps/hiring/models.py:34 apps/hiring/models.py:50 -#: apps/locations/admin.py:347 apps/locations/models/base.py:29 -#: apps/locations/models/pharmacies.py:210 +#: apps/hiring/models.py:86 apps/hiring/models.py:102 +#: apps/locations/admin.py:347 apps/locations/dislocation_columns.py:36 +#: apps/locations/models/base.py:29 apps/locations/models/pharmacies.py:210 #: apps/locations/models/pharmacies.py:349 apps/mail_connector/models.py:12 #: apps/nextcloud_connector/models.py:17 apps/utilities/models.py:17 -#: templates/pages/dislocation.html:116 templates/pages/employees.html:64 -#: templates/pages/employees.html:90 templates/pages/hr-portal.html:126 -#: templates/pages/hr-portal.html:441 templates/pages/hr-portal.html:455 -#: templates/pages/hr-portal.html:459 +#: templates/pages/employees.html:64 templates/pages/employees.html:90 +#: templates/pages/hr-portal.html:156 templates/pages/hr-portal.html:582 +#: templates/pages/hr-portal.html:600 msgid "Аптека" msgstr "Pharmacy" -#: apps/employees/models/base.py:83 apps/hiring/models.py:35 -#: apps/locations/models/base.py:30 templates/pages/hr-portal.html:128 -#: templates/pages/hr-portal.html:442 +#: apps/employees/models/base.py:83 apps/hiring/models.py:87 +#: apps/locations/models/base.py:30 templates/pages/hr-portal.html:158 +#: templates/pages/hr-portal.html:583 msgid "Склад" msgstr "Warehouse" -#: apps/employees/models/base.py:84 apps/locations/models/base.py:31 -#: templates/pages/employees.html:56 templates/pages/hr-portal.html:124 -#: templates/pages/hr-portal.html:440 templates/pages/timetrack.html:47 +#: apps/employees/models/base.py:84 apps/hiring/models.py:88 +#: apps/locations/models/base.py:31 templates/pages/employees.html:56 +#: templates/pages/hr-portal.html:154 templates/pages/hr-portal.html:581 +#: templates/pages/timetrack.html:47 msgid "Офис" msgstr "Office" -#: apps/employees/models/base.py:87 apps/locations/models/base.py:28 +#: apps/employees/models/base.py:87 apps/hiring/models.py:91 +#: apps/hiring/pdf.py:51 apps/hiring/services.py:99 +#: apps/locations/models/base.py:28 msgid "Тип локации" -msgstr "" +msgstr "Location type" #: apps/employees/models/base.py:88 msgid "Где работает: аптека / склад / офис" -msgstr "" +msgstr "Where they work: pharmacy / warehouse / office" -#: apps/employees/models/base.py:95 apps/locations/models/pharmacies.py:88 -#: templates/pages/dislocation.html:117 templates/pages/pharmacies.html:113 +#: apps/employees/models/base.py:95 apps/locations/dislocation_columns.py:37 +#: apps/locations/models/pharmacies.py:88 templates/pages/pharmacies.html:113 #: templates/pages/pharmacy-detail.html:87 msgid "Организация" msgstr "Organization" #: apps/employees/models/base.py:96 apps/locations/models/pharmacies.py:89 msgid "Юрлицо: АКС, АКС Одесса, Тира, Пентальфа..." -msgstr "" +msgstr "Legal entity: AKS, AKS Odesa, Tira, Pentalfa..." #: apps/employees/models/base.py:102 msgid "Заполняется только для location_type=pharmacy" -msgstr "" +msgstr "Filled only for location_type=pharmacy" #: apps/employees/models/base.py:115 msgid "Заполняется для office / warehouse" -msgstr "" +msgstr "Filled for office / warehouse" -#: apps/employees/models/base.py:120 osc/apps/employees/models.py:77 -#: templates/pages/dislocation.html:297 templates/pages/employees.html:104 -#: templates/pages/hr-portal.html:216 templates/pages/hr-portal.html:467 +#: apps/employees/models/base.py:120 apps/hiring/models.py:113 +#: apps/hiring/pdf.py:50 apps/hiring/services.py:98 +#: osc/apps/employees/models.py:77 templates/pages/dislocation.html:310 +#: templates/pages/employees.html:104 templates/pages/hr-portal.html:247 +#: templates/pages/hr-portal.html:617 msgid "Должность" msgstr "Position" @@ -345,7 +367,7 @@ msgstr "Manager" #: apps/employees/models/base.py:129 osc/apps/employees/models.py:84 msgid "Дата приёма на работу" -msgstr "" +msgstr "Hire date" #: apps/employees/models/base.py:130 osc/apps/employees/models.py:85 #: templates/pages/employee-detail.html:116 @@ -354,25 +376,25 @@ msgstr "Termination date" #: apps/employees/models/base.py:131 osc/apps/employees/models.py:86 msgid "Конец испытательного срока" -msgstr "" +msgstr "End of probation period" #: apps/employees/models/base.py:134 apps/visit_manager/admin.py:262 #: apps/visit_manager/admin.py:289 osc/apps/employees/models.py:88 -#: templates/pages/hr-portal.html:165 templates/pages/hr-portal.html:234 +#: templates/pages/hr-portal.html:196 templates/pages/hr-portal.html:266 msgid "Фото" msgstr "Photo" #: apps/employees/models/base.py:138 osc/apps/employees/models.py:90 msgid "ID Mattermost" -msgstr "" +msgstr "Mattermost ID" #: apps/employees/models/base.py:139 osc/apps/employees/models.py:91 msgid "Логин Mattermost" -msgstr "" +msgstr "Mattermost login" #: apps/employees/models/base.py:141 osc/apps/employees/models.py:93 msgid "Статус синхронизации Mattermost" -msgstr "" +msgstr "Mattermost sync status" #: apps/employees/models/base.py:143 apps/tasks/models.py:24 #: osc/apps/employees/models.py:95 templates/pages/employee-detail.html:155 @@ -385,112 +407,145 @@ msgstr "Pending" #: apps/locations/models/pharmacies.py:151 osc/apps/employees/models.py:96 #: osc/apps/employees/models.py:114 msgid "Синхронизирован" -msgstr "" +msgstr "Synced" #: apps/employees/models/base.py:146 apps/employees/models/base.py:161 #: osc/apps/employees/models.py:98 osc/apps/employees/models.py:116 msgid "Отключена" -msgstr "" +msgstr "Disabled" #: apps/employees/models/base.py:150 osc/apps/employees/models.py:102 msgid "Последняя синхронизация Mattermost" -msgstr "" +msgstr "Last Mattermost sync" #: apps/employees/models/base.py:151 osc/apps/employees/models.py:103 msgid "Синхронизация Mattermost" -msgstr "" +msgstr "Mattermost sync" #: apps/employees/models/base.py:156 osc/apps/employees/models.py:111 msgid "Статус синхронизации AD" -msgstr "" +msgstr "AD sync status" #: apps/employees/models/base.py:158 osc/apps/employees/models.py:113 msgid "Не синхронизирован" -msgstr "" +msgstr "Not synced" #: apps/employees/models/base.py:165 osc/apps/employees/models.py:120 msgid "Ошибка синхронизации AD" -msgstr "" +msgstr "AD sync error" #: apps/employees/models/base.py:166 osc/apps/employees/models.py:121 msgid "Последняя синхронизация AD" -msgstr "" +msgstr "Last AD sync" #: apps/employees/models/base.py:167 osc/apps/employees/models.py:122 msgid "Синхронизация AD" -msgstr "" +msgstr "AD sync" #: apps/employees/models/base.py:168 msgid "Только для офисных сотрудников" -msgstr "" +msgstr "For office employees only" #: apps/employees/models/base.py:171 msgid "Записей на странице" -msgstr "" +msgstr "Records per page" #: apps/employees/models/base.py:173 msgid "Сколько строк показывать в таблицах портала по умолчанию" -msgstr "" +msgstr "How many rows to show in portal tables by default" #: apps/employees/models/base.py:177 -msgid "Был онлайн" -msgstr "" +#| msgid "Должность / локация" +msgid "Колонки дислокации" +msgstr "Dislocation columns" -#: apps/employees/models/base.py:178 -msgid "Обновляется автоматически при активности в портале (не в AD/Mattermost)" -msgstr "" - -#: apps/employees/models/base.py:183 -msgid "TOTP-секрет" -msgstr "" - -#: apps/employees/models/base.py:184 -msgid "Секрет TOTP (шифруется). Заполняется при настройке 2FA." +#: apps/employees/models/base.py:180 +msgid "" +"Персональный набор колонок таблицы /dislocation/. Пусто — пресет отдела или " +"системный «Стандарт»." msgstr "" +"Personal set of columns for the /dislocation/ table. Empty — the department " +"preset or the system \"Standard\"." #: apps/employees/models/base.py:186 +msgid "Был онлайн" +msgstr "Last seen" + +#: apps/employees/models/base.py:187 +msgid "" +"Обновляется автоматически при активности в портале (не в AD/Mattermost)" +msgstr "Updated automatically on portal activity (not AD/Mattermost)" + +#: apps/employees/models/base.py:192 +msgid "TOTP-секрет" +msgstr "TOTP secret" + +#: apps/employees/models/base.py:193 +msgid "Секрет TOTP (шифруется). Заполняется при настройке 2FA." +msgstr "TOTP secret (encrypted). Set when configuring 2FA." + +#: apps/employees/models/base.py:195 msgid "2FA включена" -msgstr "" +msgstr "2FA enabled" -#: apps/employees/models/base.py:188 +#: apps/employees/models/base.py:197 msgid "Резервные коды 2FA (хеши)" -msgstr "" +msgstr "2FA backup codes (hashes)" -#: apps/employees/models/base.py:189 +#: apps/employees/models/base.py:198 msgid "Хеши одноразовых резервных кодов; использованный код удаляется." -msgstr "" +msgstr "Hashes of one-time backup codes; a used code is removed." -#: apps/employees/models/base.py:199 osc/apps/employees/models.py:131 +#: apps/employees/models/base.py:208 osc/apps/employees/models.py:131 #: templates/index.html:251 templates/pages/dashboard.html:174 #: templates/pages/employees.html:8 templates/pages/employees.html:24 -#: templates/pages/hr-portal.html:92 +#: templates/pages/hr-portal.html:119 msgid "Сотрудники" msgstr "Employees" +#: apps/employees/models/defaults.py:13 +#| msgid "Офисных сотрудников" +msgid "Домен email для офисных сотрудников" +msgstr "Email domain for office employees" + +#: apps/employees/models/defaults.py:17 +msgid "" +"При утверждении заявки на найм офисному сотруднику назначается email вида " +"логин@этот-домен (вместо @local.domain)." +msgstr "" +"When a hiring request is approved, an office employee is assigned an email " +"of the form login@this-domain (instead of @local.domain)." + +#: apps/employees/models/defaults.py:23 apps/employees/models/defaults.py:24 +#: apps/employees/models/defaults.py:27 +#| msgid "Активных сотрудников пока нет" +msgid "Настройки сотрудников по умолчанию" +msgstr "Default employee settings" + #: apps/employees/models/permissions.py:14 msgid "Группа" -msgstr "" +msgstr "Group" #: apps/employees/models/permissions.py:16 apps/it_manager/admin.py:611 #: core/models/directories.py:28 core/models/directories.py:120 -#: templates/pages/it-manager.html:558 templates/pages/it-manager.html:1102 +#: templates/pages/it-manager.html:561 templates/pages/it-manager.html:1105 #: templates/pages/task-detail.html:62 msgid "Описание" msgstr "Description" #: apps/employees/models/permissions.py:17 apps/locations/models/base.py:59 -#: templates/pages/dislocation.html:180 templates/pages/dislocation.html:559 -#: templates/pages/pharmacies.html:156 +#: templates/pages/dislocation.html:572 templates/pages/pharmacies.html:156 +#: templates/partials/dislo_cell.html:40 msgid "Активна" msgstr "Active" #: apps/employees/models/permissions.py:20 msgid "Группа разрешений" -msgstr "" +msgstr "Permission group" #: apps/employees/models/permissions.py:21 msgid "Группы разрешений" -msgstr "" +msgstr "Permission groups" #: apps/employees/models/permissions.py:41 templates/pages/it-manager.html:199 #: templates/pages/it-manager.html:220 @@ -499,22 +554,22 @@ msgstr "Issue" #: apps/employees/models/permissions.py:42 msgid "Отозвать" -msgstr "" +msgstr "Revoke" #: apps/employees/models/permissions.py:51 msgid "Разрешение" -msgstr "" +msgstr "Permission" #: apps/employees/models/permissions.py:54 apps/it_manager/admin.py:154 #: apps/it_manager/admin.py:241 apps/it_manager/admin.py:334 #: apps/locations/models/pharmacies.py:332 templates/pages/it-manager.html:144 -#: templates/pages/it-manager.html:620 +#: templates/pages/it-manager.html:623 msgid "Тип" msgstr "Type" #: apps/employees/models/permissions.py:56 msgid "Действует с" -msgstr "" +msgstr "Effective from" #: apps/employees/models/permissions.py:57 #: templates/pages/pharmacy-detail.html:248 @@ -525,27 +580,27 @@ msgstr "Valid until" #: apps/locations/models/pharmacies.py:334 osc/apps/employees/models.py:49 #: osc/core/models/directory.py:32 msgid "Активен" -msgstr "" +msgstr "Active" #: apps/employees/models/permissions.py:59 msgid "Причина / комментарий" -msgstr "" +msgstr "Reason / comment" #: apps/employees/models/permissions.py:62 msgid "Override разрешения" -msgstr "" +msgstr "Permission override" #: apps/employees/models/permissions.py:63 msgid "Override разрешений" -msgstr "" +msgstr "Permission overrides" #: apps/feed/models.py:22 msgid "Загальне" -msgstr "" +msgstr "General" #: apps/feed/models.py:25 msgid "Мережа" -msgstr "" +msgstr "Network" #: apps/feed/models.py:36 templates/pages/news-edit.html:26 #: templates/pages/news.html:29 @@ -554,47 +609,55 @@ msgstr "Title" #: apps/feed/models.py:37 msgid "Текст" -msgstr "" +msgstr "Text" #: apps/feed/models.py:39 msgid "Категорія" -msgstr "" +msgstr "Category" #: apps/feed/models.py:42 msgid "Важливо" -msgstr "" +msgstr "Important" #: apps/feed/models.py:43 msgid "Закріплено" -msgstr "" +msgstr "Pinned" #: apps/feed/models.py:44 msgid "Опубліковано" -msgstr "" +msgstr "Published" #: apps/feed/models.py:47 msgid "Опубліковано в Mattermost" -msgstr "" +msgstr "Published in Mattermost" #: apps/feed/models.py:48 msgid "Час останньої публікації в чат — не блокує повторну публікацію" -msgstr "" +msgstr "Time of last publication to chat — does not block re-publishing" #: apps/feed/models.py:51 msgid "ID повідомлень в Mattermost" -msgstr "" +msgstr "Message IDs in Mattermost" #: apps/feed/models.py:52 -msgid "{team_name: post_id} — за наявності id повторна публікація редагує існуюче повідомлення" +msgid "" +"{team_name: post_id} — за наявності id повторна публікація редагує існуюче " +"повідомлення" msgstr "" +"{team_name: post_id} — if id exists, re-publishing edits the existing " +"message" #: apps/feed/models.py:55 msgid "Куди публікувати в Mattermost" -msgstr "" +msgstr "Where to publish in Mattermost" #: apps/feed/models.py:56 -msgid "Список команд (akc/pharmacys/warehouse) — обирається чекбоксами при створенні/редагуванні" +msgid "" +"Список команд (akc/pharmacys/warehouse) — обирається чекбоксами при " +"створенні/редагуванні" msgstr "" +"List of teams (akc/pharmacys/warehouse) — selected via checkboxes when " +"creating/editing" #: apps/feed/models.py:61 apps/feed/models.py:100 apps/tasks/models.py:126 #: templates/pages/task-detail.html:200 @@ -603,165 +666,241 @@ msgstr "Author" #: apps/feed/models.py:65 apps/feed/models.py:96 msgid "Пост" -msgstr "" +msgstr "Post" #: apps/feed/models.py:66 msgid "Пости" -msgstr "" +msgstr "Posts" #: apps/feed/models.py:102 apps/feed/models.py:115 apps/tasks/models.py:128 #: apps/utilities/models.py:26 msgid "Коментар" -msgstr "" +msgstr "Comment" #: apps/feed/models.py:109 msgid "ID відповідей в Mattermost" -msgstr "" +msgstr "Reply IDs in Mattermost" #: apps/feed/models.py:111 msgid "ID вихідного повідомлення з Mattermost" -msgstr "" +msgstr "Source message ID from Mattermost" #: apps/feed/models.py:116 msgid "Коментарі" -msgstr "" - -#: apps/hiring/models.py:17 -msgid "На випробуванні" -msgstr "" +msgstr "Comments" #: apps/hiring/models.py:18 -msgid "Затверджено" -msgstr "" +msgid "Метка" +msgstr "Label" -#: apps/hiring/models.py:19 -msgid "Відхилено" -msgstr "" - -#: apps/hiring/models.py:21 -#, python-format -msgid "%(n)d дн." -msgstr "" - -#: apps/hiring/models.py:23 -msgid "Прізвище" -msgstr "" - -#: apps/hiring/models.py:24 -msgid "Ім'я" -msgstr "" +#: apps/hiring/models.py:20 apps/hiring/pdf.py:53 +#: core/models/connector_config.py:32 +msgid "Пароль" +msgstr "Password" #: apps/hiring/models.py:25 -msgid "По батькові" -msgstr "" +msgid "Назначено заявке" +msgstr "Assigned to request" -#: apps/hiring/models.py:27 -msgid "Особистий email кандидата" -msgstr "" +#: apps/hiring/models.py:29 apps/hiring/services.py:103 +msgid "Интерн-аккаунт" +msgstr "Intern account" -#: apps/hiring/models.py:29 templates/pages/hr-portal.html:495 +#: apps/hiring/models.py:30 +msgid "Пул интерн-аккаунтов" +msgstr "Intern account pool" + +#: apps/hiring/models.py:34 +msgid "занят" +msgstr "busy" + +#: apps/hiring/models.py:34 +msgid "свободен" +msgstr "free" + +#: apps/hiring/models.py:49 +msgid "" +"Нет свободных интерн-аккаунтов. Дождитесь завершения стажировки или добавьте" +" слот в админке." +msgstr "" +"No free intern accounts. Wait for an internship to finish or add a slot in " +"the admin." + +#: apps/hiring/models.py:69 templates/pages/hr-portal.html:121 +#: templates/pages/hr-portal.html:274 templates/pages/hr-portal.html:333 +#: templates/pages/hr-portal.html:727 +msgid "На испытании" +msgstr "On trial" + +#: apps/hiring/models.py:70 templates/pages/hr-portal.html:467 +#: templates/pages/hr-portal.html:730 +msgid "Утверждено" +msgstr "Approved" + +#: apps/hiring/models.py:71 templates/pages/hr-portal.html:732 +msgid "Отклонено" +msgstr "Rejected" + +#: apps/hiring/models.py:73 +#, python-format +msgid "%(n)d дн." +msgstr "%(n)d days" + +#: apps/hiring/models.py:79 +msgid "Личный email кандидата" +msgstr "Candidate personal email" + +#: apps/hiring/models.py:81 templates/pages/hr-portal.html:645 msgid "Телефон кандидата" msgstr "Candidate phone" -#: apps/hiring/models.py:36 -msgid "Офіс" -msgstr "" - -#: apps/hiring/models.py:39 apps/visit_manager/admin.py:284 -msgid "Тип локації" -msgstr "" - -#: apps/hiring/models.py:45 -msgid "Відділ" -msgstr "" - -#: apps/hiring/models.py:56 -msgid "Посада" -msgstr "" - -#: apps/hiring/models.py:59 +#: apps/hiring/models.py:116 msgid "Дата виходу" -msgstr "" +msgstr "Exit date" -#: apps/hiring/models.py:61 -msgid "Термін випробування, днів" -msgstr "" +#: apps/hiring/models.py:118 +msgid "Срок испытания, дней" +msgstr "Trial period, days" -#: apps/hiring/models.py:68 -msgid "Потрібні доступи" -msgstr "" +#: apps/hiring/models.py:125 apps/hiring/services.py:105 +msgid "Требуемые доступы" +msgstr "Required access" -#: apps/hiring/models.py:70 -msgid "Примітка щодо доступів" -msgstr "" +#: apps/hiring/models.py:127 templates/pages/hr-portal.html:666 +msgid "Примечание по доступам" +msgstr "Access note" -#: apps/hiring/models.py:76 -msgid "Подав HR" -msgstr "" +#: apps/hiring/models.py:133 +msgid "Подал HR" +msgstr "Submitted by HR" -#: apps/hiring/models.py:81 -msgid "Обліковий запис" -msgstr "" +#: apps/hiring/models.py:138 +msgid "Учётная запись" +msgstr "Account" -#: apps/hiring/models.py:86 -msgid "Рішення прийняв" -msgstr "" +#: apps/hiring/models.py:143 +msgid "Решение принял" +msgstr "Decided by" -#: apps/hiring/models.py:88 -msgid "Дата рішення" -msgstr "" +#: apps/hiring/models.py:145 +msgid "Дата решения" +msgstr "Decision date" -#: apps/hiring/models.py:89 -msgid "Коментар до рішення" -msgstr "" +#: apps/hiring/models.py:146 +msgid "Комментарий к решению" +msgstr "Decision note" -#: apps/hiring/models.py:94 +#: apps/hiring/models.py:151 msgid "Заявка в IT" -msgstr "" +msgstr "IT ticket" -#: apps/hiring/models.py:99 -msgid "Задача на видачу доступів" -msgstr "" +#: apps/hiring/models.py:156 +msgid "Задача на выдачу доступов" +msgstr "Access provisioning task" -#: apps/hiring/models.py:103 +#: apps/hiring/models.py:161 +msgid "Выданный пароль (PDF)" +msgstr "Issued password (PDF)" + +#: apps/hiring/models.py:165 msgid "Заявка на найм" -msgstr "" +msgstr "Hiring request" -#: apps/hiring/models.py:104 +#: apps/hiring/models.py:166 msgid "Заявки на найм" +msgstr "Hiring requests" + +#: apps/hiring/pdf.py:41 +msgid "Учётные данные сотрудника" +msgstr "Employee credentials" + +#: apps/hiring/pdf.py:54 templates/pages/employee-detail.html:70 +msgid "Рабочий email" +msgstr "Work email" + +#: apps/hiring/pdf.py:56 +msgid "Запрошенные доступы" +msgstr "Requested access" + +#: apps/hiring/pdf.py:63 apps/hiring/services.py:112 +msgid "(не указано)" +msgstr "(not specified)" + +#: apps/hiring/services.py:97 templates/pages/hr-portal.html:318 +#: templates/pages/hr-portal.html:686 +msgid "Кандидат" +msgstr "Candidate" + +#: apps/hiring/services.py:100 +msgid "Локация / отдел" +msgstr "Location / department" + +#: apps/hiring/services.py:101 templates/pages/hr-portal.html:626 +msgid "Дата выхода" +msgstr "Start date" + +#: apps/hiring/services.py:102 templates/pages/employee-detail.html:49 +msgid "Испытательный срок" +msgstr "Trial period" + +#: apps/hiring/services.py:102 templates/pages/hr-portal.html:482 +#: templates/pages/hr-portal.html:491 templates/pages/hr-portal.html:500 +#: templates/pages/hr-portal.html:633 +msgid "дн." +msgstr "days" + +#: apps/hiring/services.py:103 +msgid "логин" +msgstr "login" + +#: apps/hiring/services.py:115 +msgid "Примечание" +msgstr "Note" + +#: apps/hiring/services.py:118 +msgid "" +"Подготовьте рабочее место и выдайте доступы к интерн-аккаунту на время " +"испытания. После утверждения HR будет создан персональный аккаунт." msgstr "" +"Prepare the workplace and grant access to the intern account for the trial " +"period. After approval, HR will create a personal account." + +#: apps/hiring/services.py:129 +msgid "Подготовка рабочего места" +msgstr "Workplace preparation" #: apps/it_manager/admin.py:34 apps/it_manager/admin.py:464 #: apps/it_manager/admin.py:567 apps/it_manager/admin.py:581 #: apps/locations/models/base.py:65 apps/visit_manager/admin.py:227 -#: templates/pages/hr-portal.html:218 templates/pages/it-manager.html:235 -#: templates/pages/it-manager.html:499 templates/pages/it-manager.html:559 -#: templates/pages/it-manager.html:880 +#: templates/pages/hr-portal.html:249 templates/pages/it-manager.html:235 +#: templates/pages/it-manager.html:502 templates/pages/it-manager.html:562 +#: templates/pages/it-manager.html:883 msgid "Локация" msgstr "Location" #: apps/it_manager/admin.py:91 msgid "Экземпляров" -msgstr "" +msgstr "Copies" #: apps/it_manager/admin.py:110 templates/pages/it-manager.html:13 -#: templates/pages/it-manager.html:514 templates/pages/pharmacy-detail.html:195 +#: templates/pages/it-manager.html:517 +#: templates/pages/pharmacy-detail.html:195 msgid "Оборудование" msgstr "Equipment" #: apps/it_manager/admin.py:113 apps/it_manager/admin.py:206 msgid "Статус и размещение" -msgstr "" +msgstr "Status and placement" #: apps/it_manager/admin.py:116 msgid "Гарантия и закупка" -msgstr "" +msgstr "Warranty and purchase" #: apps/it_manager/admin.py:119 apps/it_manager/admin.py:212 #: apps/it_manager/admin.py:427 apps/it_manager/admin.py:476 #: apps/locations/admin.py:319 apps/locations/models/pharmacies.py:335 -#: core/models/connector_config.py:44 templates/pages/it-manager.html:674 -#: templates/pages/it-manager.html:845 templates/pages/it-manager.html:950 +#: core/models/connector_config.py:44 templates/pages/it-manager.html:677 +#: templates/pages/it-manager.html:848 templates/pages/it-manager.html:953 #: templates/pages/pharmacy-detail.html:98 msgid "Примечания" msgstr "Notes" @@ -770,7 +909,7 @@ msgstr "Notes" #: apps/it_manager/admin.py:480 apps/it_manager/admin.py:617 #: apps/it_manager/admin.py:673 apps/locations/admin.py:109 msgid "Служебное" -msgstr "" +msgstr "Service" #: apps/it_manager/admin.py:159 templates/pages/it-manager.html:163 msgid "Модель" @@ -782,18 +921,18 @@ msgstr "Warranty" #: apps/it_manager/admin.py:203 msgid "Расходник" -msgstr "" +msgstr "Consumable" #: apps/it_manager/admin.py:209 msgid "Эксплуатация" -msgstr "" +msgstr "Operation" -#: apps/it_manager/admin.py:254 templates/pages/it-manager.html:629 -#: templates/pages/it-manager.html:694 +#: apps/it_manager/admin.py:254 templates/pages/it-manager.html:632 +#: templates/pages/it-manager.html:697 msgid "Цвет" msgstr "Color" -#: apps/it_manager/admin.py:295 templates/pages/it-manager.html:523 +#: apps/it_manager/admin.py:295 templates/pages/it-manager.html:526 msgid "Заявка" msgstr "Ticket" @@ -804,11 +943,11 @@ msgstr "Participants" #: apps/it_manager/admin.py:301 apps/it_manager/admin.py:614 #: apps/it_manager/admin.py:670 msgid "Связи" -msgstr "" +msgstr "Relations" #: apps/it_manager/admin.py:304 msgid "Описание и решение" -msgstr "" +msgstr "Description and resolution" #: apps/it_manager/admin.py:343 templates/pages/it-manager.html:346 msgid "Приоритет" @@ -816,7 +955,7 @@ msgstr "Priority" #: apps/it_manager/admin.py:359 msgid "SLA" -msgstr "" +msgstr "SLA" #: apps/it_manager/admin.py:366 templates/pages/task-detail.html:143 msgid "Взять в работу" @@ -824,66 +963,66 @@ msgstr "Take in progress" #: apps/it_manager/admin.py:375 msgid "Отметить решёнными" -msgstr "" +msgstr "Mark as resolved" #: apps/it_manager/admin.py:383 templates/pages/chat.html:88 #: templates/pages/chat.html:112 templates/pages/it-manager.html:75 -#: templates/pages/it-manager.html:135 templates/pages/it-manager.html:480 -#: templates/pages/it-manager.html:614 templates/pages/it-manager.html:815 -#: templates/pages/it-manager.html:865 templates/pages/it-manager.html:1048 +#: templates/pages/it-manager.html:135 templates/pages/it-manager.html:483 +#: templates/pages/it-manager.html:617 templates/pages/it-manager.html:818 +#: templates/pages/it-manager.html:868 templates/pages/it-manager.html:1051 msgid "Закрыть" msgstr "Close" -#: apps/it_manager/admin.py:418 templates/pages/it-manager.html:871 +#: apps/it_manager/admin.py:418 templates/pages/it-manager.html:874 msgid "Провайдер" msgstr "Provider" #: apps/it_manager/admin.py:424 msgid "Техподдержка" -msgstr "" +msgstr "Support" #: apps/it_manager/admin.py:440 msgid "Активных договоров" -msgstr "" +msgstr "Active contracts" #: apps/it_manager/admin.py:460 msgid "Договор" -msgstr "" +msgstr "Contract" #: apps/it_manager/admin.py:467 msgid "Параметры" -msgstr "" +msgstr "Parameters" #: apps/it_manager/admin.py:470 msgid "Сроки" -msgstr "" +msgstr "Deadlines" -#: apps/it_manager/admin.py:473 templates/pages/it-manager.html:944 +#: apps/it_manager/admin.py:473 templates/pages/it-manager.html:947 msgid "IP-адреса" msgstr "IP addresses" #: apps/it_manager/admin.py:503 msgid "Услуга" -msgstr "" +msgstr "Service" #: apps/it_manager/admin.py:508 templates/pages/pharmacy-detail.html:245 msgid "Технология" msgstr "Technology" -#: apps/it_manager/admin.py:520 templates/pages/it-manager.html:972 +#: apps/it_manager/admin.py:520 templates/pages/it-manager.html:975 msgid "Окончание" msgstr "Expiry" #: apps/it_manager/admin.py:534 msgid "Статические IP" -msgstr "" +msgstr "Static IPs" #: apps/it_manager/admin.py:608 templates/pages/employee-detail.html:96 msgid "Работа" msgstr "Work" -#: apps/it_manager/admin.py:639 templates/pages/it-manager.html:490 -#: templates/pages/it-manager.html:557 +#: apps/it_manager/admin.py:639 templates/pages/it-manager.html:493 +#: templates/pages/it-manager.html:560 msgid "Вид работ" msgstr "Work type" @@ -893,41 +1032,41 @@ msgstr "Request" #: apps/it_manager/admin.py:667 msgid "Содержание" -msgstr "" +msgstr "Contents" -#: apps/it_manager/admin.py:690 templates/pages/it-manager.html:1063 -#: templates/pages/it-manager.html:1100 +#: apps/it_manager/admin.py:690 templates/pages/it-manager.html:1066 +#: templates/pages/it-manager.html:1103 msgid "Канал" msgstr "Channel" -#: apps/it_manager/admin.py:695 templates/pages/it-manager.html:1103 +#: apps/it_manager/admin.py:695 templates/pages/it-manager.html:1106 msgid "Длительность" msgstr "Duration" #: apps/locations/admin.py:40 apps/locations/models/base.py:54 msgid "Координаты на карте" -msgstr "" +msgstr "Map coordinates" #: apps/locations/admin.py:45 msgid "Без координат" -msgstr "" +msgstr "Without coordinates" #: apps/locations/admin.py:46 msgid "С координатами" -msgstr "" +msgstr "With coordinates" #: apps/locations/admin.py:64 apps/locations/admin.py:203 #: apps/locations/models/base.py:36 msgid "Город/район" -msgstr "" +msgstr "City/district" #: apps/locations/admin.py:65 apps/locations/admin.py:205 msgid "— Выберите регион —" -msgstr "" +msgstr "— Select region —" #: apps/locations/admin.py:96 msgid "Тип и регион" -msgstr "" +msgstr "Type and region" #: apps/locations/admin.py:99 apps/locations/admin.py:282 #: templates/pages/employees.html:106 templates/pages/pharmacies.html:114 @@ -942,86 +1081,140 @@ msgstr "Coordinates" #: apps/locations/admin.py:105 msgid "Прочее" -msgstr "" +msgstr "Other" #: apps/locations/admin.py:132 msgid "Регион / Адрес" -msgstr "" +msgstr "Region / Address" #: apps/locations/admin.py:148 msgid "Статус лицензии" -msgstr "" +msgstr "License status" #: apps/locations/admin.py:153 msgid "Действительна" -msgstr "" +msgstr "Valid" #: apps/locations/admin.py:154 msgid "Истекает (30 дн.)" -msgstr "" +msgstr "Expiring (30 days)" #: apps/locations/admin.py:155 msgid "Истекла" -msgstr "" +msgstr "Expired" #: apps/locations/admin.py:156 msgid "Нет лицензии" -msgstr "" +msgstr "No license" -#: apps/locations/admin.py:274 core/admin.py:253 +#: apps/locations/admin.py:274 core/admin.py:265 msgid "Основное" -msgstr "" +msgstr "Main" #: apps/locations/admin.py:295 apps/locations/admin.py:366 +#: apps/locations/dislocation_columns.py:47 #: templates/pages/pharmacy-detail.html:54 msgid "Лицензия" msgstr "License" -#: apps/locations/admin.py:302 templates/pages/dislocation.html:119 -#: templates/pages/dislocation.html:557 templates/pages/pharmacy-detail.html:44 +#: apps/locations/admin.py:302 apps/locations/dislocation_columns.py:39 +#: templates/pages/dislocation.html:570 +#: templates/pages/pharmacy-detail.html:44 msgid "Персонал" -msgstr "" +msgstr "Staff" #: apps/locations/admin.py:309 apps/locations/models/pharmacies.py:156 msgid "Почта Carbonio" -msgstr "" +msgstr "Carbonio mail" #: apps/locations/admin.py:316 -msgid "Пусто в «Почта Carbonio» — адрес сгенерируется как <префикс организации><номер>@домен. Можно задать вручную." +msgid "" +"Пусто в «Почта Carbonio» — адрес сгенерируется как <префикс " +"организации><номер>@домен. Можно задать вручную." msgstr "" +"Empty in \"Carbonio mail\" — the address is generated as @domain. Can be set manually." -#: apps/locations/admin.py:376 core/views.py:352 +#: apps/locations/admin.py:376 core/views.py:396 #: templates/pages/structure.html:61 msgid "Сотрудников" -msgstr "" +msgstr "Employees" #: apps/locations/admin.py:390 msgid "Телефоны" -msgstr "" +msgstr "Phones" + +#: apps/locations/dislocation_columns.py:38 +#: templates/pages/pharmacies.html:115 templates/pages/pharmacy-detail.html:49 +#: templates/pages/pharmacy-detail.html:141 +msgid "Заведующий" +msgstr "Manager" + +#: apps/locations/dislocation_columns.py:40 +#: apps/locations/models/pharmacies.py:143 +#: apps/locations/models/pharmacies.py:330 templates/pages/it-manager.html:828 +#: templates/pages/pharmacies.html:117 templates/pages/profile.html:80 +msgid "Телефон" +msgstr "Phone" + +#: apps/locations/dislocation_columns.py:41 +#: templates/pages/pharmacies.html:118 templates/pages/pharmacy-detail.html:59 +msgid "Режим работы" +msgstr "Working hours" + +#: apps/locations/dislocation_columns.py:43 +#: templates/pages/pharmacy-detail.html:88 +msgid "Регион" +msgstr "Region" + +#: apps/locations/dislocation_columns.py:44 +#: templates/pages/pharmacy-detail.html:90 +msgid "Категория" +msgstr "Category" + +#: apps/locations/dislocation_columns.py:45 +#: apps/locations/models/pharmacies.py:119 +#: templates/pages/pharmacy-detail.html:93 +msgid "Куратор" +msgstr "Curator" + +#: apps/locations/dislocation_columns.py:46 +#: apps/locations/models/pharmacies.py:113 +#: templates/pages/pharmacy-detail.html:92 +msgid "Ответственный менеджер" +msgstr "Responsible manager" + +#: apps/locations/dislocation_columns.py:48 +msgid "ПДВ" +msgstr "VAT" + +#: apps/locations/dislocation_columns.py:49 +msgid "Email" +msgstr "Email" #: apps/locations/models/base.py:39 msgid "Улица" -msgstr "" +msgstr "Street" #: apps/locations/models/base.py:40 msgid "Номер дома" -msgstr "" +msgstr "House number" #: apps/locations/models/base.py:41 msgid "Корпус/строение" -msgstr "" +msgstr "Building/block" #: apps/locations/models/base.py:42 msgid "Помещение/офис" -msgstr "" +msgstr "Room/office" #: apps/locations/models/base.py:43 msgid "Полный адрес" -msgstr "" +msgstr "Full address" #: apps/locations/models/base.py:44 msgid "Заполняется автоматически из полей выше" -msgstr "" +msgstr "Filled automatically from the fields above" #: apps/locations/models/base.py:60 templates/pages/news-detail.html:78 #: templates/pages/task-detail.html:98 @@ -1030,11 +1223,54 @@ msgstr "Comments" #: apps/locations/models/base.py:66 msgid "Локации" -msgstr "" +msgstr "Locations" + +#: apps/locations/models/dislocation.py:14 core/admin.py:195 +#: core/models/directories.py:119 osc/core/models/directory.py:9 +#: osc/core/models/directory.py:26 templates/pages/it-manager.html:824 +msgid "Название" +msgstr "Name" + +#: apps/locations/models/dislocation.py:15 core/models/directories.py:118 +#: osc/core/models/directory.py:8 osc/core/models/directory.py:25 +msgid "Код" +msgstr "Code" + +#: apps/locations/models/dislocation.py:17 templates/pages/dislocation.html:98 +msgid "Колонки" +msgstr "Columns" + +#: apps/locations/models/dislocation.py:19 +msgid "Упорядоченный список ключей из реестра колонок дислокации." +msgstr "Ordered list of keys from the dislocation columns registry." + +#: apps/locations/models/dislocation.py:27 +msgid "Если задан — пресет по умолчанию для сотрудников этого отдела." +msgstr "If set — the default preset for employees of this department." + +#: apps/locations/models/dislocation.py:30 +#| msgid "Примечания" +msgid "По умолчанию" +msgstr "Default" + +#: apps/locations/models/dislocation.py:31 +msgid "" +"Используется, если у сотрудника нет своего набора и нет пресета отдела." +msgstr "Used when the employee has no personal set and no department preset." + +#: apps/locations/models/dislocation.py:35 +#| msgid "персонал по каждой локации" +msgid "Пресет колонок дислокации" +msgstr "Dislocation columns preset" + +#: apps/locations/models/dislocation.py:36 +#| msgid "персонал по каждой локации" +msgid "Пресеты колонок дислокации" +msgstr "Dislocation columns presets" #: apps/locations/models/pharmacies.py:60 msgid "Открыта" -msgstr "" +msgstr "Open" #: apps/locations/models/pharmacies.py:61 templates/pages/it-manager.html:377 msgid "Закрыта" @@ -1042,140 +1278,127 @@ msgstr "Closed" #: apps/locations/models/pharmacies.py:62 msgid "Остановлена" -msgstr "" +msgstr "Stopped" #: apps/locations/models/pharmacies.py:63 msgid "Подготовка к запуску" -msgstr "" +msgstr "Preparing to launch" #: apps/locations/models/pharmacies.py:74 msgid "ПДВ 20%" -msgstr "" +msgstr "VAT 20%" #: apps/locations/models/pharmacies.py:75 msgid "ПДВ 7% (пільгова)" -msgstr "" +msgstr "VAT 7% (reduced)" #: apps/locations/models/pharmacies.py:76 msgid "ПДВ 0%" -msgstr "" +msgstr "VAT 0%" #: apps/locations/models/pharmacies.py:77 msgid "Без ПДВ" -msgstr "" +msgstr "Without VAT" #: apps/locations/models/pharmacies.py:82 msgid "Номер аптеки" -msgstr "" +msgstr "Pharmacy number" #: apps/locations/models/pharmacies.py:83 msgid "Числовой номер аптеки внутри организации" -msgstr "" +msgstr "Numeric pharmacy number within the organization" #: apps/locations/models/pharmacies.py:99 msgid "Номер лицензии" -msgstr "" +msgstr "License number" #: apps/locations/models/pharmacies.py:101 msgid "Дата окончания лицензии" -msgstr "" +msgstr "License expiry date" #: apps/locations/models/pharmacies.py:108 msgid "Заведующий аптекой" -msgstr "" - -#: apps/locations/models/pharmacies.py:113 -#: templates/pages/pharmacy-detail.html:92 -msgid "Ответственный менеджер" -msgstr "Responsible manager" +msgstr "Pharmacy manager" #: apps/locations/models/pharmacies.py:114 msgid "Сотрудник с должностью «Відповідальний менеджер»" -msgstr "" - -#: apps/locations/models/pharmacies.py:119 -#: templates/pages/pharmacy-detail.html:93 -msgid "Куратор" -msgstr "Curator" +msgstr "Employee with the position \"Responsible manager\"" #: apps/locations/models/pharmacies.py:120 msgid "Сотрудник с должностью «куратор»" -msgstr "" +msgstr "Employee with the position \"curator\"" #: apps/locations/models/pharmacies.py:123 msgid "Количество фармацевтов" -msgstr "" +msgstr "Number of pharmacists" #: apps/locations/models/pharmacies.py:124 msgid "Кешированное поле — обновляется при синхронизации" -msgstr "" +msgstr "Cached field — updated on sync" #: apps/locations/models/pharmacies.py:133 msgid "Категория аптеки" -msgstr "" +msgstr "Pharmacy category" #: apps/locations/models/pharmacies.py:135 msgid "Классификация аптеки: A/B/C/D" -msgstr "" +msgstr "Pharmacy classification: A/B/C/D" #: apps/locations/models/pharmacies.py:139 msgid "Категорія ПДВ" -msgstr "" - -#: apps/locations/models/pharmacies.py:143 -#: apps/locations/models/pharmacies.py:330 templates/pages/dislocation.html:120 -#: templates/pages/it-manager.html:825 templates/pages/pharmacies.html:117 -#: templates/pages/profile.html:80 -msgid "Телефон" -msgstr "Phone" +msgstr "VAT category" #: apps/locations/models/pharmacies.py:145 msgid "Часы работы" -msgstr "" +msgstr "Working hours" #: apps/locations/models/pharmacies.py:150 msgid "Ожидает синхронизации" -msgstr "" +msgstr "Pending sync" #: apps/locations/models/pharmacies.py:153 msgid "Отключён" -msgstr "" +msgstr "Disabled" #: apps/locations/models/pharmacies.py:157 -msgid "Ящик аптеки. Пусто — сгенерируется как <префикс организации><номер>@домен. Можно задать вручную." +msgid "" +"Ящик аптеки. Пусто — сгенерируется как <префикс организации><номер>@домен. " +"Можно задать вручную." msgstr "" +"Pharmacy mailbox. Empty — generated as @domain." +" Can be set manually." #: apps/locations/models/pharmacies.py:160 msgid "ID аккаунта Carbonio" -msgstr "" +msgstr "Carbonio account ID" #: apps/locations/models/pharmacies.py:164 msgid "Пароль ящика Carbonio" -msgstr "" +msgstr "Carbonio mailbox password" #: apps/locations/models/pharmacies.py:165 msgid "Хранится зашифрованным; показывается для входа заведующему." -msgstr "" +msgstr "Stored encrypted; shown to the manager for signing in." #: apps/locations/models/pharmacies.py:168 msgid "Синхронизация с Carbonio" -msgstr "" +msgstr "Carbonio sync" #: apps/locations/models/pharmacies.py:169 msgid "Управлять почтовым ящиком аптеки в Carbonio" -msgstr "" +msgstr "Manage the pharmacy mailbox in Carbonio" #: apps/locations/models/pharmacies.py:172 msgid "Статус синхронизации Carbonio" -msgstr "" +msgstr "Carbonio sync status" #: apps/locations/models/pharmacies.py:176 msgid "Последняя синхронизация Carbonio" -msgstr "" +msgstr "Last Carbonio sync" #: apps/locations/models/pharmacies.py:177 msgid "Ошибка синхронизации Carbonio" -msgstr "" +msgstr "Carbonio sync error" #: apps/locations/models/pharmacies.py:211 templates/index.html:382 #: templates/pages/dislocation.html:16 templates/pages/pharmacies.html:6 @@ -1185,7 +1408,7 @@ msgstr "Pharmacies" #: apps/locations/models/pharmacies.py:259 msgid "Такой адрес Carbonio уже используется другой аптекой" -msgstr "" +msgstr "This Carbonio address is already used by another pharmacy" #: apps/locations/models/pharmacies.py:325 #: templates/pages/pharmacy-detail.html:113 @@ -1194,95 +1417,95 @@ msgstr "Primary" #: apps/locations/models/pharmacies.py:326 msgid "Мобильный" -msgstr "" +msgstr "Mobile" #: apps/locations/models/pharmacies.py:333 msgid "Основной номер" -msgstr "" +msgstr "Primary number" #: apps/locations/models/pharmacies.py:338 msgid "Телефон аптеки" -msgstr "" +msgstr "Pharmacy phone" #: apps/locations/models/pharmacies.py:339 msgid "Телефоны аптек" -msgstr "" +msgstr "Pharmacy phones" #: apps/locations/models/pharmacies.py:350 msgid "Изменённое поле" -msgstr "" +msgstr "Changed field" #: apps/locations/models/pharmacies.py:351 msgid "Старое значение" -msgstr "" +msgstr "Old value" #: apps/locations/models/pharmacies.py:352 msgid "Новое значение" -msgstr "" +msgstr "New value" #: apps/locations/models/pharmacies.py:353 msgid "Дата изменения" -msgstr "" +msgstr "Change date" #: apps/locations/models/pharmacies.py:356 msgid "Кто изменил" -msgstr "" +msgstr "Changed by" #: apps/locations/models/pharmacies.py:361 msgid "История изменений аптеки" -msgstr "" +msgstr "Pharmacy change history" #: apps/locations/models/pharmacies.py:362 msgid "История изменений аптек" -msgstr "" +msgstr "Pharmacies change history" #: apps/mail_connector/models.py:30 msgid "Запись синхронизации Carbonio" -msgstr "" +msgstr "Carbonio sync record" #: apps/mail_connector/models.py:31 msgid "Журнал синхронизации Carbonio" -msgstr "" +msgstr "Carbonio sync log" #: apps/nextcloud_connector/models.py:20 msgid "Создание папки" -msgstr "" +msgstr "Folder creation" #: apps/nextcloud_connector/models.py:21 msgid "Обновление ACL" -msgstr "" +msgstr "ACL update" #: apps/nextcloud_connector/models.py:22 msgid "Пропуск" -msgstr "" +msgstr "Skip" #: apps/nextcloud_connector/models.py:27 msgid "Пробный прогон" -msgstr "" +msgstr "Dry run" #: apps/nextcloud_connector/models.py:30 msgid "Тип сущности" -msgstr "" +msgstr "Entity type" #: apps/nextcloud_connector/models.py:31 msgid "Ссылка на сущность" -msgstr "" +msgstr "Entity reference" #: apps/nextcloud_connector/models.py:32 msgid "Код отдела или номер аптеки" -msgstr "" +msgstr "Department code or pharmacy number" #: apps/nextcloud_connector/models.py:33 msgid "Путь Group Folder" -msgstr "" +msgstr "Group Folder path" #: apps/nextcloud_connector/models.py:39 msgid "Запись синхронизации Nextcloud" -msgstr "" +msgstr "Nextcloud sync record" #: apps/nextcloud_connector/models.py:40 msgid "Журнал синхронизации Nextcloud" -msgstr "" +msgstr "Nextcloud sync log" #: apps/tasks/models.py:25 templates/pages/it-manager.html:322 #: templates/pages/it-manager.html:374 templates/pages/task-detail.html:21 @@ -1315,79 +1538,79 @@ msgstr "High" #: apps/tasks/models.py:38 msgid "Назва" -msgstr "" +msgstr "Name" #: apps/tasks/models.py:39 apps/visit_manager/admin.py:88 msgid "Опис" -msgstr "" +msgstr "Description" #: apps/tasks/models.py:42 msgid "Пріоритет" -msgstr "" +msgstr "Priority" #: apps/tasks/models.py:44 msgid "Термін" -msgstr "" +msgstr "Deadline" #: apps/tasks/models.py:48 msgid "Постановник" -msgstr "" +msgstr "Reporter" #: apps/tasks/models.py:55 msgid "Відповідальний" -msgstr "" +msgstr "Assignee" #: apps/tasks/models.py:59 msgid "Співвиконавці" -msgstr "" +msgstr "Co-executors" #: apps/tasks/models.py:63 msgid "Спостерігачі" -msgstr "" +msgstr "Watchers" #: apps/tasks/models.py:66 apps/tasks/models.py:148 msgid "Виконано в" -msgstr "" +msgstr "Completed at" #: apps/tasks/models.py:71 apps/tasks/models.py:122 apps/tasks/models.py:144 msgid "Задача" -msgstr "" +msgstr "Task" #: apps/tasks/models.py:72 msgid "Задачі" -msgstr "" +msgstr "Tasks" #: apps/tasks/models.py:131 msgid "Коментар до задачі" -msgstr "" +msgstr "Task comment" #: apps/tasks/models.py:132 msgid "Коментарі до задач" -msgstr "" +msgstr "Task comments" #: apps/tasks/models.py:146 msgid "Пункт" -msgstr "" +msgstr "Item" #: apps/tasks/models.py:147 msgid "Виконано" -msgstr "" +msgstr "Done" #: apps/tasks/models.py:151 msgid "Відмітив" -msgstr "" +msgstr "Marked by" #: apps/tasks/models.py:153 osc/core/models/directory.py:33 msgid "Порядок" -msgstr "" +msgstr "Order" #: apps/tasks/models.py:156 msgid "Пункт чек-листа" -msgstr "" +msgstr "Checklist item" #: apps/tasks/models.py:157 msgid "Чек-лист задачі" -msgstr "" +msgstr "Task checklist" #: apps/tasks/views.py:211 templates/pages/task-edit.html:2 #: templates/pages/tasks.html:35 @@ -1406,140 +1629,144 @@ msgstr "Observers" #: apps/utilities/models.py:22 msgid "Тип лічильника" -msgstr "" +msgstr "Meter type" #: apps/utilities/models.py:24 msgid "Дата зняття показань" -msgstr "" +msgstr "Reading date" #: apps/utilities/models.py:25 msgid "Показання" -msgstr "" +msgstr "Reading" #: apps/utilities/models.py:31 msgid "Хто подав" -msgstr "" +msgstr "Submitted by" #: apps/utilities/models.py:35 msgid "Показання лічильника" -msgstr "" +msgstr "Meter reading" #: apps/utilities/models.py:36 msgid "Показання лічильників" -msgstr "" +msgstr "Meter readings" #: apps/utilities/models.py:54 msgid "Показання не може бути від'ємним" -msgstr "" +msgstr "The reading cannot be negative" #: apps/utilities/models.py:56 msgid "Дата не може бути в майбутньому" -msgstr "" +msgstr "The date cannot be in the future" #: apps/visit_manager/admin.py:30 apps/visit_manager/admin.py:78 #: apps/visit_manager/admin.py:137 apps/visit_manager/admin.py:259 #: apps/visit_manager/admin.py:311 msgid "Основне" -msgstr "" +msgstr "Main" #: apps/visit_manager/admin.py:33 msgid "Час роботи" -msgstr "" +msgstr "Working time" #: apps/visit_manager/admin.py:37 msgid "Обідня перерва" -msgstr "" +msgstr "Lunch break" #: apps/visit_manager/admin.py:40 msgid "Понаднормові" -msgstr "" +msgstr "Overtime" #: apps/visit_manager/admin.py:53 apps/visit_manager/admin.py:140 #: apps/visit_manager/admin.py:314 msgid "Час" -msgstr "" +msgstr "Time" #: apps/visit_manager/admin.py:61 msgid "Колір" -msgstr "" +msgstr "Color" #: apps/visit_manager/admin.py:81 msgid "Характеристики" -msgstr "" +msgstr "Characteristics" #: apps/visit_manager/admin.py:85 msgid "Відображення" -msgstr "" +msgstr "Display" #: apps/visit_manager/admin.py:92 msgid "Регіони" -msgstr "" +msgstr "Regions" #: apps/visit_manager/admin.py:107 msgid "Залишилось" -msgstr "" +msgstr "Remaining" #: apps/visit_manager/admin.py:144 msgid "Тиждень" -msgstr "" +msgstr "Week" #: apps/visit_manager/admin.py:147 msgid "Цикл (для змінного графіку)" -msgstr "" +msgstr "Cycle (for rotating schedule)" #: apps/visit_manager/admin.py:151 msgid "Налаштування" -msgstr "" +msgstr "Settings" #: apps/visit_manager/admin.py:154 msgid "Придатність" -msgstr "" +msgstr "Validity" #: apps/visit_manager/admin.py:178 msgid "Співробітник та шаблон" -msgstr "" +msgstr "Employee and template" #: apps/visit_manager/admin.py:181 msgid "Період" -msgstr "" +msgstr "Period" #: apps/visit_manager/admin.py:184 msgid "Налаштування відміток" -msgstr "" +msgstr "Check-in settings" #: apps/visit_manager/admin.py:188 msgid "Примітки" -msgstr "" +msgstr "Notes" #: apps/visit_manager/admin.py:265 msgid "Технічне" -msgstr "" +msgstr "Technical" #: apps/visit_manager/admin.py:277 msgid "Направлення" -msgstr "" +msgstr "Direction" + +#: apps/visit_manager/admin.py:284 +msgid "Тип локації" +msgstr "Location type" #: apps/visit_manager/admin.py:296 msgid "Превью" -msgstr "" +msgstr "Preview" #: apps/visit_manager/admin.py:317 msgid "Записи" -msgstr "" +msgstr "Records" #: apps/visit_manager/admin.py:329 msgid "Відпрацьовано" -msgstr "" +msgstr "Worked" #: apps/visit_manager/admin.py:334 msgid "Повна зміна" -msgstr "" +msgstr "Full shift" #: core/admin.py:20 core/models/directories.py:54 #: core/models/directories.py:116 osc/core/models/directory.py:12 #: osc/core/models/directory.py:23 msgid "Тип справочника" -msgstr "" +msgstr "Directory type" #: core/admin.py:37 templates/pages/dislocation.html:90 #: templates/pages/pharmacies.html:86 @@ -1553,19 +1780,19 @@ msgstr "Inactive" #: core/admin.py:49 msgid "Наличие потомков" -msgstr "" +msgstr "Has descendants" #: core/admin.py:54 msgid "Есть потомки" -msgstr "" +msgstr "Has descendants" #: core/admin.py:55 msgid "Нет потомков" -msgstr "" +msgstr "No descendants" #: core/admin.py:86 msgid "Записей" -msgstr "" +msgstr "Records" #: core/admin.py:144 templates/pages/pharmacy-detail.html:67 #: templates/pages/pharmacy-detail.html:85 @@ -1574,313 +1801,326 @@ msgstr "General information" #: core/admin.py:147 msgid "Должности и локации" -msgstr "" +msgstr "Positions and locations" #: core/admin.py:153 core/models/directories.py:123 msgid "Дополнительные данные" -msgstr "" +msgstr "Additional data" #: core/admin.py:167 msgid "🔄 Перестроить дерево" -msgstr "" +msgstr "🔄 Rebuild tree" #: core/admin.py:172 msgid "Дерево справочников перестроено" -msgstr "" +msgstr "The directory tree has been rebuilt" -#: core/admin.py:195 core/models/directories.py:119 -#: osc/core/models/directory.py:9 osc/core/models/directory.py:26 -#: templates/pages/it-manager.html:821 -msgid "Название" -msgstr "Name" - -#: core/admin.py:212 core/models/directories.py:131 +#: core/admin.py:212 core/admin.py:256 core/models/directories.py:131 msgid "Типы локаций" -msgstr "" +msgstr "Location types" -#: core/admin.py:256 +#: core/admin.py:268 msgid "Подключение" -msgstr "" +msgstr "Connection" -#: core/admin.py:261 +#: core/admin.py:273 msgid "Укажите токен или логин/пароль — в зависимости от типа коннектора" -msgstr "" +msgstr "Specify a token or login/password — depending on the connector type" -#: core/admin.py:263 core/models/connector_config.py:40 +#: core/admin.py:275 core/models/connector_config.py:40 msgid "Дополнительные параметры" -msgstr "" +msgstr "Additional parameters" -#: core/admin.py:274 +#: core/admin.py:286 msgid "Коннектор" -msgstr "" +msgstr "Connector" #: core/models/connector_config.py:18 msgid "Тип коннектора" -msgstr "" +msgstr "Connector type" #: core/models/connector_config.py:21 msgid "Включён" -msgstr "" +msgstr "Enabled" #: core/models/connector_config.py:24 msgid "Хост" -msgstr "" +msgstr "Host" #: core/models/connector_config.py:25 msgid "Порт" -msgstr "" +msgstr "Port" #: core/models/connector_config.py:26 msgid "Использовать SSL" -msgstr "" - -#: core/models/connector_config.py:32 -msgid "Пароль" -msgstr "" +msgstr "Use SSL" #: core/models/connector_config.py:34 msgid "Токен / API Key" -msgstr "" +msgstr "Token / API Key" #: core/models/connector_config.py:35 msgid "Bearer-токен или API-ключ, если используется вместо логина/пароля" -msgstr "" +msgstr "Bearer token or API key, if used instead of login/password" #: core/models/connector_config.py:41 msgid "Специфичные для коннектора настройки в формате JSON" -msgstr "" +msgstr "Connector-specific settings in JSON format" #: core/models/connector_config.py:47 msgid "Настройки коннектора" -msgstr "" +msgstr "Connector settings" #: core/models/connector_config.py:48 osc/core/models/connector_settings.py:33 #: osc/core/models/connector_settings.py:34 #: osc/core/models/connector_settings.py:37 msgid "Настройки коннекторов" -msgstr "" +msgstr "Connectors settings" #: core/models/connector_config.py:58 msgid "Хост обязателен для включённого коннектора" -msgstr "" +msgstr "Host is required for an enabled connector" #: core/models/connector_config.py:61 msgid "Для включённого коннектора необходимо указать токен или логин" -msgstr "" +msgstr "An enabled connector requires a token or login" #: core/models/directories.py:26 msgid "Код типа" -msgstr "" +msgstr "Type code" #: core/models/directories.py:27 msgid "Название типа" -msgstr "" +msgstr "Type name" #: core/models/directories.py:33 msgid "Строгая иерархия (одинаковые типы)" -msgstr "" +msgstr "Strict hierarchy (same types)" #: core/models/directories.py:34 msgid "Гибкая иерархия (разные типы)" -msgstr "" +msgstr "Flexible hierarchy (different types)" #: core/models/directories.py:35 msgid "Кастомная иерархия" -msgstr "" +msgstr "Custom hierarchy" #: core/models/directories.py:42 msgid "Тип иерархии" -msgstr "" +msgstr "Hierarchy type" #: core/models/directories.py:49 msgid "Разрешенные типы потомков" -msgstr "" +msgstr "Allowed descendant types" #: core/models/directories.py:50 msgid "Только для гибкой и кастомной иерархии" -msgstr "" +msgstr "For flexible and custom hierarchy only" #: core/models/directories.py:55 osc/core/models/directory.py:13 msgid "Типы справочников" -msgstr "" +msgstr "Directory types" -#: core/models/directories.py:118 osc/core/models/directory.py:8 -#: osc/core/models/directory.py:25 -msgid "Код" +#: core/models/directories.py:133 +msgid "" +"Пусто = доступно для всех типов локаций. Для должностей и систем доступа: в " +"каких локациях показывать (офис / аптека / склад)." msgstr "" +"Empty = available for all location types. For positions and access systems: " +"in which locations to show (office / pharmacy / warehouse)." -#: core/models/directories.py:132 -msgid "Для должностей: в каких типах локаций доступна эта должность" -msgstr "" - -#: core/models/directories.py:154 +#: core/models/directories.py:158 msgid "Элемент справочника" -msgstr "" +msgstr "Directory item" -#: core/models/directories.py:155 +#: core/models/directories.py:159 msgid "Элементы справочников" -msgstr "" +msgstr "Directory items" #: core/utils/validators.py:13 msgid "Номер телефона должен быть в формате +380XXXXXXXXX" -msgstr "" +msgstr "The phone number must be in the format +380XXXXXXXXX" #: core/utils/validators.py:22 msgid "Номер телефона должен быть в международном формате +XXXXXXXXXXX" -msgstr "" +msgstr "The phone number must be in international format +XXXXXXXXXXX" #: core/utils/validators.py:27 msgid "Номер телефона должен содержать от 8 до 16 цифр включая код страны" msgstr "" +"The phone number must contain 8 to 16 digits including the country code" #: core/utils/validators.py:46 msgid "Дата рождения не может быть в будущем" -msgstr "" +msgstr "The date of birth cannot be in the future" #: core/utils/validators.py:54 msgid "Возраст должен быть не менее 18 лет" -msgstr "" +msgstr "Age must be at least 18 years" #: core/utils/validators.py:83 msgid "Использование временных email запрещено" -msgstr "" +msgstr "Using temporary emails is not allowed" #: core/utils/validators.py:94 #, python-format msgid "Недопустимый IP адрес: %(value)s" -msgstr "" +msgstr "Invalid IP address: %(value)s" #: core/utils/validators.py:103 -msgid "MAC адрес должен быть в формате XX:XX:XX:XX:XX:XX или XX-XX-XX-XX-XX-XX" +msgid "" +"MAC адрес должен быть в формате XX:XX:XX:XX:XX:XX или XX-XX-XX-XX-XX-XX" msgstr "" +"The MAC address must be in the format XX:XX:XX:XX:XX:XX or XX-XX-XX-XX-XX-XX" #: core/utils/validators.py:114 msgid "Украинский налоговый номер должен содержать 10 цифр" -msgstr "" +msgstr "The Ukrainian tax number must contain 10 digits" #: core/utils/validators.py:132 msgid "Неверная контрольная сумма украинского налогового номера" -msgstr "" +msgstr "Invalid checksum of the Ukrainian tax number" #: core/utils/validators.py:142 msgid "ИНН должен содержать 10 или 12 цифр" -msgstr "" +msgstr "The tax ID must contain 10 or 12 digits" #: core/utils/validators.py:159 core/utils/validators.py:176 msgid "Неверная контрольная сумма ИНН" -msgstr "" +msgstr "Invalid tax ID checksum" -#: core/views.py:351 +#: core/views.py:395 msgid "Рабочих дней" msgstr "Working days" -#: core/views.py:351 +#: core/views.py:395 msgid "с отметками" msgstr "with check-ins" -#: core/views.py:352 +#: core/views.py:396 msgid "отмечались" msgstr "checked in" -#: core/views.py:353 +#: core/views.py:397 msgid "Смен" msgstr "Shifts" -#: core/views.py:353 +#: core/views.py:397 msgid "за месяц" msgstr "this month" -#: core/views.py:354 templates/pages/timetrack.html:62 +#: core/views.py:398 templates/pages/timetrack.html:62 msgid "Незакрытых" msgstr "Open" -#: core/views.py:354 +#: core/views.py:398 msgid "без ухода" msgstr "without checkout" -#: core/views.py:355 +#: core/views.py:399 msgid "Всего часов" msgstr "Total hours" -#: core/views.py:355 +#: core/views.py:399 msgid "отработано" msgstr "worked" #: osc/apps/employees/models.py:34 msgid "Логин для входа, совпадает с учётной записью AD" -msgstr "" +msgstr "Login for sign-in, matches the AD account" #: osc/apps/employees/models.py:105 msgid "Логин AD (sAMAccountName)" -msgstr "" +msgstr "AD login (sAMAccountName)" #: osc/apps/employees/models.py:107 msgid "AD User DN" -msgstr "" +msgstr "AD User DN" #: osc/apps/employees/models.py:151 msgid "Не может быть раньше даты приёма" -msgstr "" +msgstr "Cannot be earlier than the hire date" #: osc/apps/employees/models.py:153 msgid "Сотрудник не может быть своим руководителем" -msgstr "" +msgstr "An employee cannot be their own manager" #: osc/apps/mattermost_connector/models.py:23 msgid "Запись синхронизации Mattermost" -msgstr "" +msgstr "Mattermost sync record" #: osc/apps/mattermost_connector/models.py:24 msgid "Журнал синхронизации Mattermost" -msgstr "" +msgstr "Mattermost sync log" #: osc/core/models/connector_settings.py:10 msgid "Требовать смену пароля при создании" -msgstr "" +msgstr "Require password change on creation" #: osc/core/models/connector_settings.py:12 -msgid "Новым учётным записям в AD выставляется флаг «сменить пароль при следующем входе». Если выключено — выданный пароль действует сразу, без принудительной смены." +msgid "" +"Новым учётным записям в AD выставляется флаг «сменить пароль при следующем " +"входе». Если выключено — выданный пароль действует сразу, без принудительной" +" смены." msgstr "" +"New AD accounts get the \"change password at next logon\" flag. If disabled " +"— the issued password works immediately, without a forced change." #: osc/core/models/connector_settings.py:17 msgid "Пароль не имеет срока действия" -msgstr "" +msgstr "Password never expires" #: osc/core/models/connector_settings.py:19 -msgid "Новым и существующим учётным записям в AD выставляется флаг «Password never expires». Если выключено — действует обычная парольная политика домена." +msgid "" +"Новым и существующим учётным записям в AD выставляется флаг «Password never " +"expires». Если выключено — действует обычная парольная политика домена." msgstr "" +"New and existing AD accounts get the \"Password never expires\" flag. If " +"disabled — the normal domain password policy applies." #: osc/core/models/connector_settings.py:24 msgid "Команда Mattermost по умолчанию" -msgstr "" +msgstr "Default Mattermost team" #: osc/core/models/connector_settings.py:25 -msgid "Сотрудники автоматически добавляются в эту команду при синхронизации с Mattermost." +msgid "" +"Сотрудники автоматически добавляются в эту команду при синхронизации с " +"Mattermost." msgstr "" +"Employees are automatically added to this team during Mattermost sync." #: osc/core/models/connector_settings.py:28 msgid "Каналы по умолчанию" -msgstr "" +msgstr "Default channels" #: osc/core/models/connector_settings.py:29 -msgid "Сотрудники автоматически добавляются в эти каналы при синхронизации с Mattermost." +msgid "" +"Сотрудники автоматически добавляются в эти каналы при синхронизации с " +"Mattermost." msgstr "" +"Employees are automatically added to these channels during Mattermost sync." #: osc/core/models/directory.py:29 msgid "Родительский элемент" -msgstr "" +msgstr "Parent item" #: osc/core/models/directory.py:30 msgid "Должен принадлежать тому же типу справочника" -msgstr "" +msgstr "Must belong to the same directory type" #: osc/core/models/directory.py:35 msgid "OU в Active Directory" -msgstr "" +msgstr "OU in Active Directory" #: osc/core/models/directory.py:37 -msgid "Используется только для справочника отделов (department): сотрудники этого отдела создаются/переносятся в указанную OU. Например: OU=IT,OU=OL_Users,DC=ol,DC=local. Если не задано — используется AD_USERS_OU." +msgid "" +"Используется только для справочника отделов (department): сотрудники этого " +"отдела создаются/переносятся в указанную OU. Например: " +"OU=IT,OU=OL_Users,DC=ol,DC=local. Если не задано — используется AD_USERS_OU." msgstr "" +"Used only for the departments directory (department): employees of this " +"department are created/moved to the specified OU. For example: " +"OU=IT,OU=OL_Users,DC=ol,DC=local. If not set — AD_USERS_OU is used." #: osc/core/models/directory.py:44 templates/pages/dashboard.html:163 msgid "Справочник" @@ -1888,65 +2128,69 @@ msgstr "Directory" #: osc/core/models/directory.py:45 msgid "Справочники" -msgstr "" +msgstr "Directories" #: osc/core/models/directory.py:56 msgid "Элемент не может быть родителем самому себе" -msgstr "" +msgstr "An item cannot be its own parent" #: osc/core/models/directory.py:58 msgid "Родитель должен принадлежать тому же типу справочника" -msgstr "" +msgstr "The parent must belong to the same directory type" #: osc/core/models/directory.py:62 msgid "Циклическая вложенность недопустима" -msgstr "" +msgstr "Cyclic nesting is not allowed" #: osc/core/models/site_settings.py:9 msgid "Название портала" -msgstr "" +msgstr "Portal name" #: osc/core/models/site_settings.py:10 msgid "Логотип" -msgstr "" +msgstr "Logo" #: osc/core/models/site_settings.py:13 osc/core/models/site_settings.py:14 #: osc/core/models/site_settings.py:17 msgid "Настройки сайта" -msgstr "" +msgstr "Site settings" #: osc/core/utils/validators.py:16 msgid "Введите корректный украинский номер телефона, например +380501234567" -msgstr "" +msgstr "Enter a valid Ukrainian phone number, e.g. +380501234567" #: osc/core/utils/validators.py:24 msgid "Дата рождения должна быть в прошлом" -msgstr "" +msgstr "The date of birth must be in the past" #: osc/core/utils/validators.py:27 msgid "Сотруднику должно быть не менее 16 лет" -msgstr "" +msgstr "The employee must be at least 16 years old" #: osc/core/utils/validators.py:29 msgid "Проверьте дату рождения — возраст превышает 100 лет" -msgstr "" +msgstr "Check the date of birth — the age exceeds 100 years" #: osc/core/utils/validators.py:35 msgid "ИНН должен состоять из 10 цифр" -msgstr "" +msgstr "The tax ID must consist of 10 digits" #: osc/core/utils/validators.py:38 msgid "Некорректный ИНН: неверная контрольная сумма" -msgstr "" +msgstr "Invalid tax ID: incorrect checksum" #: osc/templates/admin/employees/employee/delete_confirmation.html:10 #, python-format -msgid "Are you sure you want to delete the %(object_name)s \"%(escaped_object)s\"? All of the following related items will be deleted:" +msgid "" +"Are you sure you want to delete the %(object_name)s \"%(escaped_object)s\"? " +"All of the following related items will be deleted:" msgstr "" +"Are you sure you want to delete the %(object_name)s \"%(escaped_object)s\"? " +"All of the following related items will be deleted:" #: osc/templates/admin/employees/employee/delete_confirmation.html:17 msgid "Objects" -msgstr "" +msgstr "Objects" #: templates/index.html:211 msgid "Сеть аптек" @@ -2032,7 +2276,7 @@ msgstr "Utilities" msgid "Рекрутинг" msgstr "Recruiting" -#: templates/index.html:411 templates/pages/hr-portal.html:292 +#: templates/index.html:411 templates/pages/hr-portal.html:428 #: templates/pages/timetrack.html:7 msgid "Учёт рабочего времени" msgstr "Time tracking" @@ -2041,7 +2285,7 @@ msgstr "Time tracking" msgid "Поддержка" msgstr "Support" -#: templates/index.html:429 templates/pages/hr-portal.html:284 +#: templates/index.html:429 templates/pages/hr-portal.html:420 msgid "Справка" msgstr "Reference" @@ -2065,7 +2309,8 @@ msgstr "Search..." msgid "Язык" msgstr "Language" -#: templates/index.html:608 templates/pages/hr-portal.html:199 +#: templates/index.html:608 templates/pages/hr-portal.html:230 +#: templates/pages/hr-portal.html:392 msgid "Профиль" msgstr "Profile" @@ -2083,11 +2328,11 @@ msgstr "Loading..." #: templates/index.html:675 msgid "Люди" -msgstr "" +msgstr "People" #: templates/index.html:691 msgid "Меню" -msgstr "" +msgstr "Menu" #: templates/pages/chat.html:6 msgid "Внутренний корпоративный мессенджер (Mattermost)" @@ -2155,7 +2400,9 @@ msgid "Ваша учётная запись ещё не синхронизиро msgstr "Your account is not synced with Mattermost yet" #: templates/pages/chat.html:132 -msgid "Нажмите кнопку ниже, чтобы создать или привязать вашу учётную запись в Mattermost." +msgid "" +"Нажмите кнопку ниже, чтобы создать или привязать вашу учётную запись в " +"Mattermost." msgstr "Click the button below to create or link your Mattermost account." #: templates/pages/chat.html:135 @@ -2167,16 +2414,26 @@ msgid "Не удалось получить доступ к Mattermost" msgstr "Failed to access Mattermost" #: templates/pages/chat.html:146 -msgid "Обратитесь в IT-отдел — возможно, отключена выдача Personal Access Token на сервере Mattermost." -msgstr "Contact the IT department — Personal Access Token issuance may be disabled on the Mattermost server." +msgid "" +"Обратитесь в IT-отдел — возможно, отключена выдача Personal Access Token на " +"сервере Mattermost." +msgstr "" +"Contact the IT department — Personal Access Token issuance may be disabled " +"on the Mattermost server." #: templates/pages/chat.html:155 msgid "Чат ещё не подключён" msgstr "Chat is not connected yet" #: templates/pages/chat.html:157 -msgid "Укажите публичный URL Mattermost (поле public_url в «Дополнительные параметры» коннектора Mattermost в админке) — и он появится здесь." -msgstr "Specify the public Mattermost URL (the public_url field under «Additional parameters» of the Mattermost connector in the admin) and it will appear here." +msgid "" +"Укажите публичный URL Mattermost (поле public_url в " +"«Дополнительные параметры» коннектора Mattermost в админке) — и он появится " +"здесь." +msgstr "" +"Specify the public Mattermost URL (the public_url field under " +"«Additional parameters» of the Mattermost connector in the admin) and it " +"will appear here." #: templates/pages/dashboard.html:7 msgid "Добрый день" @@ -2206,7 +2463,7 @@ msgstr "Messages" msgid "Сотрудников онлайн" msgstr "Employees online" -#: templates/pages/dashboard.html:22 templates/pages/dislocation.html:720 +#: templates/pages/dashboard.html:22 templates/pages/dislocation.html:791 msgid "из" msgstr "of" @@ -2337,8 +2594,8 @@ msgid "Все предприятия" msgstr "All organizations" #: templates/pages/dislocation.html:76 templates/pages/dislocation.html:81 -#: templates/pages/dislocation.html:82 templates/pages/dislocation.html:437 -#: templates/pages/dislocation.html:454 templates/pages/pharmacies.html:72 +#: templates/pages/dislocation.html:82 templates/pages/dislocation.html:450 +#: templates/pages/dislocation.html:467 templates/pages/pharmacies.html:72 msgid "Все города/регионы" msgstr "All cities/regions" @@ -2346,126 +2603,136 @@ msgstr "All cities/regions" msgid "Все статусы" msgstr "All statuses" -#: templates/pages/dislocation.html:98 templates/pages/hr-portal.html:110 +#: templates/pages/dislocation.html:103 templates/pages/hr-portal.html:139 msgid "Таблица" msgstr "Table" -#: templates/pages/dislocation.html:103 +#: templates/pages/dislocation.html:108 msgid "Карта" msgstr "Map" -#: templates/pages/dislocation.html:118 templates/pages/pharmacies.html:115 -#: templates/pages/pharmacy-detail.html:49 -#: templates/pages/pharmacy-detail.html:141 -msgid "Заведующий" -msgstr "Manager" +#: templates/pages/dislocation.html:115 +msgid "Колонки таблицы" +msgstr "Table columns" -#: templates/pages/dislocation.html:121 templates/pages/pharmacies.html:118 -#: templates/pages/pharmacy-detail.html:59 -msgid "Режим работы" -msgstr "Working hours" +#: templates/pages/dislocation.html:117 +msgid "Пресет" +msgstr "Preset" -#: templates/pages/dislocation.html:159 -msgid "не назначен" -msgstr "not assigned" +#: templates/pages/dislocation.html:119 +msgid "— свой набор —" +msgstr "— custom set —" -#: templates/pages/dislocation.html:166 -msgid "Нехватка" -msgstr "Shortage" +#: templates/pages/dislocation.html:137 +msgid "обяз." +msgstr "req." -#: templates/pages/dislocation.html:168 -msgid "Внимание" -msgstr "Warning" +#: templates/pages/dislocation.html:138 +msgid "Выше" +msgstr "Up" -#: templates/pages/dislocation.html:170 -msgid "Норма" -msgstr "Normal" +#: templates/pages/dislocation.html:139 +msgid "Ниже" +msgstr "Down" -#: templates/pages/dislocation.html:182 templates/pages/dislocation.html:559 -#: templates/pages/pharmacies.html:158 templates/pages/pharmacy-detail.html:36 -msgid "Неактивна" -msgstr "Inactive" +#: templates/pages/dislocation.html:144 templates/pages/it-manager.html:290 +#: templates/pages/it-manager.html:423 templates/pages/it-manager.html:735 +#: templates/pages/it-manager.html:1013 templates/pages/settings.html:43 +#: templates/pages/settings.html:60 +msgid "Сохранить" +msgstr "Save" -#: templates/pages/dislocation.html:208 +#: templates/pages/dislocation.html:145 +#| msgid "Отменено" +msgid "Отмена" +msgstr "Cancel" + +#: templates/pages/dislocation.html:221 #: templates/pages/pharmacy-detail.html:149 msgid "В этой аптеке пока нет активных сотрудников" msgstr "No active employees in this pharmacy yet" -#: templates/pages/dislocation.html:214 templates/pages/pharmacies.html:164 +#: templates/pages/dislocation.html:227 templates/pages/pharmacies.html:164 msgid "Аптеки не найдены" msgstr "No pharmacies found" -#: templates/pages/dislocation.html:221 templates/pages/pharmacies.html:171 +#: templates/pages/dislocation.html:234 templates/pages/pharmacies.html:171 msgid "Показано 0–0 из 0" msgstr "Showing 0–0 of 0" -#: templates/pages/dislocation.html:224 templates/pages/pharmacies.html:174 +#: templates/pages/dislocation.html:237 templates/pages/pharmacies.html:174 msgid "На странице:" msgstr "Per page:" -#: templates/pages/dislocation.html:233 +#: templates/pages/dislocation.html:246 msgid "Предыдущая" msgstr "Previous" -#: templates/pages/dislocation.html:234 templates/pages/pharmacies.html:184 +#: templates/pages/dislocation.html:247 templates/pages/pharmacies.html:184 msgid "Стр. 1 из 1" msgstr "Page 1 of 1" -#: templates/pages/dislocation.html:235 templates/pages/pharmacies.html:185 +#: templates/pages/dislocation.html:248 templates/pages/pharmacies.html:185 msgid "Следующая" msgstr "Next" -#: templates/pages/dislocation.html:246 +#: templates/pages/dislocation.html:259 msgid "апт. без указанного адреса не показаны на карте (нет координат)" msgstr "pharm. without address not shown on map (no coordinates)" -#: templates/pages/dislocation.html:255 +#: templates/pages/dislocation.html:268 msgid "Список аптек" msgstr "Pharmacy list" -#: templates/pages/dislocation.html:288 +#: templates/pages/dislocation.html:301 msgid "сотрудников офиса и склада, сгруппировано по отделам" msgstr "office and warehouse employees, grouped by department" -#: templates/pages/dislocation.html:296 +#: templates/pages/dislocation.html:309 msgid "Внутр. номер" msgstr "Ext. number" -#: templates/pages/dislocation.html:298 +#: templates/pages/dislocation.html:311 msgid "Ф.И.О." msgstr "Full name" -#: templates/pages/dislocation.html:299 +#: templates/pages/dislocation.html:312 msgid "Корпоративный телефон" msgstr "Corporate phone" -#: templates/pages/dislocation.html:320 templates/pages/structure.html:35 +#: templates/pages/dislocation.html:333 templates/pages/structure.html:35 msgid "Без отдела" msgstr "No department" -#: templates/pages/dislocation.html:335 +#: templates/pages/dislocation.html:348 msgid "Сотрудников офиса/склада не найдено" msgstr "No office/warehouse employees found" -#: templates/pages/dislocation.html:561 +#: templates/pages/dislocation.html:572 templates/pages/pharmacies.html:158 +#: templates/pages/pharmacy-detail.html:36 +#: templates/partials/dislo_cell.html:42 +msgid "Неактивна" +msgstr "Inactive" + +#: templates/pages/dislocation.html:574 msgid "Подробнее" msgstr "Details" -#: templates/pages/dislocation.html:720 +#: templates/pages/dislocation.html:791 msgid "Показано" msgstr "Showing" -#: templates/pages/dislocation.html:720 +#: templates/pages/dislocation.html:791 msgid "Нет записей" msgstr "No records" #: templates/pages/documents.html:6 msgid "Файловое хранилище: общие документы, обучение, отделы" -msgstr "" +msgstr "File storage: shared documents, training, departments" #: templates/pages/documents.html:29 msgid "Нет доступных разделов документов." -msgstr "" +msgstr "No available document sections." #: templates/pages/employee-detail.html:28 templates/pages/news-detail.html:17 #: templates/pages/task-detail.html:41 templates/pages/tasks.html:127 @@ -2476,14 +2743,6 @@ msgstr "Edit" msgid "Не активный" msgstr "Inactive" -#: templates/pages/employee-detail.html:49 -msgid "Испытательный срок" -msgstr "Probation period" - -#: templates/pages/employee-detail.html:70 -msgid "Рабочий email" -msgstr "Work email" - #: templates/pages/employee-detail.html:78 msgid "Внутренний номер" msgstr "Internal number" @@ -2492,7 +2751,7 @@ msgstr "Internal number" msgid "Подчинённых" msgstr "Subordinates" -#: templates/pages/employee-detail.html:112 templates/pages/hr-portal.html:219 +#: templates/pages/employee-detail.html:112 templates/pages/hr-portal.html:250 msgid "Дата приёма" msgstr "Hire date" @@ -2546,7 +2805,7 @@ msgstr "Add employee" msgid "сотрудников, из них" msgstr "employees, of them" -#: templates/pages/employees.html:48 templates/pages/hr-portal.html:122 +#: templates/pages/employees.html:48 templates/pages/hr-portal.html:152 #: templates/pages/news.html:111 templates/pages/tasks.html:60 #: templates/pages/timetrack.html:45 msgid "Все" @@ -2557,11 +2816,11 @@ msgstr "All" msgid "Все аптеки" msgstr "All pharmacies" -#: templates/pages/employees.html:105 templates/pages/hr-portal.html:217 +#: templates/pages/employees.html:105 templates/pages/hr-portal.html:248 msgid "Отдел / Аптека" msgstr "Department / Pharmacy" -#: templates/pages/employees.html:108 templates/pages/it-manager.html:914 +#: templates/pages/employees.html:108 templates/pages/it-manager.html:917 msgid "Дата начала" msgstr "Start date" @@ -2577,324 +2836,406 @@ msgstr "HR Portal" msgid "Управление персоналом и кадровые процессы" msgstr "HR management and personnel processes" +#: templates/pages/hr-portal.html:13 +#| msgid "Заявок пока нет" +msgid "Заявку створено" +msgstr "Request created" + #: templates/pages/hr-portal.html:16 +#| msgid "Интерфейс" +msgid "Інтерн-слот" +msgstr "Intern slot" + +#: templates/pages/hr-portal.html:16 +msgid "логін" +msgstr "login" + +#: templates/pages/hr-portal.html:16 +msgid "пароль" +msgstr "password" + +#: templates/pages/hr-portal.html:20 +#| msgid "Заявка IT:" +msgid "Заявка IT" +msgstr "IT request" + +#: templates/pages/hr-portal.html:27 +#| msgid "Подать заявку" +msgid "Не удалось создать заявку" +msgstr "Failed to create the request" + +#: templates/pages/hr-portal.html:43 msgid "Всего сотрудников" msgstr "Total employees" -#: templates/pages/hr-portal.html:18 +#: templates/pages/hr-portal.html:45 msgid "за последний месяц" msgstr "in the last month" -#: templates/pages/hr-portal.html:31 +#: templates/pages/hr-portal.html:56 +#| msgid "Срок испытания" +msgid "Открыть список на испытании" +msgstr "Open the probation list" + +#: templates/pages/hr-portal.html:58 msgid "На испытательном сроке" msgstr "On probation" -#: templates/pages/hr-portal.html:33 +#: templates/pages/hr-portal.html:60 msgid "требуют оценки" msgstr "require evaluation" -#: templates/pages/hr-portal.html:44 +#: templates/pages/hr-portal.html:71 msgid "Запланированные тренинги" msgstr "Scheduled trainings" -#: templates/pages/hr-portal.html:46 +#: templates/pages/hr-portal.html:73 msgid "раздел в разработке" msgstr "section under development" -#: templates/pages/hr-portal.html:57 +#: templates/pages/hr-portal.html:84 msgid "Заявки на отпуск" msgstr "Vacation requests" -#: templates/pages/hr-portal.html:59 +#: templates/pages/hr-portal.html:86 msgid "ожидает решения" msgstr "awaiting decision" -#: templates/pages/hr-portal.html:76 +#: templates/pages/hr-portal.html:103 msgid "Поиск работников, вакансий, тренингов..." msgstr "Search employees, vacancies, trainings..." -#: templates/pages/hr-portal.html:83 +#: templates/pages/hr-portal.html:110 msgid "Создать" msgstr "Create" -#: templates/pages/hr-portal.html:94 +#: templates/pages/hr-portal.html:123 msgid "Заявки и отпуска" msgstr "Requests and vacations" -#: templates/pages/hr-portal.html:96 +#: templates/pages/hr-portal.html:125 msgid "Тренинги" msgstr "Trainings" -#: templates/pages/hr-portal.html:98 +#: templates/pages/hr-portal.html:127 msgid "Вакансии" msgstr "Vacancies" -#: templates/pages/hr-portal.html:105 +#: templates/pages/hr-portal.html:134 msgid "Карточки" msgstr "Cards" -#: templates/pages/hr-portal.html:136 +#: templates/pages/hr-portal.html:166 msgid "Все отделы" msgstr "All departments" -#: templates/pages/hr-portal.html:143 +#: templates/pages/hr-portal.html:173 msgid "Все должности" msgstr "All positions" -#: templates/pages/hr-portal.html:176 +#: templates/pages/hr-portal.html:207 templates/pages/hr-portal.html:390 msgid "На испытании до" msgstr "On probation until" -#: templates/pages/hr-portal.html:182 +#: templates/pages/hr-portal.html:213 msgid "Отдел:" msgstr "Department:" -#: templates/pages/hr-portal.html:186 +#: templates/pages/hr-portal.html:217 msgid "Дата приёма:" msgstr "Hire date:" -#: templates/pages/hr-portal.html:194 +#: templates/pages/hr-portal.html:225 msgid "Телефон:" msgstr "Phone:" -#: templates/pages/hr-portal.html:202 templates/pages/hr-portal.html:255 +#: templates/pages/hr-portal.html:233 templates/pages/hr-portal.html:287 msgid "Активных сотрудников пока нет" msgstr "No active employees yet" -#: templates/pages/hr-portal.html:206 templates/pages/hr-portal.html:261 +#: templates/pages/hr-portal.html:237 templates/pages/hr-portal.html:293 msgid "Ничего не найдено по заданным фильтрам" msgstr "Nothing found for the given filters" -#: templates/pages/hr-portal.html:242 templates/pages/hr-portal.html:559 -msgid "На испытании" -msgstr "On probation" - -#: templates/pages/hr-portal.html:265 +#: templates/pages/hr-portal.html:297 msgid "Полный список сотрудников →" msgstr "Full employee list →" -#: templates/pages/hr-portal.html:277 -msgid "Отпуск" -msgstr "Vacation" - -#: templates/pages/hr-portal.html:278 -msgid "Подать заявку" -msgstr "Submit request" - -#: templates/pages/hr-portal.html:285 -msgid "Заказать документ" -msgstr "Order document" - -#: templates/pages/hr-portal.html:291 -msgid "Табель" -msgstr "Timesheet" - -#: templates/pages/hr-portal.html:298 -msgid "Начисления" -msgstr "Accruals" - -#: templates/pages/hr-portal.html:299 -msgid "Зарплата и премии" -msgstr "Salary and bonuses" - #: templates/pages/hr-portal.html:308 -msgid "Мои заявки" -msgstr "My requests" +#| msgid "На испытании" +msgid "Кандидаты на испытании" +msgstr "Candidates on probation" -#: templates/pages/hr-portal.html:309 templates/pages/it-manager.html:373 -msgid "Новая" -msgstr "New" +#: templates/pages/hr-portal.html:309 +msgid "Заявки на найм до утверждения персонального аккаунта" +msgstr "Hiring requests before approval of a personal account" -#: templates/pages/hr-portal.html:314 -msgid "Отпуск 01–14 мая" -msgstr "Vacation May 01–14" +#: templates/pages/hr-portal.html:311 +#| msgid "Новая задача" +msgid "Новая заявка" +msgstr "New request" -#: templates/pages/hr-portal.html:315 -msgid "Подано 10.04.2026" -msgstr "Submitted 10.04.2026" - -#: templates/pages/hr-portal.html:317 templates/pages/hr-portal.html:374 -msgid "На рассмотрении" -msgstr "Under review" - -#: templates/pages/hr-portal.html:321 -msgid "Справка о зарплате" -msgstr "Salary certificate" - -#: templates/pages/hr-portal.html:322 -msgid "Подано 05.04.2026" -msgstr "Submitted 05.04.2026" - -#: templates/pages/hr-portal.html:324 -msgid "Готово" -msgstr "Done" - -#: templates/pages/hr-portal.html:328 -msgid "Больничный 28–29 марта" -msgstr "Sick leave March 28–29" - -#: templates/pages/hr-portal.html:329 -msgid "Подано 01.04.2026" -msgstr "Submitted 01.04.2026" - -#: templates/pages/hr-portal.html:331 templates/pages/hr-portal.html:562 -msgid "Утверждено" -msgstr "Approved" - -#: templates/pages/hr-portal.html:339 -msgid "Баланс отпусков — 2026" -msgstr "Vacation balance — 2026" - -#: templates/pages/hr-portal.html:345 -msgid "Основной отпуск" -msgstr "Main vacation" - -#: templates/pages/hr-portal.html:346 templates/pages/hr-portal.html:355 -#: templates/pages/hr-portal.html:364 templates/pages/hr-portal.html:483 -msgid "дн." -msgstr "days" - -#: templates/pages/hr-portal.html:349 -msgid "Остаток: 10 дней" -msgstr "Balance: 10 days" - -#: templates/pages/hr-portal.html:354 -msgid "Дополнительный отпуск" -msgstr "Additional vacation" - -#: templates/pages/hr-portal.html:358 -msgid "Остаток: 7 дней" -msgstr "Balance: 7 days" - -#: templates/pages/hr-portal.html:363 -msgid "Больничные" -msgstr "Sick leaves" - -#: templates/pages/hr-portal.html:367 -msgid "Остаток: 28 дней" -msgstr "Balance: 28 days" - -#: templates/pages/hr-portal.html:371 -msgid "Следующий отпуск" -msgstr "Next vacation" - -#: templates/pages/hr-portal.html:389 templates/pages/hr-portal.html:400 -msgid "Раздел в разработке" -msgstr "Section under development" - -#: templates/pages/hr-portal.html:390 -msgid "Планирование тренингов и обучения появится в ближайшее время" -msgstr "Training and learning planning coming soon" - -#: templates/pages/hr-portal.html:401 -msgid "Управление открытыми вакансиями появится в ближайшее время" -msgstr "Open vacancies management coming soon" - -#: templates/pages/hr-portal.html:415 -msgid "Новая заявка на найм" -msgstr "New hiring request" - -#: templates/pages/hr-portal.html:416 -msgid "Сразу создаёт личный учётный запис кандидата на срок испытания." -msgstr "Immediately creates a personal candidate account for the probation period." - -#: templates/pages/hr-portal.html:422 -msgid "Фамилия " -msgstr "Last name " - -#: templates/pages/hr-portal.html:426 -msgid "Имя " -msgstr "First name " - -#: templates/pages/hr-portal.html:437 -msgid "Тип локации " -msgstr "Location type " - -#: templates/pages/hr-portal.html:439 -msgid "Выберите..." -msgstr "Select..." - -#: templates/pages/hr-portal.html:476 -msgid "Дата выхода" -msgstr "Start date" - -#: templates/pages/hr-portal.html:480 -msgid "Срок испытания" -msgstr "Probation period" - -#: templates/pages/hr-portal.html:491 -msgid "Email кандидата" -msgstr "Candidate email" - -#: templates/pages/hr-portal.html:501 -msgid "Требуются доступы (после утверждения — заявка в IT)" -msgstr "Required access (after approval — IT request)" - -#: templates/pages/hr-portal.html:513 -msgid "Примечание по доступам" -msgstr "Note on access" - -#: templates/pages/hr-portal.html:514 -msgid "Например: 1С Бухгалтерия — только просмотр" -msgstr "e.g. 1C Accounting — read only" - -#: templates/pages/hr-portal.html:520 -msgid "Создать заявку и временный аккаунт" -msgstr "Create request and temporary account" - -#: templates/pages/hr-portal.html:521 -msgid "Создание учётной записи…" -msgstr "Creating account…" - -#: templates/pages/hr-portal.html:533 -msgid "Кандидат" -msgstr "Candidate" - -#: templates/pages/hr-portal.html:534 +#: templates/pages/hr-portal.html:319 templates/pages/hr-portal.html:687 msgid "Должность / локация" msgstr "Position / location" -#: templates/pages/hr-portal.html:536 +#: templates/pages/hr-portal.html:320 templates/pages/hr-portal.html:688 +msgid "Інтерн / IT" +msgstr "Intern / IT" + +#: templates/pages/hr-portal.html:321 templates/pages/hr-portal.html:690 msgid "Испытание до" msgstr "Probation until" -#: templates/pages/hr-portal.html:537 templates/pages/pharmacy-detail.html:302 -msgid "Подал" -msgstr "Submitted by" - -#: templates/pages/hr-portal.html:538 templates/pages/task-detail.html:138 +#: templates/pages/hr-portal.html:322 templates/pages/hr-portal.html:692 +#: templates/pages/task-detail.html:138 msgid "Действия" msgstr "Actions" -#: templates/pages/hr-portal.html:557 +#: templates/pages/hr-portal.html:331 templates/pages/hr-portal.html:725 msgid "Просрочено" msgstr "Overdue" -#: templates/pages/hr-portal.html:564 -msgid "Отклонено" -msgstr "Rejected" - -#: templates/pages/hr-portal.html:572 +#: templates/pages/hr-portal.html:351 templates/pages/hr-portal.html:740 msgid "Утвердить кандидата" msgstr "Approve candidate" -#: templates/pages/hr-portal.html:575 +#: templates/pages/hr-portal.html:354 templates/pages/hr-portal.html:743 msgid "Утвердить" msgstr "Approve" -#: templates/pages/hr-portal.html:581 +#: templates/pages/hr-portal.html:360 templates/pages/hr-portal.html:749 msgid "Причина отказа" msgstr "Reason for rejection" -#: templates/pages/hr-portal.html:583 +#: templates/pages/hr-portal.html:362 templates/pages/hr-portal.html:751 msgid "Отклонить" msgstr "Reject" -#: templates/pages/hr-portal.html:591 -msgid "Заявка IT:" -msgstr "IT request:" +#: templates/pages/hr-portal.html:371 +#| msgid "На испытании" +msgid "Нет кандидатов на испытании" +msgstr "No candidates on probation" -#: templates/pages/hr-portal.html:598 +#: templates/pages/hr-portal.html:383 +#| msgid "На испытательном сроке" +msgid "Сотрудники с испытательным сроком" +msgstr "Employees on probation" + +#: templates/pages/hr-portal.html:384 +msgid "Учётные записи с заполненной датой окончания испытания" +msgstr "Accounts with a filled probation end date" + +#: templates/pages/hr-portal.html:399 +#| msgid "На испытательном сроке" +msgid "Нет сотрудников на испытательном сроке" +msgstr "No employees on probation" + +#: templates/pages/hr-portal.html:413 +msgid "Отпуск" +msgstr "Vacation" + +#: templates/pages/hr-portal.html:414 +msgid "Подать заявку" +msgstr "Submit request" + +#: templates/pages/hr-portal.html:421 +msgid "Заказать документ" +msgstr "Order document" + +#: templates/pages/hr-portal.html:427 +msgid "Табель" +msgstr "Timesheet" + +#: templates/pages/hr-portal.html:434 +msgid "Начисления" +msgstr "Accruals" + +#: templates/pages/hr-portal.html:435 +msgid "Зарплата и премии" +msgstr "Salary and bonuses" + +#: templates/pages/hr-portal.html:444 +msgid "Мои заявки" +msgstr "My requests" + +#: templates/pages/hr-portal.html:445 templates/pages/it-manager.html:373 +msgid "Новая" +msgstr "New" + +#: templates/pages/hr-portal.html:450 +msgid "Отпуск 01–14 мая" +msgstr "Vacation May 01–14" + +#: templates/pages/hr-portal.html:451 +msgid "Подано 10.04.2026" +msgstr "Submitted 10.04.2026" + +#: templates/pages/hr-portal.html:453 templates/pages/hr-portal.html:510 +msgid "На рассмотрении" +msgstr "Under review" + +#: templates/pages/hr-portal.html:457 +msgid "Справка о зарплате" +msgstr "Salary certificate" + +#: templates/pages/hr-portal.html:458 +msgid "Подано 05.04.2026" +msgstr "Submitted 05.04.2026" + +#: templates/pages/hr-portal.html:460 +msgid "Готово" +msgstr "Done" + +#: templates/pages/hr-portal.html:464 +msgid "Больничный 28–29 марта" +msgstr "Sick leave March 28–29" + +#: templates/pages/hr-portal.html:465 +msgid "Подано 01.04.2026" +msgstr "Submitted 01.04.2026" + +#: templates/pages/hr-portal.html:475 +msgid "Баланс отпусков — 2026" +msgstr "Vacation balance — 2026" + +#: templates/pages/hr-portal.html:481 +msgid "Основной отпуск" +msgstr "Main vacation" + +#: templates/pages/hr-portal.html:485 +msgid "Остаток: 10 дней" +msgstr "Balance: 10 days" + +#: templates/pages/hr-portal.html:490 +msgid "Дополнительный отпуск" +msgstr "Additional vacation" + +#: templates/pages/hr-portal.html:494 +msgid "Остаток: 7 дней" +msgstr "Balance: 7 days" + +#: templates/pages/hr-portal.html:499 +msgid "Больничные" +msgstr "Sick leaves" + +#: templates/pages/hr-portal.html:503 +msgid "Остаток: 28 дней" +msgstr "Balance: 28 days" + +#: templates/pages/hr-portal.html:507 +msgid "Следующий отпуск" +msgstr "Next vacation" + +#: templates/pages/hr-portal.html:525 templates/pages/hr-portal.html:536 +msgid "Раздел в разработке" +msgstr "Section under development" + +#: templates/pages/hr-portal.html:526 +msgid "Планирование тренингов и обучения появится в ближайшее время" +msgstr "Training and learning planning coming soon" + +#: templates/pages/hr-portal.html:537 +msgid "Управление открытыми вакансиями появится в ближайшее время" +msgstr "Open vacancies management coming soon" + +#: templates/pages/hr-portal.html:550 +msgid "Новая заявка на найм" +msgstr "New hiring request" + +#: templates/pages/hr-portal.html:551 +msgid "" +"Создаёт IT-заявку и выдаёт интерн-учётку на срок испытания. Персональный " +"аккаунт — после утверждения." +msgstr "" +"Creates an IT request and issues an intern account for the probation period." +" A personal account — after approval." + +#: templates/pages/hr-portal.html:553 +#, python-format +msgid "Свободных интерн-слотов: %(free)s из %(total)s" +msgstr "Free intern slots: %(free)s of %(total)s" + +#: templates/pages/hr-portal.html:555 +msgid "" +"добавьте слоты в админке (Пул інтерн-облікових записів) или дождитесь " +"освобождения" +msgstr "" +"add slots in the admin (Intern accounts pool) or wait for one to free up" + +#: templates/pages/hr-portal.html:563 +msgid "Фамилия " +msgstr "Last name " + +#: templates/pages/hr-portal.html:567 +msgid "Имя " +msgstr "First name " + +#: templates/pages/hr-portal.html:578 +msgid "Тип локации " +msgstr "Location type " + +#: templates/pages/hr-portal.html:580 +msgid "Выберите..." +msgstr "Select..." + +#: templates/pages/hr-portal.html:587 +#| msgid "Отдел" +msgid "Отдел " +msgstr "Department " + +#: templates/pages/hr-portal.html:596 +#| msgid "Аптека" +msgid "Аптека " +msgstr "Pharmacy " + +#: templates/pages/hr-portal.html:605 +#| msgid "Офис / Склад" +msgid "Офис / склад " +msgstr "Office / warehouse " + +#: templates/pages/hr-portal.html:630 +msgid "Срок испытания" +msgstr "Probation period" + +#: templates/pages/hr-portal.html:641 +msgid "Email кандидата" +msgstr "Candidate email" + +#: templates/pages/hr-portal.html:651 +#| msgid "Быстрый доступ" +msgid "Требуются доступы" +msgstr "Access required" + +#: templates/pages/hr-portal.html:652 +msgid "" +"Створює заявку IT на підготовку робочого місця. Список залежить від типу " +"локації." +msgstr "" +"Creates an IT request to prepare the workplace. The list depends on the " +"location type." + +#: templates/pages/hr-portal.html:667 +msgid "Например: 1С Бухгалтерия — только просмотр" +msgstr "e.g. 1C Accounting — read only" + +#: templates/pages/hr-portal.html:673 +msgid "Створити заявку (інтерн + IT)" +msgstr "Create request (intern + IT)" + +#: templates/pages/hr-portal.html:674 +msgid "Створення…" +msgstr "Creating…" + +#: templates/pages/hr-portal.html:691 templates/pages/pharmacy-detail.html:302 +msgid "Подал" +msgstr "Submitted by" + +#: templates/pages/hr-portal.html:762 +msgid "PDF з даними" +msgstr "PDF with data" + +#: templates/pages/hr-portal.html:772 msgid "Заявок на найм пока нет" msgstr "No hiring requests yet" @@ -2914,7 +3255,7 @@ msgstr "Completed work" msgid "Расходные материалы" msgstr "Consumables" -#: templates/pages/it-manager.html:21 templates/pages/it-manager.html:791 +#: templates/pages/it-manager.html:21 templates/pages/it-manager.html:794 msgid "Провайдеры" msgstr "Providers" @@ -2928,7 +3269,8 @@ msgid "На складе" msgstr "In stock" #: templates/pages/it-manager.html:39 templates/pages/it-manager.html:236 -#: templates/pages/it-manager.html:260 templates/pages/pharmacy-detail.html:198 +#: templates/pages/it-manager.html:260 +#: templates/pages/pharmacy-detail.html:198 #: templates/pages/pharmacy-detail.html:214 msgid "Выдано" msgstr "Issued" @@ -2970,10 +3312,14 @@ msgid "По одному в строку — вставьте список се msgstr "One per line — paste serial numbers, e.g." #: templates/pages/it-manager.html:96 -msgid "Сколько серийников — столько единиц будет создано. Дубликаты и уже имеющиеся пропускаются." +msgid "" +"Сколько серийников — столько единиц будет создано. Дубликаты и уже имеющиеся" +" пропускаются." msgstr "" +"As many serial numbers — as many units will be created. Duplicates and " +"existing ones are skipped." -#: templates/pages/it-manager.html:100 templates/pages/it-manager.html:670 +#: templates/pages/it-manager.html:100 templates/pages/it-manager.html:673 msgid "Дата покупки" msgstr "Purchase date" @@ -2990,8 +3336,8 @@ msgid "(необязательно)" msgstr "(optional)" #: templates/pages/it-manager.html:110 templates/pages/it-manager.html:202 -#: templates/pages/it-manager.html:500 templates/pages/it-manager.html:656 -#: templates/pages/it-manager.html:881 +#: templates/pages/it-manager.html:503 templates/pages/it-manager.html:659 +#: templates/pages/it-manager.html:884 msgid "Поиск по адресу или номеру аптеки..." msgstr "Search by address or pharmacy number..." @@ -3004,8 +3350,8 @@ msgid "Создать экземпляры" msgstr "Create units" #: templates/pages/it-manager.html:122 templates/pages/it-manager.html:172 -#: templates/pages/it-manager.html:542 templates/pages/it-manager.html:680 -#: templates/pages/it-manager.html:1086 +#: templates/pages/it-manager.html:545 templates/pages/it-manager.html:683 +#: templates/pages/it-manager.html:1089 msgid "Сохранение…" msgstr "Saving…" @@ -3017,8 +3363,8 @@ msgstr "Cancel" msgid "Новая модель" msgstr "New model" -#: templates/pages/it-manager.html:149 templates/pages/it-manager.html:625 -#: templates/pages/it-manager.html:900 +#: templates/pages/it-manager.html:149 templates/pages/it-manager.html:628 +#: templates/pages/it-manager.html:903 msgid "+ Добавить новый тип..." msgstr "+ Add new type..." @@ -3056,7 +3402,7 @@ msgstr "Catalog is empty — add the first model." #: templates/pages/it-manager.html:199 msgid "ед.:" -msgstr "" +msgstr "pcs:" #: templates/pages/it-manager.html:201 msgid "На локацию" @@ -3082,7 +3428,8 @@ msgstr "Type / Brand / Model" msgid "С/н · Инв. №" msgstr "S/n · Inv. no." -#: templates/pages/it-manager.html:261 templates/pages/pharmacy-detail.html:215 +#: templates/pages/it-manager.html:261 +#: templates/pages/pharmacy-detail.html:215 msgid "В ремонте" msgstr "Under repair" @@ -3098,13 +3445,7 @@ msgstr "Issued to" msgid "— (на складе)" msgstr "— (in stock)" -#: templates/pages/it-manager.html:290 templates/pages/it-manager.html:420 -#: templates/pages/it-manager.html:732 templates/pages/it-manager.html:1010 -#: templates/pages/settings.html:43 templates/pages/settings.html:60 -msgid "Сохранить" -msgstr "Save" - -#: templates/pages/it-manager.html:294 templates/pages/it-manager.html:739 +#: templates/pages/it-manager.html:294 templates/pages/it-manager.html:742 msgid "Примечания:" msgstr "Notes" @@ -3141,12 +3482,12 @@ msgid "Заявитель" msgstr "Requester" #: templates/pages/it-manager.html:348 templates/pages/it-manager.html:411 -#: templates/pages/it-manager.html:556 +#: templates/pages/it-manager.html:559 msgid "Исполнитель" msgstr "Assignee" -#: templates/pages/it-manager.html:349 templates/pages/it-manager.html:486 -#: templates/pages/it-manager.html:555 templates/pages/it-manager.html:1098 +#: templates/pages/it-manager.html:349 templates/pages/it-manager.html:489 +#: templates/pages/it-manager.html:558 templates/pages/it-manager.html:1101 #: templates/pages/pharmacy-detail.html:299 msgid "Дата" msgstr "Date" @@ -3163,268 +3504,273 @@ msgstr "Critical" msgid "Решена" msgstr "Resolved" -#: templates/pages/it-manager.html:391 templates/pages/it-manager.html:430 +#: templates/pages/it-manager.html:391 templates/pages/it-manager.html:433 msgid "внутренний" msgstr "internal" -#: templates/pages/it-manager.html:413 +#: templates/pages/it-manager.html:414 msgid "— не назначено" msgstr "— not assigned" -#: templates/pages/it-manager.html:428 +#: templates/pages/it-manager.html:418 +msgid "Нет сотрудников IT / staff — добавьте в группу IT" +msgstr "No IT / staff employees — add them to the IT group" + +#: templates/pages/it-manager.html:431 msgid "Комментарий..." msgstr "Comment..." -#: templates/pages/it-manager.html:433 templates/pages/it-manager.html:679 +#: templates/pages/it-manager.html:436 templates/pages/it-manager.html:682 msgid "Добавить" msgstr "Add" -#: templates/pages/it-manager.html:440 +#: templates/pages/it-manager.html:443 msgid "Заявок пока нет" msgstr "No tickets yet" -#: templates/pages/it-manager.html:455 +#: templates/pages/it-manager.html:458 msgid "Записей в этом месяце" msgstr "Records this month" -#: templates/pages/it-manager.html:459 +#: templates/pages/it-manager.html:462 msgid "Часов в этом месяце" msgstr "Hours this month" -#: templates/pages/it-manager.html:469 templates/pages/it-manager.html:541 +#: templates/pages/it-manager.html:472 templates/pages/it-manager.html:544 msgid "Добавить запись" msgstr "Add record" -#: templates/pages/it-manager.html:479 +#: templates/pages/it-manager.html:482 msgid "Добавить запись о выполненной работе" msgstr "Add completed work record" -#: templates/pages/it-manager.html:495 +#: templates/pages/it-manager.html:498 msgid "+ Добавить новый вид работ..." msgstr "+ Add new work type..." -#: templates/pages/it-manager.html:510 +#: templates/pages/it-manager.html:513 msgid "Затрачено часов" msgstr "Hours spent" -#: templates/pages/it-manager.html:532 +#: templates/pages/it-manager.html:535 msgid "Описание работ" msgstr "Work description" -#: templates/pages/it-manager.html:536 +#: templates/pages/it-manager.html:539 msgid "Результат" msgstr "Result" -#: templates/pages/it-manager.html:560 templates/pages/timetrack.html:59 +#: templates/pages/it-manager.html:563 templates/pages/timetrack.html:59 msgid "Часов" msgstr "Hours" -#: templates/pages/it-manager.html:574 +#: templates/pages/it-manager.html:577 msgid "Записей ещё нет" msgstr "No records yet" -#: templates/pages/it-manager.html:589 +#: templates/pages/it-manager.html:592 msgid "Всего позиций" msgstr "Total items" -#: templates/pages/it-manager.html:593 +#: templates/pages/it-manager.html:596 msgid "Заканчивается / на замену" msgstr "Expiring / to replace" -#: templates/pages/it-manager.html:603 +#: templates/pages/it-manager.html:606 msgid "Добавить материал" msgstr "Add material" -#: templates/pages/it-manager.html:613 +#: templates/pages/it-manager.html:616 msgid "Добавить расходный материал" msgstr "Add consumable" -#: templates/pages/it-manager.html:634 +#: templates/pages/it-manager.html:637 msgid "+ Добавить новый цвет..." msgstr "+ Add new color..." -#: templates/pages/it-manager.html:638 +#: templates/pages/it-manager.html:641 msgid "Производитель" msgstr "Manufacturer" -#: templates/pages/it-manager.html:642 +#: templates/pages/it-manager.html:645 msgid "Модель / артикул" msgstr "Model / article" -#: templates/pages/it-manager.html:646 templates/pages/it-manager.html:695 +#: templates/pages/it-manager.html:649 templates/pages/it-manager.html:698 msgid "Принтер" msgstr "Printer" -#: templates/pages/it-manager.html:655 +#: templates/pages/it-manager.html:658 msgid "Локация хранения" msgstr "Storage location" -#: templates/pages/it-manager.html:666 +#: templates/pages/it-manager.html:669 msgid "Ресурс (страниц)" msgstr "Capacity (pages)" -#: templates/pages/it-manager.html:693 +#: templates/pages/it-manager.html:696 msgid "Тип / Модель" msgstr "Type / Model" -#: templates/pages/it-manager.html:736 +#: templates/pages/it-manager.html:739 msgid "Ресурс:" msgstr "Capacity:" -#: templates/pages/it-manager.html:736 +#: templates/pages/it-manager.html:739 msgid "стр." msgstr "pages" -#: templates/pages/it-manager.html:745 +#: templates/pages/it-manager.html:748 msgid "Расходных материалов ещё не добавлено" msgstr "No consumables added yet" -#: templates/pages/it-manager.html:760 +#: templates/pages/it-manager.html:763 msgid "Провайдеров" msgstr "Providers" -#: templates/pages/it-manager.html:764 +#: templates/pages/it-manager.html:767 msgid "Договоров" msgstr "Contracts" -#: templates/pages/it-manager.html:768 +#: templates/pages/it-manager.html:771 msgid "Заканчиваются через 30 дней" msgstr "Expiring in 30 days" -#: templates/pages/it-manager.html:778 templates/pages/it-manager.html:850 +#: templates/pages/it-manager.html:781 templates/pages/it-manager.html:853 msgid "Добавить провайдера" msgstr "Add provider" -#: templates/pages/it-manager.html:784 templates/pages/it-manager.html:955 +#: templates/pages/it-manager.html:787 templates/pages/it-manager.html:958 msgid "Добавить договор" msgstr "Add contract" -#: templates/pages/it-manager.html:803 +#: templates/pages/it-manager.html:806 msgid "Провайдеров ещё нет" msgstr "No providers yet" -#: templates/pages/it-manager.html:814 +#: templates/pages/it-manager.html:817 msgid "Новый провайдер" msgstr "New provider" -#: templates/pages/it-manager.html:833 +#: templates/pages/it-manager.html:836 msgid "Сайт" msgstr "Website" -#: templates/pages/it-manager.html:837 +#: templates/pages/it-manager.html:840 msgid "Телефон техподдержки" msgstr "Support phone" -#: templates/pages/it-manager.html:841 +#: templates/pages/it-manager.html:844 msgid "Email техподдержки" msgstr "Support email" -#: templates/pages/it-manager.html:864 +#: templates/pages/it-manager.html:867 msgid "Новый договор" msgstr "New contract" -#: templates/pages/it-manager.html:891 +#: templates/pages/it-manager.html:894 msgid "Номер договора" msgstr "Contract number" -#: templates/pages/it-manager.html:895 templates/pages/it-manager.html:970 +#: templates/pages/it-manager.html:898 templates/pages/it-manager.html:973 #: templates/pages/pharmacy-detail.html:244 msgid "Тип услуги" msgstr "Service type" -#: templates/pages/it-manager.html:904 +#: templates/pages/it-manager.html:907 msgid "Технология подключения" msgstr "Connection technology" -#: templates/pages/it-manager.html:910 +#: templates/pages/it-manager.html:913 msgid "+ Добавить новую технологию..." msgstr "+ Add new technology..." -#: templates/pages/it-manager.html:918 +#: templates/pages/it-manager.html:921 msgid "Дата окончания" msgstr "End date" -#: templates/pages/it-manager.html:922 +#: templates/pages/it-manager.html:925 msgid "Стоимость/мес." msgstr "Cost/month" -#: templates/pages/it-manager.html:926 +#: templates/pages/it-manager.html:929 msgid "Скорость ↓ Мбит/с" msgstr "Speed ↓ Mbps" -#: templates/pages/it-manager.html:930 +#: templates/pages/it-manager.html:933 msgid "Скорость ↑ Мбит/с" msgstr "Speed ↑ Mbps" -#: templates/pages/it-manager.html:934 +#: templates/pages/it-manager.html:937 msgid "Лицевой счёт" msgstr "Account number" -#: templates/pages/it-manager.html:941 +#: templates/pages/it-manager.html:944 msgid "Выделенный IP" msgstr "Dedicated IP" -#: templates/pages/it-manager.html:945 +#: templates/pages/it-manager.html:948 msgid "По одному IP-адресу на строку" msgstr "One IP address per line" -#: templates/pages/it-manager.html:969 +#: templates/pages/it-manager.html:972 msgid "Провайдер / Локация" msgstr "Provider / Location" -#: templates/pages/it-manager.html:971 templates/pages/pharmacy-detail.html:246 +#: templates/pages/it-manager.html:974 +#: templates/pages/pharmacy-detail.html:246 msgid "Скорость" msgstr "Speed" -#: templates/pages/it-manager.html:1013 +#: templates/pages/it-manager.html:1016 msgid "Счёт:" msgstr "Account:" -#: templates/pages/it-manager.html:1013 +#: templates/pages/it-manager.html:1016 msgid "грн/мес" msgstr "UAH/mo" -#: templates/pages/it-manager.html:1018 +#: templates/pages/it-manager.html:1021 msgid "Договоров ещё нет" msgstr "No contracts yet" -#: templates/pages/it-manager.html:1032 +#: templates/pages/it-manager.html:1035 msgid "обращений" msgstr "requests" -#: templates/pages/it-manager.html:1037 templates/pages/it-manager.html:1047 +#: templates/pages/it-manager.html:1040 templates/pages/it-manager.html:1050 msgid "Новое обращение" msgstr "New request" -#: templates/pages/it-manager.html:1054 templates/pages/it-manager.html:1099 +#: templates/pages/it-manager.html:1057 templates/pages/it-manager.html:1102 msgid "Кто обратился" msgstr "Who requested" -#: templates/pages/it-manager.html:1068 +#: templates/pages/it-manager.html:1071 msgid "+ Добавить новый канал..." msgstr "+ Add new channel..." -#: templates/pages/it-manager.html:1072 +#: templates/pages/it-manager.html:1075 msgid "Затрачено минут" msgstr "Minutes spent" -#: templates/pages/it-manager.html:1076 +#: templates/pages/it-manager.html:1079 msgid "Описание обращения" msgstr "Request description" -#: templates/pages/it-manager.html:1080 +#: templates/pages/it-manager.html:1083 msgid "Решение" msgstr "Resolution" -#: templates/pages/it-manager.html:1085 +#: templates/pages/it-manager.html:1088 msgid "Добавить обращение" msgstr "Add request" -#: templates/pages/it-manager.html:1101 +#: templates/pages/it-manager.html:1104 msgid "Принял" msgstr "Accepted by" -#: templates/pages/it-manager.html:1117 +#: templates/pages/it-manager.html:1120 msgid "Обращений ещё нет" msgstr "No requests yet" @@ -3441,7 +3787,8 @@ msgid "Это необратимо." msgstr "This is irreversible." #: templates/pages/news-detail.html:24 templates/pages/task-detail.html:48 -#: templates/pages/tasks.html:135 templates/partials/pharmacy-documents.html:51 +#: templates/pages/tasks.html:135 +#: templates/partials/pharmacy-documents.html:51 msgid "Удалить" msgstr "Delete" @@ -3572,14 +3919,6 @@ msgstr "Internet provider" msgid "Коммунальный отдел" msgstr "Utilities department" -#: templates/pages/pharmacy-detail.html:88 -msgid "Регион" -msgstr "Region" - -#: templates/pages/pharmacy-detail.html:90 -msgid "Категория" -msgstr "Category" - #: templates/pages/pharmacy-detail.html:91 msgid "Категория НДС" msgstr "VAT category" @@ -3681,8 +4020,12 @@ msgid "Количество записей в таблицах по умолча msgstr "Default number of rows in tables" #: templates/pages/profile.html:100 -msgid "Применяется ко всем таблицам портала, если для конкретной таблицы размер не выбран вручную." -msgstr "Applies to all portal tables unless a size is chosen manually for a specific table." +msgid "" +"Применяется ко всем таблицам портала, если для конкретной таблицы размер не " +"выбран вручную." +msgstr "" +"Applies to all portal tables unless a size is chosen manually for a specific" +" table." #: templates/pages/settings.html:6 msgid "Конфигурация портала" @@ -3860,33 +4203,62 @@ msgstr "No time records for" msgid "сотрудников" msgstr "employees" +#: templates/partials/dislo_cell.html:15 +msgid "не назначен" +msgstr "not assigned" + +#: templates/partials/dislo_cell.html:23 +msgid "Нехватка" +msgstr "Shortage" + +#: templates/partials/dislo_cell.html:25 +msgid "Внимание" +msgstr "Warning" + +#: templates/partials/dislo_cell.html:27 +msgid "Норма" +msgstr "Normal" + #: templates/partials/documents-browser.html:28 #: templates/partials/pharmacy-documents.html:18 msgid "Файловое хранилище пока не настроено." -msgstr "" +msgstr "File storage is not configured yet." #: templates/partials/documents-browser.html:32 #: templates/partials/pharmacy-documents.html:22 msgid "Не удалось получить список документов." -msgstr "" +msgstr "Failed to fetch the document list." #: templates/partials/documents-browser.html:61 msgid "В этой папке пока нет файлов." -msgstr "" +msgstr "There are no files in this folder yet." #: templates/partials/documents-browser.html:74 #: templates/partials/pharmacy-documents.html:76 msgid "Загрузить" -msgstr "" +msgstr "Upload" #: templates/partials/pharmacy-documents.html:4 msgid "Документы аптеки" -msgstr "" +msgstr "Pharmacy documents" #: templates/partials/pharmacy-documents.html:48 msgid "Удалить файл?" -msgstr "" +msgstr "Delete the file?" #: templates/partials/pharmacy-documents.html:63 msgid "В папке аптеки пока нет файлов." -msgstr "" +msgstr "There are no files in the pharmacy folder yet." + +#~ msgid "Сразу создаёт личный учётный запис кандидата на срок испытания." +#~ msgstr "" +#~ "Immediately creates a personal candidate account for the probation period." + +#~ msgid "Требуются доступы (после утверждения — заявка в IT)" +#~ msgstr "Required access (after approval — IT request)" + +#~ msgid "Создать заявку и временный аккаунт" +#~ msgstr "Create request and temporary account" + +#~ msgid "Создание учётной записи…" +#~ msgstr "Creating account…" diff --git a/locale/uk/LC_MESSAGES/django.mo b/locale/uk/LC_MESSAGES/django.mo index a2bd89b..e8a6665 100644 Binary files a/locale/uk/LC_MESSAGES/django.mo and b/locale/uk/LC_MESSAGES/django.mo differ diff --git a/locale/uk/LC_MESSAGES/django.po b/locale/uk/LC_MESSAGES/django.po index ae4f03d..e9d6801 100644 --- a/locale/uk/LC_MESSAGES/django.po +++ b/locale/uk/LC_MESSAGES/django.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PACKAGE VERSION\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-07-28 21:52+0300\n" +"POT-Creation-Date: 2026-07-29 11:36+0300\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "Last-Translator: FULL NAME \n" "Language-Team: LANGUAGE \n" @@ -17,10 +17,10 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=4; plural=(n % 1 == 0 && n % 10 == 1 && n % 100 != 11 ? 0 : n % 1 == 0 && n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14) ? 1 : n % 1 == 0 && (n % 10 ==0 || (n % 10 >=5 && n % 10 <=9) || (n % 100 >=11 && n % 100 <=14 )) ? 2: 3);\n" -#: apps/ad_connector/models.py:10 apps/employees/models/base.py:198 +#: apps/ad_connector/models.py:10 apps/employees/models/base.py:207 #: apps/employees/models/permissions.py:47 osc/apps/ad_connector/models.py:10 #: osc/apps/employees/models.py:130 osc/apps/mattermost_connector/models.py:10 -#: templates/pages/employees.html:103 templates/pages/hr-portal.html:215 +#: templates/pages/employees.html:103 templates/pages/hr-portal.html:246 #: templates/pages/timetrack.html:58 msgid "Сотрудник" msgstr "Співробітник" @@ -29,42 +29,43 @@ msgstr "Співробітник" #: apps/nextcloud_connector/models.py:34 osc/apps/ad_connector/models.py:13 #: osc/apps/mattermost_connector/models.py:13 msgid "Действие" -msgstr "" +msgstr "Дія" #: apps/ad_connector/models.py:15 apps/mail_connector/models.py:17 #: osc/apps/ad_connector/models.py:14 #: osc/apps/mattermost_connector/models.py:14 msgid "Создание" -msgstr "" +msgstr "Створення" #: apps/ad_connector/models.py:16 apps/mail_connector/models.py:18 #: osc/apps/ad_connector/models.py:14 #: osc/apps/mattermost_connector/models.py:14 msgid "Обновление" -msgstr "" +msgstr "Оновлення" #: apps/ad_connector/models.py:17 apps/mail_connector/models.py:19 msgid "Включение" -msgstr "" +msgstr "Увімкнення" #: apps/ad_connector/models.py:18 apps/mail_connector/models.py:20 msgid "Отключение" -msgstr "" +msgstr "Вимкнення" -#: apps/ad_connector/models.py:22 apps/hiring/models.py:72 +#: apps/ad_connector/models.py:22 apps/hiring/models.py:129 #: apps/it_manager/admin.py:168 apps/it_manager/admin.py:263 #: apps/it_manager/admin.py:352 apps/it_manager/admin.py:498 +#: apps/locations/dislocation_columns.py:42 #: apps/locations/models/pharmacies.py:128 apps/mail_connector/models.py:24 #: apps/nextcloud_connector/models.py:35 apps/tasks/models.py:40 #: apps/visit_manager/admin.py:199 core/admin.py:32 #: osc/apps/ad_connector/models.py:17 #: osc/apps/mattermost_connector/models.py:17 -#: templates/pages/dislocation.html:122 templates/pages/employees.html:107 -#: templates/pages/hr-portal.html:535 templates/pages/it-manager.html:237 -#: templates/pages/it-manager.html:273 templates/pages/it-manager.html:350 -#: templates/pages/it-manager.html:403 templates/pages/it-manager.html:696 -#: templates/pages/it-manager.html:724 templates/pages/it-manager.html:973 -#: templates/pages/it-manager.html:1002 templates/pages/pharmacies.html:119 +#: templates/pages/employees.html:107 templates/pages/hr-portal.html:689 +#: templates/pages/it-manager.html:237 templates/pages/it-manager.html:273 +#: templates/pages/it-manager.html:350 templates/pages/it-manager.html:403 +#: templates/pages/it-manager.html:699 templates/pages/it-manager.html:727 +#: templates/pages/it-manager.html:976 templates/pages/it-manager.html:1005 +#: templates/pages/pharmacies.html:119 #: templates/pages/pharmacy-detail.html:197 #: templates/pages/pharmacy-detail.html:247 templates/pages/structure.html:63 msgid "Статус" @@ -74,7 +75,7 @@ msgstr "Статус" #: apps/nextcloud_connector/models.py:25 osc/apps/ad_connector/models.py:18 #: osc/apps/mattermost_connector/models.py:18 msgid "Успех" -msgstr "" +msgstr "Успіх" #: apps/ad_connector/models.py:23 apps/employees/models/base.py:145 #: apps/employees/models/base.py:160 apps/locations/models/pharmacies.py:152 @@ -90,58 +91,60 @@ msgstr "Помилка" #: apps/nextcloud_connector/models.py:36 osc/apps/ad_connector/models.py:20 #: osc/apps/mattermost_connector/models.py:20 msgid "Сообщение" -msgstr "" +msgstr "Повідомлення" #: apps/ad_connector/models.py:28 osc/apps/ad_connector/models.py:23 msgid "Запись синхронизации AD" -msgstr "" +msgstr "Запис синхронізації AD" #: apps/ad_connector/models.py:29 osc/apps/ad_connector/models.py:24 msgid "Журнал синхронизации AD" -msgstr "" +msgstr "Журнал синхронізації AD" #: apps/employees/admin.py:37 apps/employees/admin.py:259 -#: apps/employees/models/base.py:113 apps/nextcloud_connector/models.py:16 -#: osc/apps/employees/models.py:72 templates/pages/hr-portal.html:446 +#: apps/employees/models/base.py:113 apps/hiring/models.py:97 +#: apps/locations/models/dislocation.py:26 +#: apps/nextcloud_connector/models.py:16 osc/apps/employees/models.py:72 #: templates/pages/structure.html:59 msgid "Отдел" msgstr "Відділ" #: apps/employees/admin.py:39 msgid "— Выберите отдел —" -msgstr "" +msgstr "— Оберіть відділ —" #: apps/employees/admin.py:44 apps/locations/admin.py:67 msgid "Выберите из списка. Уровни вложенности отображаются отступами." -msgstr "" +msgstr "Оберіть зі списку. Рівні вкладеності відображаються відступами." #: apps/employees/admin.py:49 msgid "— Выберите офис или склад —" -msgstr "" +msgstr "— Оберіть офіс або склад —" #: apps/employees/admin.py:50 apps/employees/models/base.py:107 +#: apps/hiring/models.py:107 msgid "Локация (офис/склад)" -msgstr "" +msgstr "Локація (офіс/склад)" #: apps/employees/admin.py:51 apps/employees/models/base.py:108 msgid "Заполняется для location_type=office или warehouse" -msgstr "" +msgstr "Заповнюється для location_type=office або warehouse" -#: apps/employees/admin.py:133 core/admin.py:259 +#: apps/employees/admin.py:133 core/admin.py:271 msgid "Аутентификация" -msgstr "" +msgstr "Автентифікація" -#: apps/employees/admin.py:137 +#: apps/employees/admin.py:137 apps/hiring/pdf.py:49 msgid "ФИО" -msgstr "" +msgstr "ПІБ" #: apps/employees/admin.py:141 msgid "Рабочая информация" -msgstr "" +msgstr "Робоча інформація" #: apps/employees/admin.py:149 apps/it_manager/admin.py:307 msgid "Даты" -msgstr "" +msgstr "Дати" #: apps/employees/admin.py:153 templates/pages/employee-detail.html:126 msgid "Личные данные" @@ -155,81 +158,96 @@ msgstr "Контакти" #: apps/employees/admin.py:173 msgid "Графік та відвідуваність" -msgstr "" +msgstr "Графік та відвідуваність" #: apps/employees/admin.py:176 msgid "Графіки роботи та записи відвідуваності відображаються нижче." -msgstr "" +msgstr "Графіки роботи та записи відвідуваності відображаються нижче." #: apps/employees/admin.py:184 msgid "Системный доступ" -msgstr "" +msgstr "Системний доступ" #: apps/employees/admin.py:188 msgid "Только для IT-администраторов. is_staff — вход в Django admin." -msgstr "" +msgstr "Тільки для IT-адміністраторів. is_staff — вхід у Django admin." #: apps/employees/admin.py:232 msgid "Предпросмотр" -msgstr "" +msgstr "Попередній перегляд" #: apps/employees/admin.py:239 msgid "Группы" -msgstr "" +msgstr "Групи" -#: apps/employees/models/base.py:34 core/models/connector_config.py:29 +#: apps/employees/admin.py:266 +#| msgid "Офисных сотрудников" +msgid "Офисные сотрудники" +msgstr "Офісні співробітники" + +#: apps/employees/admin.py:269 +msgid "" +"При утверждении заявки на найм офисному сотруднику назначается рабочий " +"email: логин@домен." +msgstr "" +"При затвердженні заявки на найм офісному співробітнику призначається робочий" +" email: логін@домен." + +#: apps/employees/models/base.py:34 apps/hiring/models.py:19 +#: apps/hiring/pdf.py:52 core/models/connector_config.py:29 #: osc/apps/employees/models.py:33 msgid "Логин" -msgstr "" +msgstr "Логін" #: apps/employees/models/base.py:35 msgid "Уникальный логин для входа в систему" -msgstr "" +msgstr "Унікальний логін для входу в систему" #: apps/employees/models/base.py:38 osc/apps/employees/models.py:37 msgid "Email адрес" -msgstr "" +msgstr "Email адреса" #: apps/employees/models/base.py:39 osc/apps/employees/models.py:38 msgid "Рабочий email. Если не указан — генерируется из username@local.domain" -msgstr "" +msgstr "Робочий email. Якщо не вказано — генерується з username@local.domain" -#: apps/employees/models/base.py:42 osc/apps/employees/models.py:41 +#: apps/employees/models/base.py:42 apps/hiring/models.py:75 +#: osc/apps/employees/models.py:41 msgid "Фамилия" -msgstr "" +msgstr "Прізвище" -#: apps/employees/models/base.py:43 osc/apps/employees/models.py:42 -#: templates/pages/profile.html:76 +#: apps/employees/models/base.py:43 apps/hiring/models.py:76 +#: osc/apps/employees/models.py:42 templates/pages/profile.html:76 msgid "Имя" msgstr "Ім'я" -#: apps/employees/models/base.py:44 osc/apps/employees/models.py:43 -#: templates/pages/hr-portal.html:430 +#: apps/employees/models/base.py:44 apps/hiring/models.py:77 +#: osc/apps/employees/models.py:43 templates/pages/hr-portal.html:571 msgid "Отчество" msgstr "По батькові" #: apps/employees/models/base.py:46 osc/apps/employees/models.py:46 msgid "Доступ к панели администратора" -msgstr "" +msgstr "Доступ до панелі адміністратора" #: apps/employees/models/base.py:47 osc/apps/employees/models.py:47 msgid "Разрешает вход в Django admin. Только для IT-администраторов системы." -msgstr "" +msgstr "Дозволяє вхід у Django admin. Тільки для IT-адміністраторів системи." -#: apps/employees/models/base.py:48 core/models/directories.py:29 -#: core/models/directories.py:121 templates/pages/employee-detail.html:45 -#: templates/pages/employees.html:148 templates/pages/hr-portal.html:174 -#: templates/pages/structure.html:89 +#: apps/employees/models/base.py:48 apps/hiring/models.py:21 +#: core/models/directories.py:29 core/models/directories.py:121 +#: templates/pages/employee-detail.html:45 templates/pages/employees.html:148 +#: templates/pages/hr-portal.html:205 templates/pages/structure.html:89 msgid "Активный" msgstr "Активний" #: apps/employees/models/base.py:49 osc/apps/employees/models.py:49 msgid "Работает ли сотрудник сейчас" -msgstr "" +msgstr "Чи працює співробітник зараз" #: apps/employees/models/base.py:50 osc/apps/employees/models.py:50 msgid "Дата создания аккаунта" -msgstr "" +msgstr "Дата створення облікового запису" #: apps/employees/models/base.py:53 osc/apps/employees/models.py:52 #: templates/pages/employee-detail.html:129 @@ -243,7 +261,7 @@ msgstr "ІПН" #: apps/employees/models/base.py:58 osc/apps/employees/models.py:55 msgid "Идентификационный номер налогоплательщика" -msgstr "" +msgstr "Ідентифікаційний номер платника податків" #: apps/employees/models/base.py:63 osc/apps/employees/models.py:59 #: templates/pages/employee-detail.html:74 templates/pages/profile.html:57 @@ -252,11 +270,11 @@ msgstr "Робочий телефон" #: apps/employees/models/base.py:67 msgid "Внутрішній номер" -msgstr "" +msgstr "Внутрішній номер" #: apps/employees/models/base.py:68 msgid "Короткий внутрішній (додатковий) номер АТС" -msgstr "" +msgstr "Короткий внутрішній (додатковий) номер АТС" #: apps/employees/models/base.py:71 osc/apps/employees/models.py:60 #: templates/pages/employee-detail.html:83 templates/pages/profile.html:61 @@ -270,11 +288,11 @@ msgstr "Особистий email" #: apps/employees/models/base.py:76 osc/apps/employees/models.py:63 msgid "Контакт в экстренных случаях" -msgstr "" +msgstr "Контакт в екстрених випадках" #: apps/employees/models/base.py:77 osc/apps/employees/models.py:65 msgid "Телефон экстренного контакта" -msgstr "" +msgstr "Телефон екстреного контакту" #: apps/employees/models/base.py:79 osc/apps/employees/models.py:67 #: templates/pages/profile.html:84 @@ -282,59 +300,63 @@ msgid "Степень родства" msgstr "Ступінь спорідненості" #: apps/employees/models/base.py:82 apps/employees/models/base.py:101 -#: apps/hiring/models.py:34 apps/hiring/models.py:50 -#: apps/locations/admin.py:347 apps/locations/models/base.py:29 -#: apps/locations/models/pharmacies.py:210 +#: apps/hiring/models.py:86 apps/hiring/models.py:102 +#: apps/locations/admin.py:347 apps/locations/dislocation_columns.py:36 +#: apps/locations/models/base.py:29 apps/locations/models/pharmacies.py:210 #: apps/locations/models/pharmacies.py:349 apps/mail_connector/models.py:12 #: apps/nextcloud_connector/models.py:17 apps/utilities/models.py:17 -#: templates/pages/dislocation.html:116 templates/pages/employees.html:64 -#: templates/pages/employees.html:90 templates/pages/hr-portal.html:126 -#: templates/pages/hr-portal.html:441 templates/pages/hr-portal.html:455 -#: templates/pages/hr-portal.html:459 +#: templates/pages/employees.html:64 templates/pages/employees.html:90 +#: templates/pages/hr-portal.html:156 templates/pages/hr-portal.html:582 +#: templates/pages/hr-portal.html:600 msgid "Аптека" msgstr "Аптека" -#: apps/employees/models/base.py:83 apps/hiring/models.py:35 -#: apps/locations/models/base.py:30 templates/pages/hr-portal.html:128 -#: templates/pages/hr-portal.html:442 +#: apps/employees/models/base.py:83 apps/hiring/models.py:87 +#: apps/locations/models/base.py:30 templates/pages/hr-portal.html:158 +#: templates/pages/hr-portal.html:583 msgid "Склад" msgstr "Склад" -#: apps/employees/models/base.py:84 apps/locations/models/base.py:31 -#: templates/pages/employees.html:56 templates/pages/hr-portal.html:124 -#: templates/pages/hr-portal.html:440 templates/pages/timetrack.html:47 +#: apps/employees/models/base.py:84 apps/hiring/models.py:88 +#: apps/locations/models/base.py:31 templates/pages/employees.html:56 +#: templates/pages/hr-portal.html:154 templates/pages/hr-portal.html:581 +#: templates/pages/timetrack.html:47 msgid "Офис" msgstr "Офіс" -#: apps/employees/models/base.py:87 apps/locations/models/base.py:28 +#: apps/employees/models/base.py:87 apps/hiring/models.py:91 +#: apps/hiring/pdf.py:51 apps/hiring/services.py:99 +#: apps/locations/models/base.py:28 msgid "Тип локации" -msgstr "" +msgstr "Тип локації" #: apps/employees/models/base.py:88 msgid "Где работает: аптека / склад / офис" -msgstr "" +msgstr "Де працює: аптека / склад / офіс" -#: apps/employees/models/base.py:95 apps/locations/models/pharmacies.py:88 -#: templates/pages/dislocation.html:117 templates/pages/pharmacies.html:113 +#: apps/employees/models/base.py:95 apps/locations/dislocation_columns.py:37 +#: apps/locations/models/pharmacies.py:88 templates/pages/pharmacies.html:113 #: templates/pages/pharmacy-detail.html:87 msgid "Организация" msgstr "Організація" #: apps/employees/models/base.py:96 apps/locations/models/pharmacies.py:89 msgid "Юрлицо: АКС, АКС Одесса, Тира, Пентальфа..." -msgstr "" +msgstr "Юрособа: АКС, АКС Одеса, Тира, Пентальфа..." #: apps/employees/models/base.py:102 msgid "Заполняется только для location_type=pharmacy" -msgstr "" +msgstr "Заповнюється тільки для location_type=pharmacy" #: apps/employees/models/base.py:115 msgid "Заполняется для office / warehouse" -msgstr "" +msgstr "Заповнюється для office / warehouse" -#: apps/employees/models/base.py:120 osc/apps/employees/models.py:77 -#: templates/pages/dislocation.html:297 templates/pages/employees.html:104 -#: templates/pages/hr-portal.html:216 templates/pages/hr-portal.html:467 +#: apps/employees/models/base.py:120 apps/hiring/models.py:113 +#: apps/hiring/pdf.py:50 apps/hiring/services.py:98 +#: osc/apps/employees/models.py:77 templates/pages/dislocation.html:310 +#: templates/pages/employees.html:104 templates/pages/hr-portal.html:247 +#: templates/pages/hr-portal.html:617 msgid "Должность" msgstr "Посада" @@ -345,7 +367,7 @@ msgstr "Керівник" #: apps/employees/models/base.py:129 osc/apps/employees/models.py:84 msgid "Дата приёма на работу" -msgstr "" +msgstr "Дата прийому на роботу" #: apps/employees/models/base.py:130 osc/apps/employees/models.py:85 #: templates/pages/employee-detail.html:116 @@ -354,25 +376,25 @@ msgstr "Дата звільнення" #: apps/employees/models/base.py:131 osc/apps/employees/models.py:86 msgid "Конец испытательного срока" -msgstr "" +msgstr "Кінець випробувального терміну" #: apps/employees/models/base.py:134 apps/visit_manager/admin.py:262 #: apps/visit_manager/admin.py:289 osc/apps/employees/models.py:88 -#: templates/pages/hr-portal.html:165 templates/pages/hr-portal.html:234 +#: templates/pages/hr-portal.html:196 templates/pages/hr-portal.html:266 msgid "Фото" msgstr "Фото" #: apps/employees/models/base.py:138 osc/apps/employees/models.py:90 msgid "ID Mattermost" -msgstr "" +msgstr "ID Mattermost" #: apps/employees/models/base.py:139 osc/apps/employees/models.py:91 msgid "Логин Mattermost" -msgstr "" +msgstr "Логін Mattermost" #: apps/employees/models/base.py:141 osc/apps/employees/models.py:93 msgid "Статус синхронизации Mattermost" -msgstr "" +msgstr "Статус синхронізації Mattermost" #: apps/employees/models/base.py:143 apps/tasks/models.py:24 #: osc/apps/employees/models.py:95 templates/pages/employee-detail.html:155 @@ -385,112 +407,145 @@ msgstr "Очікує" #: apps/locations/models/pharmacies.py:151 osc/apps/employees/models.py:96 #: osc/apps/employees/models.py:114 msgid "Синхронизирован" -msgstr "" +msgstr "Синхронізовано" #: apps/employees/models/base.py:146 apps/employees/models/base.py:161 #: osc/apps/employees/models.py:98 osc/apps/employees/models.py:116 msgid "Отключена" -msgstr "" +msgstr "Вимкнено" #: apps/employees/models/base.py:150 osc/apps/employees/models.py:102 msgid "Последняя синхронизация Mattermost" -msgstr "" +msgstr "Остання синхронізація Mattermost" #: apps/employees/models/base.py:151 osc/apps/employees/models.py:103 msgid "Синхронизация Mattermost" -msgstr "" +msgstr "Синхронізація Mattermost" #: apps/employees/models/base.py:156 osc/apps/employees/models.py:111 msgid "Статус синхронизации AD" -msgstr "" +msgstr "Статус синхронізації AD" #: apps/employees/models/base.py:158 osc/apps/employees/models.py:113 msgid "Не синхронизирован" -msgstr "" +msgstr "Не синхронізовано" #: apps/employees/models/base.py:165 osc/apps/employees/models.py:120 msgid "Ошибка синхронизации AD" -msgstr "" +msgstr "Помилка синхронізації AD" #: apps/employees/models/base.py:166 osc/apps/employees/models.py:121 msgid "Последняя синхронизация AD" -msgstr "" +msgstr "Остання синхронізація AD" #: apps/employees/models/base.py:167 osc/apps/employees/models.py:122 msgid "Синхронизация AD" -msgstr "" +msgstr "Синхронізація AD" #: apps/employees/models/base.py:168 msgid "Только для офисных сотрудников" -msgstr "" +msgstr "Тільки для офісних співробітників" #: apps/employees/models/base.py:171 msgid "Записей на странице" -msgstr "" +msgstr "Записів на сторінці" #: apps/employees/models/base.py:173 msgid "Сколько строк показывать в таблицах портала по умолчанию" -msgstr "" +msgstr "Скільки рядків показувати в таблицях порталу за замовчуванням" #: apps/employees/models/base.py:177 -msgid "Был онлайн" -msgstr "" +#| msgid "Должность / локация" +msgid "Колонки дислокации" +msgstr "Колонки дислокації" -#: apps/employees/models/base.py:178 -msgid "Обновляется автоматически при активности в портале (не в AD/Mattermost)" -msgstr "" - -#: apps/employees/models/base.py:183 -msgid "TOTP-секрет" -msgstr "" - -#: apps/employees/models/base.py:184 -msgid "Секрет TOTP (шифруется). Заполняется при настройке 2FA." +#: apps/employees/models/base.py:180 +msgid "" +"Персональный набор колонок таблицы /dislocation/. Пусто — пресет отдела или " +"системный «Стандарт»." msgstr "" +"Персональний набір колонок таблиці /dislocation/. Порожньо — пресет відділу " +"або системний «Стандарт»." #: apps/employees/models/base.py:186 +msgid "Был онлайн" +msgstr "Був онлайн" + +#: apps/employees/models/base.py:187 +msgid "" +"Обновляется автоматически при активности в портале (не в AD/Mattermost)" +msgstr "Оновлюється автоматично при активності в порталі (не в AD/Mattermost)" + +#: apps/employees/models/base.py:192 +msgid "TOTP-секрет" +msgstr "TOTP-секрет" + +#: apps/employees/models/base.py:193 +msgid "Секрет TOTP (шифруется). Заполняется при настройке 2FA." +msgstr "Секрет TOTP (шифрується). Заповнюється при налаштуванні 2FA." + +#: apps/employees/models/base.py:195 msgid "2FA включена" -msgstr "" +msgstr "2FA увімкнено" -#: apps/employees/models/base.py:188 +#: apps/employees/models/base.py:197 msgid "Резервные коды 2FA (хеши)" -msgstr "" +msgstr "Резервні коди 2FA (хеші)" -#: apps/employees/models/base.py:189 +#: apps/employees/models/base.py:198 msgid "Хеши одноразовых резервных кодов; использованный код удаляется." -msgstr "" +msgstr "Хеші одноразових резервних кодів; використаний код видаляється." -#: apps/employees/models/base.py:199 osc/apps/employees/models.py:131 +#: apps/employees/models/base.py:208 osc/apps/employees/models.py:131 #: templates/index.html:251 templates/pages/dashboard.html:174 #: templates/pages/employees.html:8 templates/pages/employees.html:24 -#: templates/pages/hr-portal.html:92 +#: templates/pages/hr-portal.html:119 msgid "Сотрудники" msgstr "Працівники" +#: apps/employees/models/defaults.py:13 +#| msgid "Офисных сотрудников" +msgid "Домен email для офисных сотрудников" +msgstr "Домен email для офісних співробітників" + +#: apps/employees/models/defaults.py:17 +msgid "" +"При утверждении заявки на найм офисному сотруднику назначается email вида " +"логин@этот-домен (вместо @local.domain)." +msgstr "" +"При затвердженні заявки на найм офісному співробітнику призначається email " +"виду логін@цей-домен (замість @local.domain)." + +#: apps/employees/models/defaults.py:23 apps/employees/models/defaults.py:24 +#: apps/employees/models/defaults.py:27 +#| msgid "Активных сотрудников пока нет" +msgid "Настройки сотрудников по умолчанию" +msgstr "Налаштування співробітників за замовчуванням" + #: apps/employees/models/permissions.py:14 msgid "Группа" -msgstr "" +msgstr "Група" #: apps/employees/models/permissions.py:16 apps/it_manager/admin.py:611 #: core/models/directories.py:28 core/models/directories.py:120 -#: templates/pages/it-manager.html:558 templates/pages/it-manager.html:1102 +#: templates/pages/it-manager.html:561 templates/pages/it-manager.html:1105 #: templates/pages/task-detail.html:62 msgid "Описание" msgstr "Опис" #: apps/employees/models/permissions.py:17 apps/locations/models/base.py:59 -#: templates/pages/dislocation.html:180 templates/pages/dislocation.html:559 -#: templates/pages/pharmacies.html:156 +#: templates/pages/dislocation.html:572 templates/pages/pharmacies.html:156 +#: templates/partials/dislo_cell.html:40 msgid "Активна" msgstr "Активна" #: apps/employees/models/permissions.py:20 msgid "Группа разрешений" -msgstr "" +msgstr "Група дозволів" #: apps/employees/models/permissions.py:21 msgid "Группы разрешений" -msgstr "" +msgstr "Групи дозволів" #: apps/employees/models/permissions.py:41 templates/pages/it-manager.html:199 #: templates/pages/it-manager.html:220 @@ -499,22 +554,22 @@ msgstr "Видати" #: apps/employees/models/permissions.py:42 msgid "Отозвать" -msgstr "" +msgstr "Відкликати" #: apps/employees/models/permissions.py:51 msgid "Разрешение" -msgstr "" +msgstr "Дозвіл" #: apps/employees/models/permissions.py:54 apps/it_manager/admin.py:154 #: apps/it_manager/admin.py:241 apps/it_manager/admin.py:334 #: apps/locations/models/pharmacies.py:332 templates/pages/it-manager.html:144 -#: templates/pages/it-manager.html:620 +#: templates/pages/it-manager.html:623 msgid "Тип" msgstr "Тип" #: apps/employees/models/permissions.py:56 msgid "Действует с" -msgstr "" +msgstr "Діє з" #: apps/employees/models/permissions.py:57 #: templates/pages/pharmacy-detail.html:248 @@ -525,27 +580,27 @@ msgstr "Діє до" #: apps/locations/models/pharmacies.py:334 osc/apps/employees/models.py:49 #: osc/core/models/directory.py:32 msgid "Активен" -msgstr "" +msgstr "Активний" #: apps/employees/models/permissions.py:59 msgid "Причина / комментарий" -msgstr "" +msgstr "Причина / коментар" #: apps/employees/models/permissions.py:62 msgid "Override разрешения" -msgstr "" +msgstr "Override дозволу" #: apps/employees/models/permissions.py:63 msgid "Override разрешений" -msgstr "" +msgstr "Override дозволів" #: apps/feed/models.py:22 msgid "Загальне" -msgstr "" +msgstr "Загальне" #: apps/feed/models.py:25 msgid "Мережа" -msgstr "" +msgstr "Мережа" #: apps/feed/models.py:36 templates/pages/news-edit.html:26 #: templates/pages/news.html:29 @@ -554,47 +609,55 @@ msgstr "Заголовок" #: apps/feed/models.py:37 msgid "Текст" -msgstr "" +msgstr "Текст" #: apps/feed/models.py:39 msgid "Категорія" -msgstr "" +msgstr "Категорія" #: apps/feed/models.py:42 msgid "Важливо" -msgstr "" +msgstr "Важливо" #: apps/feed/models.py:43 msgid "Закріплено" -msgstr "" +msgstr "Закріплено" #: apps/feed/models.py:44 msgid "Опубліковано" -msgstr "" +msgstr "Опубліковано" #: apps/feed/models.py:47 msgid "Опубліковано в Mattermost" -msgstr "" +msgstr "Опубліковано в Mattermost" #: apps/feed/models.py:48 msgid "Час останньої публікації в чат — не блокує повторну публікацію" -msgstr "" +msgstr "Час останньої публікації в чат — не блокує повторну публікацію" #: apps/feed/models.py:51 msgid "ID повідомлень в Mattermost" -msgstr "" +msgstr "ID повідомлень в Mattermost" #: apps/feed/models.py:52 -msgid "{team_name: post_id} — за наявності id повторна публікація редагує існуюче повідомлення" +msgid "" +"{team_name: post_id} — за наявності id повторна публікація редагує існуюче " +"повідомлення" msgstr "" +"{team_name: post_id} — за наявності id повторна публікація редагує існуюче " +"повідомлення" #: apps/feed/models.py:55 msgid "Куди публікувати в Mattermost" -msgstr "" +msgstr "Куди публікувати в Mattermost" #: apps/feed/models.py:56 -msgid "Список команд (akc/pharmacys/warehouse) — обирається чекбоксами при створенні/редагуванні" +msgid "" +"Список команд (akc/pharmacys/warehouse) — обирається чекбоксами при " +"створенні/редагуванні" msgstr "" +"Список команд (akc/pharmacys/warehouse) — обирається чекбоксами при " +"створенні/редагуванні" #: apps/feed/models.py:61 apps/feed/models.py:100 apps/tasks/models.py:126 #: templates/pages/task-detail.html:200 @@ -603,165 +666,241 @@ msgstr "Автор" #: apps/feed/models.py:65 apps/feed/models.py:96 msgid "Пост" -msgstr "" +msgstr "Пост" #: apps/feed/models.py:66 msgid "Пости" -msgstr "" +msgstr "Пости" #: apps/feed/models.py:102 apps/feed/models.py:115 apps/tasks/models.py:128 #: apps/utilities/models.py:26 msgid "Коментар" -msgstr "" +msgstr "Коментар" #: apps/feed/models.py:109 msgid "ID відповідей в Mattermost" -msgstr "" +msgstr "ID відповідей в Mattermost" #: apps/feed/models.py:111 msgid "ID вихідного повідомлення з Mattermost" -msgstr "" +msgstr "ID вихідного повідомлення з Mattermost" #: apps/feed/models.py:116 msgid "Коментарі" -msgstr "" - -#: apps/hiring/models.py:17 -msgid "На випробуванні" -msgstr "" +msgstr "Коментарі" #: apps/hiring/models.py:18 -msgid "Затверджено" -msgstr "" +msgid "Метка" +msgstr "Мітка" -#: apps/hiring/models.py:19 -msgid "Відхилено" -msgstr "" - -#: apps/hiring/models.py:21 -#, python-format -msgid "%(n)d дн." -msgstr "" - -#: apps/hiring/models.py:23 -msgid "Прізвище" -msgstr "" - -#: apps/hiring/models.py:24 -msgid "Ім'я" -msgstr "" +#: apps/hiring/models.py:20 apps/hiring/pdf.py:53 +#: core/models/connector_config.py:32 +msgid "Пароль" +msgstr "Пароль" #: apps/hiring/models.py:25 -msgid "По батькові" -msgstr "" +msgid "Назначено заявке" +msgstr "Призначено заявці" -#: apps/hiring/models.py:27 -msgid "Особистий email кандидата" -msgstr "" +#: apps/hiring/models.py:29 apps/hiring/services.py:103 +msgid "Интерн-аккаунт" +msgstr "Інтерн-обліковий запис" -#: apps/hiring/models.py:29 templates/pages/hr-portal.html:495 +#: apps/hiring/models.py:30 +msgid "Пул интерн-аккаунтов" +msgstr "Пул інтерн-облікових записів" + +#: apps/hiring/models.py:34 +msgid "занят" +msgstr "зайнятий" + +#: apps/hiring/models.py:34 +msgid "свободен" +msgstr "вільний" + +#: apps/hiring/models.py:49 +msgid "" +"Нет свободных интерн-аккаунтов. Дождитесь завершения стажировки или добавьте" +" слот в админке." +msgstr "" +"Немає вільних інтерн-облікових записів. Дочекайтеся завершення стажування " +"або додайте слот в адмінці." + +#: apps/hiring/models.py:69 templates/pages/hr-portal.html:121 +#: templates/pages/hr-portal.html:274 templates/pages/hr-portal.html:333 +#: templates/pages/hr-portal.html:727 +msgid "На испытании" +msgstr "На випробуванні" + +#: apps/hiring/models.py:70 templates/pages/hr-portal.html:467 +#: templates/pages/hr-portal.html:730 +msgid "Утверждено" +msgstr "Затверджено" + +#: apps/hiring/models.py:71 templates/pages/hr-portal.html:732 +msgid "Отклонено" +msgstr "Відхилено" + +#: apps/hiring/models.py:73 +#, python-format +msgid "%(n)d дн." +msgstr "%(n)d дн." + +#: apps/hiring/models.py:79 +msgid "Личный email кандидата" +msgstr "Особистий email кандидата" + +#: apps/hiring/models.py:81 templates/pages/hr-portal.html:645 msgid "Телефон кандидата" msgstr "Телефон кандидата" -#: apps/hiring/models.py:36 -msgid "Офіс" -msgstr "" - -#: apps/hiring/models.py:39 apps/visit_manager/admin.py:284 -msgid "Тип локації" -msgstr "" - -#: apps/hiring/models.py:45 -msgid "Відділ" -msgstr "" - -#: apps/hiring/models.py:56 -msgid "Посада" -msgstr "" - -#: apps/hiring/models.py:59 +#: apps/hiring/models.py:116 msgid "Дата виходу" -msgstr "" +msgstr "Дата виходу" -#: apps/hiring/models.py:61 -msgid "Термін випробування, днів" -msgstr "" +#: apps/hiring/models.py:118 +msgid "Срок испытания, дней" +msgstr "Термін випробування, днів" -#: apps/hiring/models.py:68 -msgid "Потрібні доступи" -msgstr "" +#: apps/hiring/models.py:125 apps/hiring/services.py:105 +msgid "Требуемые доступы" +msgstr "Потрібні доступи" -#: apps/hiring/models.py:70 -msgid "Примітка щодо доступів" -msgstr "" +#: apps/hiring/models.py:127 templates/pages/hr-portal.html:666 +msgid "Примечание по доступам" +msgstr "Примітка щодо доступів" -#: apps/hiring/models.py:76 -msgid "Подав HR" -msgstr "" +#: apps/hiring/models.py:133 +msgid "Подал HR" +msgstr "Подав HR" -#: apps/hiring/models.py:81 -msgid "Обліковий запис" -msgstr "" +#: apps/hiring/models.py:138 +msgid "Учётная запись" +msgstr "Обліковий запис" -#: apps/hiring/models.py:86 -msgid "Рішення прийняв" -msgstr "" +#: apps/hiring/models.py:143 +msgid "Решение принял" +msgstr "Рішення прийняв" -#: apps/hiring/models.py:88 -msgid "Дата рішення" -msgstr "" +#: apps/hiring/models.py:145 +msgid "Дата решения" +msgstr "Дата рішення" -#: apps/hiring/models.py:89 -msgid "Коментар до рішення" -msgstr "" +#: apps/hiring/models.py:146 +msgid "Комментарий к решению" +msgstr "Коментар до рішення" -#: apps/hiring/models.py:94 +#: apps/hiring/models.py:151 msgid "Заявка в IT" -msgstr "" +msgstr "Заявка в IT" -#: apps/hiring/models.py:99 -msgid "Задача на видачу доступів" -msgstr "" +#: apps/hiring/models.py:156 +msgid "Задача на выдачу доступов" +msgstr "Задача на видачу доступів" -#: apps/hiring/models.py:103 +#: apps/hiring/models.py:161 +msgid "Выданный пароль (PDF)" +msgstr "Виданий пароль (PDF)" + +#: apps/hiring/models.py:165 msgid "Заявка на найм" -msgstr "" +msgstr "Заявка на найм" -#: apps/hiring/models.py:104 +#: apps/hiring/models.py:166 msgid "Заявки на найм" +msgstr "Заявки на найм" + +#: apps/hiring/pdf.py:41 +msgid "Учётные данные сотрудника" +msgstr "Облікові дані співробітника" + +#: apps/hiring/pdf.py:54 templates/pages/employee-detail.html:70 +msgid "Рабочий email" +msgstr "Робочий email" + +#: apps/hiring/pdf.py:56 +msgid "Запрошенные доступы" +msgstr "Запитані доступи" + +#: apps/hiring/pdf.py:63 apps/hiring/services.py:112 +msgid "(не указано)" +msgstr "(не вказано)" + +#: apps/hiring/services.py:97 templates/pages/hr-portal.html:318 +#: templates/pages/hr-portal.html:686 +msgid "Кандидат" +msgstr "Кандидат" + +#: apps/hiring/services.py:100 +msgid "Локация / отдел" +msgstr "Локація / відділ" + +#: apps/hiring/services.py:101 templates/pages/hr-portal.html:626 +msgid "Дата выхода" +msgstr "Дата виходу" + +#: apps/hiring/services.py:102 templates/pages/employee-detail.html:49 +msgid "Испытательный срок" +msgstr "Випробувальний термін" + +#: apps/hiring/services.py:102 templates/pages/hr-portal.html:482 +#: templates/pages/hr-portal.html:491 templates/pages/hr-portal.html:500 +#: templates/pages/hr-portal.html:633 +msgid "дн." +msgstr "дн." + +#: apps/hiring/services.py:103 +msgid "логин" +msgstr "логін" + +#: apps/hiring/services.py:115 +msgid "Примечание" +msgstr "Примітка" + +#: apps/hiring/services.py:118 +msgid "" +"Подготовьте рабочее место и выдайте доступы к интерн-аккаунту на время " +"испытания. После утверждения HR будет создан персональный аккаунт." msgstr "" +"Підготуйте робоче місце та видайте доступи до інтерн-облікового запису на " +"час випробування. Після затвердження HR буде створено персональний акаунт." + +#: apps/hiring/services.py:129 +msgid "Подготовка рабочего места" +msgstr "Підготовка робочого місця" #: apps/it_manager/admin.py:34 apps/it_manager/admin.py:464 #: apps/it_manager/admin.py:567 apps/it_manager/admin.py:581 #: apps/locations/models/base.py:65 apps/visit_manager/admin.py:227 -#: templates/pages/hr-portal.html:218 templates/pages/it-manager.html:235 -#: templates/pages/it-manager.html:499 templates/pages/it-manager.html:559 -#: templates/pages/it-manager.html:880 +#: templates/pages/hr-portal.html:249 templates/pages/it-manager.html:235 +#: templates/pages/it-manager.html:502 templates/pages/it-manager.html:562 +#: templates/pages/it-manager.html:883 msgid "Локация" msgstr "Локація" #: apps/it_manager/admin.py:91 msgid "Экземпляров" -msgstr "" +msgstr "Примірників" #: apps/it_manager/admin.py:110 templates/pages/it-manager.html:13 -#: templates/pages/it-manager.html:514 templates/pages/pharmacy-detail.html:195 +#: templates/pages/it-manager.html:517 +#: templates/pages/pharmacy-detail.html:195 msgid "Оборудование" msgstr "Обладнання" #: apps/it_manager/admin.py:113 apps/it_manager/admin.py:206 msgid "Статус и размещение" -msgstr "" +msgstr "Статус і розміщення" #: apps/it_manager/admin.py:116 msgid "Гарантия и закупка" -msgstr "" +msgstr "Гарантія та закупівля" #: apps/it_manager/admin.py:119 apps/it_manager/admin.py:212 #: apps/it_manager/admin.py:427 apps/it_manager/admin.py:476 #: apps/locations/admin.py:319 apps/locations/models/pharmacies.py:335 -#: core/models/connector_config.py:44 templates/pages/it-manager.html:674 -#: templates/pages/it-manager.html:845 templates/pages/it-manager.html:950 +#: core/models/connector_config.py:44 templates/pages/it-manager.html:677 +#: templates/pages/it-manager.html:848 templates/pages/it-manager.html:953 #: templates/pages/pharmacy-detail.html:98 msgid "Примечания" msgstr "Примітки" @@ -770,7 +909,7 @@ msgstr "Примітки" #: apps/it_manager/admin.py:480 apps/it_manager/admin.py:617 #: apps/it_manager/admin.py:673 apps/locations/admin.py:109 msgid "Служебное" -msgstr "" +msgstr "Службове" #: apps/it_manager/admin.py:159 templates/pages/it-manager.html:163 msgid "Модель" @@ -782,18 +921,18 @@ msgstr "Гарантія" #: apps/it_manager/admin.py:203 msgid "Расходник" -msgstr "" +msgstr "Витратний матеріал" #: apps/it_manager/admin.py:209 msgid "Эксплуатация" -msgstr "" +msgstr "Експлуатація" -#: apps/it_manager/admin.py:254 templates/pages/it-manager.html:629 -#: templates/pages/it-manager.html:694 +#: apps/it_manager/admin.py:254 templates/pages/it-manager.html:632 +#: templates/pages/it-manager.html:697 msgid "Цвет" msgstr "Колір" -#: apps/it_manager/admin.py:295 templates/pages/it-manager.html:523 +#: apps/it_manager/admin.py:295 templates/pages/it-manager.html:526 msgid "Заявка" msgstr "Заявка" @@ -804,11 +943,11 @@ msgstr "Учасники" #: apps/it_manager/admin.py:301 apps/it_manager/admin.py:614 #: apps/it_manager/admin.py:670 msgid "Связи" -msgstr "" +msgstr "Зв’язки" #: apps/it_manager/admin.py:304 msgid "Описание и решение" -msgstr "" +msgstr "Опис і рішення" #: apps/it_manager/admin.py:343 templates/pages/it-manager.html:346 msgid "Приоритет" @@ -816,7 +955,7 @@ msgstr "Пріоритет" #: apps/it_manager/admin.py:359 msgid "SLA" -msgstr "" +msgstr "SLA" #: apps/it_manager/admin.py:366 templates/pages/task-detail.html:143 msgid "Взять в работу" @@ -824,66 +963,66 @@ msgstr "Взяти в роботу" #: apps/it_manager/admin.py:375 msgid "Отметить решёнными" -msgstr "" +msgstr "Позначити вирішеними" #: apps/it_manager/admin.py:383 templates/pages/chat.html:88 #: templates/pages/chat.html:112 templates/pages/it-manager.html:75 -#: templates/pages/it-manager.html:135 templates/pages/it-manager.html:480 -#: templates/pages/it-manager.html:614 templates/pages/it-manager.html:815 -#: templates/pages/it-manager.html:865 templates/pages/it-manager.html:1048 +#: templates/pages/it-manager.html:135 templates/pages/it-manager.html:483 +#: templates/pages/it-manager.html:617 templates/pages/it-manager.html:818 +#: templates/pages/it-manager.html:868 templates/pages/it-manager.html:1051 msgid "Закрыть" msgstr "Закрити" -#: apps/it_manager/admin.py:418 templates/pages/it-manager.html:871 +#: apps/it_manager/admin.py:418 templates/pages/it-manager.html:874 msgid "Провайдер" msgstr "Провайдер" #: apps/it_manager/admin.py:424 msgid "Техподдержка" -msgstr "" +msgstr "Техпідтримка" #: apps/it_manager/admin.py:440 msgid "Активных договоров" -msgstr "" +msgstr "Активних договорів" #: apps/it_manager/admin.py:460 msgid "Договор" -msgstr "" +msgstr "Договір" #: apps/it_manager/admin.py:467 msgid "Параметры" -msgstr "" +msgstr "Параметри" #: apps/it_manager/admin.py:470 msgid "Сроки" -msgstr "" +msgstr "Терміни" -#: apps/it_manager/admin.py:473 templates/pages/it-manager.html:944 +#: apps/it_manager/admin.py:473 templates/pages/it-manager.html:947 msgid "IP-адреса" msgstr "IP-адреси" #: apps/it_manager/admin.py:503 msgid "Услуга" -msgstr "" +msgstr "Послуга" #: apps/it_manager/admin.py:508 templates/pages/pharmacy-detail.html:245 msgid "Технология" msgstr "Технологія" -#: apps/it_manager/admin.py:520 templates/pages/it-manager.html:972 +#: apps/it_manager/admin.py:520 templates/pages/it-manager.html:975 msgid "Окончание" msgstr "Закінчення" #: apps/it_manager/admin.py:534 msgid "Статические IP" -msgstr "" +msgstr "Статичні IP" #: apps/it_manager/admin.py:608 templates/pages/employee-detail.html:96 msgid "Работа" msgstr "Робота" -#: apps/it_manager/admin.py:639 templates/pages/it-manager.html:490 -#: templates/pages/it-manager.html:557 +#: apps/it_manager/admin.py:639 templates/pages/it-manager.html:493 +#: templates/pages/it-manager.html:560 msgid "Вид работ" msgstr "Вид робіт" @@ -893,41 +1032,41 @@ msgstr "Обращення" #: apps/it_manager/admin.py:667 msgid "Содержание" -msgstr "" +msgstr "Зміст" -#: apps/it_manager/admin.py:690 templates/pages/it-manager.html:1063 -#: templates/pages/it-manager.html:1100 +#: apps/it_manager/admin.py:690 templates/pages/it-manager.html:1066 +#: templates/pages/it-manager.html:1103 msgid "Канал" msgstr "Канал" -#: apps/it_manager/admin.py:695 templates/pages/it-manager.html:1103 +#: apps/it_manager/admin.py:695 templates/pages/it-manager.html:1106 msgid "Длительность" msgstr "Тривалість" #: apps/locations/admin.py:40 apps/locations/models/base.py:54 msgid "Координаты на карте" -msgstr "" +msgstr "Координати на карті" #: apps/locations/admin.py:45 msgid "Без координат" -msgstr "" +msgstr "Без координат" #: apps/locations/admin.py:46 msgid "С координатами" -msgstr "" +msgstr "З координатами" #: apps/locations/admin.py:64 apps/locations/admin.py:203 #: apps/locations/models/base.py:36 msgid "Город/район" -msgstr "" +msgstr "Місто/район" #: apps/locations/admin.py:65 apps/locations/admin.py:205 msgid "— Выберите регион —" -msgstr "" +msgstr "— Оберіть регіон —" #: apps/locations/admin.py:96 msgid "Тип и регион" -msgstr "" +msgstr "Тип і регіон" #: apps/locations/admin.py:99 apps/locations/admin.py:282 #: templates/pages/employees.html:106 templates/pages/pharmacies.html:114 @@ -942,86 +1081,140 @@ msgstr "Координати" #: apps/locations/admin.py:105 msgid "Прочее" -msgstr "" +msgstr "Інше" #: apps/locations/admin.py:132 msgid "Регион / Адрес" -msgstr "" +msgstr "Регіон / Адреса" #: apps/locations/admin.py:148 msgid "Статус лицензии" -msgstr "" +msgstr "Статус ліцензії" #: apps/locations/admin.py:153 msgid "Действительна" -msgstr "" +msgstr "Дійсна" #: apps/locations/admin.py:154 msgid "Истекает (30 дн.)" -msgstr "" +msgstr "Спливає (30 дн.)" #: apps/locations/admin.py:155 msgid "Истекла" -msgstr "" +msgstr "Спливла" #: apps/locations/admin.py:156 msgid "Нет лицензии" -msgstr "" +msgstr "Немає ліцензії" -#: apps/locations/admin.py:274 core/admin.py:253 +#: apps/locations/admin.py:274 core/admin.py:265 msgid "Основное" -msgstr "" +msgstr "Основне" #: apps/locations/admin.py:295 apps/locations/admin.py:366 +#: apps/locations/dislocation_columns.py:47 #: templates/pages/pharmacy-detail.html:54 msgid "Лицензия" msgstr "Ліцензія" -#: apps/locations/admin.py:302 templates/pages/dislocation.html:119 -#: templates/pages/dislocation.html:557 templates/pages/pharmacy-detail.html:44 +#: apps/locations/admin.py:302 apps/locations/dislocation_columns.py:39 +#: templates/pages/dislocation.html:570 +#: templates/pages/pharmacy-detail.html:44 msgid "Персонал" -msgstr "" +msgstr "Персонал" #: apps/locations/admin.py:309 apps/locations/models/pharmacies.py:156 msgid "Почта Carbonio" -msgstr "" +msgstr "Пошта Carbonio" #: apps/locations/admin.py:316 -msgid "Пусто в «Почта Carbonio» — адрес сгенерируется как <префикс организации><номер>@домен. Можно задать вручную." +msgid "" +"Пусто в «Почта Carbonio» — адрес сгенерируется как <префикс " +"организации><номер>@домен. Можно задать вручную." msgstr "" +"Порожньо в «Пошта Carbonio» — адреса згенерується як <префікс " +"організації><номер>@домен. Можна задати вручну." -#: apps/locations/admin.py:376 core/views.py:352 +#: apps/locations/admin.py:376 core/views.py:396 #: templates/pages/structure.html:61 msgid "Сотрудников" -msgstr "" +msgstr "Співробітників" #: apps/locations/admin.py:390 msgid "Телефоны" -msgstr "" +msgstr "Телефони" + +#: apps/locations/dislocation_columns.py:38 +#: templates/pages/pharmacies.html:115 templates/pages/pharmacy-detail.html:49 +#: templates/pages/pharmacy-detail.html:141 +msgid "Заведующий" +msgstr "Завідуючий" + +#: apps/locations/dislocation_columns.py:40 +#: apps/locations/models/pharmacies.py:143 +#: apps/locations/models/pharmacies.py:330 templates/pages/it-manager.html:828 +#: templates/pages/pharmacies.html:117 templates/pages/profile.html:80 +msgid "Телефон" +msgstr "Телефон" + +#: apps/locations/dislocation_columns.py:41 +#: templates/pages/pharmacies.html:118 templates/pages/pharmacy-detail.html:59 +msgid "Режим работы" +msgstr "Режим роботи" + +#: apps/locations/dislocation_columns.py:43 +#: templates/pages/pharmacy-detail.html:88 +msgid "Регион" +msgstr "Регіон" + +#: apps/locations/dislocation_columns.py:44 +#: templates/pages/pharmacy-detail.html:90 +msgid "Категория" +msgstr "Категорія" + +#: apps/locations/dislocation_columns.py:45 +#: apps/locations/models/pharmacies.py:119 +#: templates/pages/pharmacy-detail.html:93 +msgid "Куратор" +msgstr "Куратор" + +#: apps/locations/dislocation_columns.py:46 +#: apps/locations/models/pharmacies.py:113 +#: templates/pages/pharmacy-detail.html:92 +msgid "Ответственный менеджер" +msgstr "Відповідальний менеджер" + +#: apps/locations/dislocation_columns.py:48 +msgid "ПДВ" +msgstr "ПДВ" + +#: apps/locations/dislocation_columns.py:49 +msgid "Email" +msgstr "Email" #: apps/locations/models/base.py:39 msgid "Улица" -msgstr "" +msgstr "Вулиця" #: apps/locations/models/base.py:40 msgid "Номер дома" -msgstr "" +msgstr "Номер будинку" #: apps/locations/models/base.py:41 msgid "Корпус/строение" -msgstr "" +msgstr "Корпус/будівля" #: apps/locations/models/base.py:42 msgid "Помещение/офис" -msgstr "" +msgstr "Приміщення/офіс" #: apps/locations/models/base.py:43 msgid "Полный адрес" -msgstr "" +msgstr "Повна адреса" #: apps/locations/models/base.py:44 msgid "Заполняется автоматически из полей выше" -msgstr "" +msgstr "Заповнюється автоматично з полів вище" #: apps/locations/models/base.py:60 templates/pages/news-detail.html:78 #: templates/pages/task-detail.html:98 @@ -1030,11 +1223,57 @@ msgstr "Коментарі" #: apps/locations/models/base.py:66 msgid "Локации" +msgstr "Локації" + +#: apps/locations/models/dislocation.py:14 core/admin.py:195 +#: core/models/directories.py:119 osc/core/models/directory.py:9 +#: osc/core/models/directory.py:26 templates/pages/it-manager.html:824 +msgid "Название" +msgstr "Назва" + +#: apps/locations/models/dislocation.py:15 core/models/directories.py:118 +#: osc/core/models/directory.py:8 osc/core/models/directory.py:25 +msgid "Код" +msgstr "Код" + +#: apps/locations/models/dislocation.py:17 templates/pages/dislocation.html:98 +msgid "Колонки" +msgstr "Колонки" + +#: apps/locations/models/dislocation.py:19 +msgid "Упорядоченный список ключей из реестра колонок дислокации." +msgstr "Упорядкований список ключів з реєстру колонок дислокації." + +#: apps/locations/models/dislocation.py:27 +msgid "Если задан — пресет по умолчанию для сотрудников этого отдела." msgstr "" +"Якщо задано — пресет за замовчуванням для співробітників цього відділу." + +#: apps/locations/models/dislocation.py:30 +#| msgid "Примечания" +msgid "По умолчанию" +msgstr "За замовчуванням" + +#: apps/locations/models/dislocation.py:31 +msgid "" +"Используется, если у сотрудника нет своего набора и нет пресета отдела." +msgstr "" +"Використовується, якщо у співробітника немає власного набору і немає пресету" +" відділу." + +#: apps/locations/models/dislocation.py:35 +#| msgid "персонал по каждой локации" +msgid "Пресет колонок дислокации" +msgstr "Пресет колонок дислокації" + +#: apps/locations/models/dislocation.py:36 +#| msgid "персонал по каждой локации" +msgid "Пресеты колонок дислокации" +msgstr "Пресети колонок дислокації" #: apps/locations/models/pharmacies.py:60 msgid "Открыта" -msgstr "" +msgstr "Відкрита" #: apps/locations/models/pharmacies.py:61 templates/pages/it-manager.html:377 msgid "Закрыта" @@ -1042,140 +1281,127 @@ msgstr "Закрита" #: apps/locations/models/pharmacies.py:62 msgid "Остановлена" -msgstr "" +msgstr "Зупинена" #: apps/locations/models/pharmacies.py:63 msgid "Подготовка к запуску" -msgstr "" +msgstr "Підготовка до запуску" #: apps/locations/models/pharmacies.py:74 msgid "ПДВ 20%" -msgstr "" +msgstr "ПДВ 20%" #: apps/locations/models/pharmacies.py:75 msgid "ПДВ 7% (пільгова)" -msgstr "" +msgstr "ПДВ 7% (пільгова)" #: apps/locations/models/pharmacies.py:76 msgid "ПДВ 0%" -msgstr "" +msgstr "ПДВ 0%" #: apps/locations/models/pharmacies.py:77 msgid "Без ПДВ" -msgstr "" +msgstr "Без ПДВ" #: apps/locations/models/pharmacies.py:82 msgid "Номер аптеки" -msgstr "" +msgstr "Номер аптеки" #: apps/locations/models/pharmacies.py:83 msgid "Числовой номер аптеки внутри организации" -msgstr "" +msgstr "Числовий номер аптеки всередині організації" #: apps/locations/models/pharmacies.py:99 msgid "Номер лицензии" -msgstr "" +msgstr "Номер ліцензії" #: apps/locations/models/pharmacies.py:101 msgid "Дата окончания лицензии" -msgstr "" +msgstr "Дата закінчення ліцензії" #: apps/locations/models/pharmacies.py:108 msgid "Заведующий аптекой" -msgstr "" - -#: apps/locations/models/pharmacies.py:113 -#: templates/pages/pharmacy-detail.html:92 -msgid "Ответственный менеджер" -msgstr "Відповідальний менеджер" +msgstr "Завідувач аптеки" #: apps/locations/models/pharmacies.py:114 msgid "Сотрудник с должностью «Відповідальний менеджер»" -msgstr "" - -#: apps/locations/models/pharmacies.py:119 -#: templates/pages/pharmacy-detail.html:93 -msgid "Куратор" -msgstr "Куратор" +msgstr "Співробітник з посадою «Відповідальний менеджер»" #: apps/locations/models/pharmacies.py:120 msgid "Сотрудник с должностью «куратор»" -msgstr "" +msgstr "Співробітник з посадою «куратор»" #: apps/locations/models/pharmacies.py:123 msgid "Количество фармацевтов" -msgstr "" +msgstr "Кількість фармацевтів" #: apps/locations/models/pharmacies.py:124 msgid "Кешированное поле — обновляется при синхронизации" -msgstr "" +msgstr "Кешоване поле — оновлюється при синхронізації" #: apps/locations/models/pharmacies.py:133 msgid "Категория аптеки" -msgstr "" +msgstr "Категорія аптеки" #: apps/locations/models/pharmacies.py:135 msgid "Классификация аптеки: A/B/C/D" -msgstr "" +msgstr "Класифікація аптеки: A/B/C/D" #: apps/locations/models/pharmacies.py:139 msgid "Категорія ПДВ" -msgstr "" - -#: apps/locations/models/pharmacies.py:143 -#: apps/locations/models/pharmacies.py:330 templates/pages/dislocation.html:120 -#: templates/pages/it-manager.html:825 templates/pages/pharmacies.html:117 -#: templates/pages/profile.html:80 -msgid "Телефон" -msgstr "Телефон" +msgstr "Категорія ПДВ" #: apps/locations/models/pharmacies.py:145 msgid "Часы работы" -msgstr "" +msgstr "Години роботи" #: apps/locations/models/pharmacies.py:150 msgid "Ожидает синхронизации" -msgstr "" +msgstr "Очікує синхронізації" #: apps/locations/models/pharmacies.py:153 msgid "Отключён" -msgstr "" +msgstr "Вимкнено" #: apps/locations/models/pharmacies.py:157 -msgid "Ящик аптеки. Пусто — сгенерируется как <префикс организации><номер>@домен. Можно задать вручную." +msgid "" +"Ящик аптеки. Пусто — сгенерируется как <префикс организации><номер>@домен. " +"Можно задать вручную." msgstr "" +"Скринька аптеки. Порожньо — згенерується як <префікс " +"організації><номер>@домен. Можна задати вручну." #: apps/locations/models/pharmacies.py:160 msgid "ID аккаунта Carbonio" -msgstr "" +msgstr "ID облікового запису Carbonio" #: apps/locations/models/pharmacies.py:164 msgid "Пароль ящика Carbonio" -msgstr "" +msgstr "Пароль скриньки Carbonio" #: apps/locations/models/pharmacies.py:165 msgid "Хранится зашифрованным; показывается для входа заведующему." -msgstr "" +msgstr "Зберігається зашифрованим; показується для входу завідувачу." #: apps/locations/models/pharmacies.py:168 msgid "Синхронизация с Carbonio" -msgstr "" +msgstr "Синхронізація з Carbonio" #: apps/locations/models/pharmacies.py:169 msgid "Управлять почтовым ящиком аптеки в Carbonio" -msgstr "" +msgstr "Керувати поштовою скринькою аптеки в Carbonio" #: apps/locations/models/pharmacies.py:172 msgid "Статус синхронизации Carbonio" -msgstr "" +msgstr "Статус синхронізації Carbonio" #: apps/locations/models/pharmacies.py:176 msgid "Последняя синхронизация Carbonio" -msgstr "" +msgstr "Остання синхронізація Carbonio" #: apps/locations/models/pharmacies.py:177 msgid "Ошибка синхронизации Carbonio" -msgstr "" +msgstr "Помилка синхронізації Carbonio" #: apps/locations/models/pharmacies.py:211 templates/index.html:382 #: templates/pages/dislocation.html:16 templates/pages/pharmacies.html:6 @@ -1185,7 +1411,7 @@ msgstr "Аптеки" #: apps/locations/models/pharmacies.py:259 msgid "Такой адрес Carbonio уже используется другой аптекой" -msgstr "" +msgstr "Така адреса Carbonio вже використовується іншою аптекою" #: apps/locations/models/pharmacies.py:325 #: templates/pages/pharmacy-detail.html:113 @@ -1194,95 +1420,95 @@ msgstr "Основний" #: apps/locations/models/pharmacies.py:326 msgid "Мобильный" -msgstr "" +msgstr "Мобільний" #: apps/locations/models/pharmacies.py:333 msgid "Основной номер" -msgstr "" +msgstr "Основний номер" #: apps/locations/models/pharmacies.py:338 msgid "Телефон аптеки" -msgstr "" +msgstr "Телефон аптеки" #: apps/locations/models/pharmacies.py:339 msgid "Телефоны аптек" -msgstr "" +msgstr "Телефони аптек" #: apps/locations/models/pharmacies.py:350 msgid "Изменённое поле" -msgstr "" +msgstr "Змінене поле" #: apps/locations/models/pharmacies.py:351 msgid "Старое значение" -msgstr "" +msgstr "Старе значення" #: apps/locations/models/pharmacies.py:352 msgid "Новое значение" -msgstr "" +msgstr "Нове значення" #: apps/locations/models/pharmacies.py:353 msgid "Дата изменения" -msgstr "" +msgstr "Дата зміни" #: apps/locations/models/pharmacies.py:356 msgid "Кто изменил" -msgstr "" +msgstr "Хто змінив" #: apps/locations/models/pharmacies.py:361 msgid "История изменений аптеки" -msgstr "" +msgstr "Історія змін аптеки" #: apps/locations/models/pharmacies.py:362 msgid "История изменений аптек" -msgstr "" +msgstr "Історія змін аптек" #: apps/mail_connector/models.py:30 msgid "Запись синхронизации Carbonio" -msgstr "" +msgstr "Запис синхронізації Carbonio" #: apps/mail_connector/models.py:31 msgid "Журнал синхронизации Carbonio" -msgstr "" +msgstr "Журнал синхронізації Carbonio" #: apps/nextcloud_connector/models.py:20 msgid "Создание папки" -msgstr "" +msgstr "Створення папки" #: apps/nextcloud_connector/models.py:21 msgid "Обновление ACL" -msgstr "" +msgstr "Оновлення ACL" #: apps/nextcloud_connector/models.py:22 msgid "Пропуск" -msgstr "" +msgstr "Пропуск" #: apps/nextcloud_connector/models.py:27 msgid "Пробный прогон" -msgstr "" +msgstr "Пробний прогін" #: apps/nextcloud_connector/models.py:30 msgid "Тип сущности" -msgstr "" +msgstr "Тип сутності" #: apps/nextcloud_connector/models.py:31 msgid "Ссылка на сущность" -msgstr "" +msgstr "Посилання на сутність" #: apps/nextcloud_connector/models.py:32 msgid "Код отдела или номер аптеки" -msgstr "" +msgstr "Код відділу або номер аптеки" #: apps/nextcloud_connector/models.py:33 msgid "Путь Group Folder" -msgstr "" +msgstr "Шлях Group Folder" #: apps/nextcloud_connector/models.py:39 msgid "Запись синхронизации Nextcloud" -msgstr "" +msgstr "Запис синхронізації Nextcloud" #: apps/nextcloud_connector/models.py:40 msgid "Журнал синхронизации Nextcloud" -msgstr "" +msgstr "Журнал синхронізації Nextcloud" #: apps/tasks/models.py:25 templates/pages/it-manager.html:322 #: templates/pages/it-manager.html:374 templates/pages/task-detail.html:21 @@ -1315,79 +1541,79 @@ msgstr "Високий" #: apps/tasks/models.py:38 msgid "Назва" -msgstr "" +msgstr "Назва" #: apps/tasks/models.py:39 apps/visit_manager/admin.py:88 msgid "Опис" -msgstr "" +msgstr "Опис" #: apps/tasks/models.py:42 msgid "Пріоритет" -msgstr "" +msgstr "Пріоритет" #: apps/tasks/models.py:44 msgid "Термін" -msgstr "" +msgstr "Термін" #: apps/tasks/models.py:48 msgid "Постановник" -msgstr "" +msgstr "Постановник" #: apps/tasks/models.py:55 msgid "Відповідальний" -msgstr "" +msgstr "Відповідальний" #: apps/tasks/models.py:59 msgid "Співвиконавці" -msgstr "" +msgstr "Співвиконавці" #: apps/tasks/models.py:63 msgid "Спостерігачі" -msgstr "" +msgstr "Спостерігачі" #: apps/tasks/models.py:66 apps/tasks/models.py:148 msgid "Виконано в" -msgstr "" +msgstr "Виконано в" #: apps/tasks/models.py:71 apps/tasks/models.py:122 apps/tasks/models.py:144 msgid "Задача" -msgstr "" +msgstr "Задача" #: apps/tasks/models.py:72 msgid "Задачі" -msgstr "" +msgstr "Задачі" #: apps/tasks/models.py:131 msgid "Коментар до задачі" -msgstr "" +msgstr "Коментар до задачі" #: apps/tasks/models.py:132 msgid "Коментарі до задач" -msgstr "" +msgstr "Коментарі до задач" #: apps/tasks/models.py:146 msgid "Пункт" -msgstr "" +msgstr "Пункт" #: apps/tasks/models.py:147 msgid "Виконано" -msgstr "" +msgstr "Виконано" #: apps/tasks/models.py:151 msgid "Відмітив" -msgstr "" +msgstr "Відмітив" #: apps/tasks/models.py:153 osc/core/models/directory.py:33 msgid "Порядок" -msgstr "" +msgstr "Порядок" #: apps/tasks/models.py:156 msgid "Пункт чек-листа" -msgstr "" +msgstr "Пункт чек-листа" #: apps/tasks/models.py:157 msgid "Чек-лист задачі" -msgstr "" +msgstr "Чек-лист задачі" #: apps/tasks/views.py:211 templates/pages/task-edit.html:2 #: templates/pages/tasks.html:35 @@ -1406,140 +1632,144 @@ msgstr "Спостерігачі" #: apps/utilities/models.py:22 msgid "Тип лічильника" -msgstr "" +msgstr "Тип лічильника" #: apps/utilities/models.py:24 msgid "Дата зняття показань" -msgstr "" +msgstr "Дата зняття показань" #: apps/utilities/models.py:25 msgid "Показання" -msgstr "" +msgstr "Показання" #: apps/utilities/models.py:31 msgid "Хто подав" -msgstr "" +msgstr "Хто подав" #: apps/utilities/models.py:35 msgid "Показання лічильника" -msgstr "" +msgstr "Показання лічильника" #: apps/utilities/models.py:36 msgid "Показання лічильників" -msgstr "" +msgstr "Показання лічильників" #: apps/utilities/models.py:54 msgid "Показання не може бути від'ємним" -msgstr "" +msgstr "Показання не може бути від'ємним" #: apps/utilities/models.py:56 msgid "Дата не може бути в майбутньому" -msgstr "" +msgstr "Дата не може бути в майбутньому" #: apps/visit_manager/admin.py:30 apps/visit_manager/admin.py:78 #: apps/visit_manager/admin.py:137 apps/visit_manager/admin.py:259 #: apps/visit_manager/admin.py:311 msgid "Основне" -msgstr "" +msgstr "Основне" #: apps/visit_manager/admin.py:33 msgid "Час роботи" -msgstr "" +msgstr "Час роботи" #: apps/visit_manager/admin.py:37 msgid "Обідня перерва" -msgstr "" +msgstr "Обідня перерва" #: apps/visit_manager/admin.py:40 msgid "Понаднормові" -msgstr "" +msgstr "Понаднормові" #: apps/visit_manager/admin.py:53 apps/visit_manager/admin.py:140 #: apps/visit_manager/admin.py:314 msgid "Час" -msgstr "" +msgstr "Час" #: apps/visit_manager/admin.py:61 msgid "Колір" -msgstr "" +msgstr "Колір" #: apps/visit_manager/admin.py:81 msgid "Характеристики" -msgstr "" +msgstr "Характеристики" #: apps/visit_manager/admin.py:85 msgid "Відображення" -msgstr "" +msgstr "Відображення" #: apps/visit_manager/admin.py:92 msgid "Регіони" -msgstr "" +msgstr "Регіони" #: apps/visit_manager/admin.py:107 msgid "Залишилось" -msgstr "" +msgstr "Залишилось" #: apps/visit_manager/admin.py:144 msgid "Тиждень" -msgstr "" +msgstr "Тиждень" #: apps/visit_manager/admin.py:147 msgid "Цикл (для змінного графіку)" -msgstr "" +msgstr "Цикл (для змінного графіку)" #: apps/visit_manager/admin.py:151 msgid "Налаштування" -msgstr "" +msgstr "Налаштування" #: apps/visit_manager/admin.py:154 msgid "Придатність" -msgstr "" +msgstr "Придатність" #: apps/visit_manager/admin.py:178 msgid "Співробітник та шаблон" -msgstr "" +msgstr "Співробітник та шаблон" #: apps/visit_manager/admin.py:181 msgid "Період" -msgstr "" +msgstr "Період" #: apps/visit_manager/admin.py:184 msgid "Налаштування відміток" -msgstr "" +msgstr "Налаштування відміток" #: apps/visit_manager/admin.py:188 msgid "Примітки" -msgstr "" +msgstr "Примітки" #: apps/visit_manager/admin.py:265 msgid "Технічне" -msgstr "" +msgstr "Технічне" #: apps/visit_manager/admin.py:277 msgid "Направлення" -msgstr "" +msgstr "Направлення" + +#: apps/visit_manager/admin.py:284 +msgid "Тип локації" +msgstr "Тип локації" #: apps/visit_manager/admin.py:296 msgid "Превью" -msgstr "" +msgstr "Прев’ю" #: apps/visit_manager/admin.py:317 msgid "Записи" -msgstr "" +msgstr "Записи" #: apps/visit_manager/admin.py:329 msgid "Відпрацьовано" -msgstr "" +msgstr "Відпрацьовано" #: apps/visit_manager/admin.py:334 msgid "Повна зміна" -msgstr "" +msgstr "Повна зміна" #: core/admin.py:20 core/models/directories.py:54 #: core/models/directories.py:116 osc/core/models/directory.py:12 #: osc/core/models/directory.py:23 msgid "Тип справочника" -msgstr "" +msgstr "Тип довідника" #: core/admin.py:37 templates/pages/dislocation.html:90 #: templates/pages/pharmacies.html:86 @@ -1553,19 +1783,19 @@ msgstr "Неактивні" #: core/admin.py:49 msgid "Наличие потомков" -msgstr "" +msgstr "Наявність нащадків" #: core/admin.py:54 msgid "Есть потомки" -msgstr "" +msgstr "Є нащадки" #: core/admin.py:55 msgid "Нет потомков" -msgstr "" +msgstr "Немає нащадків" #: core/admin.py:86 msgid "Записей" -msgstr "" +msgstr "Записів" #: core/admin.py:144 templates/pages/pharmacy-detail.html:67 #: templates/pages/pharmacy-detail.html:85 @@ -1574,313 +1804,329 @@ msgstr "Основна інформація" #: core/admin.py:147 msgid "Должности и локации" -msgstr "" +msgstr "Посади та локації" #: core/admin.py:153 core/models/directories.py:123 msgid "Дополнительные данные" -msgstr "" +msgstr "Додаткові дані" #: core/admin.py:167 msgid "🔄 Перестроить дерево" -msgstr "" +msgstr "🔄 Перебудувати дерево" #: core/admin.py:172 msgid "Дерево справочников перестроено" -msgstr "" +msgstr "Дерево довідників перебудовано" -#: core/admin.py:195 core/models/directories.py:119 -#: osc/core/models/directory.py:9 osc/core/models/directory.py:26 -#: templates/pages/it-manager.html:821 -msgid "Название" -msgstr "Назва" - -#: core/admin.py:212 core/models/directories.py:131 +#: core/admin.py:212 core/admin.py:256 core/models/directories.py:131 msgid "Типы локаций" -msgstr "" +msgstr "Типи локацій" -#: core/admin.py:256 +#: core/admin.py:268 msgid "Подключение" -msgstr "" +msgstr "Підключення" -#: core/admin.py:261 +#: core/admin.py:273 msgid "Укажите токен или логин/пароль — в зависимости от типа коннектора" -msgstr "" +msgstr "Вкажіть токен або логін/пароль — залежно від типу конектора" -#: core/admin.py:263 core/models/connector_config.py:40 +#: core/admin.py:275 core/models/connector_config.py:40 msgid "Дополнительные параметры" -msgstr "" +msgstr "Додаткові параметри" -#: core/admin.py:274 +#: core/admin.py:286 msgid "Коннектор" -msgstr "" +msgstr "Конектор" #: core/models/connector_config.py:18 msgid "Тип коннектора" -msgstr "" +msgstr "Тип конектора" #: core/models/connector_config.py:21 msgid "Включён" -msgstr "" +msgstr "Увімкнено" #: core/models/connector_config.py:24 msgid "Хост" -msgstr "" +msgstr "Хост" #: core/models/connector_config.py:25 msgid "Порт" -msgstr "" +msgstr "Порт" #: core/models/connector_config.py:26 msgid "Использовать SSL" -msgstr "" - -#: core/models/connector_config.py:32 -msgid "Пароль" -msgstr "" +msgstr "Використовувати SSL" #: core/models/connector_config.py:34 msgid "Токен / API Key" -msgstr "" +msgstr "Токен / API Key" #: core/models/connector_config.py:35 msgid "Bearer-токен или API-ключ, если используется вместо логина/пароля" msgstr "" +"Bearer-токен або API-ключ, якщо використовується замість логіна/пароля" #: core/models/connector_config.py:41 msgid "Специфичные для коннектора настройки в формате JSON" -msgstr "" +msgstr "Специфічні для конектора налаштування у форматі JSON" #: core/models/connector_config.py:47 msgid "Настройки коннектора" -msgstr "" +msgstr "Налаштування конектора" #: core/models/connector_config.py:48 osc/core/models/connector_settings.py:33 #: osc/core/models/connector_settings.py:34 #: osc/core/models/connector_settings.py:37 msgid "Настройки коннекторов" -msgstr "" +msgstr "Налаштування конекторів" #: core/models/connector_config.py:58 msgid "Хост обязателен для включённого коннектора" -msgstr "" +msgstr "Хост обов’язковий для увімкненого конектора" #: core/models/connector_config.py:61 msgid "Для включённого коннектора необходимо указать токен или логин" -msgstr "" +msgstr "Для увімкненого конектора необхідно вказати токен або логін" #: core/models/directories.py:26 msgid "Код типа" -msgstr "" +msgstr "Код типу" #: core/models/directories.py:27 msgid "Название типа" -msgstr "" +msgstr "Назва типу" #: core/models/directories.py:33 msgid "Строгая иерархия (одинаковые типы)" -msgstr "" +msgstr "Сувора ієрархія (однакові типи)" #: core/models/directories.py:34 msgid "Гибкая иерархия (разные типы)" -msgstr "" +msgstr "Гнучка ієрархія (різні типи)" #: core/models/directories.py:35 msgid "Кастомная иерархия" -msgstr "" +msgstr "Кастомна ієрархія" #: core/models/directories.py:42 msgid "Тип иерархии" -msgstr "" +msgstr "Тип ієрархії" #: core/models/directories.py:49 msgid "Разрешенные типы потомков" -msgstr "" +msgstr "Дозволені типи нащадків" #: core/models/directories.py:50 msgid "Только для гибкой и кастомной иерархии" -msgstr "" +msgstr "Тільки для гнучкої та кастомної ієрархії" #: core/models/directories.py:55 osc/core/models/directory.py:13 msgid "Типы справочников" -msgstr "" +msgstr "Типи довідників" -#: core/models/directories.py:118 osc/core/models/directory.py:8 -#: osc/core/models/directory.py:25 -msgid "Код" +#: core/models/directories.py:133 +msgid "" +"Пусто = доступно для всех типов локаций. Для должностей и систем доступа: в " +"каких локациях показывать (офис / аптека / склад)." msgstr "" +"Порожньо = доступно для всіх типів локацій. Для посад і систем доступу: в " +"яких локаціях показувати (офіс / аптека / склад)." -#: core/models/directories.py:132 -msgid "Для должностей: в каких типах локаций доступна эта должность" -msgstr "" - -#: core/models/directories.py:154 +#: core/models/directories.py:158 msgid "Элемент справочника" -msgstr "" +msgstr "Елемент довідника" -#: core/models/directories.py:155 +#: core/models/directories.py:159 msgid "Элементы справочников" -msgstr "" +msgstr "Елементи довідників" #: core/utils/validators.py:13 msgid "Номер телефона должен быть в формате +380XXXXXXXXX" -msgstr "" +msgstr "Номер телефону має бути у форматі +380XXXXXXXXX" #: core/utils/validators.py:22 msgid "Номер телефона должен быть в международном формате +XXXXXXXXXXX" -msgstr "" +msgstr "Номер телефону має бути в міжнародному форматі +XXXXXXXXXXX" #: core/utils/validators.py:27 msgid "Номер телефона должен содержать от 8 до 16 цифр включая код страны" -msgstr "" +msgstr "Номер телефону має містити від 8 до 16 цифр разом із кодом країни" #: core/utils/validators.py:46 msgid "Дата рождения не может быть в будущем" -msgstr "" +msgstr "Дата народження не може бути в майбутньому" #: core/utils/validators.py:54 msgid "Возраст должен быть не менее 18 лет" -msgstr "" +msgstr "Вік має бути не менше 18 років" #: core/utils/validators.py:83 msgid "Использование временных email запрещено" -msgstr "" +msgstr "Використання тимчасових email заборонено" #: core/utils/validators.py:94 #, python-format msgid "Недопустимый IP адрес: %(value)s" -msgstr "" +msgstr "Неприпустима IP адреса: %(value)s" #: core/utils/validators.py:103 -msgid "MAC адрес должен быть в формате XX:XX:XX:XX:XX:XX или XX-XX-XX-XX-XX-XX" -msgstr "" +msgid "" +"MAC адрес должен быть в формате XX:XX:XX:XX:XX:XX или XX-XX-XX-XX-XX-XX" +msgstr "MAC адреса має бути у форматі XX:XX:XX:XX:XX:XX або XX-XX-XX-XX-XX-XX" #: core/utils/validators.py:114 msgid "Украинский налоговый номер должен содержать 10 цифр" -msgstr "" +msgstr "Український податковий номер має містити 10 цифр" #: core/utils/validators.py:132 msgid "Неверная контрольная сумма украинского налогового номера" -msgstr "" +msgstr "Невірна контрольна сума українського податкового номера" #: core/utils/validators.py:142 msgid "ИНН должен содержать 10 или 12 цифр" -msgstr "" +msgstr "ІПН має містити 10 або 12 цифр" #: core/utils/validators.py:159 core/utils/validators.py:176 msgid "Неверная контрольная сумма ИНН" -msgstr "" +msgstr "Невірна контрольна сума ІПН" -#: core/views.py:351 +#: core/views.py:395 msgid "Рабочих дней" msgstr "Робочих днів" -#: core/views.py:351 +#: core/views.py:395 msgid "с отметками" msgstr "з відмітками" -#: core/views.py:352 +#: core/views.py:396 msgid "отмечались" msgstr "відмічались" -#: core/views.py:353 +#: core/views.py:397 msgid "Смен" msgstr "Змін" -#: core/views.py:353 +#: core/views.py:397 msgid "за месяц" msgstr "за місяць" -#: core/views.py:354 templates/pages/timetrack.html:62 +#: core/views.py:398 templates/pages/timetrack.html:62 msgid "Незакрытых" msgstr "Незакритих" -#: core/views.py:354 +#: core/views.py:398 msgid "без ухода" msgstr "без виходу" -#: core/views.py:355 +#: core/views.py:399 msgid "Всего часов" msgstr "Всього годин" -#: core/views.py:355 +#: core/views.py:399 msgid "отработано" msgstr "відпрацьовано" #: osc/apps/employees/models.py:34 msgid "Логин для входа, совпадает с учётной записью AD" -msgstr "" +msgstr "Логін для входу, збігається з обліковим записом AD" #: osc/apps/employees/models.py:105 msgid "Логин AD (sAMAccountName)" -msgstr "" +msgstr "Логін AD (sAMAccountName)" #: osc/apps/employees/models.py:107 msgid "AD User DN" -msgstr "" +msgstr "AD User DN" #: osc/apps/employees/models.py:151 msgid "Не может быть раньше даты приёма" -msgstr "" +msgstr "Не може бути раніше дати прийому" #: osc/apps/employees/models.py:153 msgid "Сотрудник не может быть своим руководителем" -msgstr "" +msgstr "Співробітник не може бути своїм керівником" #: osc/apps/mattermost_connector/models.py:23 msgid "Запись синхронизации Mattermost" -msgstr "" +msgstr "Запис синхронізації Mattermost" #: osc/apps/mattermost_connector/models.py:24 msgid "Журнал синхронизации Mattermost" -msgstr "" +msgstr "Журнал синхронізації Mattermost" #: osc/core/models/connector_settings.py:10 msgid "Требовать смену пароля при создании" -msgstr "" +msgstr "Вимагати зміну пароля при створенні" #: osc/core/models/connector_settings.py:12 -msgid "Новым учётным записям в AD выставляется флаг «сменить пароль при следующем входе». Если выключено — выданный пароль действует сразу, без принудительной смены." +msgid "" +"Новым учётным записям в AD выставляется флаг «сменить пароль при следующем " +"входе». Если выключено — выданный пароль действует сразу, без принудительной" +" смены." msgstr "" +"Новим обліковим записам в AD виставляється прапорець «змінити пароль при " +"наступному вході». Якщо вимкнено — виданий пароль діє одразу, без примусової" +" зміни." #: osc/core/models/connector_settings.py:17 msgid "Пароль не имеет срока действия" -msgstr "" +msgstr "Пароль не має терміну дії" #: osc/core/models/connector_settings.py:19 -msgid "Новым и существующим учётным записям в AD выставляется флаг «Password never expires». Если выключено — действует обычная парольная политика домена." +msgid "" +"Новым и существующим учётным записям в AD выставляется флаг «Password never " +"expires». Если выключено — действует обычная парольная политика домена." msgstr "" +"Новим і наявним обліковим записам в AD виставляється прапорець «Password " +"never expires». Якщо вимкнено — діє звичайна парольна політика домену." #: osc/core/models/connector_settings.py:24 msgid "Команда Mattermost по умолчанию" -msgstr "" +msgstr "Команда Mattermost за замовчуванням" #: osc/core/models/connector_settings.py:25 -msgid "Сотрудники автоматически добавляются в эту команду при синхронизации с Mattermost." +msgid "" +"Сотрудники автоматически добавляются в эту команду при синхронизации с " +"Mattermost." msgstr "" +"Співробітники автоматично додаються до цієї команди при синхронізації з " +"Mattermost." #: osc/core/models/connector_settings.py:28 msgid "Каналы по умолчанию" -msgstr "" +msgstr "Канали за замовчуванням" #: osc/core/models/connector_settings.py:29 -msgid "Сотрудники автоматически добавляются в эти каналы при синхронизации с Mattermost." +msgid "" +"Сотрудники автоматически добавляются в эти каналы при синхронизации с " +"Mattermost." msgstr "" +"Співробітники автоматично додаються до цих каналів при синхронізації з " +"Mattermost." #: osc/core/models/directory.py:29 msgid "Родительский элемент" -msgstr "" +msgstr "Батьківський елемент" #: osc/core/models/directory.py:30 msgid "Должен принадлежать тому же типу справочника" -msgstr "" +msgstr "Має належати до того самого типу довідника" #: osc/core/models/directory.py:35 msgid "OU в Active Directory" -msgstr "" +msgstr "OU в Active Directory" #: osc/core/models/directory.py:37 -msgid "Используется только для справочника отделов (department): сотрудники этого отдела создаются/переносятся в указанную OU. Например: OU=IT,OU=OL_Users,DC=ol,DC=local. Если не задано — используется AD_USERS_OU." +msgid "" +"Используется только для справочника отделов (department): сотрудники этого " +"отдела создаются/переносятся в указанную OU. Например: " +"OU=IT,OU=OL_Users,DC=ol,DC=local. Если не задано — используется AD_USERS_OU." msgstr "" +"Використовується лише для довідника відділів (department): співробітники " +"цього відділу створюються/переносяться до вказаної OU. Наприклад: " +"OU=IT,OU=OL_Users,DC=ol,DC=local. Якщо не задано — використовується " +"AD_USERS_OU." #: osc/core/models/directory.py:44 templates/pages/dashboard.html:163 msgid "Справочник" @@ -1888,65 +2134,69 @@ msgstr "Довідник" #: osc/core/models/directory.py:45 msgid "Справочники" -msgstr "" +msgstr "Довідники" #: osc/core/models/directory.py:56 msgid "Элемент не может быть родителем самому себе" -msgstr "" +msgstr "Елемент не може бути батьком самому собі" #: osc/core/models/directory.py:58 msgid "Родитель должен принадлежать тому же типу справочника" -msgstr "" +msgstr "Батько має належати до того самого типу довідника" #: osc/core/models/directory.py:62 msgid "Циклическая вложенность недопустима" -msgstr "" +msgstr "Циклічна вкладеність неприпустима" #: osc/core/models/site_settings.py:9 msgid "Название портала" -msgstr "" +msgstr "Назва порталу" #: osc/core/models/site_settings.py:10 msgid "Логотип" -msgstr "" +msgstr "Логотип" #: osc/core/models/site_settings.py:13 osc/core/models/site_settings.py:14 #: osc/core/models/site_settings.py:17 msgid "Настройки сайта" -msgstr "" +msgstr "Налаштування сайту" #: osc/core/utils/validators.py:16 msgid "Введите корректный украинский номер телефона, например +380501234567" -msgstr "" +msgstr "Введіть коректний український номер телефону, наприклад +380501234567" #: osc/core/utils/validators.py:24 msgid "Дата рождения должна быть в прошлом" -msgstr "" +msgstr "Дата народження має бути в минулому" #: osc/core/utils/validators.py:27 msgid "Сотруднику должно быть не менее 16 лет" -msgstr "" +msgstr "Співробітнику має бути не менше 16 років" #: osc/core/utils/validators.py:29 msgid "Проверьте дату рождения — возраст превышает 100 лет" -msgstr "" +msgstr "Перевірте дату народження — вік перевищує 100 років" #: osc/core/utils/validators.py:35 msgid "ИНН должен состоять из 10 цифр" -msgstr "" +msgstr "ІПН має складатися з 10 цифр" #: osc/core/utils/validators.py:38 msgid "Некорректный ИНН: неверная контрольная сумма" -msgstr "" +msgstr "Некоректний ІПН: невірна контрольна сума" #: osc/templates/admin/employees/employee/delete_confirmation.html:10 #, python-format -msgid "Are you sure you want to delete the %(object_name)s \"%(escaped_object)s\"? All of the following related items will be deleted:" +msgid "" +"Are you sure you want to delete the %(object_name)s \"%(escaped_object)s\"? " +"All of the following related items will be deleted:" msgstr "" +"Ви впевнені, що хочете видалити %(object_name)s «%(escaped_object)s»? Усі " +"наведені нижче пов’язані об’єкти буде видалено:" #: osc/templates/admin/employees/employee/delete_confirmation.html:17 msgid "Objects" -msgstr "" +msgstr "Об’єкти" #: templates/index.html:211 msgid "Сеть аптек" @@ -2032,7 +2282,7 @@ msgstr "Комунальні послуги" msgid "Рекрутинг" msgstr "Рекрутинг" -#: templates/index.html:411 templates/pages/hr-portal.html:292 +#: templates/index.html:411 templates/pages/hr-portal.html:428 #: templates/pages/timetrack.html:7 msgid "Учёт рабочего времени" msgstr "Облік робочого часу" @@ -2041,7 +2291,7 @@ msgstr "Облік робочого часу" msgid "Поддержка" msgstr "Підтримка" -#: templates/index.html:429 templates/pages/hr-portal.html:284 +#: templates/index.html:429 templates/pages/hr-portal.html:420 msgid "Справка" msgstr "Довідка" @@ -2065,7 +2315,8 @@ msgstr "Пошук..." msgid "Язык" msgstr "Мова" -#: templates/index.html:608 templates/pages/hr-portal.html:199 +#: templates/index.html:608 templates/pages/hr-portal.html:230 +#: templates/pages/hr-portal.html:392 msgid "Профиль" msgstr "Профіль" @@ -2083,11 +2334,11 @@ msgstr "Завантаження..." #: templates/index.html:675 msgid "Люди" -msgstr "" +msgstr "Люди" #: templates/index.html:691 msgid "Меню" -msgstr "" +msgstr "Меню" #: templates/pages/chat.html:6 msgid "Внутренний корпоративный мессенджер (Mattermost)" @@ -2155,8 +2406,11 @@ msgid "Ваша учётная запись ещё не синхронизиро msgstr "Ваш акаунт ще не синхронізовано з Mattermost" #: templates/pages/chat.html:132 -msgid "Нажмите кнопку ниже, чтобы создать или привязать вашу учётную запись в Mattermost." -msgstr "Натисніть кнопку нижче, щоб створити або прив'язати ваш акаунт у Mattermost." +msgid "" +"Нажмите кнопку ниже, чтобы создать или привязать вашу учётную запись в " +"Mattermost." +msgstr "" +"Натисніть кнопку нижче, щоб створити або прив'язати ваш акаунт у Mattermost." #: templates/pages/chat.html:135 msgid "Синхронизировать сейчас" @@ -2167,16 +2421,26 @@ msgid "Не удалось получить доступ к Mattermost" msgstr "Не вдалося отримати доступ до Mattermost" #: templates/pages/chat.html:146 -msgid "Обратитесь в IT-отдел — возможно, отключена выдача Personal Access Token на сервере Mattermost." -msgstr "Зверніться до IT-відділу — можливо, вимкнено видачу Personal Access Token на сервері Mattermost." +msgid "" +"Обратитесь в IT-отдел — возможно, отключена выдача Personal Access Token на " +"сервере Mattermost." +msgstr "" +"Зверніться до IT-відділу — можливо, вимкнено видачу Personal Access Token на" +" сервері Mattermost." #: templates/pages/chat.html:155 msgid "Чат ещё не подключён" msgstr "Чат ще не підключено" #: templates/pages/chat.html:157 -msgid "Укажите публичный URL Mattermost (поле public_url в «Дополнительные параметры» коннектора Mattermost в админке) — и он появится здесь." -msgstr "Вкажіть публічну URL-адресу Mattermost (поле public_url у «Додаткові параметри» коннектора Mattermost в адмінці) — і він з'явиться тут." +msgid "" +"Укажите публичный URL Mattermost (поле public_url в " +"«Дополнительные параметры» коннектора Mattermost в админке) — и он появится " +"здесь." +msgstr "" +"Вкажіть публічну URL-адресу Mattermost (поле public_url у " +"«Додаткові параметри» коннектора Mattermost в адмінці) — і він з'явиться " +"тут." #: templates/pages/dashboard.html:7 msgid "Добрый день" @@ -2206,7 +2470,7 @@ msgstr "Повідомлення" msgid "Сотрудников онлайн" msgstr "Співробітників онлайн" -#: templates/pages/dashboard.html:22 templates/pages/dislocation.html:720 +#: templates/pages/dashboard.html:22 templates/pages/dislocation.html:791 msgid "из" msgstr "із" @@ -2337,8 +2601,8 @@ msgid "Все предприятия" msgstr "Всі підприємства" #: templates/pages/dislocation.html:76 templates/pages/dislocation.html:81 -#: templates/pages/dislocation.html:82 templates/pages/dislocation.html:437 -#: templates/pages/dislocation.html:454 templates/pages/pharmacies.html:72 +#: templates/pages/dislocation.html:82 templates/pages/dislocation.html:450 +#: templates/pages/dislocation.html:467 templates/pages/pharmacies.html:72 msgid "Все города/регионы" msgstr "Всі міста/регіони" @@ -2346,126 +2610,136 @@ msgstr "Всі міста/регіони" msgid "Все статусы" msgstr "Всі статуси" -#: templates/pages/dislocation.html:98 templates/pages/hr-portal.html:110 +#: templates/pages/dislocation.html:103 templates/pages/hr-portal.html:139 msgid "Таблица" msgstr "Таблиця" -#: templates/pages/dislocation.html:103 +#: templates/pages/dislocation.html:108 msgid "Карта" msgstr "Карта" -#: templates/pages/dislocation.html:118 templates/pages/pharmacies.html:115 -#: templates/pages/pharmacy-detail.html:49 -#: templates/pages/pharmacy-detail.html:141 -msgid "Заведующий" -msgstr "Завідуючий" +#: templates/pages/dislocation.html:115 +msgid "Колонки таблицы" +msgstr "Колонки таблиці" -#: templates/pages/dislocation.html:121 templates/pages/pharmacies.html:118 -#: templates/pages/pharmacy-detail.html:59 -msgid "Режим работы" -msgstr "Режим роботи" +#: templates/pages/dislocation.html:117 +msgid "Пресет" +msgstr "Пресет" -#: templates/pages/dislocation.html:159 -msgid "не назначен" -msgstr "не призначений" +#: templates/pages/dislocation.html:119 +msgid "— свой набор —" +msgstr "— свій набір —" -#: templates/pages/dislocation.html:166 -msgid "Нехватка" -msgstr "Нестача" +#: templates/pages/dislocation.html:137 +msgid "обяз." +msgstr "обов." -#: templates/pages/dislocation.html:168 -msgid "Внимание" -msgstr "Увага" +#: templates/pages/dislocation.html:138 +msgid "Выше" +msgstr "Вище" -#: templates/pages/dislocation.html:170 -msgid "Норма" -msgstr "Норма" +#: templates/pages/dislocation.html:139 +msgid "Ниже" +msgstr "Нижче" -#: templates/pages/dislocation.html:182 templates/pages/dislocation.html:559 -#: templates/pages/pharmacies.html:158 templates/pages/pharmacy-detail.html:36 -msgid "Неактивна" -msgstr "Неактивна" +#: templates/pages/dislocation.html:144 templates/pages/it-manager.html:290 +#: templates/pages/it-manager.html:423 templates/pages/it-manager.html:735 +#: templates/pages/it-manager.html:1013 templates/pages/settings.html:43 +#: templates/pages/settings.html:60 +msgid "Сохранить" +msgstr "Зберегти" -#: templates/pages/dislocation.html:208 +#: templates/pages/dislocation.html:145 +#| msgid "Отменено" +msgid "Отмена" +msgstr "Скасувати" + +#: templates/pages/dislocation.html:221 #: templates/pages/pharmacy-detail.html:149 msgid "В этой аптеке пока нет активных сотрудников" msgstr "У цій аптеці поки немає активних співробітників" -#: templates/pages/dislocation.html:214 templates/pages/pharmacies.html:164 +#: templates/pages/dislocation.html:227 templates/pages/pharmacies.html:164 msgid "Аптеки не найдены" msgstr "Аптеки не знайдені" -#: templates/pages/dislocation.html:221 templates/pages/pharmacies.html:171 +#: templates/pages/dislocation.html:234 templates/pages/pharmacies.html:171 msgid "Показано 0–0 из 0" msgstr "Показано 0–0 з 0" -#: templates/pages/dislocation.html:224 templates/pages/pharmacies.html:174 +#: templates/pages/dislocation.html:237 templates/pages/pharmacies.html:174 msgid "На странице:" msgstr "На сторінці:" -#: templates/pages/dislocation.html:233 +#: templates/pages/dislocation.html:246 msgid "Предыдущая" msgstr "Попередня" -#: templates/pages/dislocation.html:234 templates/pages/pharmacies.html:184 +#: templates/pages/dislocation.html:247 templates/pages/pharmacies.html:184 msgid "Стр. 1 из 1" msgstr "Стор. 1 з 1" -#: templates/pages/dislocation.html:235 templates/pages/pharmacies.html:185 +#: templates/pages/dislocation.html:248 templates/pages/pharmacies.html:185 msgid "Следующая" msgstr "Наступна" -#: templates/pages/dislocation.html:246 +#: templates/pages/dislocation.html:259 msgid "апт. без указанного адреса не показаны на карте (нет координат)" msgstr "апт. без вказаної адреси не показані на карті (немає координат)" -#: templates/pages/dislocation.html:255 +#: templates/pages/dislocation.html:268 msgid "Список аптек" msgstr "Список аптек" -#: templates/pages/dislocation.html:288 +#: templates/pages/dislocation.html:301 msgid "сотрудников офиса и склада, сгруппировано по отделам" msgstr "співробітників офісу та складу, згруповано по відділах" -#: templates/pages/dislocation.html:296 +#: templates/pages/dislocation.html:309 msgid "Внутр. номер" msgstr "Внутр. номер" -#: templates/pages/dislocation.html:298 +#: templates/pages/dislocation.html:311 msgid "Ф.И.О." msgstr "П.І.Б." -#: templates/pages/dislocation.html:299 +#: templates/pages/dislocation.html:312 msgid "Корпоративный телефон" msgstr "Корпоративний телефон" -#: templates/pages/dislocation.html:320 templates/pages/structure.html:35 +#: templates/pages/dislocation.html:333 templates/pages/structure.html:35 msgid "Без отдела" msgstr "Без відділу" -#: templates/pages/dislocation.html:335 +#: templates/pages/dislocation.html:348 msgid "Сотрудников офиса/склада не найдено" msgstr "Співробітників офісу/складу не знайдено" -#: templates/pages/dislocation.html:561 +#: templates/pages/dislocation.html:572 templates/pages/pharmacies.html:158 +#: templates/pages/pharmacy-detail.html:36 +#: templates/partials/dislo_cell.html:42 +msgid "Неактивна" +msgstr "Неактивна" + +#: templates/pages/dislocation.html:574 msgid "Подробнее" msgstr "Детальніше" -#: templates/pages/dislocation.html:720 +#: templates/pages/dislocation.html:791 msgid "Показано" msgstr "Показано" -#: templates/pages/dislocation.html:720 +#: templates/pages/dislocation.html:791 msgid "Нет записей" msgstr "Немає записів" #: templates/pages/documents.html:6 msgid "Файловое хранилище: общие документы, обучение, отделы" -msgstr "" +msgstr "Файлове сховище: спільні документи, навчання, відділи" #: templates/pages/documents.html:29 msgid "Нет доступных разделов документов." -msgstr "" +msgstr "Немає доступних розділів документів." #: templates/pages/employee-detail.html:28 templates/pages/news-detail.html:17 #: templates/pages/task-detail.html:41 templates/pages/tasks.html:127 @@ -2476,14 +2750,6 @@ msgstr "Редагувати" msgid "Не активный" msgstr "Не активний" -#: templates/pages/employee-detail.html:49 -msgid "Испытательный срок" -msgstr "Випробувальний термін" - -#: templates/pages/employee-detail.html:70 -msgid "Рабочий email" -msgstr "Робочий email" - #: templates/pages/employee-detail.html:78 msgid "Внутренний номер" msgstr "Внутрішній номер" @@ -2492,7 +2758,7 @@ msgstr "Внутрішній номер" msgid "Подчинённых" msgstr "Підлеглих" -#: templates/pages/employee-detail.html:112 templates/pages/hr-portal.html:219 +#: templates/pages/employee-detail.html:112 templates/pages/hr-portal.html:250 msgid "Дата приёма" msgstr "Дата прийняття" @@ -2546,7 +2812,7 @@ msgstr "Додати співробітника" msgid "сотрудников, из них" msgstr "співробітників, з них" -#: templates/pages/employees.html:48 templates/pages/hr-portal.html:122 +#: templates/pages/employees.html:48 templates/pages/hr-portal.html:152 #: templates/pages/news.html:111 templates/pages/tasks.html:60 #: templates/pages/timetrack.html:45 msgid "Все" @@ -2557,11 +2823,11 @@ msgstr "Всі" msgid "Все аптеки" msgstr "Всі аптеки" -#: templates/pages/employees.html:105 templates/pages/hr-portal.html:217 +#: templates/pages/employees.html:105 templates/pages/hr-portal.html:248 msgid "Отдел / Аптека" msgstr "Відділ / Аптека" -#: templates/pages/employees.html:108 templates/pages/it-manager.html:914 +#: templates/pages/employees.html:108 templates/pages/it-manager.html:917 msgid "Дата начала" msgstr "Дата початку" @@ -2577,324 +2843,407 @@ msgstr "HR Портал" msgid "Управление персоналом и кадровые процессы" msgstr "Управління персоналом та кадрові процеси" +#: templates/pages/hr-portal.html:13 +#| msgid "Заявок пока нет" +msgid "Заявку створено" +msgstr "Заявку створено" + #: templates/pages/hr-portal.html:16 +#| msgid "Интерфейс" +msgid "Інтерн-слот" +msgstr "Інтерн-слот" + +#: templates/pages/hr-portal.html:16 +msgid "логін" +msgstr "логін" + +#: templates/pages/hr-portal.html:16 +msgid "пароль" +msgstr "пароль" + +#: templates/pages/hr-portal.html:20 +#| msgid "Заявка IT:" +msgid "Заявка IT" +msgstr "Заявка IT" + +#: templates/pages/hr-portal.html:27 +#| msgid "Подать заявку" +msgid "Не удалось создать заявку" +msgstr "Не вдалося створити заявку" + +#: templates/pages/hr-portal.html:43 msgid "Всего сотрудников" msgstr "Всього співробітників" -#: templates/pages/hr-portal.html:18 +#: templates/pages/hr-portal.html:45 msgid "за последний месяц" msgstr "за останній місяць" -#: templates/pages/hr-portal.html:31 +#: templates/pages/hr-portal.html:56 +#| msgid "Срок испытания" +msgid "Открыть список на испытании" +msgstr "Відкрити список на випробуванні" + +#: templates/pages/hr-portal.html:58 msgid "На испытательном сроке" msgstr "На випробувальному терміні" -#: templates/pages/hr-portal.html:33 +#: templates/pages/hr-portal.html:60 msgid "требуют оценки" msgstr "потребують оцінки" -#: templates/pages/hr-portal.html:44 +#: templates/pages/hr-portal.html:71 msgid "Запланированные тренинги" msgstr "Заплановані тренінги" -#: templates/pages/hr-portal.html:46 +#: templates/pages/hr-portal.html:73 msgid "раздел в разработке" msgstr "розділ у розробці" -#: templates/pages/hr-portal.html:57 +#: templates/pages/hr-portal.html:84 msgid "Заявки на отпуск" msgstr "Заявки на відпустку" -#: templates/pages/hr-portal.html:59 +#: templates/pages/hr-portal.html:86 msgid "ожидает решения" msgstr "очікує рішення" -#: templates/pages/hr-portal.html:76 +#: templates/pages/hr-portal.html:103 msgid "Поиск работников, вакансий, тренингов..." msgstr "Пошук працівників, вакансій, тренінгів..." -#: templates/pages/hr-portal.html:83 +#: templates/pages/hr-portal.html:110 msgid "Создать" msgstr "Створити" -#: templates/pages/hr-portal.html:94 +#: templates/pages/hr-portal.html:123 msgid "Заявки и отпуска" msgstr "Заявки та відпустки" -#: templates/pages/hr-portal.html:96 +#: templates/pages/hr-portal.html:125 msgid "Тренинги" msgstr "Тренінги" -#: templates/pages/hr-portal.html:98 +#: templates/pages/hr-portal.html:127 msgid "Вакансии" msgstr "Вакансії" -#: templates/pages/hr-portal.html:105 +#: templates/pages/hr-portal.html:134 msgid "Карточки" msgstr "Картки" -#: templates/pages/hr-portal.html:136 +#: templates/pages/hr-portal.html:166 msgid "Все отделы" msgstr "Всі відділи" -#: templates/pages/hr-portal.html:143 +#: templates/pages/hr-portal.html:173 msgid "Все должности" msgstr "Всі посади" -#: templates/pages/hr-portal.html:176 +#: templates/pages/hr-portal.html:207 templates/pages/hr-portal.html:390 msgid "На испытании до" msgstr "На випробуванні до" -#: templates/pages/hr-portal.html:182 +#: templates/pages/hr-portal.html:213 msgid "Отдел:" msgstr "Відділ:" -#: templates/pages/hr-portal.html:186 +#: templates/pages/hr-portal.html:217 msgid "Дата приёма:" msgstr "Дата прийняття:" -#: templates/pages/hr-portal.html:194 +#: templates/pages/hr-portal.html:225 msgid "Телефон:" msgstr "Телефон:" -#: templates/pages/hr-portal.html:202 templates/pages/hr-portal.html:255 +#: templates/pages/hr-portal.html:233 templates/pages/hr-portal.html:287 msgid "Активных сотрудников пока нет" msgstr "Активних співробітників поки немає" -#: templates/pages/hr-portal.html:206 templates/pages/hr-portal.html:261 +#: templates/pages/hr-portal.html:237 templates/pages/hr-portal.html:293 msgid "Ничего не найдено по заданным фильтрам" msgstr "Нічого не знайдено за заданими фільтрами" -#: templates/pages/hr-portal.html:242 templates/pages/hr-portal.html:559 -msgid "На испытании" -msgstr "На випробуванні" - -#: templates/pages/hr-portal.html:265 +#: templates/pages/hr-portal.html:297 msgid "Полный список сотрудников →" msgstr "Повний список співробітників →" -#: templates/pages/hr-portal.html:277 -msgid "Отпуск" -msgstr "Відпустка" - -#: templates/pages/hr-portal.html:278 -msgid "Подать заявку" -msgstr "Подати заявку" - -#: templates/pages/hr-portal.html:285 -msgid "Заказать документ" -msgstr "Замовити документ" - -#: templates/pages/hr-portal.html:291 -msgid "Табель" -msgstr "Табель" - -#: templates/pages/hr-portal.html:298 -msgid "Начисления" -msgstr "Нарахування" - -#: templates/pages/hr-portal.html:299 -msgid "Зарплата и премии" -msgstr "Зарплата та премії" - #: templates/pages/hr-portal.html:308 -msgid "Мои заявки" -msgstr "Мої заявки" +#| msgid "На испытании" +msgid "Кандидаты на испытании" +msgstr "Кандидати на випробуванні" -#: templates/pages/hr-portal.html:309 templates/pages/it-manager.html:373 -msgid "Новая" -msgstr "Нова" +#: templates/pages/hr-portal.html:309 +msgid "Заявки на найм до утверждения персонального аккаунта" +msgstr "Заявки на найм до затвердження персонального облікового запису" -#: templates/pages/hr-portal.html:314 -msgid "Отпуск 01–14 мая" -msgstr "Відпустка 01–14 травня" +#: templates/pages/hr-portal.html:311 +#| msgid "Новая задача" +msgid "Новая заявка" +msgstr "Нова заявка" -#: templates/pages/hr-portal.html:315 -msgid "Подано 10.04.2026" -msgstr "Подано 10.04.2026" - -#: templates/pages/hr-portal.html:317 templates/pages/hr-portal.html:374 -msgid "На рассмотрении" -msgstr "На розгляді" - -#: templates/pages/hr-portal.html:321 -msgid "Справка о зарплате" -msgstr "Довідка про зарплату" - -#: templates/pages/hr-portal.html:322 -msgid "Подано 05.04.2026" -msgstr "Подано 05.04.2026" - -#: templates/pages/hr-portal.html:324 -msgid "Готово" -msgstr "Готово" - -#: templates/pages/hr-portal.html:328 -msgid "Больничный 28–29 марта" -msgstr "Лікарняний 28–29 березня" - -#: templates/pages/hr-portal.html:329 -msgid "Подано 01.04.2026" -msgstr "Подано 01.04.2026" - -#: templates/pages/hr-portal.html:331 templates/pages/hr-portal.html:562 -msgid "Утверждено" -msgstr "Затверджено" - -#: templates/pages/hr-portal.html:339 -msgid "Баланс отпусков — 2026" -msgstr "Баланс відпусток — 2026" - -#: templates/pages/hr-portal.html:345 -msgid "Основной отпуск" -msgstr "Основна відпустка" - -#: templates/pages/hr-portal.html:346 templates/pages/hr-portal.html:355 -#: templates/pages/hr-portal.html:364 templates/pages/hr-portal.html:483 -msgid "дн." -msgstr "дн." - -#: templates/pages/hr-portal.html:349 -msgid "Остаток: 10 дней" -msgstr "Залишок: 10 днів" - -#: templates/pages/hr-portal.html:354 -msgid "Дополнительный отпуск" -msgstr "Додаткова відпустка" - -#: templates/pages/hr-portal.html:358 -msgid "Остаток: 7 дней" -msgstr "Залишок: 7 днів" - -#: templates/pages/hr-portal.html:363 -msgid "Больничные" -msgstr "Лікарняні" - -#: templates/pages/hr-portal.html:367 -msgid "Остаток: 28 дней" -msgstr "Залишок: 28 днів" - -#: templates/pages/hr-portal.html:371 -msgid "Следующий отпуск" -msgstr "Наступна відпустка" - -#: templates/pages/hr-portal.html:389 templates/pages/hr-portal.html:400 -msgid "Раздел в разработке" -msgstr "Розділ у розробці" - -#: templates/pages/hr-portal.html:390 -msgid "Планирование тренингов и обучения появится в ближайшее время" -msgstr "Планування тренінгів та навчання з'явиться найближчим часом" - -#: templates/pages/hr-portal.html:401 -msgid "Управление открытыми вакансиями появится в ближайшее время" -msgstr "Управління відкритими вакансіями з'явиться найближчим часом" - -#: templates/pages/hr-portal.html:415 -msgid "Новая заявка на найм" -msgstr "Нова заявка на найм" - -#: templates/pages/hr-portal.html:416 -msgid "Сразу создаёт личный учётный запис кандидата на срок испытания." -msgstr "Одразу створює особистий обліковий запис кандидата на термін випробування." - -#: templates/pages/hr-portal.html:422 -msgid "Фамилия " -msgstr "Прізвище " - -#: templates/pages/hr-portal.html:426 -msgid "Имя " -msgstr "Ім'я " - -#: templates/pages/hr-portal.html:437 -msgid "Тип локации " -msgstr "Тип локації " - -#: templates/pages/hr-portal.html:439 -msgid "Выберите..." -msgstr "Оберіть..." - -#: templates/pages/hr-portal.html:476 -msgid "Дата выхода" -msgstr "Дата виходу" - -#: templates/pages/hr-portal.html:480 -msgid "Срок испытания" -msgstr "Термін випробування" - -#: templates/pages/hr-portal.html:491 -msgid "Email кандидата" -msgstr "Email кандидата" - -#: templates/pages/hr-portal.html:501 -msgid "Требуются доступы (после утверждения — заявка в IT)" -msgstr "Потрібні доступи (після затвердження — заявка в IT)" - -#: templates/pages/hr-portal.html:513 -msgid "Примечание по доступам" -msgstr "Примітка щодо доступів" - -#: templates/pages/hr-portal.html:514 -msgid "Например: 1С Бухгалтерия — только просмотр" -msgstr "Наприклад: 1С Бухгалтерія — лише перегляд" - -#: templates/pages/hr-portal.html:520 -msgid "Создать заявку и временный аккаунт" -msgstr "Створити заявку та тимчасовий акаунт" - -#: templates/pages/hr-portal.html:521 -msgid "Создание учётной записи…" -msgstr "Створення облікового запису…" - -#: templates/pages/hr-portal.html:533 -msgid "Кандидат" -msgstr "Кандидат" - -#: templates/pages/hr-portal.html:534 +#: templates/pages/hr-portal.html:319 templates/pages/hr-portal.html:687 msgid "Должность / локация" msgstr "Посада / локація" -#: templates/pages/hr-portal.html:536 +#: templates/pages/hr-portal.html:320 templates/pages/hr-portal.html:688 +msgid "Інтерн / IT" +msgstr "Інтерн / IT" + +#: templates/pages/hr-portal.html:321 templates/pages/hr-portal.html:690 msgid "Испытание до" msgstr "Випробування до" -#: templates/pages/hr-portal.html:537 templates/pages/pharmacy-detail.html:302 -msgid "Подал" -msgstr "Подав" - -#: templates/pages/hr-portal.html:538 templates/pages/task-detail.html:138 +#: templates/pages/hr-portal.html:322 templates/pages/hr-portal.html:692 +#: templates/pages/task-detail.html:138 msgid "Действия" msgstr "Дії" -#: templates/pages/hr-portal.html:557 +#: templates/pages/hr-portal.html:331 templates/pages/hr-portal.html:725 msgid "Просрочено" msgstr "Прострочено" -#: templates/pages/hr-portal.html:564 -msgid "Отклонено" -msgstr "Відхилено" - -#: templates/pages/hr-portal.html:572 +#: templates/pages/hr-portal.html:351 templates/pages/hr-portal.html:740 msgid "Утвердить кандидата" msgstr "Затвердити кандидата" -#: templates/pages/hr-portal.html:575 +#: templates/pages/hr-portal.html:354 templates/pages/hr-portal.html:743 msgid "Утвердить" msgstr "Затвердити" -#: templates/pages/hr-portal.html:581 +#: templates/pages/hr-portal.html:360 templates/pages/hr-portal.html:749 msgid "Причина отказа" msgstr "Причина відмови" -#: templates/pages/hr-portal.html:583 +#: templates/pages/hr-portal.html:362 templates/pages/hr-portal.html:751 msgid "Отклонить" msgstr "Відхилити" -#: templates/pages/hr-portal.html:591 -msgid "Заявка IT:" -msgstr "Заявка IT:" +#: templates/pages/hr-portal.html:371 +#| msgid "На испытании" +msgid "Нет кандидатов на испытании" +msgstr "Немає кандидатів на випробуванні" -#: templates/pages/hr-portal.html:598 +#: templates/pages/hr-portal.html:383 +#| msgid "На испытательном сроке" +msgid "Сотрудники с испытательным сроком" +msgstr "Співробітники з випробувальним терміном" + +#: templates/pages/hr-portal.html:384 +msgid "Учётные записи с заполненной датой окончания испытания" +msgstr "Облікові записи із заповненою датою закінчення випробування" + +#: templates/pages/hr-portal.html:399 +#| msgid "На испытательном сроке" +msgid "Нет сотрудников на испытательном сроке" +msgstr "Немає співробітників на випробувальному терміні" + +#: templates/pages/hr-portal.html:413 +msgid "Отпуск" +msgstr "Відпустка" + +#: templates/pages/hr-portal.html:414 +msgid "Подать заявку" +msgstr "Подати заявку" + +#: templates/pages/hr-portal.html:421 +msgid "Заказать документ" +msgstr "Замовити документ" + +#: templates/pages/hr-portal.html:427 +msgid "Табель" +msgstr "Табель" + +#: templates/pages/hr-portal.html:434 +msgid "Начисления" +msgstr "Нарахування" + +#: templates/pages/hr-portal.html:435 +msgid "Зарплата и премии" +msgstr "Зарплата та премії" + +#: templates/pages/hr-portal.html:444 +msgid "Мои заявки" +msgstr "Мої заявки" + +#: templates/pages/hr-portal.html:445 templates/pages/it-manager.html:373 +msgid "Новая" +msgstr "Нова" + +#: templates/pages/hr-portal.html:450 +msgid "Отпуск 01–14 мая" +msgstr "Відпустка 01–14 травня" + +#: templates/pages/hr-portal.html:451 +msgid "Подано 10.04.2026" +msgstr "Подано 10.04.2026" + +#: templates/pages/hr-portal.html:453 templates/pages/hr-portal.html:510 +msgid "На рассмотрении" +msgstr "На розгляді" + +#: templates/pages/hr-portal.html:457 +msgid "Справка о зарплате" +msgstr "Довідка про зарплату" + +#: templates/pages/hr-portal.html:458 +msgid "Подано 05.04.2026" +msgstr "Подано 05.04.2026" + +#: templates/pages/hr-portal.html:460 +msgid "Готово" +msgstr "Готово" + +#: templates/pages/hr-portal.html:464 +msgid "Больничный 28–29 марта" +msgstr "Лікарняний 28–29 березня" + +#: templates/pages/hr-portal.html:465 +msgid "Подано 01.04.2026" +msgstr "Подано 01.04.2026" + +#: templates/pages/hr-portal.html:475 +msgid "Баланс отпусков — 2026" +msgstr "Баланс відпусток — 2026" + +#: templates/pages/hr-portal.html:481 +msgid "Основной отпуск" +msgstr "Основна відпустка" + +#: templates/pages/hr-portal.html:485 +msgid "Остаток: 10 дней" +msgstr "Залишок: 10 днів" + +#: templates/pages/hr-portal.html:490 +msgid "Дополнительный отпуск" +msgstr "Додаткова відпустка" + +#: templates/pages/hr-portal.html:494 +msgid "Остаток: 7 дней" +msgstr "Залишок: 7 днів" + +#: templates/pages/hr-portal.html:499 +msgid "Больничные" +msgstr "Лікарняні" + +#: templates/pages/hr-portal.html:503 +msgid "Остаток: 28 дней" +msgstr "Залишок: 28 днів" + +#: templates/pages/hr-portal.html:507 +msgid "Следующий отпуск" +msgstr "Наступна відпустка" + +#: templates/pages/hr-portal.html:525 templates/pages/hr-portal.html:536 +msgid "Раздел в разработке" +msgstr "Розділ у розробці" + +#: templates/pages/hr-portal.html:526 +msgid "Планирование тренингов и обучения появится в ближайшее время" +msgstr "Планування тренінгів та навчання з'явиться найближчим часом" + +#: templates/pages/hr-portal.html:537 +msgid "Управление открытыми вакансиями появится в ближайшее время" +msgstr "Управління відкритими вакансіями з'явиться найближчим часом" + +#: templates/pages/hr-portal.html:550 +msgid "Новая заявка на найм" +msgstr "Нова заявка на найм" + +#: templates/pages/hr-portal.html:551 +msgid "" +"Создаёт IT-заявку и выдаёт интерн-учётку на срок испытания. Персональный " +"аккаунт — после утверждения." +msgstr "" +"Створює IT-заявку і видає інтерн-обліковку на строк випробування. " +"Персональний обліковий запис — після затвердження." + +#: templates/pages/hr-portal.html:553 +#, python-format +msgid "Свободных интерн-слотов: %(free)s из %(total)s" +msgstr "Вільних інтерн-слотів: %(free)s із %(total)s" + +#: templates/pages/hr-portal.html:555 +msgid "" +"добавьте слоты в админке (Пул інтерн-облікових записів) или дождитесь " +"освобождения" +msgstr "" +"додайте слоти в адмінці (Пул інтерн-облікових записів) або дочекайтеся " +"звільнення" + +#: templates/pages/hr-portal.html:563 +msgid "Фамилия " +msgstr "Прізвище " + +#: templates/pages/hr-portal.html:567 +msgid "Имя " +msgstr "Ім'я " + +#: templates/pages/hr-portal.html:578 +msgid "Тип локации " +msgstr "Тип локації " + +#: templates/pages/hr-portal.html:580 +msgid "Выберите..." +msgstr "Оберіть..." + +#: templates/pages/hr-portal.html:587 +#| msgid "Отдел" +msgid "Отдел " +msgstr "Відділ " + +#: templates/pages/hr-portal.html:596 +#| msgid "Аптека" +msgid "Аптека " +msgstr "Аптека " + +#: templates/pages/hr-portal.html:605 +#| msgid "Офис / Склад" +msgid "Офис / склад " +msgstr "Офіс / склад " + +#: templates/pages/hr-portal.html:630 +msgid "Срок испытания" +msgstr "Термін випробування" + +#: templates/pages/hr-portal.html:641 +msgid "Email кандидата" +msgstr "Email кандидата" + +#: templates/pages/hr-portal.html:651 +#| msgid "Быстрый доступ" +msgid "Требуются доступы" +msgstr "Потрібні доступи" + +#: templates/pages/hr-portal.html:652 +msgid "" +"Створює заявку IT на підготовку робочого місця. Список залежить від типу " +"локації." +msgstr "" +"Створює заявку IT на підготовку робочого місця. Список залежить від типу " +"локації." + +#: templates/pages/hr-portal.html:667 +msgid "Например: 1С Бухгалтерия — только просмотр" +msgstr "Наприклад: 1С Бухгалтерія — лише перегляд" + +#: templates/pages/hr-portal.html:673 +msgid "Створити заявку (інтерн + IT)" +msgstr "Створити заявку (інтерн + IT)" + +#: templates/pages/hr-portal.html:674 +msgid "Створення…" +msgstr "Створення…" + +#: templates/pages/hr-portal.html:691 templates/pages/pharmacy-detail.html:302 +msgid "Подал" +msgstr "Подав" + +#: templates/pages/hr-portal.html:762 +msgid "PDF з даними" +msgstr "PDF з даними" + +#: templates/pages/hr-portal.html:772 msgid "Заявок на найм пока нет" msgstr "Заявок на найм поки немає" @@ -2914,7 +3263,7 @@ msgstr "Виконані роботи" msgid "Расходные материалы" msgstr "Витратні матеріали" -#: templates/pages/it-manager.html:21 templates/pages/it-manager.html:791 +#: templates/pages/it-manager.html:21 templates/pages/it-manager.html:794 msgid "Провайдеры" msgstr "Провайдери" @@ -2928,7 +3277,8 @@ msgid "На складе" msgstr "На складі" #: templates/pages/it-manager.html:39 templates/pages/it-manager.html:236 -#: templates/pages/it-manager.html:260 templates/pages/pharmacy-detail.html:198 +#: templates/pages/it-manager.html:260 +#: templates/pages/pharmacy-detail.html:198 #: templates/pages/pharmacy-detail.html:214 msgid "Выдано" msgstr "Видано" @@ -2970,10 +3320,14 @@ msgid "По одному в строку — вставьте список се msgstr "По одному в рядку — вставте список серійників, наприклад" #: templates/pages/it-manager.html:96 -msgid "Сколько серийников — столько единиц будет создано. Дубликаты и уже имеющиеся пропускаются." +msgid "" +"Сколько серийников — столько единиц будет создано. Дубликаты и уже имеющиеся" +" пропускаются." msgstr "" +"Скільки серійників — стільки одиниць буде створено. Дублікати та вже наявні " +"пропускаються." -#: templates/pages/it-manager.html:100 templates/pages/it-manager.html:670 +#: templates/pages/it-manager.html:100 templates/pages/it-manager.html:673 msgid "Дата покупки" msgstr "Дата покупки" @@ -2990,8 +3344,8 @@ msgid "(необязательно)" msgstr "(необов'язково)" #: templates/pages/it-manager.html:110 templates/pages/it-manager.html:202 -#: templates/pages/it-manager.html:500 templates/pages/it-manager.html:656 -#: templates/pages/it-manager.html:881 +#: templates/pages/it-manager.html:503 templates/pages/it-manager.html:659 +#: templates/pages/it-manager.html:884 msgid "Поиск по адресу или номеру аптеки..." msgstr "Пошук за адресою чи номером аптеки..." @@ -3004,8 +3358,8 @@ msgid "Создать экземпляры" msgstr "Створити екземпляри" #: templates/pages/it-manager.html:122 templates/pages/it-manager.html:172 -#: templates/pages/it-manager.html:542 templates/pages/it-manager.html:680 -#: templates/pages/it-manager.html:1086 +#: templates/pages/it-manager.html:545 templates/pages/it-manager.html:683 +#: templates/pages/it-manager.html:1089 msgid "Сохранение…" msgstr "Збереження…" @@ -3017,8 +3371,8 @@ msgstr "Скасувати" msgid "Новая модель" msgstr "Нова модель" -#: templates/pages/it-manager.html:149 templates/pages/it-manager.html:625 -#: templates/pages/it-manager.html:900 +#: templates/pages/it-manager.html:149 templates/pages/it-manager.html:628 +#: templates/pages/it-manager.html:903 msgid "+ Добавить новый тип..." msgstr "+ Додати новий тип..." @@ -3056,7 +3410,7 @@ msgstr "Каталог порожній — додайте першу модел #: templates/pages/it-manager.html:199 msgid "ед.:" -msgstr "" +msgstr "од.:" #: templates/pages/it-manager.html:201 msgid "На локацию" @@ -3082,7 +3436,8 @@ msgstr "Тип / Бренд / Модель" msgid "С/н · Инв. №" msgstr "С/н · Інв. №" -#: templates/pages/it-manager.html:261 templates/pages/pharmacy-detail.html:215 +#: templates/pages/it-manager.html:261 +#: templates/pages/pharmacy-detail.html:215 msgid "В ремонте" msgstr "В ремонті" @@ -3098,13 +3453,7 @@ msgstr "Видано кому" msgid "— (на складе)" msgstr "— (на складі)" -#: templates/pages/it-manager.html:290 templates/pages/it-manager.html:420 -#: templates/pages/it-manager.html:732 templates/pages/it-manager.html:1010 -#: templates/pages/settings.html:43 templates/pages/settings.html:60 -msgid "Сохранить" -msgstr "Зберегти" - -#: templates/pages/it-manager.html:294 templates/pages/it-manager.html:739 +#: templates/pages/it-manager.html:294 templates/pages/it-manager.html:742 msgid "Примечания:" msgstr "Примітки" @@ -3141,12 +3490,12 @@ msgid "Заявитель" msgstr "Заявник" #: templates/pages/it-manager.html:348 templates/pages/it-manager.html:411 -#: templates/pages/it-manager.html:556 +#: templates/pages/it-manager.html:559 msgid "Исполнитель" msgstr "Виконавець" -#: templates/pages/it-manager.html:349 templates/pages/it-manager.html:486 -#: templates/pages/it-manager.html:555 templates/pages/it-manager.html:1098 +#: templates/pages/it-manager.html:349 templates/pages/it-manager.html:489 +#: templates/pages/it-manager.html:558 templates/pages/it-manager.html:1101 #: templates/pages/pharmacy-detail.html:299 msgid "Дата" msgstr "Дата" @@ -3163,268 +3512,273 @@ msgstr "Критичний" msgid "Решена" msgstr "Вирішена" -#: templates/pages/it-manager.html:391 templates/pages/it-manager.html:430 +#: templates/pages/it-manager.html:391 templates/pages/it-manager.html:433 msgid "внутренний" msgstr "внутрішній" -#: templates/pages/it-manager.html:413 +#: templates/pages/it-manager.html:414 msgid "— не назначено" msgstr "— не призначено" -#: templates/pages/it-manager.html:428 +#: templates/pages/it-manager.html:418 +msgid "Нет сотрудников IT / staff — добавьте в группу IT" +msgstr "Немає співробітників IT / staff — додайте до групи IT" + +#: templates/pages/it-manager.html:431 msgid "Комментарий..." msgstr "Коментар..." -#: templates/pages/it-manager.html:433 templates/pages/it-manager.html:679 +#: templates/pages/it-manager.html:436 templates/pages/it-manager.html:682 msgid "Добавить" msgstr "Додати" -#: templates/pages/it-manager.html:440 +#: templates/pages/it-manager.html:443 msgid "Заявок пока нет" msgstr "Заявок поки немає" -#: templates/pages/it-manager.html:455 +#: templates/pages/it-manager.html:458 msgid "Записей в этом месяце" msgstr "Записів цього місяця" -#: templates/pages/it-manager.html:459 +#: templates/pages/it-manager.html:462 msgid "Часов в этом месяце" msgstr "Годин цього місяця" -#: templates/pages/it-manager.html:469 templates/pages/it-manager.html:541 +#: templates/pages/it-manager.html:472 templates/pages/it-manager.html:544 msgid "Добавить запись" msgstr "Додати запис" -#: templates/pages/it-manager.html:479 +#: templates/pages/it-manager.html:482 msgid "Добавить запись о выполненной работе" msgstr "Додати запис про виконану роботу" -#: templates/pages/it-manager.html:495 +#: templates/pages/it-manager.html:498 msgid "+ Добавить новый вид работ..." msgstr "+ Додати новий вид робіт..." -#: templates/pages/it-manager.html:510 +#: templates/pages/it-manager.html:513 msgid "Затрачено часов" msgstr "Витрачено годин" -#: templates/pages/it-manager.html:532 +#: templates/pages/it-manager.html:535 msgid "Описание работ" msgstr "Опис робіт" -#: templates/pages/it-manager.html:536 +#: templates/pages/it-manager.html:539 msgid "Результат" msgstr "Результат" -#: templates/pages/it-manager.html:560 templates/pages/timetrack.html:59 +#: templates/pages/it-manager.html:563 templates/pages/timetrack.html:59 msgid "Часов" msgstr "Годин" -#: templates/pages/it-manager.html:574 +#: templates/pages/it-manager.html:577 msgid "Записей ещё нет" msgstr "Записів ще немає" -#: templates/pages/it-manager.html:589 +#: templates/pages/it-manager.html:592 msgid "Всего позиций" msgstr "Всього позицій" -#: templates/pages/it-manager.html:593 +#: templates/pages/it-manager.html:596 msgid "Заканчивается / на замену" msgstr "Закінчується / на заміну" -#: templates/pages/it-manager.html:603 +#: templates/pages/it-manager.html:606 msgid "Добавить материал" msgstr "Додати матеріал" -#: templates/pages/it-manager.html:613 +#: templates/pages/it-manager.html:616 msgid "Добавить расходный материал" msgstr "Додати витратний матеріал" -#: templates/pages/it-manager.html:634 +#: templates/pages/it-manager.html:637 msgid "+ Добавить новый цвет..." msgstr "+ Додати новий колір..." -#: templates/pages/it-manager.html:638 +#: templates/pages/it-manager.html:641 msgid "Производитель" msgstr "Виробник" -#: templates/pages/it-manager.html:642 +#: templates/pages/it-manager.html:645 msgid "Модель / артикул" msgstr "Модель / артикул" -#: templates/pages/it-manager.html:646 templates/pages/it-manager.html:695 +#: templates/pages/it-manager.html:649 templates/pages/it-manager.html:698 msgid "Принтер" msgstr "Принтер" -#: templates/pages/it-manager.html:655 +#: templates/pages/it-manager.html:658 msgid "Локация хранения" msgstr "Локація зберігання" -#: templates/pages/it-manager.html:666 +#: templates/pages/it-manager.html:669 msgid "Ресурс (страниц)" msgstr "Ресурс (сторінок)" -#: templates/pages/it-manager.html:693 +#: templates/pages/it-manager.html:696 msgid "Тип / Модель" msgstr "Тип / Модель" -#: templates/pages/it-manager.html:736 +#: templates/pages/it-manager.html:739 msgid "Ресурс:" msgstr "Ресурс:" -#: templates/pages/it-manager.html:736 +#: templates/pages/it-manager.html:739 msgid "стр." msgstr "стор." -#: templates/pages/it-manager.html:745 +#: templates/pages/it-manager.html:748 msgid "Расходных материалов ещё не добавлено" msgstr "Витратних матеріалів ще не додано" -#: templates/pages/it-manager.html:760 +#: templates/pages/it-manager.html:763 msgid "Провайдеров" msgstr "Провайдерів" -#: templates/pages/it-manager.html:764 +#: templates/pages/it-manager.html:767 msgid "Договоров" msgstr "Договорів" -#: templates/pages/it-manager.html:768 +#: templates/pages/it-manager.html:771 msgid "Заканчиваются через 30 дней" msgstr "Закінчуються за 30 днів" -#: templates/pages/it-manager.html:778 templates/pages/it-manager.html:850 +#: templates/pages/it-manager.html:781 templates/pages/it-manager.html:853 msgid "Добавить провайдера" msgstr "Додати провайдера" -#: templates/pages/it-manager.html:784 templates/pages/it-manager.html:955 +#: templates/pages/it-manager.html:787 templates/pages/it-manager.html:958 msgid "Добавить договор" msgstr "Додати договір" -#: templates/pages/it-manager.html:803 +#: templates/pages/it-manager.html:806 msgid "Провайдеров ещё нет" msgstr "Провайдерів ще немає" -#: templates/pages/it-manager.html:814 +#: templates/pages/it-manager.html:817 msgid "Новый провайдер" msgstr "Новий провайдер" -#: templates/pages/it-manager.html:833 +#: templates/pages/it-manager.html:836 msgid "Сайт" msgstr "Сайт" -#: templates/pages/it-manager.html:837 +#: templates/pages/it-manager.html:840 msgid "Телефон техподдержки" msgstr "Телефон техпідтримки" -#: templates/pages/it-manager.html:841 +#: templates/pages/it-manager.html:844 msgid "Email техподдержки" msgstr "Email техпідтримки" -#: templates/pages/it-manager.html:864 +#: templates/pages/it-manager.html:867 msgid "Новый договор" msgstr "Новий договір" -#: templates/pages/it-manager.html:891 +#: templates/pages/it-manager.html:894 msgid "Номер договора" msgstr "Номер договору" -#: templates/pages/it-manager.html:895 templates/pages/it-manager.html:970 +#: templates/pages/it-manager.html:898 templates/pages/it-manager.html:973 #: templates/pages/pharmacy-detail.html:244 msgid "Тип услуги" msgstr "Тип послуги" -#: templates/pages/it-manager.html:904 +#: templates/pages/it-manager.html:907 msgid "Технология подключения" msgstr "Технологія підключення" -#: templates/pages/it-manager.html:910 +#: templates/pages/it-manager.html:913 msgid "+ Добавить новую технологию..." msgstr "+ Додати нову технологію..." -#: templates/pages/it-manager.html:918 +#: templates/pages/it-manager.html:921 msgid "Дата окончания" msgstr "Дата закінчення" -#: templates/pages/it-manager.html:922 +#: templates/pages/it-manager.html:925 msgid "Стоимость/мес." msgstr "Вартість/міс." -#: templates/pages/it-manager.html:926 +#: templates/pages/it-manager.html:929 msgid "Скорость ↓ Мбит/с" msgstr "Швидкість ↓ Мбіт/с" -#: templates/pages/it-manager.html:930 +#: templates/pages/it-manager.html:933 msgid "Скорость ↑ Мбит/с" msgstr "Швидкість ↑ Мбіт/с" -#: templates/pages/it-manager.html:934 +#: templates/pages/it-manager.html:937 msgid "Лицевой счёт" msgstr "Особовий рахунок" -#: templates/pages/it-manager.html:941 +#: templates/pages/it-manager.html:944 msgid "Выделенный IP" msgstr "Виділений IP" -#: templates/pages/it-manager.html:945 +#: templates/pages/it-manager.html:948 msgid "По одному IP-адресу на строку" msgstr "По одній IP-адресі на рядок" -#: templates/pages/it-manager.html:969 +#: templates/pages/it-manager.html:972 msgid "Провайдер / Локация" msgstr "Провайдер / Локація" -#: templates/pages/it-manager.html:971 templates/pages/pharmacy-detail.html:246 +#: templates/pages/it-manager.html:974 +#: templates/pages/pharmacy-detail.html:246 msgid "Скорость" msgstr "Швидкість" -#: templates/pages/it-manager.html:1013 +#: templates/pages/it-manager.html:1016 msgid "Счёт:" msgstr "Рахунок:" -#: templates/pages/it-manager.html:1013 +#: templates/pages/it-manager.html:1016 msgid "грн/мес" msgstr "грн/міс" -#: templates/pages/it-manager.html:1018 +#: templates/pages/it-manager.html:1021 msgid "Договоров ещё нет" msgstr "Договорів ще немає" -#: templates/pages/it-manager.html:1032 +#: templates/pages/it-manager.html:1035 msgid "обращений" msgstr "звернень" -#: templates/pages/it-manager.html:1037 templates/pages/it-manager.html:1047 +#: templates/pages/it-manager.html:1040 templates/pages/it-manager.html:1050 msgid "Новое обращение" msgstr "Нове звернення" -#: templates/pages/it-manager.html:1054 templates/pages/it-manager.html:1099 +#: templates/pages/it-manager.html:1057 templates/pages/it-manager.html:1102 msgid "Кто обратился" msgstr "Хто звернувся" -#: templates/pages/it-manager.html:1068 +#: templates/pages/it-manager.html:1071 msgid "+ Добавить новый канал..." msgstr "+ Додати новий канал..." -#: templates/pages/it-manager.html:1072 +#: templates/pages/it-manager.html:1075 msgid "Затрачено минут" msgstr "Витрачено хвилин" -#: templates/pages/it-manager.html:1076 +#: templates/pages/it-manager.html:1079 msgid "Описание обращения" msgstr "Опис звернення" -#: templates/pages/it-manager.html:1080 +#: templates/pages/it-manager.html:1083 msgid "Решение" msgstr "Рішення" -#: templates/pages/it-manager.html:1085 +#: templates/pages/it-manager.html:1088 msgid "Добавить обращение" msgstr "Додати звернення" -#: templates/pages/it-manager.html:1101 +#: templates/pages/it-manager.html:1104 msgid "Принял" msgstr "Прийняв" -#: templates/pages/it-manager.html:1117 +#: templates/pages/it-manager.html:1120 msgid "Обращений ещё нет" msgstr "Звернень ще немає" @@ -3441,7 +3795,8 @@ msgid "Это необратимо." msgstr "Це незворотньо." #: templates/pages/news-detail.html:24 templates/pages/task-detail.html:48 -#: templates/pages/tasks.html:135 templates/partials/pharmacy-documents.html:51 +#: templates/pages/tasks.html:135 +#: templates/partials/pharmacy-documents.html:51 msgid "Удалить" msgstr "Видалити" @@ -3572,14 +3927,6 @@ msgstr "Інтернет-провайдер" msgid "Коммунальный отдел" msgstr "Комунальний відділ" -#: templates/pages/pharmacy-detail.html:88 -msgid "Регион" -msgstr "Регіон" - -#: templates/pages/pharmacy-detail.html:90 -msgid "Категория" -msgstr "Категорія" - #: templates/pages/pharmacy-detail.html:91 msgid "Категория НДС" msgstr "Категорія ПДВ" @@ -3681,8 +4028,12 @@ msgid "Количество записей в таблицах по умолча msgstr "Кількість записів у таблицях за замовчуванням" #: templates/pages/profile.html:100 -msgid "Применяется ко всем таблицам портала, если для конкретной таблицы размер не выбран вручную." -msgstr "Застосовується до всіх таблиць порталу, якщо для конкретної таблиці розмір не вибрано вручну." +msgid "" +"Применяется ко всем таблицам портала, если для конкретной таблицы размер не " +"выбран вручную." +msgstr "" +"Застосовується до всіх таблиць порталу, якщо для конкретної таблиці розмір " +"не вибрано вручну." #: templates/pages/settings.html:6 msgid "Конфигурация портала" @@ -3860,33 +4211,62 @@ msgstr "Немає відміток робочого часу за" msgid "сотрудников" msgstr "співробітників" +#: templates/partials/dislo_cell.html:15 +msgid "не назначен" +msgstr "не призначений" + +#: templates/partials/dislo_cell.html:23 +msgid "Нехватка" +msgstr "Нестача" + +#: templates/partials/dislo_cell.html:25 +msgid "Внимание" +msgstr "Увага" + +#: templates/partials/dislo_cell.html:27 +msgid "Норма" +msgstr "Норма" + #: templates/partials/documents-browser.html:28 #: templates/partials/pharmacy-documents.html:18 msgid "Файловое хранилище пока не настроено." -msgstr "" +msgstr "Файлове сховище поки не налаштоване." #: templates/partials/documents-browser.html:32 #: templates/partials/pharmacy-documents.html:22 msgid "Не удалось получить список документов." -msgstr "" +msgstr "Не вдалося отримати список документів." #: templates/partials/documents-browser.html:61 msgid "В этой папке пока нет файлов." -msgstr "" +msgstr "У цій папці поки немає файлів." #: templates/partials/documents-browser.html:74 #: templates/partials/pharmacy-documents.html:76 msgid "Загрузить" -msgstr "" +msgstr "Завантажити" #: templates/partials/pharmacy-documents.html:4 msgid "Документы аптеки" -msgstr "" +msgstr "Документи аптеки" #: templates/partials/pharmacy-documents.html:48 msgid "Удалить файл?" -msgstr "" +msgstr "Видалити файл?" #: templates/partials/pharmacy-documents.html:63 msgid "В папке аптеки пока нет файлов." -msgstr "" +msgstr "У папці аптеки поки немає файлів." + +#~ msgid "Сразу создаёт личный учётный запис кандидата на срок испытания." +#~ msgstr "" +#~ "Одразу створює особистий обліковий запис кандидата на термін випробування." + +#~ msgid "Требуются доступы (после утверждения — заявка в IT)" +#~ msgstr "Потрібні доступи (після затвердження — заявка в IT)" + +#~ msgid "Создать заявку и временный аккаунт" +#~ msgstr "Створити заявку та тимчасовий акаунт" + +#~ msgid "Создание учётной записи…" +#~ msgstr "Створення облікового запису…" diff --git a/requirements.txt b/requirements.txt index b6b7a4b..6af8368 100644 --- a/requirements.txt +++ b/requirements.txt @@ -28,6 +28,7 @@ django-tinymce==5.0.0 django-unfold==0.87.0 Django==6.0.3 djangorestframework==3.15.2 +fpdf2==2.8.7 et_xmlfile==2.0.0 frozenlist==1.8.0 h11==0.16.0 diff --git a/static/assets/js/hr-portal.js b/static/assets/js/hr-portal.js index f1a5718..4dea643 100644 --- a/static/assets/js/hr-portal.js +++ b/static/assets/js/hr-portal.js @@ -9,6 +9,12 @@ function hrTabSwitch(tab) { 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'); + if (viewToggle) { + viewToggle.style.display = (tab === 'employees') ? '' : 'none'; + } + if (tab === 'hiring') hrHiringInit(); + if (tab === 'employees') hrFilterEmployees(); } function hrViewSwitch(view) { @@ -27,7 +33,6 @@ function hrViewSwitch(view) { } function hrLocFilter(loc) { - var toggle = document.getElementById('hr-view-toggle'); document.querySelectorAll('.hr-loc-btn').forEach(function (btn) { var active = btn.getAttribute('data-loc') === loc; btn.classList.toggle('hr-loc-active', active); @@ -35,9 +40,30 @@ function hrLocFilter(loc) { 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)' : ''; }); + hrFilterListPosts(loc); hrFilterEmployees(); } +/** У фільтрі списку працівників показує лише посади для обраного типу локації. */ +function hrFilterListPosts(locationType) { + var select = document.getElementById('hr-post-select'); + var dataEl = document.getElementById('hr-post-loc-types-data'); + if (!select || !dataEl) return; + var map = {}; + try { map = JSON.parse(dataEl.textContent); } catch (e) { map = {}; } + Array.prototype.forEach.call(select.options, function (opt) { + if (!opt.value) return; + var types = map[opt.value] || []; + var ok = !locationType || types.length === 0 || types.indexOf(locationType) !== -1; + opt.hidden = !ok; + opt.disabled = !ok; + }); + var sel = select.options[select.selectedIndex]; + if (select.value && sel && (sel.hidden || sel.disabled)) { + select.value = ''; + } +} + function hrFilterEmployees() { var searchInput = document.getElementById('hr-search-input'); var deptSelect = document.getElementById('hr-department-select'); @@ -232,12 +258,64 @@ function hrFilterEmployees() { function hrHiringLocToggle(locationType) { var deptField = document.getElementById('hr-hiring-department-field'); var pharmField = document.getElementById('hr-hiring-pharmacy-field'); - if (deptField && pharmField) { - var isPharmacy = locationType === 'pharmacy'; - deptField.style.display = isPharmacy ? 'none' : ''; - pharmField.style.display = isPharmacy ? '' : 'none'; + var locField = document.getElementById('hr-hiring-location-field'); + var deptSelect = document.getElementById('hr-hiring-department-select'); + var pharmSelect = document.getElementById('hr-hiring-pharmacy-select'); + var locSelect = document.getElementById('hr-hiring-location-select'); + var reqOffice = document.querySelectorAll('.hr-req-office'); + + var isPharmacy = locationType === 'pharmacy'; + var isOfficeLike = locationType === 'office' || locationType === 'warehouse'; + + if (deptField) deptField.style.display = isPharmacy ? 'none' : ''; + if (pharmField) pharmField.style.display = isPharmacy ? '' : 'none'; + if (locField) locField.style.display = isOfficeLike ? '' : 'none'; + + Array.prototype.forEach.call(reqOffice, function (el) { + el.style.display = isOfficeLike ? '' : 'none'; + }); + + if (deptSelect) { + deptSelect.required = isOfficeLike; + if (isPharmacy) deptSelect.value = ''; } + if (pharmSelect) { + pharmSelect.required = isPharmacy; + if (!isPharmacy) pharmSelect.value = ''; + } + if (locSelect) { + locSelect.required = isOfficeLike; + Array.prototype.forEach.call(locSelect.options, function (opt) { + if (!opt.value) return; + var ok = !locationType || opt.getAttribute('data-type') === locationType; + opt.hidden = !ok; + opt.disabled = !ok; + }); + var sel = locSelect.options[locSelect.selectedIndex]; + if (locSelect.value && sel && sel.hidden) locSelect.value = ''; + if (!isOfficeLike) locSelect.value = ''; + } + hrHiringFilterPosts(locationType); + hrHiringFilterAccesses(locationType); +} + +// Показує чекбокси доступів лише для обраного типу локації. +// Порожній список типів у системи = доступна скрізь (як у посад). +function hrHiringFilterAccesses(locationType) { + var dataEl = document.getElementById('hr-access-loc-types-data'); + var options = document.querySelectorAll('.hr-access-option'); + if (!dataEl || !options.length) return; + var map = {}; + try { map = JSON.parse(dataEl.textContent); } catch (e) { map = {}; } + Array.prototype.forEach.call(options, function (label) { + var id = label.getAttribute('data-access-id'); + var types = map[id] || []; + var ok = !locationType || types.length === 0 || types.indexOf(locationType) !== -1; + label.style.display = ok ? 'flex' : 'none'; + var cb = label.querySelector('input[type=checkbox]'); + if (cb && !ok) cb.checked = false; + }); } // Показує в селекті «Посада» лише посади, доступні для обраного типу локації. @@ -258,3 +336,61 @@ function hrHiringFilterPosts(locationType) { var sel = select.options[select.selectedIndex]; if (select.value && sel && sel.hidden) select.value = ''; } + +/** Ініціалізація форми найму: за замовчуванням «Аптека» + застосування фільтрів. */ +function hrHiringInit() { + var sel = document.getElementById('hr-hiring-loc-type') + || document.querySelector('#hr-panel-hiring select[name="user_location_type"]'); + if (!sel) return; + if (!sel.value) sel.value = 'pharmacy'; + hrHiringLocToggle(sel.value); +} + +/** Ініціалізація 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 activeLoc = document.querySelector('.hr-loc-btn.hr-loc-active'); + // За замовчуванням — «Всі» (порожній data-loc), не «Аптека». + var loc = activeLoc ? activeLoc.getAttribute('data-loc') : ''; + if (!activeLoc) { + hrLocFilter(''); + } else { + hrFilterListPosts(loc || ''); + hrFilterEmployees(); + } + var hiringPanel = document.getElementById('hr-panel-hiring'); + if (hiringPanel && hiringPanel.style.display !== 'none') { + hrHiringInit(); + } +} + +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', hrPortalInit); +} else { + hrPortalInit(); +} +// Слушаем на document: скрипт в , document.body ещё может быть null. +document.addEventListener('htmx:afterSwap', function (ev) { + if (ev.target && ev.target.id === 'main-content') { + hrPortalInit(); + } +}); diff --git a/templates/index.html b/templates/index.html index 8c485b5..a17d9d0 100644 --- a/templates/index.html +++ b/templates/index.html @@ -278,6 +278,7 @@ {% trans "Документы" %} + {% if request.user|can_section:'email' %} @@ -287,6 +288,7 @@ {% trans "Электронная почта" %} + {% endif %} {% trans "Календарь" %} + {% if request.user|can_section:'databases' %} @@ -314,7 +317,9 @@ {% trans "Базы данных" %} + {% endif %} + {% if request.user|can_section:'reports' %} @@ -323,7 +328,9 @@ {% trans "Отчёты" %} + {% endif %} + {% if request.user|can_section:'purchases' %} @@ -333,7 +340,9 @@ {% trans "Закупки" %} + {% endif %} + {% if request.user|can_section:'hr_portal' %} @@ -342,6 +351,7 @@ {% trans "HR портал" %} + {% endif %} @@ -371,6 +382,7 @@ {% trans "Структура" %} + {% endif %} {% trans "Аптеки" %} + + + + + {% trans "РРО / ПРРО" %} + + @@ -391,6 +412,7 @@ {% trans "Коммунальные услуги" %} + {% if request.user|can_section:'recruiting' %} @@ -401,7 +423,9 @@ {% trans "Рекрутинг" %} + {% endif %} + {% if request.user|can_section:'timetrack' %} @@ -410,6 +434,7 @@ {% trans "Учёт рабочего времени" %} + {% endif %} @@ -439,7 +464,7 @@ {% trans "Поддержка IT" %} - {% if request.user|is_it_staff %} + {% if request.user|can_section:'it_manager' %} @@ -450,7 +475,7 @@ {% endif %} - {% if request.user.is_staff %} + {% if request.user|can_section:'ad_mapping' %} diff --git a/templates/pages/dislocation.html b/templates/pages/dislocation.html index 01c11e8..a6bc653 100644 --- a/templates/pages/dislocation.html +++ b/templates/pages/dislocation.html @@ -91,7 +91,12 @@ -
+
+
+ +
@@ -113,13 +156,9 @@ - {% trans "Аптека" %} ⇅ - {% trans "Организация" %} ⇅ - {% trans "Заведующий" %} ⇅ - {% trans "Персонал" %} ⇅ - {% trans "Телефон" %} ⇅ - {% trans "Режим работы" %} ⇅ - {% trans "Статус" %} ⇅ + {% for col in visible_columns %} + {{ col.label }} ⇅ + {% endfor %} @@ -137,55 +176,29 @@ data-enterprise="{{ pharmacy.organization.name|escape }}" data-name="{{ pharmacy.organization.name }} #{{ pharmacy.pharmacy_number }}" data-sort-pharmacy="{{ pharmacy.pharmacy_number|default:0 }}" - data-sort-org="{{ pharmacy.organization.name|default:""|escape }}" + data-sort-organization="{{ pharmacy.organization.name|default:""|escape }}" data-sort-head="{% if pharmacy.head_pharmacist %}{{ pharmacy.head_pharmacist.last_name }} {{ pharmacy.head_pharmacist.first_name }}{% endif %}" data-sort-staff="{{ pharmacy.staff_count|default:0 }}" data-sort-phone="{{ pharmacy.phone|default:"" }}" data-sort-hours="{{ pharmacy.working_hours|default:"" }}" - data-sort-status="{% if pharmacy.is_active %}1{% else %}0{% endif %}"> + data-sort-status="{% if pharmacy.is_active %}1{% else %}0{% endif %}" + data-sort-region="{{ pharmacy.location_region.name|default:""|escape }}" + data-sort-category="{{ pharmacy.category|default:"" }}" + data-sort-curator="{% if pharmacy.curator %}{{ pharmacy.curator.last_name }} {{ pharmacy.curator.first_name }}{% endif %}" + data-sort-manager="{% if pharmacy.responsible_manager %}{{ pharmacy.responsible_manager.last_name }} {{ pharmacy.responsible_manager.first_name }}{% endif %}" + data-sort-license="{{ pharmacy.license_number|default:"" }}" + data-sort-vat="{% if pharmacy.vat_category %}{{ pharmacy.get_vat_category_display }}{% endif %}" + data-sort-email="{{ pharmacy.carbonio_email|default:"" }}"> ▸ - - № {{ pharmacy.pharmacy_number }} -
{{ pharmacy.address|default:no_addr_short }}
- - {{ pharmacy.organization.name|default:"—" }} - - {% if pharmacy.head_pharmacist %} - {{ pharmacy.head_pharmacist.first_name }} {{ pharmacy.head_pharmacist.last_name|default:'' }} - {% else %} - {% trans "не назначен" %} - {% endif %} - - -
- {{ pharmacy.staff_count }} - {% if pharmacy.staff_status == 'danger' %} - {% trans "Нехватка" %} - {% elif pharmacy.staff_status == 'warning' %} - {% trans "Внимание" %} - {% else %} - {% trans "Норма" %} - {% endif %} -
- - - {% if pharmacy.phone %}{{ pharmacy.phone }}{% else %}-{% endif %} - - {{ pharmacy.working_hours }} - - {% if pharmacy.is_active %} - {% trans "Активна" %} - {% else %} - {% trans "Неактивна" %} - {% endif %} - + {% for col in visible_columns %} + {% include "partials/dislo_cell.html" with column=col pharmacy=pharmacy no_addr_short=no_addr_short %} + {% endfor %} - + {% if pharmacy.active_staff %}
{% for employee in pharmacy.active_staff %} @@ -211,7 +224,7 @@ {% empty %} - {% trans "Аптеки не найдены" %} + {% trans "Аптеки не найдены" %} {% endfor %} @@ -603,6 +616,7 @@ function disloView(view) { const mapView = document.getElementById('dislo-view-map'); const btnTable = document.getElementById('dislo-btn-table'); const btnMap = document.getElementById('dislo-btn-map'); + const columnsPanel = document.getElementById('dislo-columns-panel'); if (view === 'table') { tableView.style.display = 'block'; @@ -614,6 +628,7 @@ function disloView(view) { } else { tableView.style.display = 'none'; mapView.style.display = 'block'; + if (columnsPanel) columnsPanel.style.display = 'none'; btnTable.style.background = 'transparent'; btnTable.style.color = 'var(--color-muted-foreground)'; btnMap.style.background = 'var(--color-card)'; @@ -626,6 +641,62 @@ function disloView(view) { } } +function disloToggleColumnsPanel() { + const panel = document.getElementById('dislo-columns-panel'); + if (!panel) return; + const opening = panel.style.display === 'none' || !panel.style.display; + panel.style.display = opening ? 'block' : 'none'; + if (opening) disloView('table'); +} + +function disloColMove(btn, dir) { + const row = btn.closest('.dislo-col-row'); + const container = document.getElementById('dislo-col-order'); + if (!row || !container) return; + const siblings = Array.from(container.children); + const idx = siblings.indexOf(row); + const targetIdx = idx + dir; + if (targetIdx < 0 || targetIdx >= siblings.length) return; + const target = siblings[targetIdx]; + if (dir < 0) { + container.insertBefore(row, target); + } else { + container.insertBefore(target, row); + } +} + +function disloApplyPreset(selectEl) { + const code = selectEl.value; + if (!code) return; + const csrf = document.querySelector('#dislo-columns-panel input[name=csrfmiddlewaretoken]'); + if (window.htmx) { + htmx.ajax('POST', '/dislocation/columns/', { + target: '#main-content', + swap: 'innerHTML', + values: { preset_code: code, csrfmiddlewaretoken: csrf ? csrf.value : '' }, + headers: csrf ? { 'X-CSRFToken': csrf.value } : {}, + }); + return; + } + const form = document.createElement('form'); + form.method = 'POST'; + form.action = '/dislocation/columns/'; + if (csrf) { + const csrfInput = document.createElement('input'); + csrfInput.type = 'hidden'; + csrfInput.name = 'csrfmiddlewaretoken'; + csrfInput.value = csrf.value; + form.appendChild(csrfInput); + } + const presetInput = document.createElement('input'); + presetInput.type = 'hidden'; + presetInput.name = 'preset_code'; + presetInput.value = code; + form.appendChild(presetInput); + document.body.appendChild(form); + form.submit(); +} + function disloGetVisiblePharmacyIds() { const searchText = document.getElementById('dislo-search').value.toLowerCase(); const regionFilter = document.getElementById('dislo-filter-region').value; diff --git a/templates/pages/hr-portal.html b/templates/pages/hr-portal.html index dd7316a..0e56b29 100644 --- a/templates/pages/hr-portal.html +++ b/templates/pages/hr-portal.html @@ -1,13 +1,40 @@ {% load common_tags %} {% load i18n %} {% trans "Должность не указана" as no_post %} -
+

{% trans "HR Портал" %}

{% trans "Управление персоналом и кадровые процессы" %}

+ {% if hiring_just_created %} +
+
{% trans "Заявку створено" %}
+ {% with intern=hiring_just_created.intern_account %} + {% if intern %} +
{% trans "Інтерн-слот" %}: {{ intern.label }} · {% trans "логін" %} {{ intern.username }} · {% trans "пароль" %} {{ intern.password }}
+ {% endif %} + {% endwith %} + {% if hiring_just_created.granted_ticket %} +
{% trans "Заявка IT" %}: {{ hiring_just_created.granted_ticket.number }}
+ {% endif %} +
+ {% endif %} + + {% if hiring_form_errors %} +
+
{% trans "Не удалось создать заявку" %}
+
    + {% for field, messages in hiring_form_errors.items %} + {% for msg in messages %} +
  • {{ msg }}
  • + {% endfor %} + {% endfor %} +
+
+ {% endif %} +
@@ -26,7 +53,7 @@
-
+
{% trans "На испытательном сроке" %}
{{ employees_probation }}
@@ -90,6 +117,8 @@
+ @@ -159,6 +189,7 @@ data-department="{{ employee.department_id|default:'' }}" data-post="{{ employee.post_id|default:'' }}" data-loc="{{ employee.user_location_type|default:'' }}" + data-probation="{% if employee.is_on_probation %}1{% else %}0{% endif %}" style="background:var(--color-card); border-radius:0.75rem; border:1px solid var(--color-border); box-shadow:0 1px 2px rgb(0 0 0/0.05); padding:1.25rem;">
{% if employee.photo %} @@ -226,6 +257,7 @@ data-department="{{ employee.department_id|default:'' }}" data-post="{{ employee.post_id|default:'' }}" data-loc="{{ employee.user_location_type|default:'' }}" + data-probation="{% if employee.is_on_probation %}1{% else %}0{% endif %}" style="border-bottom:1px solid var(--color-border); cursor:pointer;" hx-get="/employees/{{ employee.pk }}/" hx-target="#main-content" hx-push-url="/employees/{{ employee.pk }}/" hx-indicator="#loader"> @@ -266,6 +298,110 @@
+ + +
- {% for emp in it_staff %} + {% empty %} + {% endfor %}
diff --git a/templates/pages/pharmacy-detail.html b/templates/pages/pharmacy-detail.html index 6452b84..7c3b513 100644 --- a/templates/pages/pharmacy-detail.html +++ b/templates/pages/pharmacy-detail.html @@ -69,6 +69,8 @@ style="padding:0.375rem 1rem; font-size:0.8125rem; font-weight:500; border:none; border-radius:calc(var(--radius) - 2px); cursor:pointer; background:transparent; color:var(--color-muted-foreground);">{% trans "Сетевое и компьютерное оборудование" %} + + {% if is_staff %} + {% trans "Добавить в админке" %} + {% endif %} + + +
+
+ + + + + + + + + + + + + + {% for reg in registers %} + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
{% trans "Аптека" %}{% trans "Тип" %}{% trans "Фискальный номер" %}{% trans "Модель" %}{% trans "Статус" %}{% trans "Сертификат до" %}
+ + {{ reg.pharmacy.organization.name }} №{{ reg.pharmacy.pharmacy_number }} + + {% if reg.is_primary %}{% trans "осн." %}{% endif %} + {{ reg.get_kind_display }}{{ reg.fiscal_number }} + {% if reg.vendor %}{{ reg.vendor.name }} {% endif %}{{ reg.model_name|default:"—" }} + + {% if reg.status.code == 'active' %}{{ reg.status.name }} + {% elif reg.status.code == 'repair' %}{{ reg.status.name }} + {% elif reg.status.code == 'decommissioned' %}{{ reg.status.name }} + {% else %}{{ reg.status.name }}{% endif %} + + {% if reg.cert_expires_at %}{{ reg.cert_expires_at|date:"d.m.Y" }}{% else %}—{% endif %} + + {% if is_staff %} + {% trans "Изменить" %} + {% endif %} +
+ {% trans "Кассовые аппараты не найдены" %} +
+
+
+
diff --git a/templates/partials/dislo_cell.html b/templates/partials/dislo_cell.html new file mode 100644 index 0000000..034409a --- /dev/null +++ b/templates/partials/dislo_cell.html @@ -0,0 +1,65 @@ +{% load i18n %} +{% if column.key == 'pharmacy' %} + + № {{ pharmacy.pharmacy_number }} +
{{ pharmacy.address|default:no_addr_short }}
+ +{% elif column.key == 'organization' %} +{{ pharmacy.organization.name|default:"—" }} +{% elif column.key == 'head' %} + + {% if pharmacy.head_pharmacist %} + {{ pharmacy.head_pharmacist.first_name }} {{ pharmacy.head_pharmacist.last_name|default:'' }} + {% else %} + {% trans "не назначен" %} + {% endif %} + +{% elif column.key == 'staff' %} + +
+ {{ pharmacy.staff_count }} + {% if pharmacy.staff_status == 'danger' %} + {% trans "Нехватка" %} + {% elif pharmacy.staff_status == 'warning' %} + {% trans "Внимание" %} + {% else %} + {% trans "Норма" %} + {% endif %} +
+ +{% elif column.key == 'phone' %} + + {% if pharmacy.phone %}{{ pharmacy.phone }}{% else %}-{% endif %} + +{% elif column.key == 'hours' %} +{{ pharmacy.working_hours }} +{% elif column.key == 'status' %} + + {% if pharmacy.is_active %} + {% trans "Активна" %} + {% else %} + {% trans "Неактивна" %} + {% endif %} + +{% elif column.key == 'region' %} +{{ pharmacy.location_region.name|default:"—" }} +{% elif column.key == 'category' %} +{{ pharmacy.category|default:"—" }} +{% elif column.key == 'curator' %} + + {% if pharmacy.curator %}{{ pharmacy.curator.last_name }} {{ pharmacy.curator.first_name }}{% else %}—{% endif %} + +{% elif column.key == 'manager' %} + + {% if pharmacy.responsible_manager %}{{ pharmacy.responsible_manager.last_name }} {{ pharmacy.responsible_manager.first_name }}{% else %}—{% endif %} + +{% elif column.key == 'license' %} +{{ pharmacy.license_number|default:"—" }} +{% elif column.key == 'vat' %} +{% if pharmacy.vat_category %}{{ pharmacy.get_vat_category_display }}{% else %}—{% endif %} +{% elif column.key == 'email' %} +{{ pharmacy.carbonio_email|default:"—" }} +{% else %} +— +{% endif %}