From 5e4528b23fadef08ba287637e092e83b15523336 Mon Sep 17 00:00:00 2001 From: lokses Date: Wed, 22 Jul 2026 17:33:48 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=BE=D1=80=D1=82=D0=B0=D0=BB:=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D0=B0=D0=BD=D0=B8=D1=86=D1=8B=20=D0=BD=D0=B0=20?= =?UTF-8?q?=D1=80=D0=B5=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B=D1=85=20=D0=B4=D0=B0?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D1=85,=20=D0=B4=D0=B5=D1=82=D0=B0=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=8B=D0=B5=20=D1=81=D1=82=D1=80=D0=B0=D0=BD=D0=B8?= =?UTF-8?q?=D1=86=D1=8B,=20=D1=83=D1=82=D0=B8=D0=BB=D0=B8=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - core/views: dashboard/databases/it-support/structure на живых данных; детальная сотрудника (/employees//) с PII-гейтингом (staff/сам/ руководитель), детальная аптеки, дислокация, профиль, показания счётчиков - core/utils: crypto (Fernet-обёртка), pagination; партиалы пагинации и AD-вкладок - шаблоны страниц обновлены под реальные контексты - тесты (21): смоук всех страниц, PII-доступ сотрудника, дерево справочников Co-Authored-By: Claude Opus 4.8 --- core/tests.py | 147 +++- core/utils/crypto.py | 19 + core/utils/pagination.py | 39 ++ core/views.py | 623 ++++++++++++++--- static/location_field/js/form.js | 656 ++++++++++++++++++ templates/pages/chat.html | 231 +++---- templates/pages/dashboard.html | 40 +- templates/pages/dislocation.html | 775 ++++++++++++++++++++++ templates/pages/employee-detail.html | 249 +++++++ templates/pages/employees.html | 17 +- templates/pages/hr-portal.html | 668 ++++++++++++++++--- templates/pages/it-support.html | 24 +- templates/pages/pharmacies.html | 85 ++- templates/pages/pharmacy-detail.html | 384 ++++++++--- templates/pages/profile.html | 106 +++ templates/pages/structure.html | 270 +++----- templates/pages/utilities.html | 150 +++++ templates/partials/ad_connector_tabs.html | 15 + templates/partials/pagination.html | 40 ++ 19 files changed, 3900 insertions(+), 638 deletions(-) create mode 100644 core/utils/crypto.py create mode 100644 core/utils/pagination.py create mode 100644 static/location_field/js/form.js create mode 100644 templates/pages/dislocation.html create mode 100644 templates/pages/employee-detail.html create mode 100644 templates/pages/profile.html create mode 100644 templates/pages/utilities.html create mode 100644 templates/partials/ad_connector_tabs.html create mode 100644 templates/partials/pagination.html diff --git a/core/tests.py b/core/tests.py index e0965b5..bbc1f2d 100644 --- a/core/tests.py +++ b/core/tests.py @@ -3,6 +3,7 @@ структура и перестройка дерева Directory. """ from datetime import date +from unittest import mock from django.contrib.auth import get_user_model from django.core.management import call_command @@ -10,7 +11,9 @@ from django.db.models import Count from django.test import TestCase from apps.it_manager.models import Ticket +from apps.mattermost_connector.models import MattermostUser from core.models import Directory, DirectoryType +from core.views import _build_chat_context Employee = get_user_model() @@ -40,25 +43,108 @@ class PortalPagesSmokeTests(TestCase): PAGES = [ '/dashboard/', '/employees/', '/pharmacies/', '/structure/', + '/dislocation/', '/timetrack/', '/calendar/', '/reports/', '/documents/', - '/recruiting/', '/settings/', '/news/', '/tasks/', '/tasks/detail/', + '/recruiting/', '/settings/', '/news/', '/tasks/', '/email/', '/chat/', '/databases/', '/purchases/', '/hr-portal/', - '/help/', '/it-support/', '/ad-mapping/', '/pharmacies/detail/', + '/help/', '/it-support/', '/ad-mapping/', '/it-manager/', ] @classmethod def setUpTestData(cls): + from apps.tasks.models import Task + from apps.locations.models.pharmacies import Pharmacy + from core.models import Directory, DirectoryType + cls.user = make_employee(is_staff=True, is_superuser=True) + # /tasks// — детальная страница задачи, для неё нужен реальный pk + cls.task = Task.objects.create(title='Смоук-задача', creator=cls.user) + # /pharmacies// — те саме для аптеки + region_type, _ = DirectoryType.objects.get_or_create( + code='region', defaults={'name': 'Регіони', 'hierarchy_type': 'strict'}, + ) + region = Directory.objects.create(directory_type=region_type, name='Смоук-регіон') + org_type, _ = DirectoryType.objects.get_or_create( + code='organization', defaults={'name': 'Організації', 'hierarchy_type': 'strict'}, + ) + org = Directory.objects.create(directory_type=org_type, name='Смоук-організація') + cls.pharmacy = Pharmacy.objects.create( + location_type='pharmacy', location_region=region, organization=org, + pharmacy_number=1, street='Смоук-тестова', house_number='1', + ) def test_all_pages_render(self): self.client.force_login(self.user) - for url in self.PAGES: + detail_urls = [ + f'/tasks/{self.task.pk}/', + f'/employees/{self.user.pk}/', + f'/pharmacies/{self.pharmacy.pk}/', + ] + for url in self.PAGES + detail_urls: with self.subTest(url=url): fragment = self.client.get(url, HTTP_HX_REQUEST='true') self.assertEqual(fragment.status_code, 200, url) full = self.client.get(url) self.assertEqual(full.status_code, 200, url) + +class EmployeeDetailTests(TestCase): + """Детальна сторінка співробітника: PII бачать лише staff/сам/керівник""" + + @classmethod + def setUpTestData(cls): + cls.staff = make_employee(is_staff=True) + cls.manager = make_employee(personal_phone='+380501111111') + cls.employee = make_employee( + manager=cls.manager, personal_phone='+380502222222', + ) + cls.stranger = make_employee() + + def test_staff_sees_pii(self): + self.client.force_login(self.staff) + html = self.client.get(f'/employees/{self.employee.pk}/', HTTP_HX_REQUEST='true').content.decode() + self.assertIn('Особисті дані', html) + self.assertIn('Інтеграції', html) + self.assertIn(self.employee.personal_phone, html) + + def test_manager_sees_subordinate_pii(self): + self.client.force_login(self.manager) + html = self.client.get(f'/employees/{self.employee.pk}/', HTTP_HX_REQUEST='true').content.decode() + self.assertIn('Особисті дані', html) + self.assertIn(self.employee.personal_phone, html) + + def test_self_sees_own_pii(self): + self.client.force_login(self.employee) + html = self.client.get(f'/employees/{self.employee.pk}/', HTTP_HX_REQUEST='true').content.decode() + self.assertIn('Особисті дані', html) + + def test_stranger_does_not_see_pii(self): + self.client.force_login(self.stranger) + response = self.client.get(f'/employees/{self.employee.pk}/', HTTP_HX_REQUEST='true') + html = response.content.decode() + self.assertEqual(response.status_code, 200) + self.assertIn(self.employee.last_name, html) + self.assertNotIn('Особисті дані', html) + self.assertNotIn('Інтеграції', html) + self.assertNotIn(self.employee.personal_phone, html) + + def test_manager_chain_and_subordinates_shown(self): + self.client.force_login(self.staff) + html = self.client.get(f'/employees/{self.manager.pk}/', HTTP_HX_REQUEST='true').content.decode() + self.assertIn(self.employee.last_name, html) + + html2 = self.client.get(f'/employees/{self.employee.pk}/', HTTP_HX_REQUEST='true').content.decode() + self.assertIn(self.manager.last_name, html2) + + def test_unknown_employee_404(self): + self.client.force_login(self.staff) + response = self.client.get('/employees/999999/', HTTP_HX_REQUEST='true') + self.assertEqual(response.status_code, 404) + + def test_anonymous_redirected(self): + response = self.client.get(f'/employees/{self.employee.pk}/') + self.assertEqual(response.status_code, 302) + def test_anonymous_is_redirected_to_login(self): response = self.client.get('/dashboard/') self.assertEqual(response.status_code, 302) @@ -84,8 +170,8 @@ class ITSupportViewTests(TestCase): self.assertEqual(response.status_code, 200) ticket = Ticket.objects.get(requester=self.user) self.assertEqual(ticket.title, 'Не працює принтер') - self.assertEqual(ticket.ticket_type, 'repair') - self.assertEqual(ticket.priority, 'high') + self.assertEqual(ticket.ticket_type.code, 'repair') + self.assertEqual(ticket.priority.code, 'high') self.assertContains(response, ticket.number) def test_invalid_choices_fall_back_to_defaults(self): @@ -96,8 +182,8 @@ class ITSupportViewTests(TestCase): 'priority': 'hacked_value', }, HTTP_HX_REQUEST='true') ticket = Ticket.objects.get(requester=self.user) - self.assertEqual(ticket.ticket_type, 'other') - self.assertEqual(ticket.priority, 'medium') + self.assertEqual(ticket.ticket_type.code, 'other') + self.assertEqual(ticket.priority.code, 'medium') def test_empty_title_does_not_create_ticket(self): self.client.post('/it-support/', { @@ -107,9 +193,11 @@ class ITSupportViewTests(TestCase): def test_my_tickets_shows_only_own(self): other = make_employee() + from core.models.directories import Directory Ticket.objects.create( title='Чужа заявка', description='x', - ticket_type='other', requester=other, + ticket_type_id=Directory.objects.get(directory_type__code='ticket_type', code='other').id, + requester=other, ) response = self.client.get('/it-support/', HTTP_HX_REQUEST='true') self.assertNotContains(response, 'Чужа заявка') @@ -204,3 +292,46 @@ class RebuildDirectoryTreeTests(TestCase): .annotate(cnt=Count('id')).filter(cnt__gt=1) ) self.assertFalse(duplicates.exists()) + + +class ChatColleaguesTests(TestCase): + def setUp(self): + self.me = make_employee(mattermost_user_id='mm-me', first_name='Іван', last_name='Мене') + self.colleague = make_employee(mattermost_user_id='mm-colleague', first_name='Петро', last_name='Колега') + self.inactive_colleague = make_employee(mattermost_user_id='mm-inactive', first_name='Гість', last_name='Неактивний') + + MattermostUser.objects.create( + django_user=self.me, mattermost_id='mm-me', username='me', email=self.me.email, is_active=True, + ) + MattermostUser.objects.create( + django_user=self.colleague, mattermost_id='mm-colleague', username='colleague', + email=self.colleague.email, is_active=True, + ) + MattermostUser.objects.create( + django_user=self.inactive_colleague, mattermost_id='mm-inactive', username='inactive', + email=self.inactive_colleague.email, is_active=False, + ) + + def _build(self): + request = mock.Mock() + request.user = self.me + with mock.patch('core.views.ConnectorConfig.get_for') as get_for: + get_for.return_value = mock.Mock(extra_config={'public_url': 'https://chat.example.com'}) + with mock.patch( + 'apps.mattermost_connector.services.token_service.ensure_personal_access_token', + return_value='fake-token', + ): + return _build_chat_context(request) + + def test_colleagues_excludes_self_and_inactive(self): + context = self._build() + self.assertEqual(context['chat_state'], 'ready') + ids = [c['mattermostId'] for c in context['chat_colleagues']] + self.assertIn('mm-colleague', ids) + self.assertNotIn('mm-me', ids) + self.assertNotIn('mm-inactive', ids) + + def test_colleague_name_from_full_name(self): + context = self._build() + names = {c['mattermostId']: c['name'] for c in context['chat_colleagues']} + self.assertEqual(names['mm-colleague'], 'Петро Колега') diff --git a/core/utils/crypto.py b/core/utils/crypto.py new file mode 100644 index 0000000..d8e6e98 --- /dev/null +++ b/core/utils/crypto.py @@ -0,0 +1,19 @@ +import base64 +import hashlib + +from django.conf import settings +from cryptography.fernet import Fernet + + +def _get_fernet() -> Fernet: + key_source = getattr(settings, 'FIELD_ENCRYPTION_KEY', None) or settings.SECRET_KEY + derived_key = base64.urlsafe_b64encode(hashlib.sha256(key_source.encode()).digest()) + return Fernet(derived_key) + + +def encrypt_secret(plain: str) -> str: + return _get_fernet().encrypt(plain.encode()).decode() + + +def decrypt_secret(cipher: str) -> str: + return _get_fernet().decrypt(cipher.encode()).decode() diff --git a/core/utils/pagination.py b/core/utils/pagination.py new file mode 100644 index 0000000..99f0207 --- /dev/null +++ b/core/utils/pagination.py @@ -0,0 +1,39 @@ +from django.core.paginator import Paginator + +PAGE_SIZE_CHOICES = [10, 15, 25, 100] +DEFAULT_PAGE_SIZE = 25 + + +def _fallback_size(request, default_size): + """Розмір сторінки, коли в GET нічого не передано: персональна настройка + користувача (Employee.default_page_size), якщо вона задана і валідна, + інакше default_size, переданий конкретним view.""" + user = getattr(request, 'user', None) + user_size = getattr(user, 'default_page_size', None) if user and user.is_authenticated else None + return user_size if user_size in PAGE_SIZE_CHOICES else default_size + + +def paginate(request, queryset, default_size=DEFAULT_PAGE_SIZE, page_param='page', size_param='per_page'): + """ + Постраничная разбивка queryset/list по GET-параметрам page_param/size_param. + Размер страницы ограничен PAGE_SIZE_CHOICES (10/15/25/100) — произвольные + значения из строки запроса откатываются на default_size. + Именованные page_param/size_param позволяют держать на одной странице + несколько независимо пагинируемых таблиц (например tickets_page, tickets_per_page). + + Если size_param в запросе не передан явно (обычная навигация, не смена + размера страницы через селектор), используется персональная настройка + пользователя (Employee.default_page_size), заданная на /settings/. + """ + raw = request.GET.get(size_param) + if raw is None: + raw = _fallback_size(request, default_size) + try: + per_page = int(raw) + except (TypeError, ValueError): + per_page = default_size + if per_page not in PAGE_SIZE_CHOICES: + per_page = default_size + + paginator = Paginator(queryset, per_page) + return paginator.get_page(request.GET.get(page_param, 1)) diff --git a/core/views.py b/core/views.py index 9c0858f..b33c714 100644 --- a/core/views.py +++ b/core/views.py @@ -1,4 +1,7 @@ -from django.shortcuts import render, redirect +from django.core.exceptions import ValidationError +from django.db.models import Q, Count, Prefetch +from django.http import HttpResponseForbidden, JsonResponse, HttpResponseNotAllowed +from django.shortcuts import render, redirect, get_object_or_404 from django.contrib.auth import authenticate, login, logout from django.contrib.auth.decorators import login_required from django.utils.http import url_has_allowed_host_and_scheme @@ -6,14 +9,13 @@ from django.conf import settings 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.utils.htmx import render_page +from core.utils.pagination import paginate import json # Create your views here. -def is_htmx_request(request): - """Check if request is from HTMX""" - return request.headers.get('HX-Request') == 'true' - @login_required def index_view(request): return render(request, 'index.html') @@ -21,21 +23,24 @@ def index_view(request): @login_required def dashboard_view(request): from django.utils import timezone as dj_timezone + from core.middleware import ONLINE_WINDOW from core.models.directories import DirectoryType + from apps.feed.models import Post from apps.it_manager.models import Ticket + employees_active = Employee.objects.filter(is_active=True) + context = { 'today': dj_timezone.localdate(), - 'employees_active': Employee.objects.filter(is_active=True).count(), - 'employees_total': Employee.objects.count(), + 'latest_posts': Post.objects.filter(is_published=True).select_related('author')[:3], + 'employees_online': employees_active.filter(last_seen__gte=dj_timezone.now() - ONLINE_WINDOW).count(), + 'employees_active': employees_active.count(), 'pharmacies_active': Pharmacy.objects.filter(is_active=True).count(), - 'open_tickets': Ticket.objects.filter(status__in=['new', 'in_progress', 'waiting']).count(), + 'open_tickets': Ticket.objects.filter(status__code__in=['new', 'in_progress', 'waiting']).count(), 'directory_types_count': DirectoryType.objects.filter(is_active=True).count(), 'directories_count': Directory.objects.count(), } - if is_htmx_request(request): - return render(request, 'pages/dashboard.html', context) - return render(request, 'index.html', {'page_template': 'pages/dashboard.html', **context}) + return render_page(request, 'dashboard.html', context) @login_required def employees_view(request): @@ -58,6 +63,8 @@ def employees_view(request): employees = employees.filter(user_pharmacy_id=pharmacy_id) active_count = employees.filter(is_active=True).count() + total_count = employees.count() + page = paginate(request, employees) organizations = Directory.objects.filter( directory_type__code='organization', is_active=True @@ -86,8 +93,10 @@ def employees_view(request): ) context = { - 'employees': employees, + 'employees': page, + 'page': page, 'active_count': active_count, + 'total_count': total_count, 'organizations': organizations, 'current_pharmacies': current_pharmacies, 'pharmacies_by_org': pharmacies_by_org, @@ -95,9 +104,60 @@ def employees_view(request): 'filter_organization_id': organization_id, 'filter_pharmacy_id': pharmacy_id, } - if is_htmx_request(request): - return render(request, 'pages/employees.html', context) - return render(request, 'index.html', {'page_template': 'pages/employees.html', **context}) + return render_page(request, 'employees.html', context) + + +def _can_view_employee_pii(request, employee) -> bool: + """Особисті дані (телефони, дата народження, ІПН, екстрений контакт, + статуси AD/Mattermost) бачить сам співробітник, його безпосередній + керівник або staff. Решта колег бачать лише робочу картку. + Межа узгоджена з employees/serializers.py (EmployeeSerializer/ + EmployeeDetailSerializer) з коміту faa06b9.""" + return ( + request.user.is_staff + or request.user.pk == employee.pk + or employee.manager_id == request.user.pk + ) + + +@login_required +def employee_detail_view(request, pk): + employee = get_object_or_404( + Employee.objects.select_related( + 'post', 'department', 'user_organization', + 'user_pharmacy__organization', 'user_location', 'manager__post', + ).prefetch_related('groups'), + pk=pk, + ) + can_view_pii = _can_view_employee_pii(request, employee) + + context = { + 'employee': employee, + 'can_view_pii': can_view_pii, + 'can_edit': request.user.is_staff, + 'subordinates': employee.subordinates.filter(is_active=True).select_related('post') + if employee.is_manager else [], + 'manager_chain': employee.get_manager_chain(), + } + + if can_view_pii: + from apps.tasks.models import Task + + employee_tasks = ( + Task.objects.filter(Q(responsible=employee) | Q(co_executors=employee)) + .exclude(status='cancelled') + .distinct().order_by('-created_at') + ) + shown_limit = 10 + context['recent_tasks'] = list(employee_tasks[:shown_limit]) + context['recent_tasks_total'] = employee_tasks.count() + context['recent_tasks_hidden'] = max(0, context['recent_tasks_total'] - shown_limit) + context['last_attendance'] = ( + employee.attendance_logs.select_related('location').order_by('-timestamp').first() + ) + + return render_page(request, 'employee-detail.html', context) + def build_region_tree(region_nodes): allowed_types = {'region', 'district', 'city', 'city_district'} @@ -129,58 +189,109 @@ def build_region_tree(region_nodes): def pharmacies_view(request): pharmacies = Pharmacy.objects.select_related('organization', 'location_region', 'head_pharmacist').order_by('organization__name', 'pharmacy_number') active_count = pharmacies.filter(is_active=True).count() - + # Get unique regions for filter regions = pharmacies.values_list('location_region__name', flat=True).distinct().order_by('location_region__name') region_ids = sorted({rid for rid in pharmacies.values_list('location_region_id', flat=True).distinct() if rid}) region_nodes = Directory.objects.filter(id__in=region_ids).select_related('parent', 'directory_type') region_tree = build_region_tree(region_nodes) - + context = { 'pharmacies': pharmacies, 'active_count': active_count, 'regions': regions, 'region_tree_json': json.dumps(region_tree, ensure_ascii=False), } - if is_htmx_request(request): - return render(request, 'pages/pharmacies.html', context) - return render(request, 'index.html', {'page_template': 'pages/pharmacies.html', **context}) + return render_page(request, 'pharmacies.html', context) + + +@login_required +def dislocation_view(request): + """Дислокація: аптеки на карті/у таблиці разом з їхнім персоналом (замінює вкладку «Аптеки» на /structure/).""" + pharmacies = ( + Pharmacy.objects + .select_related('organization', 'location_region', 'head_pharmacist') + .prefetch_related(Prefetch( + 'pharmacy_employees', + queryset=Employee.objects.filter(is_active=True).select_related('post'), + to_attr='active_staff', + )) + .annotate(staff_count=Count('pharmacy_employees', filter=Q(pharmacy_employees__is_active=True))) + .order_by('organization__name', 'pharmacy_number') + ) + pharmacies = list(pharmacies) + for pharmacy in pharmacies: + if pharmacy.staff_count == 0: + pharmacy.staff_status = 'danger' + elif not pharmacy.head_pharmacist_id: + pharmacy.staff_status = 'warning' + else: + pharmacy.staff_status = 'ok' + pharmacy.has_map_location = pharmacy.has_coordinates + + active_count = sum(1 for p in pharmacies if p.is_active) + pharmacies_no_location = sum(1 for p in pharmacies if not p.has_map_location) + region_ids = sorted({p.location_region_id for p in pharmacies if p.location_region_id}) + region_nodes = Directory.objects.filter(id__in=region_ids).select_related('parent', 'directory_type') + region_tree = build_region_tree(region_nodes) + + # --- Офіс/склад: телефонний довідник по відділах --- + office_employees = list( + Employee.objects.filter(is_active=True, user_location_type__in=['office', 'warehouse']) + .select_related('post', 'department') + .order_by('last_name', 'first_name') + ) + by_dept = {} + for emp in office_employees: + by_dept.setdefault(emp.department_id, []).append(emp) + + office_departments = list( + Directory.objects.filter(directory_type__code='department', is_active=True) + .order_by('_path') + ) + for dept in office_departments: + dept.indent_depth = (dept._path or '').count('.') + dept.staff = by_dept.get(dept.id, []) + office_departments = [d for d in office_departments if d.staff] + office_no_department = by_dept.get(None, []) + + context = { + 'pharmacies': pharmacies, + 'active_count': active_count, + 'organizations_count': len({p.organization_id for p in pharmacies if p.organization_id}), + 'pharmacies_no_head': sum(1 for p in pharmacies if not p.head_pharmacist_id), + 'pharmacies_empty': sum(1 for p in pharmacies if p.staff_count == 0), + 'pharmacies_no_location': pharmacies_no_location, + 'region_tree_json': json.dumps(region_tree, ensure_ascii=False), + 'office_departments': office_departments, + 'office_no_department': office_no_department, + 'office_employees_count': len(office_employees), + } + return render_page(request, 'dislocation.html', context) @login_required def calendar_view(request): - if is_htmx_request(request): - return render(request, 'pages/calendar.html') - return render(request, 'index.html', {'page_template': 'pages/calendar.html'}) + return render_page(request, 'calendar.html') @login_required def timetrack_view(request): - if is_htmx_request(request): - return render(request, 'pages/timetrack.html') - return render(request, 'index.html', {'page_template': 'pages/timetrack.html'}) + return render_page(request, 'timetrack.html') @login_required def reports_view(request): - if is_htmx_request(request): - return render(request, 'pages/reports.html') - return render(request, 'index.html', {'page_template': 'pages/reports.html'}) + return render_page(request, 'reports.html') @login_required def documents_view(request): - if is_htmx_request(request): - return render(request, 'pages/documents.html') - return render(request, 'index.html', {'page_template': 'pages/documents.html'}) + return render_page(request, 'documents.html') @login_required def recruiting_view(request): - if is_htmx_request(request): - return render(request, 'pages/recruiting.html') - return render(request, 'index.html', {'page_template': 'pages/recruiting.html'}) + return render_page(request, 'recruiting.html') @login_required def structure_view(request): - from django.db.models import Count, Q - - # --- Офіс: відділи --- + """Структура офісу: відділи. Аптеки з їхнім персоналом — на /dislocation/.""" departments = list( Directory.objects.filter(directory_type__code='department', is_active=True) .order_by('_path') @@ -209,24 +320,6 @@ def structure_view(request): or (candidates[0] if candidates else None) ) - # --- Аптеки --- - pharmacies = list( - Pharmacy.objects.filter(is_active=True) - .select_related('organization', 'head_pharmacist', 'location_region') - .annotate(staff_count=Count( - 'pharmacy_employees', - filter=Q(pharmacy_employees__is_active=True), - )) - .order_by('organization__name', 'pharmacy_number') - ) - for pharmacy in pharmacies: - if pharmacy.staff_count == 0: - pharmacy.staff_status = 'danger' - elif not pharmacy.head_pharmacist_id: - pharmacy.staff_status = 'warning' - else: - pharmacy.staff_status = 'ok' - pharmacy_employees_count = Employee.objects.filter( is_active=True, user_location_type='pharmacy', ).count() @@ -235,43 +328,342 @@ def structure_view(request): 'departments': departments, 'office_count': len(office_employees), 'office_no_dept': sum(1 for e in office_employees if not e.department_id), - 'pharmacies': pharmacies, 'pharmacy_employees_count': pharmacy_employees_count, - 'pharmacies_no_head': sum(1 for p in pharmacies if not p.head_pharmacist_id), - 'pharmacies_empty': sum(1 for p in pharmacies if p.staff_count == 0), - 'organizations_count': len({p.organization_id for p in pharmacies if p.organization_id}), - 'total_employees': Employee.objects.filter(is_active=True).count(), } - if is_htmx_request(request): - return render(request, 'pages/structure.html', context) - return render(request, 'index.html', {'page_template': 'pages/structure.html', **context}) + return render_page(request, 'structure.html', context) @login_required def settings_view(request): - if is_htmx_request(request): - return render(request, 'pages/settings.html') - return render(request, 'index.html', {'page_template': 'pages/settings.html'}) + return render_page(request, 'settings.html') + + +# Поля, які співробітник редагує сам на /profile/. Кадрові поля (посада, +# відділ, дата прийому, username/робочий email тощо) — лише через Django admin. +PROFILE_EDITABLE_FIELDS = [ + 'photo', 'phone', 'personal_phone', 'personal_email', + 'emergency_contact_name', 'emergency_contact_phone', 'emergency_contact_relation', + 'default_page_size', +] + + +@login_required +def profile_view(request): + """Особиста сторінка співробітника: контактні дані «для себе» + налаштування інтерфейсу порталу.""" + from core.utils.pagination import PAGE_SIZE_CHOICES + + employee = request.user + saved = False + errors = None + + if request.method == 'POST': + employee.phone = request.POST.get('phone', '').strip() + employee.personal_phone = request.POST.get('personal_phone', '').strip() + employee.personal_email = request.POST.get('personal_email', '').strip() + employee.emergency_contact_name = request.POST.get('emergency_contact_name', '').strip() + employee.emergency_contact_phone = request.POST.get('emergency_contact_phone', '').strip() + employee.emergency_contact_relation = request.POST.get('emergency_contact_relation', '').strip() + + if request.FILES.get('photo'): + employee.photo = request.FILES['photo'] + + try: + page_size = int(request.POST.get('default_page_size', '')) + except (TypeError, ValueError): + page_size = None + if page_size in PAGE_SIZE_CHOICES: + employee.default_page_size = page_size + + try: + unrelated_fields = [f.name for f in Employee._meta.fields if f.name not in PROFILE_EDITABLE_FIELDS] + employee.full_clean(exclude=unrelated_fields) + employee.save() + saved = True + except ValidationError as exc: + errors = exc.messages + + context = { + 'employee': employee, + 'page_size_choices': PAGE_SIZE_CHOICES, + 'saved': saved, + 'errors': errors, + } + return render_page(request, 'profile.html', context) + + +def _build_chat_context(request): + from apps.mattermost_connector.models import MattermostUser + from apps.mattermost_connector.services.token_service import ensure_personal_access_token + + cfg = ConnectorConfig.get_for('mattermost') + public_url = '' + if cfg: + public_url = (cfg.extra_config or {}).get('public_url', '').strip().rstrip('/') + + if not public_url: + return {'chat_state': 'not_configured'} + + if not request.user.mattermost_user_id: + return {'chat_state': 'not_synced'} + + token = ensure_personal_access_token(request.user) + if not token: + return {'chat_state': 'token_error'} + + colleagues = [ + { + 'mattermostId': row['mattermost_id'], + 'name': (f"{row['django_user__first_name']} {row['django_user__last_name']}".strip() + or row['django_user__username']), + } + for row in MattermostUser.objects.filter(is_active=True) + .exclude(django_user=request.user) + .select_related('django_user') + .values('mattermost_id', 'django_user__first_name', 'django_user__last_name', 'django_user__username') + ] + + ws_url = public_url.replace('https://', 'wss://', 1).replace('http://', 'ws://', 1) + '/api/v4/websocket' + return { + 'chat_state': 'ready', + 'chat_config': { + 'apiBase': f'{public_url}/api/v4', + 'wsUrl': ws_url, + 'token': token, + 'currentUserId': request.user.mattermost_user_id, + }, + 'chat_colleagues': colleagues, + } + + +@login_required +def chat_view(request): + return render_page(request, 'chat.html', _build_chat_context(request)) + + +@login_required +def chat_sync_now_view(request): + if request.method != 'POST': + return HttpResponseNotAllowed(['POST']) + + from apps.mattermost_connector.services.sync_service import sync_employee + sync_employee(request.user) + request.user.refresh_from_db() + return render_page(request, 'chat.html', _build_chat_context(request)) def _static_page_view(template_name): """Фабрика простых страниц: HTMX-фрагмент или полный layout""" @login_required def view(request): - if is_htmx_request(request): - return render(request, f'pages/{template_name}') - return render(request, 'index.html', {'page_template': f'pages/{template_name}'}) + return render_page(request, template_name) return view -news_view = _static_page_view('news.html') -tasks_view = _static_page_view('tasks.html') -task_detail_view = _static_page_view('task-detail.html') email_view = _static_page_view('email.html') -chat_view = _static_page_view('chat.html') purchases_view = _static_page_view('purchases.html') -hr_portal_view = _static_page_view('hr-portal.html') help_view = _static_page_view('help.html') -pharmacy_detail_view = _static_page_view('pharmacy-detail.html') + + +@login_required +def pharmacy_detail_view(request, pk): + """Аптека целиком: реквизиты, лицензия, адрес/карта, персонал, + IT-обладнання, інтернет-договори, показання лічильників.""" + from apps.it_manager.models import Equipment, InternetContract + from apps.utilities.models import UtilityReading + + pharmacy = get_object_or_404( + Pharmacy.objects.select_related('organization', 'location_region', 'head_pharmacist__post') + .prefetch_related('phones'), + pk=pk, + ) + staff = list( + Employee.objects.filter(user_pharmacy=pharmacy, is_active=True) + .select_related('post').order_by('last_name', 'first_name') + ) + equipment = list( + Equipment.objects.filter(location=pharmacy) + .select_related('equipment_model__equipment_type', 'equipment_model__brand', 'status', 'assigned_to') + .order_by('equipment_model__equipment_type__name', 'equipment_model__name') + ) + internet_contracts = list( + InternetContract.objects.filter(location=pharmacy) + .select_related('provider', 'service_type', 'connection_type', 'status') + .order_by('-start_date') + ) + utility_readings = list( + UtilityReading.objects.filter(pharmacy=pharmacy) + .select_related('utility_type', 'submitted_by') + .order_by('-reading_date')[:50] + ) + context = { + 'pharmacy': pharmacy, + 'staff': staff, + 'staff_count': len(staff), + 'phones': list(pharmacy.phones.filter(is_active=True)), + 'equipment': equipment, + 'internet_contracts': internet_contracts, + 'utility_readings': utility_readings, + 'can_edit': request.user.is_staff, + } + return render_page(request, 'pharmacy-detail.html', context) + + +def _hr_department_tree(): + """Дерево відділів {id, name, count, children: [...]} для деревовидного фільтра HR-порталу.""" + depts = list( + Directory.objects.filter(directory_type__code='department', is_active=True) + .select_related('parent').order_by('name') + ) + counts = dict( + Employee.objects.filter(is_active=True, department__isnull=False) + .values_list('department_id').annotate(c=Count('id')) + ) + + nodes = {d.pk: {'id': d.pk, 'name': d.name, 'count': counts.get(d.pk, 0), 'children': []} for d in depts} + roots = [] + for d in depts: + node = nodes[d.pk] + parent_node = nodes.get(d.parent_id) + if parent_node is not None: + parent_node['children'].append(node) + else: + roots.append(node) + return roots + + +def _hr_portal_context(request): + from django.utils import timezone as dj_timezone + from datetime import timedelta + from apps.hiring.models import HiringRequest + from apps.locations.models.pharmacies import Pharmacy + + today = dj_timezone.localdate() + month_ago = today - timedelta(days=30) + + employees = Employee.objects.filter(is_active=True).select_related( + 'post', 'department', 'user_pharmacy', + ).order_by('last_name', 'first_name') + + 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(), + 'hr_department_tree': _hr_department_tree(), + 'hr_posts': Directory.objects.filter(directory_type__code='post', is_active=True).order_by('name'), + 'today': today, + } + + if request.user.is_staff: + hiring_requests_qs = HiringRequest.objects.select_related( + 'employee', 'post', 'department', 'user_pharmacy', 'requested_by', 'decision_by', + ).order_by('-created_at') + 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'), + 'hr_pharmacies': Pharmacy.objects.filter(is_active=True).order_by('pharmacy_number'), + 'hr_trial_days_choices': range(1, 6), + 'hr_departments_flat': Directory.objects.filter( + directory_type__code='department', is_active=True, + ).order_by('name'), + }) + + return context + + +@login_required +def hr_portal_view(request): + return render_page(request, 'hr-portal.html', _hr_portal_context(request)) + + +@login_required +def hiring_create_view(request): + if not request.user.is_staff: + return HttpResponseForbidden() + + if request.method == 'POST': + from datetime import datetime + from apps.hiring.services import create_hiring_request + + last_name = request.POST.get('last_name', '').strip() + first_name = request.POST.get('first_name', '').strip() + middle_name = request.POST.get('middle_name', '').strip() + location_type = request.POST.get('user_location_type', '') + hire_date_raw = request.POST.get('hire_date', '') + trial_days_raw = request.POST.get('trial_days', '3') + + if last_name and first_name and location_type: + try: + hire_date = datetime.strptime(hire_date_raw, '%Y-%m-%d').date() + except ValueError: + from django.utils import timezone as dj_timezone + hire_date = dj_timezone.localdate() + try: + trial_days = max(1, min(5, int(trial_days_raw))) + except ValueError: + trial_days = 3 + + department_id = request.POST.get('department_id') or None + pharmacy_id = request.POST.get('pharmacy_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(), + ) + + context = _hr_portal_context(request) + context['hr_active_tab'] = 'hiring' + return render_page(request, 'hr-portal.html', context) + + +@login_required +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 + + if request.method == 'POST': + hiring_request = get_object_or_404(HiringRequest, pk=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' + return render_page(request, 'hr-portal.html', context) + + +@login_required +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 + + if request.method == 'POST': + hiring_request = get_object_or_404(HiringRequest, pk=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' + return render_page(request, 'hr-portal.html', context) @login_required @@ -297,45 +689,92 @@ def databases_view(request): default=None, ), } - if is_htmx_request(request): - return render(request, 'pages/databases.html', context) - return render(request, 'index.html', {'page_template': 'pages/databases.html', **context}) + return render_page(request, 'databases.html', context) @login_required def it_support_view(request): """Підтримка IT: створення заявки та мої заявки""" from apps.it_manager.models import Ticket + from apps.it_manager.views import _directory_choices, _resolve_directory ticket_created = None if request.method == 'POST': title = request.POST.get('title', '').strip() description = request.POST.get('description', '').strip() - ticket_type = request.POST.get('ticket_type', 'other') - priority = request.POST.get('priority', 'medium') + ticket_type_id = _resolve_directory('ticket_type', request.POST.get('ticket_type', '')) \ + or _resolve_directory('ticket_type', 'other') + priority_id = _resolve_directory('ticket_priority', request.POST.get('priority', '')) \ + or _resolve_directory('ticket_priority', 'medium') if title and description: - valid_types = {v for v, _ in Ticket.TICKET_TYPES} - valid_priorities = {v for v, _ in Ticket.PRIORITY_CHOICES} ticket_created = Ticket.objects.create( title=title, description=description, - ticket_type=ticket_type if ticket_type in valid_types else 'other', - priority=priority if priority in valid_priorities else 'medium', + ticket_type_id=ticket_type_id, + priority_id=priority_id, requester=request.user, ) - my_tickets = Ticket.objects.filter(requester=request.user).order_by('-created_at')[:8] + my_tickets_qs = Ticket.objects.filter(requester=request.user).select_related( + 'ticket_type', 'priority', 'status', + ).order_by('-created_at') + my_tickets_page = paginate(request, my_tickets_qs, default_size=10, page_param='tickets_page', size_param='tickets_per_page') context = { - 'ticket_types': Ticket.TICKET_TYPES, - 'my_tickets': my_tickets, + 'ticket_types': _directory_choices('ticket_type'), + 'ticket_priorities': _directory_choices('ticket_priority'), + 'my_tickets': my_tickets_page, 'my_open_count': Ticket.objects.filter( - requester=request.user, status__in=['new', 'in_progress', 'waiting'], + requester=request.user, status__code__in=['new', 'in_progress', 'waiting'], ).count(), 'ticket_created': ticket_created, } - if is_htmx_request(request): - return render(request, 'pages/it-support.html', context) - return render(request, 'index.html', {'page_template': 'pages/it-support.html', **context}) + return render_page(request, 'it-support.html', context) + + +# Довідники, у яких дозволено "на льоту" додавати нові записи прямо з форм. +# None — може будь-який залогінений (форма відкрита всім, напр. /it-support/); +# 'it' — лише IT-група (форма доступна тільки на /it-manager/). +DIRECTORY_QUICK_ADD_TYPES = { + 'ticket_type': None, + 'ticket_priority': None, + 'equipment_type': 'it', + 'equipment_brand': 'it', + 'worklog_type': 'it', + 'consumable_type': 'it', + 'consumable_color': 'it', + 'contract_service_type': 'it', + 'contract_connection_type': 'it', + 'servicecall_channel': 'it', +} + + +@login_required +def directory_quick_add_view(request): + """Швидке додавання нового запису в довідник прямо з форми (select), без переходу в admin.""" + if request.method != 'POST': + return HttpResponseNotAllowed(['POST']) + + from apps.it_manager.views import _is_it_staff + from core.models.directories import DirectoryType + + type_code = request.POST.get('type_code', '') + name = request.POST.get('name', '').strip() + + if type_code not in DIRECTORY_QUICK_ADD_TYPES or not name: + return JsonResponse({'error': 'Некоректні дані'}, status=400) + + required_role = DIRECTORY_QUICK_ADD_TYPES[type_code] + if required_role == 'it' and not _is_it_staff(request.user): + return HttpResponseForbidden() + + directory_type = DirectoryType.objects.filter(code=type_code).first() + if not directory_type: + return JsonResponse({'error': 'Довідник не знайдено'}, status=400) + + directory, _ = Directory.objects.get_or_create( + directory_type=directory_type, name=name, defaults={'is_active': True}, + ) + return JsonResponse({'id': directory.id, 'code': directory.code, 'name': directory.name}) def login_view(request): diff --git a/static/location_field/js/form.js b/static/location_field/js/form.js new file mode 100644 index 0000000..8775d67 --- /dev/null +++ b/static/location_field/js/form.js @@ -0,0 +1,656 @@ +/* ACORE: локальна тінь-копія django_location_field 2.7.3 static/location_field/js/form.js. + * Django FileSystemFinder (наш STATICFILES_DIRS) перевіряється РАНІШЕ AppDirectoriesFinder + * пакета — тому цей файл підміняє оригінал по тому самому static-шляху, без форку пакета. + * ЄДИНА зміна: protocol-relative URL ("//...") замінені на явний https:// — інакше на + * локальному http-сервері тайли/geocoder тягнуться по http і OSM/Nominatim 301-редиректять + * на https (curl підтвердив), зайвий round-trip на кожен тайл. При апдейті пакета звірити діфф. */ +var SequentialLoader = function() { + var SL = { + loadJS: function(src, onload) { + //console.log(src); + // add to pending list + this._load_pending.push({'src': src, 'onload': onload}); + // check if not already loading + if ( ! this._loading) { + this._loading = true; + // load first + this.loadNextJS(); + } + }, + + loadNextJS: function() { + // get next + var next = this._load_pending.shift(); + if (next == undefined) { + // nothing to load + this._loading = false; + return; + } + // check not loaded + if (this._load_cache[next.src] != undefined) { + next.onload(); + this.loadNextJS(); + return; // already loaded + } + else { + this._load_cache[next.src] = 1; + } + // load + var el = document.createElement('script'); + el.type = 'application/javascript'; + el.src = next.src; + // onload callback + var self = this; + el.onload = function(){ + //console.log('Loaded: ' + next.src); + // trigger onload + next.onload(); + // try to load next + self.loadNextJS(); + }; + document.body.appendChild(el); + }, + + _loading: false, + _load_pending: [], + _load_cache: {} + }; + + return { + loadJS: SL.loadJS.bind(SL) + } +}; + + +!function($){ + var LocationFieldCache = { + load: [], + onload: {}, + + isLoading: false + }; + + var LocationFieldResourceLoader; + + $.locationField = function(options) { + var LocationField = { + options: $.extend({ + provider: 'google', + providerOptions: { + google: { + api: 'https://maps.google.com/maps/api/js', + mapType: 'ROADMAP' + } + }, + searchProvider: 'google', + id: 'map', + latLng: '0,0', + mapOptions: { + zoom: 9 + }, + basedFields: $(), + inputField: $(), + suffix: '', + path: '', + fixMarker: true + }, options), + + providers: /google|openstreetmap|mapbox/, + searchProviders: /google|yandex|nominatim|addok/, + + render: function() { + this.$id = $('#' + this.options.id); + + if ( ! this.providers.test(this.options.provider)) { + this.error('render failed, invalid map provider: ' + this.options.provider); + return; + } + + if ( ! this.searchProviders.test(this.options.searchProvider)) { + this.error('render failed, invalid search provider: ' + this.options.searchProvider); + return; + } + + var self = this; + + this.loadAll(function(){ + var mapOptions = self._getMapOptions(), + map = self._getMap(mapOptions); + + var marker = self._getMarker(map, mapOptions.center); + + // fix issue w/ marker not appearing + if (self.options.provider == 'google' && self.options.fixMarker) + self.__fixMarker(); + + // watch based fields + self._watchBasedFields(map, marker); + }); + }, + + fill: function(latLng) { + this.options.inputField.val(latLng.lat + ',' + latLng.lng); + }, + + search: function(map, marker, address) { + if (this.options.searchProvider === 'google') { + var provider = new GeoSearch.GoogleProvider({ apiKey: this.options.providerOptions.google.apiKey }); + provider.search({query: address}).then(data => { + if (data.length > 0) { + var result = data[0], + latLng = new L.LatLng(result.y, result.x); + + marker.setLatLng(latLng); + map.panTo(latLng); + } + }); + } + + else if (this.options.searchProvider === 'yandex') { + // https://yandex.com/dev/maps/geocoder/doc/desc/concepts/input_params.html + var url = 'https://geocode-maps.yandex.ru/1.x/?format=json&geocode=' + address; + + if (typeof this.options.providerOptions.yandex.apiKey !== 'undefined') { + url += '&apikey=' + this.options.providerOptions.yandex.apiKey; + } + + var request = new XMLHttpRequest(); + request.open('GET', url, true); + + request.onload = function () { + if (request.status >= 200 && request.status < 400) { + var data = JSON.parse(request.responseText); + var pos = data.response.GeoObjectCollection.featureMember[0].GeoObject.Point.pos.split(' '); + var latLng = new L.LatLng(pos[1], pos[0]); + marker.setLatLng(latLng); + map.panTo(latLng); + } else { + console.error('Yandex geocoder error response'); + } + }; + + request.onerror = function () { + console.error('Check connection to Yandex geocoder'); + }; + + request.send(); + } + + else if (this.options.searchProvider === 'addok') { + var url = 'https://api-adresse.data.gouv.fr/search/?limit=1&q=' + address; + + var request = new XMLHttpRequest(); + request.open('GET', url, true); + + request.onload = function () { + if (request.status >= 200 && request.status < 400) { + var data = JSON.parse(request.responseText); + var pos = data.features[0].geometry.coordinates; + var latLng = new L.LatLng(pos[1], pos[0]); + marker.setLatLng(latLng); + map.panTo(latLng); + } else { + console.error('Addok geocoder error response'); + } + }; + + request.onerror = function () { + console.error('Check connection to Addok geocoder'); + }; + + request.send(); + } + + else if (this.options.searchProvider === 'nominatim') { + var url = 'https://nominatim.openstreetmap.org/search?format=json&q=' + address; + + var request = new XMLHttpRequest(); + request.open('GET', url, true); + + request.onload = function () { + if (request.status >= 200 && request.status < 400) { + var data = JSON.parse(request.responseText); + if (data.length > 0) { + var pos = data[0]; + var latLng = new L.LatLng(pos.lat, pos.lon); + marker.setLatLng(latLng); + map.panTo(latLng); + } else { + console.error(address + ': not found via Nominatim'); + } + } else { + console.error('Nominatim geocoder error response'); + } + }; + + request.onerror = function () { + console.error('Check connection to Nominatim geocoder'); + }; + + request.send(); + } + }, + + loadAll: function(onload) { + this.$id.html('Loading...'); + + // resource loader + if (LocationFieldResourceLoader == undefined) + LocationFieldResourceLoader = SequentialLoader(); + + this.load.loader = LocationFieldResourceLoader; + this.load.path = this.options.path; + + var self = this; + + this.load.common(function(){ + var mapProvider = self.options.provider, + onLoadMapProvider = function() { + var searchProvider = self.options.searchProvider + 'SearchProvider', + onLoadSearchProvider = function() { + self.$id.html(''); + onload(); + }; + + if (self.load[searchProvider] != undefined) { + self.load[searchProvider](self.options.providerOptions[self.options.searchProvider] || {}, onLoadSearchProvider); + } + else { + onLoadSearchProvider(); + } + }; + + if (self.load[mapProvider] != undefined) { + self.load[mapProvider](self.options.providerOptions[mapProvider] || {}, onLoadMapProvider); + } + else { + onLoadMapProvider(); + } + }); + }, + + load: { + google: function(options, onload) { + var js = [ + this.path + '/@googlemaps/js-api-loader/index.min.js', + this.path + '/Leaflet.GoogleMutant.js', + ]; + + this._loadJSList(js, function(){ + const loader = new google.maps.plugins.loader.Loader({ + apiKey: options.apiKey, + version: "weekly", + }); + loader.load().then(() => onload()); + }); + }, + + googleSearchProvider: function(options, onload) { + onload(); + //var url = options.api; + + //if (typeof options.apiKey !== 'undefined') { + // url += url.indexOf('?') === -1 ? '?' : '&'; + // url += 'key=' + options.apiKey; + //} + + //var js = [ + // url, + // this.path + '/l.geosearch.provider.google.js' + // ]; + + //this._loadJSList(js, function(){ + // // https://github.com/smeijer/L.GeoSearch/issues/57#issuecomment-148393974 + // L.GeoSearch.Provider.Google.Geocoder = new google.maps.Geocoder(); + + // onload(); + //}); + }, + + yandexSearchProvider: function (options, onload) { + onload(); + }, + + mapbox: function(options, onload) { + onload(); + }, + + openstreetmap: function(options, onload) { + onload(); + }, + + common: function(onload) { + var self = this, + js = [ + // map providers + this.path + '/leaflet/leaflet.js', + // search providers + this.path + '/leaflet-geosearch/geosearch.umd.js', + ], + css = [ + // map providers + this.path + '/leaflet/leaflet.css' + ]; + + // Leaflet docs note: + // Include Leaflet JavaScript file *after* Leaflet’s CSS + // https://leafletjs.com/examples/quick-start/ + this._loadCSSList(css, function(){ + self._loadJSList(js, onload); + }); + }, + + _loadJS: function(src, onload) { + this.loader.loadJS(src, onload); + }, + + _loadJSList: function(srclist, onload) { + this.__loadList(this._loadJS, srclist, onload); + }, + + _loadCSS: function(src, onload) { + if (LocationFieldCache.onload[src] != undefined) { + onload(); + } + else { + LocationFieldCache.onload[src] = 1; + onloadCSS(loadCSS(src), onload); + } + }, + + _loadCSSList: function(srclist, onload) { + this.__loadList(this._loadCSS, srclist, onload); + }, + + __loadList: function(fn, srclist, onload) { + if (srclist.length > 1) { + for (var i = 0; i < srclist.length-1; ++i) { + fn.call(this, srclist[i], function(){}); + } + } + + fn.call(this, srclist[srclist.length-1], onload); + } + }, + + error: function(message) { + console.log(message); + this.$id.html(message); + }, + + _getMap: function(mapOptions) { + var map = new L.Map(this.options.id, mapOptions), layer; + + if (this.options.provider == 'google') { + layer = new L.gridLayer.googleMutant({ + type: this.options.providerOptions.google.mapType.toLowerCase(), + }); + } + else if (this.options.provider == 'openstreetmap') { + layer = new L.tileLayer( + 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { + maxZoom: 18 + }); + } + else if (this.options.provider == 'mapbox') { + layer = new L.tileLayer( + 'https://api.mapbox.com/styles/v1/{id}/tiles/{z}/{x}/{y}?access_token={accessToken}', { + maxZoom: 18, + accessToken: this.options.providerOptions.mapbox.access_token, + id: 'mapbox/streets-v11' + }); + } + + map.addLayer(layer); + + return map; + }, + + _getMapOptions: function() { + return $.extend(this.options.mapOptions, { + center: this._getLatLng() + }); + }, + + _getLatLng: function() { + var l = this.options.latLng.split(',').map(parseFloat); + return new L.LatLng(l[0], l[1]); + }, + + _getMarker: function(map, center) { + var self = this, + markerOptions = { + draggable: true + }; + + var marker = L.marker(center, markerOptions).addTo(map); + + // fill input on dragend + marker.on('dragend move', function(){ + self.fill(this.getLatLng()); + }); + + // place marker on map click + map.on('click', function(e){ + marker.setLatLng(e.latlng); + }); + + return marker; + }, + + _watchBasedFields: function(map, marker) { + var self = this, + basedFields = this.options.basedFields, + onchangeTimer, + onchange = function() { + var values = basedFields.map(function() { + var value = $(this).val(); + return value === '' ? null : value; + }); + var address = values.toArray().join(', '); + clearTimeout(onchangeTimer); + onchangeTimer = setTimeout(function(){ + self.search(map, marker, address); + }, 300); + }; + + basedFields.each(function(){ + var el = $(this); + + if (el.is('select')) + el.change(onchange); + else + el.keyup(onchange); + }); + }, + + __fixMarker: function() { + $('.leaflet-map-pane').css('z-index', '2 !important'); + $('.leaflet-google-layer').css('z-index', '1 !important'); + } + } + + return { + render: LocationField.render.bind(LocationField) + } + } + + function dataLocationFieldObserver(callback) { + function _findAndEnableDataLocationFields() { + var dataLocationFields = $('input[data-location-field-options]'); + + dataLocationFields + .filter(':not([data-location-field-observed])') + .attr('data-location-field-observed', true) + .each(callback); + } + + var observer = new MutationObserver(function(mutations){ + _findAndEnableDataLocationFields(); + }); + + var container = document.documentElement || document.body; + + $(container).ready(function(){ + _findAndEnableDataLocationFields(); + }); + + observer.observe(container, {attributes: true}); + } + + dataLocationFieldObserver(function(){ + var el = $(this); + + var name = el.attr('name'), + options = el.data('location-field-options'), + basedFields = options.field_options.based_fields, + pluginOptions = { + id: 'map_' + name, + inputField: el, + latLng: el.val() || '0,0', + suffix: options['search.suffix'], + path: options['resources.root_path'], + provider: options['map.provider'], + searchProvider: options['search.provider'], + providerOptions: { + google: { + api: options['provider.google.api'], + apiKey: options['provider.google.api_key'], + mapType: options['provider.google.map_type'] + }, + mapbox: { + access_token: options['provider.mapbox.access_token'] + }, + yandex: { + apiKey: options['provider.yandex.api_key'] + }, + }, + mapOptions: { + zoom: options['map.zoom'] + } + }; + + // prefix + var prefixNumber; + + try { + prefixNumber = name.match(/-(\d+)-/)[1]; + } catch (e) {} + + if (options.field_options.prefix) { + var prefix = options.field_options.prefix; + + if (prefixNumber != null) { + prefix = prefix.replace(/__prefix__/, prefixNumber); + } + + basedFields = basedFields.map(function(n){ + return prefix + n + }); + } + + // based fields + pluginOptions.basedFields = $(basedFields.map(function(n){ + return '#id_' + n + }).join(',')); + + // render + $.locationField(pluginOptions).render(); + }); + +}(jQuery || django.jQuery); + +/*! +loadCSS: load a CSS file asynchronously. +[c]2015 @scottjehl, Filament Group, Inc. +Licensed MIT +*/ +(function(w){ + "use strict"; + /* exported loadCSS */ + var loadCSS = function( href, before, media ){ + // Arguments explained: + // `href` [REQUIRED] is the URL for your CSS file. + // `before` [OPTIONAL] is the element the script should use as a reference for injecting our stylesheet before + // By default, loadCSS attempts to inject the link after the last stylesheet or script in the DOM. However, you might desire a more specific location in your document. + // `media` [OPTIONAL] is the media type or query of the stylesheet. By default it will be 'all' + var doc = w.document; + var ss = doc.createElement( "link" ); + var ref; + if( before ){ + ref = before; + } + else { + var refs = ( doc.body || doc.getElementsByTagName( "head" )[ 0 ] ).childNodes; + ref = refs[ refs.length - 1]; + } + + var sheets = doc.styleSheets; + ss.rel = "stylesheet"; + ss.href = href; + // temporarily set media to something inapplicable to ensure it'll fetch without blocking render + ss.media = "only x"; + + // Inject link + // Note: the ternary preserves the existing behavior of "before" argument, but we could choose to change the argument to "after" in a later release and standardize on ref.nextSibling for all refs + // Note: `insertBefore` is used instead of `appendChild`, for safety re: http://www.paulirish.com/2011/surefire-dom-element-insertion/ + ref.parentNode.insertBefore( ss, ( before ? ref : ref.nextSibling ) ); + // A method (exposed on return object for external use) that mimics onload by polling until document.styleSheets until it includes the new sheet. + var onloadcssdefined = function( cb ){ + var resolvedHref = ss.href; + var i = sheets.length; + while( i-- ){ + if( sheets[ i ].href === resolvedHref ){ + return cb(); + } + } + setTimeout(function() { + onloadcssdefined( cb ); + }); + }; + + // once loaded, set link's media back to `all` so that the stylesheet applies once it loads + ss.onloadcssdefined = onloadcssdefined; + onloadcssdefined(function() { + ss.media = media || "all"; + }); + return ss; + }; + // commonjs + if( typeof module !== "undefined" ){ + module.exports = loadCSS; + } + else { + w.loadCSS = loadCSS; + } +}( typeof global !== "undefined" ? global : this )); + + +/*! +onloadCSS: adds onload support for asynchronous stylesheets loaded with loadCSS. +[c]2014 @zachleat, Filament Group, Inc. +Licensed MIT +*/ + +/* global navigator */ +/* exported onloadCSS */ +function onloadCSS( ss, callback ) { + ss.onload = function() { + ss.onload = null; + if( callback ) { + callback.call( ss ); + } + }; + + // This code is for browsers that don’t support onload, any browser that + // supports onload should use that instead. + // No support for onload: + // * Android 4.3 (Samsung Galaxy S4, Browserstack) + // * Android 4.2 Browser (Samsung Galaxy SIII Mini GT-I8200L) + // * Android 2.3 (Pantech Burst P9070) + + // Weak inference targets Android < 4.4 + if( "isApplicationInstalled" in navigator && "onloadcssdefined" in ss ) { + ss.onloadcssdefined( callback ); + } +} diff --git a/templates/pages/chat.html b/templates/pages/chat.html index 0f19655..4579ccc 100644 --- a/templates/pages/chat.html +++ b/templates/pages/chat.html @@ -2,148 +2,135 @@

Чат

-

Внутрішній корпоративний месенджер

+

Внутрішній корпоративний месенджер (Mattermost)

-
+ {% if chat_state == 'ready' %} + {{ chat_config|json_script:"chat-config-data" }} + {{ chat_colleagues|json_script:"chat-colleagues-data" }} +
-
-
- +
+ Особисті повідомлення +
-
- -
-
IT
-
-
- #it-відділ - 10:22 -
-

Оновлення системи заплановано...

-
- 3 -
- -
-
HR
-
-
- #hr-загальний - Вчора -
-

Нові інструкції з охорони праці...

-
-
- -
-
МП
-
-
- Марія Петренко - 07.04 -
-

Дякую! Побачимось завтра.

-
-
- -
-
РО
-
-
- #розробка - 05.04 -
-

Виправлено баг у формі звітності

-
-
- -
-
ЗА
-
-
- #загальний - 03.04 -
-

Вітаємо нових співробітників!

-
-
- +
+
Завантаження…
+
+
+ Канали +
+
+
Завантаження…
-
- -
-
#IT
-
-
#it-відділ
-
8 учасників
-
+
+ Оберіть канал зліва
- -
+
-
-
СМ
-
-
- Сергій Мороз - 09:30 -
-
Колеги, нагадую про заплановане технічне обслуговування 10 квітня з 18:00 до 20:00. Система буде недоступна.
-
-
+ -
-
ОТ
-
-
- Олексій Ткаченко - 09:45 -
-
Зрозумів, дякую за попередження!
-
-
- -
-
ІК
-
-
- Ви - 10:05 -
-
Добре, врахую. Чи є резервний доступ до критичних систем в цей час?
-
-
- -
-
СМ
-
-
- Сергій Мороз - 10:22 -
-
Так, резервний доступ до 1С та складського обліку буде активований. Деталі надішлю окремо.
-
-
- -
- - -
- -
+ + + +
+
+ Тред + +
+
+ +
+ + + + +
+
+ + + +
+
+ Нове повідомлення + +
+
+ +
+ +
+
+ + + + {% elif chat_state == 'not_synced' %} +
+ + + +
Ваш акаунт ще не синхронізовано з Mattermost
+

+ Натисніть кнопку нижче, щоб створити/прив'язати ваш акаунт у Mattermost. +

+ +
+ + {% elif chat_state == 'token_error' %} +
+ + + +
Не вдалося отримати доступ до Mattermost
+

+ Зверніться до IT-відділу — можливо, вимкнено видачу Personal Access Token на сервері Mattermost. +

+
+ + {% else %} +
+ + + +
Чат ще не підключено
+

+ Вкажіть публічну URL-адресу Mattermost (поле public_url у "Дополнительные параметры" коннектора Mattermost в адмінці) — і вона з'явиться тут. +

+
+ {% endif %} +
diff --git a/templates/pages/dashboard.html b/templates/pages/dashboard.html index bacd7ec..bb56224 100644 --- a/templates/pages/dashboard.html +++ b/templates/pages/dashboard.html @@ -1,3 +1,4 @@ +{% load common_tags %}
@@ -15,9 +16,9 @@
-
Активних співробітників
-
{{ employees_active }}
-
із {{ employees_total }} у системі
+
Співробітників онлайн
+
{{ employees_online }}
+
із {{ employees_active }} активних
@@ -80,23 +81,26 @@

Оголошення та важливі події компанії

-
-
-
-
Корпоративний портал оновлено
-
Новий дизайн, мобільна версія, теми оформлення та нові розділи. Пропозиції надсилайте через Підтримку IT.
-
- {{ today|date:"d.m.Y" }} -
-
-
-
-
-
Розділ новин у розробці
-
Незабаром тут з'являтимуться оголошення компанії.
+ {% for post in latest_posts %} + +
+ {% if post.first_image_url %} + + {% endif %} +
+
+
{{ post.title }}
+
{{ post.body|clean_excerpt:160 }}
+
+ {{ post.created_at|date:"d.m.Y" }}
-
+ + {% empty %} +
Публікацій поки немає
+ {% endfor %}
Всі новини → diff --git a/templates/pages/dislocation.html b/templates/pages/dislocation.html new file mode 100644 index 0000000..8bfa460 --- /dev/null +++ b/templates/pages/dislocation.html @@ -0,0 +1,775 @@ +{% load common_tags %} +
+ +
+
+

Дислокація

+

{{ pharmacies|length }} аптек · {{ office_employees_count }} співробітників офісу/складу · персонал по кожній локації

+
+
+ + +
+ + +
+ + +
+ + +
+
+
Всього аптек
+
{{ pharmacies|length }}
+
{{ active_count }} активних
+
+
+
Організацій
+
{{ organizations_count }}
+
юридичних осіб
+
+
+
Без завідувача
+
{{ pharmacies_no_head }}
+ {% if pharmacies_no_head %} +
потребують призначення
+ {% else %} +
всі аптеки із завідувачем
+ {% endif %} +
+
+
Без персоналу
+
{{ pharmacies_empty }}
+ {% if pharmacies_empty %} +
аптек без співробітників
+ {% else %} +
всі укомплектовані
+ {% endif %} +
+
+ + + {% regroup pharmacies by organization.name as enterprise_list %} +
+
+
+ + + + +
+ +
+ + + +
+ +
+
+ + +
+
+ + +
+
+
+ + + + + + + + + + + + + + + {% for pharmacy in pharmacies %} + + + + + + + + + + + + + + + {% empty %} + + + + {% endfor %} + +
Аптека ⇅Організація ⇅Завідуючий ⇅Персонал ⇅Телефон ⇅Режим роботи ⇅Статус ⇅
+ ▸ + + № {{ pharmacy.pharmacy_number }} +
{{ pharmacy.address|default:"адресу не вказано" }}
+
{{ pharmacy.organization.name|default:"—" }} + {% if pharmacy.head_pharmacist %} + {{ pharmacy.head_pharmacist.first_name }} {{ pharmacy.head_pharmacist.last_name|default:'' }} + {% else %} + не призначений + {% endif %} + +
+ {{ pharmacy.staff_count }} + {% if pharmacy.staff_status == 'danger' %} + Нестача + {% elif pharmacy.staff_status == 'warning' %} + Увага + {% else %} + Норма + {% endif %} +
+
+ {% if pharmacy.phone %}{{ pharmacy.phone }}{% else %}-{% endif %} + {{ pharmacy.working_hours }} + {% if pharmacy.is_active %} + Активна + {% else %} + Неактивна + {% endif %} +
Аптеки не знайдені
+
+
+ Показано 0–0 з 0 +
+
+ На сторінці: + +
+
+ + Стор. 1 з 1 + +
+
+
+
+
+ + + + +
+ + + + + + + + + + + + + + + +
diff --git a/templates/pages/employee-detail.html b/templates/pages/employee-detail.html new file mode 100644 index 0000000..a0aee67 --- /dev/null +++ b/templates/pages/employee-detail.html @@ -0,0 +1,249 @@ +{% load common_tags %} +
+ + + + + +
+
+ {% if employee.photo %} + + {% else %} + {{ employee|initials }} + {% endif %} +
+
+

{{ employee.last_name }} {{ employee.first_name }}{% if employee.middle_name %} {{ employee.middle_name }}{% endif %}

+ {% if employee.is_active %} + Активний + {% else %} + Не активний + {% endif %} + {% if can_view_pii and employee.is_on_probation %}Випробувальний термін{% endif %} +
+
+ {% if employee.post %}{{ employee.post.name }}{% else %}{{ employee.groups.first.name|default:"Посада не вказана" }}{% endif %} + {% if employee.department %} · {{ employee.department.name }}{% elif employee.user_pharmacy %} · Аптека №{{ employee.user_pharmacy.pharmacy_number }}{% endif %} +
+ {% if employee.user_organization %}
{{ employee.user_organization.name }}
{% endif %} +
+
+
+ +
+ + +
+ + +
+

Контакти

+
+
+
Робочий email
+
{{ employee.email|default:"—" }}
+
+
+
Робочий телефон
+
{{ employee.phone|default:"—" }}
+
+
+
Внутрішній номер
+
{{ employee.internal_number|default:"—" }}
+
+ {% if can_view_pii %} +
+
Особистий телефон
+
{{ employee.personal_phone|default:"—" }}
+
+
+
Особистий email
+
{{ employee.personal_email|default:"—" }}
+
+ {% endif %} +
+
+ + +
+

Робота

+
+
+
Керівник
+
+ {% if employee.manager %} + {{ employee.manager.get_full_name }} + {% else %}—{% endif %} +
+
+
+
Підлеглих
+
{{ employee.subordinate_count }}
+
+ {% if can_view_pii %} +
+
Дата прийняття
+
{{ employee.hire_date|date:"d.m.Y" }} ({{ employee.work_experience }} р.)
+
+
+
Дата звільнення
+
{{ employee.termination_date|date:"d.m.Y"|default:"—" }}
+
+ {% endif %} +
+
+ + {% if can_view_pii %} + +
+

Особисті дані

+
+
+
Дата народження
+
{{ employee.birth_date|date:"d.m.Y"|default:"—" }}{% if employee.age %} ({{ employee.age }} р.){% endif %}
+
+
+
ІПН
+
{{ employee.inn_code|default:"—" }}
+
+
+ {% if employee.emergency_contact_name %} +
+
Екстрений контакт
+
{{ employee.emergency_contact_name }}{% if employee.emergency_contact_relation %} ({{ employee.emergency_contact_relation }}){% endif %}
+ {% if employee.emergency_contact_phone %}
{{ employee.emergency_contact_phone }}
{% endif %} +
+ {% endif %} +
+ + +
+

Інтеграції

+
+
+ Mattermost{% if employee.mattermost_username %} ({{ employee.mattermost_username }}){% endif %} + {% if employee.mattermost_sync_status == 'synced' %}Синхронізовано + {% elif employee.mattermost_sync_status == 'error' %}Помилка + {% elif employee.mattermost_sync_status == 'disabled' %}Вимкнено + {% else %}Очікує{% endif %} +
+
+ Active Directory + {% if employee.ad_sync_status == 'synced' %}Синхронізовано + {% elif employee.ad_sync_status == 'error' %}Помилка + {% elif employee.ad_sync_status == 'disabled' %}Вимкнено + {% else %}Не синхронізовано{% endif %} +
+
+
+ + +
+
+

Задачі{% if recent_tasks_total %} · {{ recent_tasks_total }}{% endif %}

+ + Нова +
+ {% for task in recent_tasks %} + +
+
{{ task.title|truncatechars:50 }}
+
+ {% if task.status == 'done' %}Виконано + {% elif task.status == 'in_progress' %}В роботі + {% else %}Очікує{% endif %} + {% if task.due_date %} · до {{ task.due_date|date:"d.m.Y" }}{% if task.is_overdue %} (прострочено){% endif %}{% endif %} +
+
+ {{ task.get_priority_display }} +
+ {% empty %} +
Задач поки немає
+ {% endfor %} + {% if recent_tasks_hidden %} +
і ще {{ recent_tasks_hidden }}…
+ {% endif %} +
+ {% endif %} +
+ + +
+ + {% if manager_chain %} +
+

Ланцюжок керівництва

+
+ {% for mgr in manager_chain %} + + {{ mgr|initials }} + {{ mgr.get_full_name }} + + {% endfor %} +
+
+ {% endif %} + + {% if subordinates %} +
+

Підлеглі · {{ subordinates|length }}

+ +
+ {% endif %} + + {% if can_view_pii and last_attendance %} +
+

Останнє відвідування

+
+ {% if last_attendance.direction == 'in' %}Прихід{% else %}Вихід{% endif %} · {{ last_attendance.timestamp|date:"d.m.Y H:i" }} +
+
{{ last_attendance.location.name }}
+
+ {% endif %} + + {% if employee.groups.all %} +
+

Групи доступу

+
+ {% for group in employee.groups.all %} + {{ group.name }} + {% endfor %} +
+
+ {% endif %} + +
+
+
diff --git a/templates/pages/employees.html b/templates/pages/employees.html index 239b133..29a94eb 100644 --- a/templates/pages/employees.html +++ b/templates/pages/employees.html @@ -1,3 +1,4 @@ +{% load common_tags %}
{{ pharmacies_by_org|json_script:"emp-pharmacies-data" }} @@ -20,7 +21,7 @@

Співробітники

-

{{ employees|length }} співробітників, з них {{ active_count }} активних

+

{{ total_count }} співробітників, з них {{ active_count }} активних

@@ -108,14 +109,15 @@ {% for employee in employees %} - +
{% if employee.photo %} Фото {{ employee.last_name }} {{ employee.first_name }} {% else %} - {{ employee.last_name|slice:":1" }}{{ employee.first_name|slice:":1" }} + {{ employee|initials }} {% endif %} {{ employee.last_name }} {{ employee.first_name }}{% if employee.middle_name %} {{ employee.middle_name }}{% endif %} @@ -158,14 +160,7 @@
- -
- Показано 7 з 156 співробітників -
- - -
-
+ {% include "partials/pagination.html" with page=page base_url="/employees/" page_param="page" size_param="per_page" item_label="співробітників" %}
diff --git a/templates/pages/hr-portal.html b/templates/pages/hr-portal.html index e27d0fc..9836d17 100644 --- a/templates/pages/hr-portal.html +++ b/templates/pages/hr-portal.html @@ -1,3 +1,4 @@ +{% load common_tags %}
@@ -5,114 +6,607 @@

Управління персоналом та кадрові процеси

- -
- - - - -
+ +
-
- - -
-
- Мої заявки - -
+
-
-
-
Відпустка 01–14 травня
-
Подано 10.04.2026
-
- На розгляді -
-
-
-
Довідка про зарплату
-
Подано 05.04.2026
-
- Готово -
-
-
-
Лікарняний 28–29 березня
-
Подано 01.04.2026
-
- Затверджено -
+
Всього співробітників
+
{{ employees_total }}
+
+{{ employees_new }} за останній місяць
+
+
+ + + + +
- -
+
+
+
На випробувальному терміні
+
{{ employees_probation }}
+
потребують оцінки
+
+
+ + + +
+
+ +
+
+
Заплановані тренінги
+
—
+
розділ у розробці
+
+
+ + + +
+
+ +
+
+
Заявки на відпустку
+
3
+
1 очікує рішення
+
+
+ + + +
+
+ +
+ + +
+
+ + + + +
+ +
+ + +
+
+ + + + + {% if request.user.is_staff %} + + {% endif %} +
+ +
+ + +
+
+ + +
+ + +
+
+ + + + +
+ + {{ hr_department_tree|json_script:"hr-dept-tree-data" }} +
+ + + +
+ + + + +
+ + +
+
+ + {% for employee in employees %} +
+
+ {% if employee.photo %} + Фото {{ employee.last_name }} + {% else %} + + {{ employee|initials }} + + {% endif %} +
{{ employee.last_name }} {{ employee.first_name }}
+
{{ employee.post.name|default:"Посада не вказана" }}
+
+ Активний + {% if employee.is_on_probation %} + На випробуванні до {{ employee.probation_end_date|date:"d.m" }} + {% endif %} +
+
+
+
+ Відділ: + {{ employee.department.name|default:"—" }} +
+
+ Дата прийняття: + {{ employee.hire_date|date:"d.m.Y" }} +
+
+ Email: + {{ employee.email|default:"—" }} +
+
+ Телефон: + {{ employee.phone|default:"—" }} +
+
+ +
+ {% empty %} +
Активних співробітників поки немає
+ {% endfor %} + +
+ +
+ + + + + +
+ + + + + + + + + + + {% if request.user.is_staff %} + + diff --git a/templates/pages/pharmacies.html b/templates/pages/pharmacies.html index 90d75ad..df5889a 100644 --- a/templates/pages/pharmacies.html +++ b/templates/pages/pharmacies.html @@ -166,11 +166,23 @@
-
- Всього аптек: {{ pharmacies.count }} -
- - +
+ Показано 0–0 з 0 +
+
+ На сторінці: + +
+
+ + Стор. 1 з 1 + +
@@ -396,6 +408,7 @@ function renderRegionTree() { } renderRegionTree(); +pharmaFilter(); function addMapBaseLayerWithFallback(map) { const sources = [ @@ -541,44 +554,80 @@ function pharmaView(view) { } } +// Пагінація таблиці (клієнтська — усі рядки вже в DOM, карта показує весь відфільтрований набір) +let pharmaPage = 1; +let pharmaPageSize = 25; + +function pharmaSetPageSize(size) { + pharmaPageSize = parseInt(size, 10) || 25; + pharmaPage = 1; + pharmaFilter(); +} + +function pharmaGoToPage(page) { + pharmaPage = Math.max(1, page); + pharmaFilter(); +} + +function pharmaUpdatePaginationBar(total, start, end, page, totalPages) { + const info = document.getElementById('pharma-page-info'); + const label = document.getElementById('pharma-page-label'); + const prevBtn = document.getElementById('pharma-prev-btn'); + const nextBtn = document.getElementById('pharma-next-btn'); + if (info) info.textContent = total ? `Показано ${start + 1}–${end} з ${total}` : 'Немає записів'; + if (label) label.textContent = `Стор. ${page} з ${totalPages}`; + if (prevBtn) prevBtn.disabled = page <= 1; + if (nextBtn) nextBtn.disabled = page >= totalPages; +} + function pharmaFilter() { const searchText = document.getElementById('pharma-search').value.toLowerCase(); const regionFilter = document.getElementById('filter-region').value; const statusFilter = document.getElementById('filter-status').value; const enterpriseFilter = document.getElementById('filter-enterprise').value; const pharmacyFilter = document.getElementById('filter-pharmacy') ? document.getElementById('filter-pharmacy').value : ''; - - console.log('Filter:', { searchText, regionFilter, statusFilter }); - + // Фільтрація таблиці const tableRows = document.querySelectorAll('tbody tr[data-pharmacy-id]'); const visibleIds = new Set(); - + const matchedRows = []; + tableRows.forEach(row => { const name = (row.dataset.name || '').toLowerCase(); const regionIds = (row.dataset.regionIds || '').split(',').filter(Boolean); const status = row.dataset.status || ''; const enterprise = row.dataset.enterprise || ''; const pharmacyId = row.dataset.pharmacyId || ''; - + const matchSearch = searchText === '' || name.includes(searchText); const matchRegion = regionFilter === '' || regionIds.includes(regionFilter); const matchStatus = statusFilter === '' || status === statusFilter; const matchEnterprise = enterpriseFilter === '' || enterprise === enterpriseFilter; const matchPharmacy = pharmacyFilter === '' || pharmacyId === pharmacyFilter; - - const visible = matchSearch && matchRegion && matchStatus && matchEnterprise && matchPharmacy; - - if (visible) { + + const matches = matchSearch && matchRegion && matchStatus && matchEnterprise && matchPharmacy; + + if (matches) { visibleIds.add(parseInt(row.dataset.pharmacyId)); - row.style.display = ''; + matchedRows.push(row); } else { row.style.display = 'none'; } - - console.log(`Row ${row.dataset.pharmacyId}: ${visible ? 'visible' : 'hidden'}`); }); - + + // Пагінація в межах відфільтрованих рядків таблиці (карта/сайдбар показують весь набір — див. нижче) + const totalMatched = matchedRows.length; + const totalPages = Math.max(1, Math.ceil(totalMatched / pharmaPageSize)); + if (pharmaPage > totalPages) pharmaPage = totalPages; + const start = (pharmaPage - 1) * pharmaPageSize; + const end = Math.min(start + pharmaPageSize, totalMatched); + + matchedRows.forEach((row, idx) => { + row.style.display = (idx >= start && idx < end) ? '' : 'none'; + }); + + pharmaUpdatePaginationBar(totalMatched, totalMatched ? start : 0, end, pharmaPage, totalPages); + // Фильтрация элементов сайдбара на карте const sidebarItems = document.querySelectorAll('div[data-pharmacy-id]'); sidebarItems.forEach(item => { diff --git a/templates/pages/pharmacy-detail.html b/templates/pages/pharmacy-detail.html index 9025b95..bfc709b 100644 --- a/templates/pages/pharmacy-detail.html +++ b/templates/pages/pharmacy-detail.html @@ -1,113 +1,345 @@ -
+{% load common_tags %} +
+
-
- -
Аптеки / Деталі
-
-
- Норма - -
+ + + + + До дислокації + + {% if can_edit %} + Редагувати в адмінці + {% endif %}
-
+ +
-
-
А01
+
+
№{{ pharmacy.pharmacy_number }}
-

Аптека

-
Адреса
-
Місто
+

{{ pharmacy.organization.name|default:"Аптека" }} №{{ pharmacy.pharmacy_number }}

+
{{ pharmacy.address|default:"Адреса не вказана" }}{% if pharmacy.location_region %} · {{ pharmacy.location_region.name }}{% endif %}
-
- +
+ {% if pharmacy.category %}Категорія {{ pharmacy.category }}{% endif %} + {% if pharmacy.status == 'open' %}{{ pharmacy.get_status_display }} + {% elif pharmacy.status == 'closed' %}{{ pharmacy.get_status_display }} + {% elif pharmacy.status == 'suspended' %}{{ pharmacy.get_status_display }} + {% else %}{{ pharmacy.get_status_display }}{% endif %} + {% if not pharmacy.is_active %}Неактивна{% endif %}
-
+ +
-
Укомплектованість
-
0%
-
по ключових ролях
+
Персонал
+
{{ staff_count }}
+
{% if staff_count == 0 %}немає співробітників{% else %}активних{% endif %}
-
Завідуючий
-
0/0
-
поточний стан
+
Завідувач
+
{% if pharmacy.head_pharmacist %}{{ pharmacy.head_pharmacist.last_name }} {{ pharmacy.head_pharmacist.first_name }}{% else %}—{% endif %}
+ {% if not pharmacy.head_pharmacist %}
не призначений
{% endif %}
-
Фармацевти
-
0/0
-
штат
+
Ліцензія
+
{{ pharmacy.license_status }}
+
{{ pharmacy.license_number|default:"без номера" }}
-
Касири
-
0/0
-
штат
+
Режим роботи
+
{{ pharmacy.working_hours|default:"—" }}
-
-
-

Ключові показники

+ +
+ + + + +
-
-
-
- Завідуючий - 0% -
-
-
-
-
+ +
+
+
-
-
- Фармацевти - 0% -
-
-
-
+ +
+

Основна інформація

+
+
Організація
{{ pharmacy.organization.name|default:"—" }}
+
Регіон
{{ pharmacy.location_region.name|default:"—" }}
+
Адреса
{{ pharmacy.address|default:"не вказана" }}
+
Категорія
{{ pharmacy.get_category_display|default:"не вказана" }}
+
Категорія ПДВ
{{ pharmacy.get_vat_category_display|default:"не вказана" }}
+
Відповідальний менеджер
{% if pharmacy.responsible_manager %}{{ pharmacy.responsible_manager.last_name }} {{ pharmacy.responsible_manager.first_name }}{% else %}—{% endif %}
+
Куратор
{% if pharmacy.curator %}{{ pharmacy.curator.last_name }} {{ pharmacy.curator.first_name }}{% else %}—{% endif %}
+
Кількість фармацевтів (штат)
{{ pharmacy.pharmacists_count }}
- -
-
- Касири - 0% -
-
-
-
+ {% if pharmacy.comments %} +
+ Примітки +
{{ pharmacy.comments }}
+ {% endif %}
-
- Режим роботи: - Пн-Нд 9-21 + +
+

Контакти

+ {% if phones %} +
+ {% for p in phones %} +
+ ☎️ {{ p.phone }} + {{ p.get_phone_type_display }} + {% if p.is_primary %}Основний{% endif %} + {% if p.notes %}— {{ p.notes }}{% endif %} +
+ {% endfor %} +
+ {% elif pharmacy.phone %} +
☎️ {{ pharmacy.phone }}
+ {% else %} +
Телефони не вказані
+ {% endif %} +
+ + +
+

Персонал · {{ staff_count }}

+ {% if staff %} +
+ {% for employee in staff %} +
+ {% if employee.photo %} + + {% else %} + {{ employee|initials }} + {% endif %} +
+
+ {{ employee.last_name }} {{ employee.first_name }} + {% if employee.pk == pharmacy.head_pharmacist_id %}Завідувач{% endif %} +
+
{{ employee.post.name|default:"Посада не вказана" }}
+
+
+ {% endfor %} +
+ {% else %} +
У цій аптеці поки немає активних співробітників
+ {% endif %}
-
-
-

Потреби у наймі

-
    +
    + +
    +

    Координати

    + {% if pharmacy.has_coordinates %} +
    +
    {{ pharmacy.latitude|floatformat:5 }}, {{ pharmacy.longitude|floatformat:5 }}
    + + + + {% else %} +
    ⚠ Координати не вказані
    + {% endif %}
    -
    -

    Координати

    -
    -
    + +
    +
    Створено: {{ pharmacy.created_at|date:"d.m.Y H:i" }}
    +
    Оновлено: {{ pharmacy.updated_at|date:"d.m.Y H:i" }}
    +
    + -
    \ No newline at end of file + + + + + + + + + +
    + + diff --git a/templates/pages/profile.html b/templates/pages/profile.html new file mode 100644 index 0000000..79650c8 --- /dev/null +++ b/templates/pages/profile.html @@ -0,0 +1,106 @@ +{% load common_tags %} +
    + +
    +

    Мій профіль

    +

    Особисті контактні дані та налаштування інтерфейсу

    +
    + + {% if saved %} +
    + Зміни збережено +
    + {% endif %} + + {% if errors %} +
    + {% for err in errors %}
    {{ err }}
    {% endfor %} +
    + {% endif %} + +
    + {% csrf_token %} + + +
    +
    + +
    +

    {{ employee.last_name }} {{ employee.first_name }}{% if employee.middle_name %} {{ employee.middle_name }}{% endif %}

    +
    + {% if employee.post %}{{ employee.post.name }}{% endif %}{% if employee.department %} · {{ employee.department.name }}{% elif employee.user_pharmacy %} · Аптека №{{ employee.user_pharmacy.pharmacy_number }}{% endif %} +
    +
    {{ employee.username }} · {{ employee.email }}
    +
    +
    +

    + Службові дані (посада, відділ, робочий email) редагує адміністратор. Клацніть на фото, щоб замінити його. +

    +
    + + +
    +

    Контактні дані

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +

    Контакт для екстрених випадків

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    +
    + + +
    +

    Інтерфейс

    +
    + + +

    Застосовується до всіх таблиць порталу, якщо для конкретної таблиці розмір не вибрано вручну.

    +
    +
    + + +
    + +
    diff --git a/templates/pages/structure.html b/templates/pages/structure.html index aeadcf9..d434419 100644 --- a/templates/pages/structure.html +++ b/templates/pages/structure.html @@ -1,9 +1,10 @@ +{% load common_tags %}

    Структура

    -

    {{ departments|length }} відділів · {{ pharmacies|length }} аптек · {{ total_employees }} співробітників

    +

    {{ departments|length }} відділів · {{ office_count }} співробітників офісу

    {% if request.user.is_staff %}
    @@ -17,205 +18,84 @@ {% endif %}
    - -
    - - -
    - - -
    - - -
    -
    -
    Офісних співробітників
    -
    {{ office_count }}
    -
    активних
    -
    -
    -
    Відділів
    -
    {{ departments|length }}
    -
    активних у довіднику
    -
    -
    -
    Без відділу
    -
    {{ office_no_dept }}
    - {% if office_no_dept %} -
    потребують призначення
    - {% else %} -
    всі розподілені
    - {% endif %} -
    -
    -
    Аптечних співробітників
    -
    {{ pharmacy_employees_count }}
    -
    {{ pharmacies|length }} локацій
    -
    + +
    +
    +
    Офісних співробітників
    +
    {{ office_count }}
    +
    активних
    - - -
    -
    -

    Офісні відділи

    -
    -
    - - - - - - - - - - - - {% for dept in departments %} - - - - - - - - {% empty %} - - {% endfor %} - -
    ВідділКерівникСпівробітниківПосадСтатус
    -
    -
    - {{ dept.name }} -
    -
    - {% if dept.head %} -
    - {{ dept.head.last_name|slice:":1" }}{{ dept.head.first_name|slice:":1" }} - {{ dept.head.get_full_name }} -
    - {% else %} - — - {% endif %} -
    {{ dept.employees_count }}{{ dept.posts_count }} - {% if dept.employees_count %} - Активний - {% else %} - Порожній - {% endif %} -
    Відділи ще не додані до довідника
    -
    +
    +
    Відділів
    +
    {{ departments|length }}
    +
    активних у довіднику
    +
    +
    +
    Без відділу
    +
    {{ office_no_dept }}
    + {% if office_no_dept %} +
    потребують призначення
    + {% else %} +
    всі розподілені
    + {% endif %} +
    +
    +
    Аптечних співробітників
    +
    {{ pharmacy_employees_count }}
    + на Дислокації →
    - -