From c3f56f8a3e9a21bef6b7d17489a77aa7fc871688 Mon Sep 17 00:00:00 2001 From: lokses Date: Thu, 2 Jul 2026 22:41:46 +0300 Subject: [PATCH] Portal redesign from template, real-data pages, test suite, Celery config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - templates: new shell from ACORE-template (mobile sidebar + bottom nav, themes, wallpaper, user dropdown), Django-ized nav with push-url; login rebranding; ACORE-template/ gitignored - new pages wired to real data: databases (Directory types/counts), it-support (Ticket create form + my tickets), dashboard (live counters), structure (departments with head heuristic + pharmacy staffing status); static mockups for news/tasks/email/chat/purchases/hr-portal/help - tests: 57 tests — ad_connector (ldap3 MOCK_SYNC client, sync scenarios, signals, mapping page), mattermost_connector (sync on mocks, bare-except regression), core (pages smoke, tickets, directories, tree rebuild) - celery: add missing acore/celery.py app + CELERY_* settings (broker from env, eager mode without broker locally); wrap signal .delay calls so an unreachable broker no longer breaks employee saves Co-Authored-By: Claude Fable 5 --- .gitignore | 4 +- acore/__init__.py | 3 + acore/celery.py | 9 + acore/settings/base.py | 8 +- acore/settings/local.py | 4 + acore/urls.py | 11 + apps/ad_connector/signals.py | 24 +- apps/ad_connector/tests.py | 368 ++++++++++++++- apps/mattermost_connector/signals.py | 8 +- apps/mattermost_connector/tests.py | 218 ++++++++- core/tests.py | 205 ++++++++- core/views.py | 170 ++++++- static/assets/css/input.css | 273 ++++++++++- static/assets/css/main.css | 297 ++++++++++-- static/assets/img/logosm.svg | 1 + templates/index.html | 665 ++++++++++++++++++++------- templates/login.html | 11 +- templates/pages/chat.html | 149 ++++++ templates/pages/dashboard.html | 218 +++++++-- templates/pages/databases.html | 106 +++++ templates/pages/email.html | 136 ++++++ templates/pages/help.html | 127 +++++ templates/pages/hr-portal.html | 118 +++++ templates/pages/it-support.html | 126 +++++ templates/pages/news.html | 119 +++++ templates/pages/pharmacy-detail.html | 113 +++++ templates/pages/purchases.html | 131 ++++++ templates/pages/structure.html | 382 ++++----------- templates/pages/task-detail.html | 221 +++++++++ templates/pages/tasks.html | 241 ++++++++++ 30 files changed, 3892 insertions(+), 574 deletions(-) create mode 100644 acore/celery.py create mode 100755 static/assets/img/logosm.svg create mode 100644 templates/pages/chat.html create mode 100644 templates/pages/databases.html create mode 100644 templates/pages/email.html create mode 100644 templates/pages/help.html create mode 100644 templates/pages/hr-portal.html create mode 100644 templates/pages/it-support.html create mode 100644 templates/pages/news.html create mode 100644 templates/pages/pharmacy-detail.html create mode 100644 templates/pages/purchases.html create mode 100644 templates/pages/task-detail.html create mode 100644 templates/pages/tasks.html diff --git a/.gitignore b/.gitignore index 2db7688..3671433 100644 --- a/.gitignore +++ b/.gitignore @@ -149,4 +149,6 @@ media/ packets.txt # Claude Code -.claude/settings.local.json \ No newline at end of file +.claude/settings.local.json +# Шаблон-референс дизайна (не часть Django-проекта) +ACORE-template/ diff --git a/acore/__init__.py b/acore/__init__.py index e69de29..fb989c4 100644 --- a/acore/__init__.py +++ b/acore/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ('celery_app',) diff --git a/acore/celery.py b/acore/celery.py new file mode 100644 index 0000000..7dd24ee --- /dev/null +++ b/acore/celery.py @@ -0,0 +1,9 @@ +import os + +from celery import Celery + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'acore.settings.local') + +app = Celery('acore') +app.config_from_object('django.conf:settings', namespace='CELERY') +app.autodiscover_tasks() diff --git a/acore/settings/base.py b/acore/settings/base.py index 7998215..31e3da3 100644 --- a/acore/settings/base.py +++ b/acore/settings/base.py @@ -415,4 +415,10 @@ LOGGING = { 'propagate': False, }, }, -} \ No newline at end of file +} +# --- Celery --- +CELERY_BROKER_URL = env('CELERY_BROKER_URL', default='redis://localhost:6379/0') +CELERY_RESULT_BACKEND = env('CELERY_RESULT_BACKEND', default=CELERY_BROKER_URL) +CELERY_TIMEZONE = TIME_ZONE +# Без брокера (локальная разработка) задачи выполняются синхронно +CELERY_TASK_ALWAYS_EAGER = env.bool('CELERY_TASK_ALWAYS_EAGER', default=False) diff --git a/acore/settings/local.py b/acore/settings/local.py index d107c4b..727d4ab 100644 --- a/acore/settings/local.py +++ b/acore/settings/local.py @@ -8,3 +8,7 @@ ALLOWED_HOSTS = ['*'] # INSTALLED_APPS += ['debug_toolbar'] # MIDDLEWARE += ['debug_toolbar.middleware.DebugToolbarMiddleware'] # INTERNAL_IPS = ['127.0.0.1'] + +# Локально брокер обычно не запущен — задачи выполняются синхронно. +# Для работы с реальным Redis/RabbitMQ поставьте CELERY_TASK_ALWAYS_EAGER=False в .env +CELERY_TASK_ALWAYS_EAGER = env.bool('CELERY_TASK_ALWAYS_EAGER', default=True) diff --git a/acore/urls.py b/acore/urls.py index 9d0fb34..9fa4600 100644 --- a/acore/urls.py +++ b/acore/urls.py @@ -40,6 +40,17 @@ urlpatterns = [ path('recruiting/', views.recruiting_view, name='recruiting'), path('structure/', views.structure_view, name='structure'), path('settings/', views.settings_view, name='settings'), + path('news/', views.news_view, name='news'), + path('tasks/', views.tasks_view, name='tasks'), + path('tasks/detail/', views.task_detail_view, name='task_detail'), + path('email/', views.email_view, name='email'), + path('chat/', views.chat_view, name='chat'), + path('databases/', views.databases_view, name='databases'), + path('purchases/', views.purchases_view, name='purchases'), + path('hr-portal/', views.hr_portal_view, name='hr_portal'), + path('help/', views.help_view, name='help'), + path('it-support/', views.it_support_view, name='it_support'), + path('pharmacies/detail/', views.pharmacy_detail_view, name='pharmacy_detail'), path('modal-add-employee/', views.modal_add_employee_view, name='modal_add_employee'), ] diff --git a/apps/ad_connector/signals.py b/apps/ad_connector/signals.py index cab1f38..d3ad898 100644 --- a/apps/ad_connector/signals.py +++ b/apps/ad_connector/signals.py @@ -21,6 +21,15 @@ def _is_office(instance) -> bool: return instance.user_location_type == 'office' +def _queue(task, employee, action: str) -> None: + """Ставим Celery-задачу; недоступный брокер не должен ронять сохранение.""" + try: + task.delay(employee.pk) + logger.info(f"Поставлена задача {action} AD-аккаунта для {employee.email}") + except Exception as e: + logger.error(f"Не удалось поставить задачу {action} AD для {employee.email}: {e}") + + @receiver(pre_save, sender=Employee) def cache_employee_ad_state(sender, instance, **kwargs): """Кешируем состояние до сохранения чтобы в post_save можно было сравнить.""" @@ -61,8 +70,7 @@ def sync_new_employee_to_ad(sender, instance, created, **kwargs): return from apps.ad_connector.tasks import sync_employee_to_ad_task - sync_employee_to_ad_task.delay(instance.pk) - logger.info(f"Поставлена задача создания AD-аккаунта для {instance.email}") + _queue(sync_employee_to_ad_task, instance, 'создания') @receiver(post_save, sender=Employee) @@ -84,8 +92,7 @@ def update_employee_in_ad(sender, instance, created, **kwargs): return from apps.ad_connector.tasks import sync_employee_to_ad_task - sync_employee_to_ad_task.delay(instance.pk) - logger.info(f"Поставлена задача обновления AD-аккаунта для {instance.email}") + _queue(sync_employee_to_ad_task, instance, 'обновления') @receiver(post_save, sender=Employee) @@ -97,8 +104,7 @@ def handle_location_change(sender, instance, created, **kwargs): old_location = getattr(instance, '_ad_pre_save_location_type', None) if old_location == 'office' and not _is_office(instance): from apps.ad_connector.tasks import deactivate_employee_in_ad_task - deactivate_employee_in_ad_task.delay(instance.pk) - logger.info(f"Сотрудник {instance.email} переведён из офиса — AD-аккаунт будет отключён") + _queue(deactivate_employee_in_ad_task, instance, 'отключения (перевод из офиса)') @receiver(post_save, sender=Employee) @@ -130,10 +136,8 @@ def toggle_employee_in_ad(sender, instance, created, **kwargs): old_active = getattr(instance, '_ad_pre_save_is_active', None) if old_active is True and not instance.is_active: from apps.ad_connector.tasks import deactivate_employee_in_ad_task - deactivate_employee_in_ad_task.delay(instance.pk) - logger.info(f"Поставлена задача отключения AD-аккаунта для {instance.email}") + _queue(deactivate_employee_in_ad_task, instance, 'отключения') elif (old_active is False and instance.is_active and instance.ad_sync_enabled and _is_office(instance)): from apps.ad_connector.tasks import activate_employee_in_ad_task - activate_employee_in_ad_task.delay(instance.pk) - logger.info(f"Поставлена задача включения AD-аккаунта для {instance.email}") + _queue(activate_employee_in_ad_task, instance, 'включения') diff --git a/apps/ad_connector/tests.py b/apps/ad_connector/tests.py index 7ce503c..dab647f 100644 --- a/apps/ad_connector/tests.py +++ b/apps/ad_connector/tests.py @@ -1,3 +1,367 @@ -from django.test import TestCase +""" +Тесты AD-коннектора: LDAP-клиент (на мок-каталоге ldap3), логика синхронизации, +сигналы и страница сопоставления. +""" +from datetime import date +from unittest import mock -# Create your tests here. +from django.contrib.auth import get_user_model +from django.test import TestCase +from ldap3 import MOCK_SYNC, OFFLINE_AD_2012_R2, Connection, Server + +from apps.ad_connector import views +from apps.ad_connector.ad_service import ADService +from apps.ad_connector.services import sync_service + +Employee = get_user_model() + +BASE_DN = 'DC=company,DC=local' +USERS_OU = 'OU=Users,DC=company,DC=local' + + +def make_mock_ad(use_ssl=True): + """ADService поверх мок-каталога ldap3 (без реального подключения)""" + svc = ADService.__new__(ADService) + svc.base_dn = BASE_DN + svc.users_ou = USERS_OU + svc.domain = 'company.local' + svc.use_ssl = use_ssl + server = Server('mock', get_info=OFFLINE_AD_2012_R2) + conn = Connection(server, client_strategy=MOCK_SYNC, raise_exceptions=True) + conn.strategy.add_entry(BASE_DN, {'objectClass': ['domain'], 'dc': 'company'}) + conn.strategy.add_entry(USERS_OU, {'objectClass': ['organizationalUnit'], 'ou': 'Users'}) + conn.bind() + svc.conn = conn + + # у MOCK_SYNC нет microsoft-расширения смены пароля — подменяем и записываем вызовы + passwords = [] + + class _Microsoft: + modify_password = staticmethod(lambda dn, pw: passwords.append((dn, pw)) or True) + + class _Extend: + microsoft = _Microsoft() + + svc.conn.extend = _Extend() + svc.set_passwords = passwords + svc.unbind = lambda: None + return svc + + +_employee_seq = 0 + + +def make_employee(**overrides): + """Сотрудник с выключенными синхронизациями (чтобы сигналы не звали Celery)""" + global _employee_seq + _employee_seq += 1 + defaults = { + 'username': f'test.user{_employee_seq}', + 'email': f'test.user{_employee_seq}@company.local', + 'first_name': 'Іван', + 'last_name': 'Тестовий', + 'hire_date': date(2026, 1, 1), + 'user_location_type': 'office', + 'mattermost_sync_enabled': False, + 'ad_sync_enabled': False, + } + defaults.update(overrides) + return Employee.objects.create(**defaults) + + +class ADServiceMockTests(TestCase): + def setUp(self): + self.svc = make_mock_ad() + + def _create_ivanov(self): + self.svc.create_user(f'CN=Іванов Іван,{USERS_OU}', { + 'sAMAccountName': 'ivanov.i', + 'mail': 'ivanov@company.local', + 'displayName': 'Іванов Іван', + 'givenName': 'Іван', 'sn': 'Іванов', + 'title': 'Фармацевт', + }) + + def test_create_user_is_disabled_by_default(self): + self._create_ivanov() + user = self.svc.get_user_by_sam_account_name('ivanov.i') + self.assertIsNotNone(user) + self.assertTrue(ADService.is_disabled(user)) + + def test_search_by_sam_email_and_dn(self): + self._create_ivanov() + self.assertIsNotNone(self.svc.get_user_by_sam_account_name('ivanov.i')) + self.assertIsNotNone(self.svc.get_user_by_email('ivanov@company.local')) + self.assertIsNotNone(self.svc.get_user_by_dn(f'CN=Іванов Іван,{USERS_OU}')) + self.assertIsNone(self.svc.get_user_by_dn(f'CN=Нема Такого,{USERS_OU}')) + + def test_filter_injection_is_escaped(self): + self._create_ivanov() + self.assertIsNone(self.svc.get_user_by_sam_account_name('a)(mail=*')) + + def test_update_user_empty_value_removes_attribute(self): + self._create_ivanov() + dn = f'CN=Іванов Іван,{USERS_OU}' + self.svc.update_user(dn, {'title': 'Провізор', 'mail': ''}) + user = self.svc.get_user_by_dn(dn) + self.assertEqual(user.get('title'), 'Провізор') + self.assertFalse(user.get('mail')) + + def test_enable_and_disable_user(self): + self._create_ivanov() + dn = f'CN=Іванов Іван,{USERS_OU}' + self.svc.enable_user(dn) + self.assertFalse(ADService.is_disabled(self.svc.get_user_by_dn(dn))) + self.svc.disable_user(dn) + self.assertTrue(ADService.is_disabled(self.svc.get_user_by_dn(dn))) + + def test_rename_user(self): + self._create_ivanov() + old_dn = f'CN=Іванов Іван,{USERS_OU}' + new_dn = self.svc.rename_user(old_dn, 'Петренко Іван') + self.assertEqual(new_dn, f'CN=Петренко Іван,{USERS_OU}') + self.assertIsNotNone(self.svc.get_user_by_dn(new_dn)) + self.assertIsNone(self.svc.get_user_by_dn(old_dn)) + + def test_get_all_users(self): + self._create_ivanov() + users = self.svc.get_all_users() + self.assertEqual(len(users), 1) + self.assertEqual(users[0]['sAMAccountName'], 'ivanov.i') + + +class SyncEmployeeToADTests(TestCase): + def setUp(self): + self.svc = make_mock_ad() + patcher = mock.patch.object(sync_service, 'ADService', lambda: self.svc) + patcher.start() + self.addCleanup(patcher.stop) + + def test_creates_enabled_user_with_password(self): + employee = make_employee(first_name='Анна', last_name='Сидорова') + result = sync_service.sync_employee_to_ad(employee, password='Secret123') + self.assertTrue(result) + employee.refresh_from_db() + self.assertEqual(employee.ad_sync_status, 'synced') + self.assertTrue(employee.ad_user_dn.startswith('CN=')) + user = self.svc.get_user_by_dn(employee.ad_user_dn) + self.assertFalse(ADService.is_disabled(user)) + self.assertEqual(self.svc.set_passwords[-1][1], 'Secret123') + + def test_non_office_employee_is_skipped(self): + employee = make_employee(user_location_type='pharmacy') + result = sync_service.sync_employee_to_ad(employee) + self.assertFalse(result) + employee.refresh_from_db() + self.assertEqual(employee.ad_sync_status, 'disabled') + self.assertEqual(employee.ad_user_dn, '') + + def test_update_renames_on_name_change(self): + employee = make_employee(first_name='Анна', last_name='Сидорова') + sync_service.sync_employee_to_ad(employee, password='Secret123') + employee.refresh_from_db() + old_dn = employee.ad_user_dn + + employee.last_name = 'Ковальська' + employee.save() + result = sync_service.sync_employee_to_ad(employee) + self.assertTrue(result) + employee.refresh_from_db() + self.assertNotEqual(employee.ad_user_dn, old_dn) + self.assertIn('Ковальська', employee.ad_user_dn) + user = self.svc.get_user_by_dn(employee.ad_user_dn) + self.assertEqual(user.get('sn'), 'Ковальська') + self.assertIsNone(self.svc.get_user_by_dn(old_dn)) + + def test_recovers_lost_dn_by_sam_account_name(self): + employee = make_employee() + sync_service.sync_employee_to_ad(employee, password='Secret123') + employee.refresh_from_db() + real_dn = employee.ad_user_dn + + Employee.objects.filter(pk=employee.pk).update( + ad_user_dn=f'CN=Загублений,{USERS_OU}', + ) + employee.refresh_from_db() + result = sync_service.sync_employee_to_ad(employee) + self.assertTrue(result) + employee.refresh_from_db() + self.assertEqual(employee.ad_user_dn, real_dn) + + def test_update_reenables_disabled_account(self): + employee = make_employee() + sync_service.sync_employee_to_ad(employee, password='Secret123') + employee.refresh_from_db() + self.svc.disable_user(employee.ad_user_dn) + + sync_service.sync_employee_to_ad(employee) + user = self.svc.get_user_by_dn(employee.ad_user_dn) + self.assertFalse(ADService.is_disabled(user)) + + def test_deactivate_and_activate(self): + employee = make_employee() + sync_service.sync_employee_to_ad(employee, password='Secret123') + employee.refresh_from_db() + + self.assertTrue(sync_service.deactivate_ad_user(employee)) + self.assertTrue(ADService.is_disabled(self.svc.get_user_by_dn(employee.ad_user_dn))) + employee.refresh_from_db() + self.assertEqual(employee.ad_sync_status, 'disabled') + + self.assertTrue(sync_service.activate_ad_user(employee)) + self.assertFalse(ADService.is_disabled(self.svc.get_user_by_dn(employee.ad_user_dn))) + employee.refresh_from_db() + self.assertEqual(employee.ad_sync_status, 'synced') + + def test_sync_password_requires_linked_account(self): + employee = make_employee() + self.assertFalse(sync_service.sync_ad_password(employee, 'NewPass1')) + + sync_service.sync_employee_to_ad(employee, password='Secret123') + employee.refresh_from_db() + self.assertTrue(sync_service.sync_ad_password(employee, 'NewPass1')) + self.assertEqual(self.svc.set_passwords[-1][1], 'NewPass1') + + def test_generate_sam_account_name_limits_and_sanitizes(self): + employee = make_employee(username='Ivanov Ivan+Petrovich-Very-Long-Name') + sam = sync_service.generate_sam_account_name(employee) + self.assertLessEqual(len(sam), 20) + self.assertNotIn(' ', sam) + self.assertNotIn('+', sam) + + def test_generate_random_password_meets_complexity(self): + for _ in range(5): + password = sync_service.generate_random_password() + self.assertEqual(len(password), 16) + self.assertTrue(any(c.islower() for c in password)) + self.assertTrue(any(c.isupper() for c in password)) + self.assertTrue(any(c.isdigit() for c in password)) + + +def make_synced_employee(**extra): + """Сотрудник с включённой AD-синхронизацией и привязанным DN. + Флаги ставятся через update() — сигнал создания не уходит в Celery.""" + employee = make_employee() + fields = {'ad_sync_enabled': True, 'ad_user_dn': f'CN=X,{USERS_OU}'} + fields.update(extra) + Employee.objects.filter(pk=employee.pk).update(**fields) + employee.refresh_from_db() + return employee + + +class ADSignalTests(TestCase): + def test_create_office_employee_with_sync_queues_task(self): + with mock.patch('apps.ad_connector.tasks.sync_employee_to_ad_task.delay') as delay: + employee = make_employee(ad_sync_enabled=True) + delay.assert_called_once_with(employee.pk) + + def test_create_non_office_employee_does_not_queue(self): + with mock.patch('apps.ad_connector.tasks.sync_employee_to_ad_task.delay') as delay: + make_employee(ad_sync_enabled=True, user_location_type='pharmacy') + delay.assert_not_called() + + def test_create_without_sync_flag_does_not_queue(self): + with mock.patch('apps.ad_connector.tasks.sync_employee_to_ad_task.delay') as delay: + make_employee(ad_sync_enabled=False) + delay.assert_not_called() + + def test_attribute_change_queues_update(self): + employee = make_synced_employee() + with mock.patch('apps.ad_connector.tasks.sync_employee_to_ad_task.delay') as delay: + employee.first_name = 'Петро' + employee.save() + delay.assert_called_once_with(employee.pk) + + def test_save_without_changes_does_not_queue(self): + employee = make_synced_employee() + with mock.patch('apps.ad_connector.tasks.sync_employee_to_ad_task.delay') as delay: + employee.save() + delay.assert_not_called() + + def test_transfer_out_of_office_queues_deactivation(self): + employee = make_synced_employee() + with mock.patch('apps.ad_connector.tasks.deactivate_employee_in_ad_task.delay') as delay: + employee.user_location_type = 'pharmacy' + employee.save() + delay.assert_called_once_with(employee.pk) + + def test_block_employee_queues_deactivation(self): + employee = make_employee(ad_user_dn=f'CN=X,{USERS_OU}') + with mock.patch('apps.ad_connector.tasks.deactivate_employee_in_ad_task.delay') as delay: + employee.is_active = False + employee.save() + delay.assert_called_once_with(employee.pk) + + def test_unblock_office_employee_queues_activation(self): + employee = make_synced_employee(is_active=False) + with mock.patch('apps.ad_connector.tasks.activate_employee_in_ad_task.delay') as delay: + employee.is_active = True + employee.save() + delay.assert_called_once_with(employee.pk) + + def test_password_change_syncs_to_ad(self): + employee = make_synced_employee() + with mock.patch( + 'apps.ad_connector.services.sync_service.sync_ad_password', return_value=True, + ) as sync_pw: + employee.set_password('NewSecret1') + employee.save() + sync_pw.assert_called_once() + self.assertEqual(sync_pw.call_args.args[1], 'NewSecret1') + + +class ADMappingViewTests(TestCase): + def setUp(self): + self.staff = make_employee(is_staff=True) + self.svc = make_mock_ad() + patcher = mock.patch.object(views, 'ADService', lambda: self.svc) + patcher.start() + self.addCleanup(patcher.stop) + + def test_requires_staff(self): + regular = make_employee() + self.client.force_login(regular) + response = self.client.get('/ad-mapping/') + self.assertEqual(response.status_code, 302) + + def test_page_renders_with_ad_users(self): + self.svc.create_user(f'CN=Вільний Аккаунт,{USERS_OU}', { + 'sAMAccountName': 'free.account', 'displayName': 'Вільний Аккаунт', + }) + self.client.force_login(self.staff) + response = self.client.get('/ad-mapping/', HTTP_HX_REQUEST='true') + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'free.account') + + def test_link_action_sets_dn_and_queues_sync(self): + employee = make_employee() + dn = f'CN=Вільний Аккаунт,{USERS_OU}' + self.svc.create_user(dn, {'sAMAccountName': 'free.account'}) + self.client.force_login(self.staff) + with mock.patch('apps.ad_connector.tasks.sync_employee_to_ad_task.delay') as delay: + response = self.client.post('/ad-mapping/', { + 'action': 'link', 'employee_id': employee.pk, 'dn': dn, + }, HTTP_HX_REQUEST='true') + self.assertEqual(response.status_code, 200) + employee.refresh_from_db() + self.assertEqual(employee.ad_user_dn, dn) + delay.assert_called_once_with(employee.pk) + + def test_unlink_action_clears_dn(self): + employee = make_employee(ad_user_dn=f'CN=X,{USERS_OU}', ad_sync_status='synced') + self.client.force_login(self.staff) + response = self.client.post('/ad-mapping/', { + 'action': 'unlink', 'employee_id': employee.pk, + }, HTTP_HX_REQUEST='true') + self.assertEqual(response.status_code, 200) + employee.refresh_from_db() + self.assertEqual(employee.ad_user_dn, '') + self.assertEqual(employee.ad_sync_status, 'not_synced') + + def test_action_on_non_office_employee_returns_404(self): + employee = make_employee(user_location_type='pharmacy') + self.client.force_login(self.staff) + response = self.client.post('/ad-mapping/', { + 'action': 'unlink', 'employee_id': employee.pk, + }, HTTP_HX_REQUEST='true') + self.assertEqual(response.status_code, 404) diff --git a/apps/mattermost_connector/signals.py b/apps/mattermost_connector/signals.py index 0b85097..8e358dc 100755 --- a/apps/mattermost_connector/signals.py +++ b/apps/mattermost_connector/signals.py @@ -36,8 +36,12 @@ def sync_new_employee_to_mattermost(sender, instance, created, **kwargs): return from apps.mattermost_connector.tasks import sync_employee_to_mattermost - sync_employee_to_mattermost.delay(instance.pk) - logger.info(f"Поставлена задача создания Mattermost-аккаунта для {instance.email}") + try: + sync_employee_to_mattermost.delay(instance.pk) + logger.info(f"Поставлена задача создания Mattermost-аккаунта для {instance.email}") + except Exception as e: + # Недоступный брокер не должен ронять сохранение сотрудника + logger.error(f"Не удалось поставить задачу Mattermost для {instance.email}: {e}") @receiver(post_save, sender=Employee) diff --git a/apps/mattermost_connector/tests.py b/apps/mattermost_connector/tests.py index de8bdc0..e8602c4 100755 --- a/apps/mattermost_connector/tests.py +++ b/apps/mattermost_connector/tests.py @@ -1,3 +1,215 @@ -from django.test import TestCase - -# Create your tests here. +""" +Тесты Mattermost-коннектора: генерация учётных данных и логика синхронизации +(MattermostService замокан — сеть не используется). +""" +from datetime import date +from unittest import mock + +from django.contrib.auth import get_user_model +from django.test import TestCase +from mattermostautodriver.exceptions import ResourceNotFound + +from apps.mattermost_connector.models import MattermostUser +from apps.mattermost_connector.services import sync_service + +Employee = get_user_model() + +_employee_seq = 0 + + +def make_employee(**overrides): + """Сотрудник с выключенными синхронизациями (сигналы не зовут Celery)""" + global _employee_seq + _employee_seq += 1 + defaults = { + 'username': f'mm.user{_employee_seq}', + 'email': f'mm.user{_employee_seq}@company.local', + 'first_name': 'Іван', + 'last_name': 'Тестовий', + 'hire_date': date(2026, 1, 1), + 'user_location_type': 'office', + 'mattermost_sync_enabled': False, + 'ad_sync_enabled': False, + } + defaults.update(overrides) + return Employee.objects.create(**defaults) + + +def not_found(): + return ResourceNotFound('not found', 'store.sql_user.missing', 'rid', False) + + +class GenerateUsernameTests(TestCase): + def test_uses_existing_username(self): + employee = make_employee(username='petrov.p') + self.assertEqual(sync_service.generate_username(employee), 'petrov.p') + + def test_generates_from_email_when_no_username(self): + employee = make_employee() + employee.username = '' + employee.email = 'Ivan+Petrov@company.local' + username = sync_service.generate_username(employee) + self.assertEqual(username, 'ivan_petrov') + + +class GenerateRandomPasswordTests(TestCase): + def test_default_length(self): + self.assertEqual(len(sync_service.generate_random_password()), 16) + + def test_custom_length(self): + self.assertEqual(len(sync_service.generate_random_password(24)), 24) + + +class SyncEmployeeTests(TestCase): + """sync_employee с замоканным MattermostService""" + + def setUp(self): + self.service = mock.MagicMock() + patcher = mock.patch.object( + sync_service, 'MattermostService', return_value=self.service, + ) + patcher.start() + self.addCleanup(patcher.stop) + + def test_finds_existing_user_by_email(self): + employee = make_employee() + self.service.get_user_by_email.return_value = { + 'id': 'mm-abc', 'username': 'found.user', + 'email': employee.email, 'delete_at': 0, + } + + result = sync_service.sync_employee(employee) + + self.assertTrue(result) + employee.refresh_from_db() + self.assertEqual(employee.mattermost_user_id, 'mm-abc') + self.assertEqual(employee.mattermost_username, 'found.user') + self.assertEqual(employee.mattermost_sync_status, 'synced') + self.assertTrue(MattermostUser.objects.filter(mattermost_id='mm-abc').exists()) + # существующего обновляем, нового не создаём + self.service.update_user.assert_called_once() + self.service.create_user.assert_not_called() + + def test_creates_new_user_when_not_found(self): + employee = make_employee(username='new.user') + self.service.get_user_by_email.side_effect = not_found() + self.service.get_user_by_username.side_effect = not_found() + self.service.create_user.return_value = { + 'id': 'mm-new', 'username': 'new.user', 'email': employee.email, + } + + result = sync_service.sync_employee(employee, password='Secret123') + + self.assertTrue(result) + employee.refresh_from_db() + self.assertEqual(employee.mattermost_user_id, 'mm-new') + self.assertEqual(employee.mattermost_sync_status, 'synced') + user_data = self.service.create_user.call_args.args[0] + self.assertEqual(user_data['username'], 'new.user') + self.assertEqual(user_data['password'], 'Secret123') + self.assertEqual(user_data['email'], employee.email) + + def test_username_conflict_appends_counter(self): + employee = make_employee(username='taken.name') + self.service.get_user_by_email.side_effect = not_found() + # первый username занят, второй свободен + self.service.get_user_by_username.side_effect = [ + {'id': 'mm-other', 'username': 'taken.name'}, + not_found(), + ] + self.service.create_user.return_value = { + 'id': 'mm-new2', 'username': 'taken.name1', 'email': employee.email, + } + + result = sync_service.sync_employee(employee) + + self.assertTrue(result) + user_data = self.service.create_user.call_args.args[0] + self.assertEqual(user_data['username'], 'taken.name1') + + def test_username_check_does_not_swallow_network_errors(self): + """Сетевая ошибка при проверке username — это ошибка синхронизации, + а не сигнал «имя свободно» (регресс голого except).""" + employee = make_employee() + self.service.get_user_by_email.side_effect = not_found() + self.service.get_user_by_username.side_effect = OSError('connection refused') + + result = sync_service.sync_employee(employee) + + self.assertFalse(result) + employee.refresh_from_db() + self.assertEqual(employee.mattermost_sync_status, 'error') + self.service.create_user.assert_not_called() + + def test_stale_mattermost_id_is_reset_and_user_recreated(self): + employee = make_employee(mattermost_user_id='mm-stale') + self.service.get_user.side_effect = not_found() + self.service.get_user_by_email.side_effect = not_found() + self.service.get_user_by_username.side_effect = not_found() + self.service.create_user.return_value = { + 'id': 'mm-fresh', 'username': employee.username, 'email': employee.email, + } + + result = sync_service.sync_employee(employee) + + self.assertTrue(result) + employee.refresh_from_db() + self.assertEqual(employee.mattermost_user_id, 'mm-fresh') + + def test_error_marks_sync_status(self): + employee = make_employee() + self.service.get_user_by_email.side_effect = not_found() + self.service.get_user_by_username.side_effect = not_found() + self.service.create_user.side_effect = OSError('boom') + + result = sync_service.sync_employee(employee) + + self.assertFalse(result) + employee.refresh_from_db() + self.assertEqual(employee.mattermost_sync_status, 'error') + + +class DeactivateActivateTests(TestCase): + def setUp(self): + self.service = mock.MagicMock() + patcher = mock.patch.object( + sync_service, 'MattermostService', return_value=self.service, + ) + patcher.start() + self.addCleanup(patcher.stop) + + def test_deactivate_requires_linked_account(self): + employee = make_employee() + self.assertFalse(sync_service.deactivate_mattermost_user(employee)) + self.service.deactivate_user.assert_not_called() + + def test_deactivate_linked_user(self): + employee = make_employee(mattermost_user_id='mm-abc') + result = sync_service.deactivate_mattermost_user(employee) + self.assertTrue(result) + self.service.deactivate_user.assert_called_once_with('mm-abc') + + +class MattermostSignalTests(TestCase): + def test_create_employee_with_sync_queues_task(self): + with mock.patch( + 'apps.mattermost_connector.tasks.sync_employee_to_mattermost.delay', + ) as delay: + employee = make_employee(mattermost_sync_enabled=True) + delay.assert_called_once_with(employee.pk) + + def test_create_employee_without_sync_does_not_queue(self): + with mock.patch( + 'apps.mattermost_connector.tasks.sync_employee_to_mattermost.delay', + ) as delay: + make_employee(mattermost_sync_enabled=False) + delay.assert_not_called() + + def test_block_employee_deactivates_account(self): + employee = make_employee(mattermost_user_id='mm-abc') + with mock.patch( + 'apps.mattermost_connector.services.sync_service.deactivate_mattermost_user', + ) as deactivate: + employee.is_active = False + employee.save() + deactivate.assert_called_once() diff --git a/core/tests.py b/core/tests.py index 7ce503c..e0965b5 100644 --- a/core/tests.py +++ b/core/tests.py @@ -1,3 +1,206 @@ +""" +Тесты core: смоук всех страниц портала, заявки IT, справочники, +структура и перестройка дерева Directory. +""" +from datetime import date + +from django.contrib.auth import get_user_model +from django.core.management import call_command +from django.db.models import Count from django.test import TestCase -# Create your tests here. +from apps.it_manager.models import Ticket +from core.models import Directory, DirectoryType + +Employee = get_user_model() + +_employee_seq = 0 + + +def make_employee(**overrides): + """Сотрудник с выключенными синхронизациями (сигналы не зовут Celery)""" + global _employee_seq + _employee_seq += 1 + defaults = { + 'username': f'core.user{_employee_seq}', + 'email': f'core.user{_employee_seq}@company.local', + 'first_name': 'Іван', + 'last_name': 'Тестовий', + 'hire_date': date(2026, 1, 1), + 'user_location_type': 'office', + 'mattermost_sync_enabled': False, + 'ad_sync_enabled': False, + } + defaults.update(overrides) + return Employee.objects.create(**defaults) + + +class PortalPagesSmokeTests(TestCase): + """Все страницы портала отдают 200 — и как HTMX-фрагмент, и в полном шелле""" + + PAGES = [ + '/dashboard/', '/employees/', '/pharmacies/', '/structure/', + '/timetrack/', '/calendar/', '/reports/', '/documents/', + '/recruiting/', '/settings/', '/news/', '/tasks/', '/tasks/detail/', + '/email/', '/chat/', '/databases/', '/purchases/', '/hr-portal/', + '/help/', '/it-support/', '/ad-mapping/', '/pharmacies/detail/', + ] + + @classmethod + def setUpTestData(cls): + cls.user = make_employee(is_staff=True, is_superuser=True) + + def test_all_pages_render(self): + self.client.force_login(self.user) + for url in self.PAGES: + 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) + + def test_anonymous_is_redirected_to_login(self): + response = self.client.get('/dashboard/') + self.assertEqual(response.status_code, 302) + self.assertIn('login', response.url) + + def test_login_page_renders(self): + response = self.client.get('/login/') + self.assertEqual(response.status_code, 200) + + +class ITSupportViewTests(TestCase): + def setUp(self): + self.user = make_employee() + self.client.force_login(self.user) + + def test_post_creates_ticket_for_requester(self): + response = self.client.post('/it-support/', { + 'title': 'Не працює принтер', + 'description': 'Кабінет 204, принтер не друкує', + 'ticket_type': 'repair', + 'priority': 'high', + }, HTTP_HX_REQUEST='true') + 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.assertContains(response, ticket.number) + + def test_invalid_choices_fall_back_to_defaults(self): + self.client.post('/it-support/', { + 'title': 'Питання', + 'description': 'Опис', + 'ticket_type': 'hacked_value', + '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') + + def test_empty_title_does_not_create_ticket(self): + self.client.post('/it-support/', { + 'title': '', 'description': 'Опис', + }, HTTP_HX_REQUEST='true') + self.assertFalse(Ticket.objects.exists()) + + def test_my_tickets_shows_only_own(self): + other = make_employee() + Ticket.objects.create( + title='Чужа заявка', description='x', + ticket_type='other', requester=other, + ) + response = self.client.get('/it-support/', HTTP_HX_REQUEST='true') + self.assertNotContains(response, 'Чужа заявка') + + +class DatabasesViewTests(TestCase): + def setUp(self): + self.user = make_employee() + # в тестовой базе уже есть типы, созданные data-миграциями (equipment_brand) + self.base_types = DirectoryType.objects.count() + self.base_records = Directory.objects.count() + self.base_active = Directory.objects.filter(is_active=True).count() + self.dtype = DirectoryType.objects.create( + code='test_type', name='Тестовий довідник', hierarchy_type='strict', + ) + Directory.objects.create(directory_type=self.dtype, name='Запис 1') + Directory.objects.create(directory_type=self.dtype, name='Запис 2', is_active=False) + + def test_counts_and_listing(self): + self.client.force_login(self.user) + response = self.client.get('/databases/', HTTP_HX_REQUEST='true') + self.assertEqual(response.status_code, 200) + self.assertContains(response, 'Тестовий довідник') + self.assertEqual(response.context['types_count'], self.base_types + 1) + self.assertEqual(response.context['records_count'], self.base_records + 2) + self.assertEqual(response.context['active_records_count'], self.base_active + 1) + + def test_admin_link_visible_only_to_staff(self): + self.client.force_login(self.user) + response = self.client.get('/databases/', HTTP_HX_REQUEST='true') + self.assertNotContains(response, '/admin/core/directory/') + + staff = make_employee(is_staff=True) + self.client.force_login(staff) + response = self.client.get('/databases/', HTTP_HX_REQUEST='true') + self.assertContains(response, '/admin/core/directory/') + + +class StructureViewTests(TestCase): + def setUp(self): + self.dtype = DirectoryType.objects.create( + code='department', name='Відділи', hierarchy_type='strict', + ) + self.dept = Directory.objects.create(directory_type=self.dtype, name='IT-відділ') + + def test_department_head_heuristic(self): + head = make_employee(first_name='Керівник', last_name='Відділу', department=self.dept) + make_employee(department=self.dept, manager=head) + make_employee(department=self.dept, manager=head) + + self.client.force_login(head) + response = self.client.get('/structure/', HTTP_HX_REQUEST='true') + self.assertEqual(response.status_code, 200) + departments = response.context['departments'] + dept = next(d for d in departments if d.pk == self.dept.pk) + self.assertEqual(dept.head, head) + self.assertEqual(dept.employees_count, 3) + + def test_empty_department_has_no_head(self): + user = make_employee() + self.client.force_login(user) + response = self.client.get('/structure/', HTTP_HX_REQUEST='true') + departments = response.context['departments'] + dept = next(d for d in departments if d.pk == self.dept.pk) + self.assertIsNone(dept.head) + self.assertContains(response, 'Порожній') + + +class RebuildDirectoryTreeTests(TestCase): + def setUp(self): + self.dtype = DirectoryType.objects.create( + code='test_tree', name='Дерево', hierarchy_type='strict', + ) + self.root = Directory.objects.create(directory_type=self.dtype, name='Корінь') + self.child = Directory.objects.create( + directory_type=self.dtype, name='Дитина', parent=self.root, + ) + + def test_command_repairs_broken_paths(self): + # ломаем дерево так, как это делают data-миграции и импорт + Directory.objects.update(_path='') + call_command('rebuild_directory_tree', verbosity=0) + + self.root.refresh_from_db() + self.child.refresh_from_db() + self.assertTrue(self.root._path) + self.assertTrue(self.child._path) + self.assertTrue(self.child._path.startswith(self.root._path)) + + duplicates = ( + Directory.objects.values('_path') + .annotate(cnt=Count('id')).filter(cnt__gt=1) + ) + self.assertFalse(duplicates.exists()) diff --git a/core/views.py b/core/views.py index bf3296f..9c0858f 100644 --- a/core/views.py +++ b/core/views.py @@ -20,9 +20,22 @@ def index_view(request): @login_required def dashboard_view(request): + from django.utils import timezone as dj_timezone + from core.models.directories import DirectoryType + from apps.it_manager.models import Ticket + + context = { + 'today': dj_timezone.localdate(), + 'employees_active': Employee.objects.filter(is_active=True).count(), + 'employees_total': Employee.objects.count(), + 'pharmacies_active': Pharmacy.objects.filter(is_active=True).count(), + 'open_tickets': Ticket.objects.filter(status__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') - return render(request, 'index.html', {'page_template': 'pages/dashboard.html'}) + return render(request, 'pages/dashboard.html', context) + return render(request, 'index.html', {'page_template': 'pages/dashboard.html', **context}) @login_required def employees_view(request): @@ -165,9 +178,73 @@ def recruiting_view(request): @login_required def structure_view(request): + from django.db.models import Count, Q + + # --- Офіс: відділи --- + departments = list( + Directory.objects.filter(directory_type__code='department', is_active=True) + .order_by('_path') + ) + office_employees = list( + Employee.objects.filter(is_active=True, user_location_type='office') + .select_related('post') + ) + by_dept = {} + for emp in office_employees: + by_dept.setdefault(emp.department_id, []).append(emp) + + palette = ['#3b82f6', '#8b5cf6', '#f59e0b', '#10b981', '#ec4899', '#06b6d4', '#64748b'] + for i, dept in enumerate(departments): + emps = by_dept.get(dept.id, []) + dept.color = palette[i % len(palette)] + dept.indent_depth = (dept._path or '').count('.') + dept.employees_count = len(emps) + dept.posts_count = len({e.post_id for e in emps if e.post_id}) + # Керівник: у нього є підлеглі у відділі, а його керівник — поза відділом + ids = {e.pk for e in emps} + managers_in_dept = {e.manager_id for e in emps if e.manager_id in ids} + candidates = [e for e in emps if e.pk in managers_in_dept] + dept.head = ( + next((e for e in candidates if e.manager_id not in ids), None) + 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() + + context = { + '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') - return render(request, 'index.html', {'page_template': 'pages/structure.html'}) + return render(request, 'pages/structure.html', context) + return render(request, 'index.html', {'page_template': 'pages/structure.html', **context}) @login_required def settings_view(request): @@ -176,6 +253,91 @@ def settings_view(request): return render(request, 'index.html', {'page_template': 'pages/settings.html'}) +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 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 databases_view(request): + """Довідники: типи справочников с количеством записей""" + from django.db.models import Count, Max + from core.models.directories import DirectoryType + + directory_types = list( + DirectoryType.objects + .annotate(records=Count('directory'), last_updated=Max('directory__updated_at')) + .order_by('name') + ) + directories = Directory.objects.all() + + context = { + 'directory_types': directory_types, + 'types_count': len(directory_types), + 'records_count': directories.count(), + 'active_records_count': directories.filter(is_active=True).count(), + 'last_updated': max( + (dt.last_updated for dt in directory_types if dt.last_updated), + 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}) + + +@login_required +def it_support_view(request): + """Підтримка IT: створення заявки та мої заявки""" + from apps.it_manager.models import Ticket + + 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') + 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', + requester=request.user, + ) + + my_tickets = Ticket.objects.filter(requester=request.user).order_by('-created_at')[:8] + context = { + 'ticket_types': Ticket.TICKET_TYPES, + 'my_tickets': my_tickets, + 'my_open_count': Ticket.objects.filter( + requester=request.user, status__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}) + + def login_view(request): if request.method == 'POST': username = request.POST.get('username') diff --git a/static/assets/css/input.css b/static/assets/css/input.css index 20f18ec..c82ab30 100644 --- a/static/assets/css/input.css +++ b/static/assets/css/input.css @@ -17,8 +17,8 @@ --color-popover-foreground: hsl(215 25% 15%); /* Primary */ - --color-primary: hsl(215 60% 22%); - --color-primary-foreground: hsl(210 40% 98%); + --color-primary: hsl(224.8, 81%, 60.8%); + --color-primary-foreground: hsl(0 0% 100%); /* Secondary */ --color-secondary: hsl(210 25% 93%); @@ -39,20 +39,20 @@ /* Border / Input / Ring */ --color-border: hsl(214 20% 90%); --color-input: hsl(214 20% 90%); - --color-ring: hsl(215 60% 22%); + --color-ring: hsl(0 72% 44%); /* Radius */ --radius: 0.625rem; /* Sidebar */ - --color-sidebar: hsl(215 60% 22%); - --color-sidebar-foreground: hsl(210 30% 85%); - --color-sidebar-primary: hsl(174 55% 50%); + --color-sidebar: hsl(0 0% 100%); + --color-sidebar-foreground: hsl(215 15% 40%); + --color-sidebar-primary: hsl(224.8, 81%, 60.8%); --color-sidebar-primary-foreground: hsl(0 0% 100%); - --color-sidebar-accent: hsl(215 50% 28%); - --color-sidebar-accent-foreground: hsl(210 30% 95%); - --color-sidebar-border: hsl(215 45% 30%); - --color-sidebar-ring: hsl(174 55% 50%); + --color-sidebar-accent: hsl(214 20% 94%); + --color-sidebar-accent-foreground: hsl(215 25% 20%); + --color-sidebar-border: hsl(214 20% 90%); + --color-sidebar-ring: hsl(0 72% 44%); /* Status */ --color-success: hsl(152 55% 45%); @@ -69,14 +69,14 @@ /* ─── Dark mode ──────────────────────────────────── */ @layer base { - .dark { + [data-theme="dark"] { --color-background: hsl(215 25% 10%); --color-foreground: hsl(210 40% 98%); --color-card: hsl(215 25% 13%); --color-card-foreground: hsl(210 40% 98%); --color-popover: hsl(215 25% 13%); --color-popover-foreground: hsl(210 40% 98%); - --color-primary: hsl(174 55% 50%); + --color-primary: hsl(208.1, 55.4%, 27.3%); --color-primary-foreground: hsl(215 60% 15%); --color-secondary: hsl(215 30% 20%); --color-secondary-foreground: hsl(210 40% 98%); @@ -88,15 +88,15 @@ --color-destructive-foreground: hsl(210 40% 98%); --color-border: hsl(215 25% 22%); --color-input: hsl(215 25% 22%); - --color-ring: hsl(174 55% 50%); + --color-ring: hsl(208.1, 55.4%, 27.3%); --color-sidebar: hsl(215 30% 8%); --color-sidebar-foreground: hsl(210 20% 80%); - --color-sidebar-primary: hsl(174 55% 50%); + --color-sidebar-primary: hsl(208.1, 55.4%, 27.3%); --color-sidebar-primary-foreground: hsl(0 0% 100%); --color-sidebar-accent: hsl(215 25% 15%); --color-sidebar-accent-foreground: hsl(210 20% 90%); --color-sidebar-border: hsl(215 25% 18%); - --color-sidebar-ring: hsl(174 55% 50%); + --color-sidebar-ring: hsl(208.1, 55.4%, 27.3%); --color-success: hsl(152 55% 45%); --color-success-foreground: hsl(0 0% 100%); --color-warning: hsl(38 92% 50%); @@ -105,6 +105,30 @@ --color-info-foreground: hsl(0 0% 100%); } + /* Slate — темний сайдбар, світлий контент */ + [data-theme="slate"] { + --color-sidebar: hsl(215 30% 14%); + --color-sidebar-foreground: hsl(210 20% 70%); + --color-sidebar-primary: hsl(224.8 81% 65%); + --color-sidebar-primary-foreground: hsl(0 0% 100%); + --color-sidebar-accent: hsl(215 30% 22%); + --color-sidebar-accent-foreground: hsl(210 20% 92%); + --color-sidebar-border: hsl(215 25% 20%); + --color-sidebar-ring: hsl(224.8 81% 60%); + } + + /* Emerald — зелений акцент */ + [data-theme="emerald"] { + --color-primary: hsl(152 58% 38%); + --color-primary-foreground: hsl(0 0% 100%); + --color-accent: hsl(152 55% 42%); + --color-accent-foreground: hsl(0 0% 100%); + --color-ring: hsl(152 58% 38%); + --color-sidebar-primary: hsl(152 58% 38%); + --color-sidebar-primary-foreground: hsl(0 0% 100%); + --color-sidebar-ring: hsl(152 58% 38%); + } + * { border-color: var(--color-border); } @@ -146,6 +170,7 @@ flex-direction: column; transition: width 200ms ease-in-out; overflow: hidden; + border-right: 1px solid var(--color-sidebar-border); } .sidebar.collapsed { @@ -166,8 +191,8 @@ width: 2rem; height: 2rem; border-radius: 0.5rem; - background-color: var(--color-sidebar-primary); - color: var(--color-sidebar-primary-foreground); + /* background-color: var(--color-sidebar-primary); + color: var(--color-sidebar-primary-foreground); */ display: flex; align-items: center; justify-content: center; @@ -212,8 +237,8 @@ } .nav-item.active { - background-color: var(--color-sidebar-accent); - color: var(--color-sidebar-accent-foreground); + background-color: var(--color-sidebar-primary); + color: var(--color-sidebar-primary-foreground); font-weight: 500; } @@ -356,7 +381,7 @@ &::placeholder { color: var(--color-muted-foreground); } &:focus { border-color: var(--color-ring); - box-shadow: 0 0 0 3px hsl(215 60% 22% / 0.1); + box-shadow: 0 0 0 3px hsl(0 72% 44% / 0.1); } } @@ -364,6 +389,21 @@ .tr-hover:hover { background-color: var(--color-muted); } .li-hover:hover { background-color: var(--color-muted); } + /* ─── Task row ─── */ + .task-row { + display: flex; + align-items: center; + gap: 0.875rem; + padding: 0.75rem 1.25rem; + } + + .task-meta { + display: flex; + align-items: center; + gap: 0.625rem; + flex-shrink: 0; + } + /* ─── Прогресс-бар ─── */ .progress { height: 4px; @@ -378,4 +418,197 @@ background-color: var(--color-accent); transition: width 300ms ease; } + + /* ─── Backdrop для мобильного сайдбара ─── */ + .sidebar-backdrop { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 199; + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); + } + + .sidebar-backdrop.active { + display: block; + } + + /* ─── Bottom navigation (скрыт на десктопе) ─── */ + .bottom-nav { + display: none; + } + + /* ─── Theme switcher ─── */ + .theme-option { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.625rem; + border-radius: calc(var(--radius) - 2px); + font-size: 0.8125rem; + color: var(--color-foreground); + background: transparent; + border: none; + cursor: pointer; + width: 100%; + text-align: left; + transition: background 150ms; + } + + .theme-option:hover { background: var(--color-muted); } + + .theme-option.active { + background: var(--color-muted); + font-weight: 500; + } + + .theme-check { + margin-left: auto; + font-size: 0.75rem; + color: var(--color-primary); + opacity: 0; + } + + .theme-option.active .theme-check { opacity: 1; } + + .theme-dot { + width: 14px; + height: 14px; + border-radius: 50%; + flex-shrink: 0; + border: 1.5px solid rgba(0,0,0,0.12); + } +} + +/* ─── Mobile breakpoint ─────────────────────── */ +@media (max-width: 767px) { + + /* Сайдбар как overlay */ + .sidebar { + position: fixed !important; + left: 0; + top: 0; + z-index: 200; + width: 220px !important; + height: 100dvh !important; + transform: translateX(-100%); + transition: transform 220ms cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 4px 0 24px rgba(0, 0, 0, 0.18); + } + + .sidebar.mobile-open { + transform: translateX(0); + } + + /* Отменяем collapsed-состояние на мобиле */ + .sidebar.collapsed { + width: 220px !important; + transform: translateX(-100%); + } + .sidebar.collapsed.mobile-open { + transform: translateX(0); + } + .sidebar.collapsed .sidebar-logo-text { opacity: 1 !important; width: auto !important; pointer-events: auto !important; } + .sidebar.collapsed .nav-section-label { opacity: 0.4 !important; height: auto !important; padding: 0.5rem 0.625rem 0.25rem !important; overflow: visible !important; } + .sidebar.collapsed .nav-label-text { display: inline !important; } + .sidebar.collapsed .nav-badge { display: inline-flex !important; } + .sidebar.collapsed .nav-item { justify-content: flex-start !important; padding: 0.4375rem 0.625rem !important; gap: 0.5rem !important; } + .sidebar.collapsed .sidebar-header { justify-content: flex-start !important; padding: 0 1rem !important; gap: 0.75rem !important; } + .sidebar.collapsed .sidebar-footer { padding: 0.75rem !important; } + .sidebar.collapsed .sidebar-footer .sidebar-logo-text { opacity: 1 !important; width: auto !important; pointer-events: auto !important; } + .sidebar.collapsed .sidebar-footer .footer-actions { display: flex !important; } + + /* Основной контент — не смещается, но имеет отступ снизу под bottom nav */ + #main-content { + padding-bottom: calc(1.5rem + 60px + env(safe-area-inset-bottom)) !important; + } + + /* Task rows: meta wraps to second line */ + .task-row { + flex-wrap: wrap; + align-items: flex-start; + gap: 0.5rem; + padding: 0.75rem 1rem; + } + + .task-meta { + width: 100%; + margin-left: 1.625rem; + flex-wrap: wrap; + gap: 0.375rem; + } + + .task-date { display: none !important; } + + /* Tabs: горизонтальный скролл вместо переноса */ + .task-tabs { + width: 100% !important; + overflow-x: auto; + flex-wrap: nowrap !important; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + .task-tabs::-webkit-scrollbar { display: none; } + + /* Скрыть поиск в хедере на мобиле */ + .header-search { + display: none !important; + } + + /* ─── Bottom navigation ─── */ + .bottom-nav { + display: flex; + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: calc(60px + env(safe-area-inset-bottom)); + padding-bottom: env(safe-area-inset-bottom); + background: var(--color-card); + border-top: 1px solid var(--color-border); + z-index: 100; + align-items: stretch; + box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.07); + } + + .bottom-nav-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + color: var(--color-muted-foreground); + font-size: 0.5625rem; + font-weight: 500; + text-decoration: none; + cursor: pointer; + transition: color 150ms; + border: none; + background: transparent; + padding: 0; + -webkit-tap-highlight-color: transparent; + min-width: 44px; + } + + .bottom-nav-item svg { + width: 22px; + height: 22px; + flex-shrink: 0; + opacity: 0.7; + transition: opacity 150ms; + } + + .bottom-nav-item.active { + color: var(--color-primary); + } + + .bottom-nav-item.active svg { + opacity: 1; + } + + .bottom-nav-item:active { + opacity: 0.7; + } } diff --git a/static/assets/css/main.css b/static/assets/css/main.css index 680612f..c97803b 100644 --- a/static/assets/css/main.css +++ b/static/assets/css/main.css @@ -17,10 +17,8 @@ --color-foreground: hsl(215 25% 15%); --color-card: hsl(0 0% 100%); --color-card-foreground: hsl(215 25% 15%); - --color-popover: hsl(0 0% 100%); - --color-popover-foreground: hsl(215 25% 15%); - --color-primary: hsl(215 60% 22%); - --color-primary-foreground: hsl(210 40% 98%); + --color-primary: hsl(224.8, 81%, 60.8%); + --color-primary-foreground: hsl(0 0% 100%); --color-secondary: hsl(210 25% 93%); --color-secondary-foreground: hsl(215 25% 15%); --color-muted: hsl(210 20% 96%); @@ -28,24 +26,18 @@ --color-accent: hsl(174 55% 42%); --color-accent-foreground: hsl(0 0% 100%); --color-destructive: hsl(0 72% 51%); - --color-destructive-foreground: hsl(210 40% 98%); --color-border: hsl(214 20% 90%); --color-input: hsl(214 20% 90%); - --color-ring: hsl(215 60% 22%); - --color-sidebar: hsl(215 60% 22%); - --color-sidebar-foreground: hsl(210 30% 85%); - --color-sidebar-primary: hsl(174 55% 50%); + --color-ring: hsl(0 72% 44%); + --color-sidebar: hsl(0 0% 100%); + --color-sidebar-foreground: hsl(215 15% 40%); + --color-sidebar-primary: hsl(224.8, 81%, 60.8%); --color-sidebar-primary-foreground: hsl(0 0% 100%); - --color-sidebar-accent: hsl(215 50% 28%); - --color-sidebar-accent-foreground: hsl(210 30% 95%); - --color-sidebar-border: hsl(215 45% 30%); - --color-sidebar-ring: hsl(174 55% 50%); + --color-sidebar-accent: hsl(214 20% 94%); + --color-sidebar-accent-foreground: hsl(215 25% 20%); + --color-sidebar-border: hsl(214 20% 90%); --color-success: hsl(152 55% 45%); - --color-success-foreground: hsl(0 0% 100%); --color-warning: hsl(38 92% 50%); - --color-warning-foreground: hsl(0 0% 100%); - --color-info: hsl(205 80% 55%); - --color-info-foreground: hsl(0 0% 100%); } } @layer base { @@ -197,21 +189,24 @@ } } @layer utilities { - .mb-6 { - margin-bottom: calc(var(--spacing) * 6); + .start { + inset-inline-start: var(--spacing); } .block { display: block; } + .flex { + display: flex; + } + .hidden { + display: none; + } .table { display: table; } .flex-shrink { flex-shrink: 1; } - .border-collapse { - border-collapse: collapse; - } .transform { transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,); } @@ -222,13 +217,6 @@ border-style: var(--tw-border-style); border-width: 1px; } - .underline { - text-decoration-line: underline; - } - .outline { - outline-style: var(--tw-outline-style); - outline-width: 1px; - } .filter { filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); } @@ -239,14 +227,14 @@ } } @layer base { - .dark { + [data-theme="dark"] { --color-background: hsl(215 25% 10%); --color-foreground: hsl(210 40% 98%); --color-card: hsl(215 25% 13%); --color-card-foreground: hsl(210 40% 98%); --color-popover: hsl(215 25% 13%); --color-popover-foreground: hsl(210 40% 98%); - --color-primary: hsl(174 55% 50%); + --color-primary: hsl(208.1, 55.4%, 27.3%); --color-primary-foreground: hsl(215 60% 15%); --color-secondary: hsl(215 30% 20%); --color-secondary-foreground: hsl(210 40% 98%); @@ -258,15 +246,15 @@ --color-destructive-foreground: hsl(210 40% 98%); --color-border: hsl(215 25% 22%); --color-input: hsl(215 25% 22%); - --color-ring: hsl(174 55% 50%); + --color-ring: hsl(208.1, 55.4%, 27.3%); --color-sidebar: hsl(215 30% 8%); --color-sidebar-foreground: hsl(210 20% 80%); - --color-sidebar-primary: hsl(174 55% 50%); + --color-sidebar-primary: hsl(208.1, 55.4%, 27.3%); --color-sidebar-primary-foreground: hsl(0 0% 100%); --color-sidebar-accent: hsl(215 25% 15%); --color-sidebar-accent-foreground: hsl(210 20% 90%); --color-sidebar-border: hsl(215 25% 18%); - --color-sidebar-ring: hsl(174 55% 50%); + --color-sidebar-ring: hsl(208.1, 55.4%, 27.3%); --color-success: hsl(152 55% 45%); --color-success-foreground: hsl(0 0% 100%); --color-warning: hsl(38 92% 50%); @@ -274,6 +262,26 @@ --color-info: hsl(205 80% 55%); --color-info-foreground: hsl(0 0% 100%); } + [data-theme="slate"] { + --color-sidebar: hsl(215 30% 14%); + --color-sidebar-foreground: hsl(210 20% 70%); + --color-sidebar-primary: hsl(224.8 81% 65%); + --color-sidebar-primary-foreground: hsl(0 0% 100%); + --color-sidebar-accent: hsl(215 30% 22%); + --color-sidebar-accent-foreground: hsl(210 20% 92%); + --color-sidebar-border: hsl(215 25% 20%); + --color-sidebar-ring: hsl(224.8 81% 60%); + } + [data-theme="emerald"] { + --color-primary: hsl(152 58% 38%); + --color-primary-foreground: hsl(0 0% 100%); + --color-accent: hsl(152 55% 42%); + --color-accent-foreground: hsl(0 0% 100%); + --color-ring: hsl(152 58% 38%); + --color-sidebar-primary: hsl(152 58% 38%); + --color-sidebar-primary-foreground: hsl(0 0% 100%); + --color-sidebar-ring: hsl(152 58% 38%); + } * { border-color: var(--color-border); } @@ -307,6 +315,7 @@ flex-direction: column; transition: width 200ms ease-in-out; overflow: hidden; + border-right: 1px solid var(--color-sidebar-border); } .sidebar.collapsed { width: 56px; @@ -324,8 +333,6 @@ width: 2rem; height: 2rem; border-radius: 0.5rem; - background-color: var(--color-sidebar-primary); - color: var(--color-sidebar-primary-foreground); display: flex; align-items: center; justify-content: center; @@ -395,8 +402,8 @@ color: var(--color-sidebar-accent-foreground); } .nav-item.active { - background-color: var(--color-sidebar-accent); - color: var(--color-sidebar-accent-foreground); + background-color: var(--color-sidebar-primary); + color: var(--color-sidebar-primary-foreground); font-weight: 500; } .nav-item svg { @@ -550,7 +557,7 @@ } &:focus { border-color: var(--color-ring); - box-shadow: 0 0 0 3px hsl(215 60% 22% / 0.1); + box-shadow: 0 0 0 3px hsl(0 72% 44% / 0.1); } } .tr-hover:hover { @@ -559,6 +566,18 @@ .li-hover:hover { background-color: var(--color-muted); } + .task-row { + display: flex; + align-items: center; + gap: 0.875rem; + padding: 0.75rem 1.25rem; + } + .task-meta { + display: flex; + align-items: center; + gap: 0.625rem; + flex-shrink: 0; + } .progress { height: 4px; background-color: var(--color-muted); @@ -571,6 +590,200 @@ background-color: var(--color-accent); transition: width 300ms ease; } + .sidebar-backdrop { + display: none; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.45); + z-index: 199; + backdrop-filter: blur(2px); + -webkit-backdrop-filter: blur(2px); + } + .sidebar-backdrop.active { + display: block; + } + .bottom-nav { + display: none; + } + .theme-option { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.625rem; + border-radius: calc(var(--radius) - 2px); + font-size: 0.8125rem; + color: var(--color-foreground); + background: transparent; + border: none; + cursor: pointer; + width: 100%; + text-align: left; + transition: background 150ms; + } + .theme-option:hover { + background: var(--color-muted); + } + .theme-option.active { + background: var(--color-muted); + font-weight: 500; + } + .theme-check { + margin-left: auto; + font-size: 0.75rem; + color: var(--color-primary); + opacity: 0; + } + .theme-option.active .theme-check { + opacity: 1; + } + .theme-dot { + width: 14px; + height: 14px; + border-radius: 50%; + flex-shrink: 0; + border: 1.5px solid rgba(0,0,0,0.12); + } +} +@media (max-width: 767px) { + .sidebar { + position: fixed !important; + left: 0; + top: 0; + z-index: 200; + width: 220px !important; + height: 100dvh !important; + transform: translateX(-100%); + transition: transform 220ms cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 4px 0 24px rgba(0, 0, 0, 0.18); + } + .sidebar.mobile-open { + transform: translateX(0); + } + .sidebar.collapsed { + width: 220px !important; + transform: translateX(-100%); + } + .sidebar.collapsed.mobile-open { + transform: translateX(0); + } + .sidebar.collapsed .sidebar-logo-text { + opacity: 1 !important; + width: auto !important; + pointer-events: auto !important; + } + .sidebar.collapsed .nav-section-label { + opacity: 0.4 !important; + height: auto !important; + padding: 0.5rem 0.625rem 0.25rem !important; + overflow: visible !important; + } + .sidebar.collapsed .nav-label-text { + display: inline !important; + } + .sidebar.collapsed .nav-badge { + display: inline-flex !important; + } + .sidebar.collapsed .nav-item { + justify-content: flex-start !important; + padding: 0.4375rem 0.625rem !important; + gap: 0.5rem !important; + } + .sidebar.collapsed .sidebar-header { + justify-content: flex-start !important; + padding: 0 1rem !important; + gap: 0.75rem !important; + } + .sidebar.collapsed .sidebar-footer { + padding: 0.75rem !important; + } + .sidebar.collapsed .sidebar-footer .sidebar-logo-text { + opacity: 1 !important; + width: auto !important; + pointer-events: auto !important; + } + .sidebar.collapsed .sidebar-footer .footer-actions { + display: flex !important; + } + #main-content { + padding-bottom: calc(1.5rem + 60px + env(safe-area-inset-bottom)) !important; + } + .task-row { + flex-wrap: wrap; + align-items: flex-start; + gap: 0.5rem; + padding: 0.75rem 1rem; + } + .task-meta { + width: 100%; + margin-left: 1.625rem; + flex-wrap: wrap; + gap: 0.375rem; + } + .task-date { + display: none !important; + } + .task-tabs { + width: 100% !important; + overflow-x: auto; + flex-wrap: nowrap !important; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + .task-tabs::-webkit-scrollbar { + display: none; + } + .header-search { + display: none !important; + } + .bottom-nav { + display: flex; + position: fixed; + bottom: 0; + left: 0; + right: 0; + height: calc(60px + env(safe-area-inset-bottom)); + padding-bottom: env(safe-area-inset-bottom); + background: var(--color-card); + border-top: 1px solid var(--color-border); + z-index: 100; + align-items: stretch; + box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.07); + } + .bottom-nav-item { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 3px; + color: var(--color-muted-foreground); + font-size: 0.5625rem; + font-weight: 500; + text-decoration: none; + cursor: pointer; + transition: color 150ms; + border: none; + background: transparent; + padding: 0; + -webkit-tap-highlight-color: transparent; + min-width: 44px; + } + .bottom-nav-item svg { + width: 22px; + height: 22px; + flex-shrink: 0; + opacity: 0.7; + transition: opacity 150ms; + } + .bottom-nav-item.active { + color: var(--color-primary); + } + .bottom-nav-item.active svg { + opacity: 1; + } + .bottom-nav-item:active { + opacity: 0.7; + } } @property --tw-rotate-x { syntax: "*"; @@ -597,11 +810,6 @@ inherits: false; initial-value: solid; } -@property --tw-outline-style { - syntax: "*"; - inherits: false; - initial-value: solid; -} @property --tw-blur { syntax: "*"; inherits: false; @@ -664,7 +872,6 @@ --tw-skew-x: initial; --tw-skew-y: initial; --tw-border-style: solid; - --tw-outline-style: solid; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial; diff --git a/static/assets/img/logosm.svg b/static/assets/img/logosm.svg new file mode 100755 index 0000000..2bc6fda --- /dev/null +++ b/static/assets/img/logosm.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/templates/index.html b/templates/index.html index 5193a87..0cb8807 100644 --- a/templates/index.html +++ b/templates/index.html @@ -3,11 +3,12 @@ - - АКС HR — Портал управління + + АКС — Корпоративний Портал + @@ -16,60 +17,205 @@ + + + +
-
+
+ + + @@ -244,12 +431,12 @@ -
+ + diff --git a/templates/login.html b/templates/login.html index 6480540..a15b120 100644 --- a/templates/login.html +++ b/templates/login.html @@ -4,7 +4,7 @@ - АКС HR — Вхід + АКС — Корпоративний Портал @@ -20,11 +20,12 @@
-
- HR +
+ АКС
-

Вхід в систему

-

АКС HR Портал управління

+
Мережа аптек
+

Вхід в систему

+

Корпоративний портал співробітників

diff --git a/templates/pages/chat.html b/templates/pages/chat.html new file mode 100644 index 0000000..0f19655 --- /dev/null +++ b/templates/pages/chat.html @@ -0,0 +1,149 @@ +
+ +
+

Чат

+

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

+
+ +
+ + +
+
+ +
+
+ +
+
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С та складського обліку буде активований. Деталі надішлю окремо.
+
+
+ +
+ + +
+ + +
+ +
+
+
diff --git a/templates/pages/dashboard.html b/templates/pages/dashboard.html index 292d3b2..bacd7ec 100644 --- a/templates/pages/dashboard.html +++ b/templates/pages/dashboard.html @@ -1,32 +1,196 @@ -
-

Дашборд

-

Загальна картина по мережі

-
+
-
-
-
Всього
-
318
-
+8 за місяць
+ +
+

Доброго дня, {{ request.user.first_name|default:request.user.username }}

+

Ласкаво просимо до корпоративного порталу АКС. Сьогодні {{ today|date:"j E Y" }} р.

+
+ + +
-
-
Офіс
-
156
-
8 відділів
+ + +
+ +
+
+
Активних співробітників
+
{{ employees_active }}
+
із {{ employees_total }} у системі
+
+
+ + + + + +
+
+ +
+
+
Аптек у мережі
+
{{ pharmacies_active }}
+
активних точок
+
+
+ + + +
+
+ +
+
+
Відкритих IT-заявок
+
{{ open_tickets }}
+
в роботі та нових
+
+
+ + + + +
+
+ +
+
+
Довідників
+
{{ directory_types_count }}
+
{{ directories_count }} записів
+
+
+ + + +
+
+
-
-
Аптеки
-
162
-
100+ локацій
-
-
-
Вакансій
-
27
-
12 термінових
-
-
-
Нестача
-
14
-
аптек без фармацевта
+ + +
+ + +
+
+

Останні новини

+

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

+
+
+
+
+
+
Корпоративний портал оновлено
+
Новий дизайн, мобільна версія, теми оформлення та нові розділи. Пропозиції надсилайте через Підтримку IT.
+
+ {{ today|date:"d.m.Y" }} +
+
+
+
+
+
Розділ новин у розробці
+
Незабаром тут з'являтимуться оголошення компанії.
+
+
+
+
+ +
+ + + +
+
diff --git a/templates/pages/databases.html b/templates/pages/databases.html new file mode 100644 index 0000000..50f213f --- /dev/null +++ b/templates/pages/databases.html @@ -0,0 +1,106 @@ +
+ +
+
+

Бази даних

+

Корпоративні довідники та реєстри

+
+
+ + +
+
+
+ Довідники +
+ +
+
+
{{ types_count }}
+
типів довідників
+
+
+
+ Записів +
+ +
+
+
{{ records_count }}
+
всього записів
+
+
+
+ Оновлено +
+ +
+
+
{{ last_updated|date:"d.m.Y"|default:"—" }}
+
{{ last_updated|date:"H:i"|default:"" }}
+
+
+
+ Активні +
+ +
+
+
{{ active_records_count }}
+
активних записів
+
+
+ + +
+
+ Список довідників +
+ + + + + + + + + {% if request.user.is_staff %}{% endif %} + + + + {% for dtype in directory_types %} + + + + + + + {% if request.user.is_staff %} + + {% endif %} + + {% empty %} + + {% endfor %} + +
НазваКодЗаписівОновленоСтатус
+
+
+ +
+
+
{{ dtype.name }}
+
{{ dtype.get_hierarchy_type_display|default:dtype.hierarchy_type }}
+
+
+
{{ dtype.code }}{{ dtype.records }}{{ dtype.last_updated|date:"d.m.Y H:i"|default:"—" }} + {% if dtype.is_active %} + Активний + {% else %} + Вимкнений + {% endif %} + + Відкрити +
Довідників поки немає
+
+
diff --git a/templates/pages/email.html b/templates/pages/email.html new file mode 100644 index 0000000..223d7cc --- /dev/null +++ b/templates/pages/email.html @@ -0,0 +1,136 @@ +
+ +
+
+

Електронна пошта

+

Внутрішня корпоративна пошта

+
+ +
+ +
+ + + + + +
+ +
+ +
+ + +
+ Непрочитані · 4 +
+ +
+
МП
+
+
+ Марія Петренко + 09:41 +
+
Квартальний звіт — уточнення
+

Іване, нагадую — звіт потрібно здати до 15 числа. Підтвердіть, будь ласка...

+
+
+ +
+
IT
+
+
+ IT Відділ + Вчора +
+
Технічне обслуговування 10 квітня
+

Повідомляємо про планові роботи з 18:00 до 20:00. Система буде недоступна...

+
+
+ +
+
ОТ
+
+
+ Олексій Ткаченко + 07.04 +
+
Re: Документи до тендеру
+

Дякую, отримав. Перевірю і напишу до кінця дня...

+
+
+ +
+
ОН
+
+
+ Ольга Новикова + 05.04 +
+
Оновлення корпоративної політики
+

Прошу ознайомитись із новою версією документа та підписати...

+
+
+ + +
+ Прочитані +
+ +
+
ДК
+
+
+ Дмитро Козлов + 03.04 +
+
Зустріч у вівторок
+

Підтверджую участь у нараді о 10:00...

+
+
+ +
+
МП
+
+
+ Марія Петренко + 28.03 +
+
Новий проект — вступна нарада
+

Запрошуємо на вступну нараду по новому проекту...

+
+
+ +
+
+
diff --git a/templates/pages/help.html b/templates/pages/help.html new file mode 100644 index 0000000..1f2ec9c --- /dev/null +++ b/templates/pages/help.html @@ -0,0 +1,127 @@ +
+ +
+

Довідка

+

Відповіді на часті запитання та інструкції

+
+ + +
+

Як ми можемо допомогти?

+

Шукайте в базі знань або перегляньте категорії нижче

+
+ + +
+
+ + +
+
+
+ +
+
Профіль та акаунт
+
Налаштування облікового запису, паролів та особистих даних
+
8 статей →
+
+
+
+ +
+
HR та відпустки
+
Оформлення відпусток, лікарняних, довідок та нарахувань
+
12 статей →
+
+
+
+ +
+
Документи
+
Робота з корпоративними документами та шаблонами
+
6 статей →
+
+
+
+ +
+
Задачі та проекти
+
Управління задачами, дедлайнами та командна робота
+
9 статей →
+
+
+
+ +
+
Безпека
+
Паролі, двофакторна авторизація та безпека акаунту
+
5 статей →
+
+
+
+ +
+
Технічна підтримка
+
Вирішення технічних проблем, звернення до IT-відділу
+
4 статті →
+
+
+ + +
+
+ Часті запитання +
+ +
+
+
+ + Як подати заявку на відпустку? +
+ +
+
+ +
+
+
+ + Як змінити пароль до корпоративного порталу? +
+ +
+
+ +
+
+
+ + Де знайти шаблони корпоративних документів? +
+ +
+
+ +
+
+
+ + Як замовити довідку про доходи? +
+ +
+
+ +
+
+
+ + Що робити, якщо не можу увійти до системи? +
+ +
+
+ +
+
diff --git a/templates/pages/hr-portal.html b/templates/pages/hr-portal.html new file mode 100644 index 0000000..e27d0fc --- /dev/null +++ b/templates/pages/hr-portal.html @@ -0,0 +1,118 @@ +
+ +
+

HR Портал

+

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

+
+ + +
+ + + + +
+ +
+ + +
+
+ Мої заявки + +
+
+
+
+
Відпустка 01–14 травня
+
Подано 10.04.2026
+
+ На розгляді +
+
+
+
Довідка про зарплату
+
Подано 05.04.2026
+
+ Готово +
+
+
+
Лікарняний 28–29 березня
+
Подано 01.04.2026
+
+ Затверджено +
+
+
+ + +
+
+ Баланс відпусток — 2026 +
+
+ +
+
+ Основна відпустка + 14 / 24 дні +
+
+
Залишок: 10 днів
+
+ +
+
+ Додаткова відпустка + 0 / 7 днів +
+
+
Залишок: 7 днів
+
+ +
+
+ Лікарняні + 2 / 30 днів +
+
+
Залишок: 28 днів
+
+ +
+
Наступна відпустка
+
+ 01.05 – 14.05.2026 + На розгляді +
+
+
+
+ +
+
diff --git a/templates/pages/it-support.html b/templates/pages/it-support.html new file mode 100644 index 0000000..d06f154 --- /dev/null +++ b/templates/pages/it-support.html @@ -0,0 +1,126 @@ +
+ +
+

Підтримка IT

+

Технічна допомога та заявки на обслуговування

+
+ + {% if ticket_created %} +
+ + + +
Заявку {{ ticket_created.number }} створено. IT-відділ зв'яжеться з вами.
+
+ {% endif %} + +
+ + +
+ {% csrf_token %} +
+

Нова заявка

+

Опишіть вашу проблему і ми зв'яжемося якнайшвидше.

+
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ +
+
+ + +
+ + +
+
+ Контакти IT-відділу +
+
+
+ + it-support@aks-pharma.ua +
+
+ + Внутрішній: 021 +
+
+ + Пн–Пт, 08:00–18:00 +
+
+
+ + +
+
+ Мої заявки + {% if my_open_count %} + {{ my_open_count }} відкритих + {% endif %} +
+
+ {% for ticket in my_tickets %} +
+
+ {{ ticket.number }} + {% if ticket.status == 'resolved' or ticket.status == 'closed' %} + {{ ticket.get_status_display }} + {% elif ticket.status == 'new' %} + {{ ticket.get_status_display }} + {% else %} + {{ ticket.get_status_display }} + {% endif %} +
+
{{ ticket.title|truncatechars:40 }} · {{ ticket.created_at|date:"d.m" }}
+
+ {% empty %} +
У вас поки немає заявок
+ {% endfor %} +
+
+ +
+
+
diff --git a/templates/pages/news.html b/templates/pages/news.html new file mode 100644 index 0000000..79c9e91 --- /dev/null +++ b/templates/pages/news.html @@ -0,0 +1,119 @@ +
+ +
+
+

Новини

+

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

+
+
+ + +
+ + + + +
+ + +
+
+ 📌 Закріплено + Важливо +
+

Квартальний звіт — необхідно заповнити до 15 квітня

+

Всім керівникам відділів необхідно заповнити та подати квартальні звіти до 15 квітня 2026 року. Форми звітів доступні у розділі «Документи». Зверніться до відділу HR за додатковою інформацією.

+
+
+
МП
+ Марія Петренко · 08.04.2026 +
+ Читати далі → +
+
+ + +
+ +
+ Останні оголошення +
+ + +
+
+ +
+
+
+
Оновлення системи безпеки
+ 06.04.2026 +
+

IT відділ повідомляє про заплановане оновлення системи безпеки 10 квітня з 18:00 до 20:00. Портал буде недоступний.

+ IT +
+
+ + +
+
+ +
+
+
+
Зустріч директорів — 12 квітня
+ 05.04.2026 +
+

Нагадування про щомісячну зустріч директорів у конференц-залі (3 поверх) о 10:00. Порядок денний у вкладенні.

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

Опубліковані нові інструкції з охорони праці. Всім співробітникам необхідно ознайомитися до 20 квітня.

+ HR +
+
+ + +
+
+ +
+
+
+
Оновлення корпоративної політики
+ 28.03.2026 +
+

Внесено зміни до корпоративної політики щодо дистанційної роботи. Нова версія документу доступна у розділі «Документи».

+ Загальне +
+
+ + +
+
+ +
+
+
+
Відкриття нової аптеки А-47 у Харкові
+ 20.03.2026 +
+

Рада повідомити про відкриття нової аптеки АКС А-47 у Харкові. Це 47-а точка нашої мережі.

+ Мережа +
+
+ +
+ +
diff --git a/templates/pages/pharmacy-detail.html b/templates/pages/pharmacy-detail.html new file mode 100644 index 0000000..9025b95 --- /dev/null +++ b/templates/pages/pharmacy-detail.html @@ -0,0 +1,113 @@ +
+ +
+
+ +
Аптеки / Деталі
+
+
+ Норма + +
+
+ +
+
+
+
А01
+
+

Аптека

+
Адреса
+
Місто
+
+
+
+ +
+
+
+ +
+
+
Укомплектованість
+
0%
+
по ключових ролях
+
+
+
Завідуючий
+
0/0
+
поточний стан
+
+
+
Фармацевти
+
0/0
+
штат
+
+
+
Касири
+
0/0
+
штат
+
+
+ +
+
+

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

+ +
+
+
+ Завідуючий + 0% +
+
+
+
+
+ +
+
+ Фармацевти + 0% +
+
+
+
+
+ +
+
+ Касири + 0% +
+
+
+
+
+
+ +
+ Режим роботи: + Пн-Нд 9-21 +
+
+ +
+
+

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

+
    +
    + +
    +

    Координати

    +
    -
    +
    +
    +
    + +
    \ No newline at end of file diff --git a/templates/pages/purchases.html b/templates/pages/purchases.html new file mode 100644 index 0000000..d0fde7c --- /dev/null +++ b/templates/pages/purchases.html @@ -0,0 +1,131 @@ +
    + +
    +
    +

    Закупівлі

    +

    Управління замовленнями та постачальниками

    +
    + +
    + + +
    +
    +
    + Відкриті +
    + +
    +
    +
    14
    +
    замовлень
    +
    +
    +
    + В дорозі +
    + +
    +
    +
    6
    +
    відправлень
    +
    +
    +
    + Виконано +
    + +
    +
    +
    38
    +
    цього місяця
    +
    +
    +
    + Сума +
    + +
    +
    +
    ₴ 1.24M
    +
    квітень 2026
    +
    +
    + + +
    +
    + Поточні замовлення + +
    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    № ЗамовленняПостачальникКатегоріяСумаДатаСтатус
    ЗМ-2026-0142БаДМ УкраїнаМедикаменти₴ 84 52016.04.2026В дорозі
    ЗМ-2026-0141Оптіма ФармМедикаменти₴ 127 30015.04.2026Отримано
    ЗМ-2026-0140МедТек ТОВОбладнання₴ 43 80014.04.2026Підтверджено
    ЗМ-2026-0139ФармакоМедикаменти₴ 92 15012.04.2026Очікує
    ЗМ-2026-0138CleanProГосптовари₴ 18 60010.04.2026Отримано
    +
    +
    diff --git a/templates/pages/structure.html b/templates/pages/structure.html index d6afb60..aeadcf9 100644 --- a/templates/pages/structure.html +++ b/templates/pages/structure.html @@ -3,33 +3,29 @@

    Структура

    -

    8 відділів · 100+ аптек · 318 співробітників

    +

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

    + {% if request.user.is_staff %}
    - - +
    + {% endif %}
    @@ -40,23 +36,27 @@
    Офісних співробітників
    -
    156
    -
    +4 за місяць
    +
    {{ office_count }}
    +
    активних
    Відділів
    -
    8
    -
    всі активні
    +
    {{ departments|length }}
    +
    активних у довіднику
    -
    Вакансій
    -
    12
    -
    3 термінових
    +
    Без відділу
    +
    {{ office_no_dept }}
    + {% if office_no_dept %} +
    потребують призначення
    + {% else %} +
    всі розподілені
    + {% endif %}
    -
    Сер. заповненість
    -
    84%
    -
    +2% vs минулий міс.
    +
    Аптечних співробітників
    +
    {{ pharmacy_employees_count }}
    +
    {{ pharmacies|length }} локацій
    @@ -72,205 +72,42 @@ Відділ Керівник Співробітників - Вакансій - Заповненість + Посад Статус - - + {% for dept in departments %} + -
    -
    - Розробка +
    +
    + {{ dept.name }}
    + {% if dept.head %}
    - ІВ - Ігор Волков + {{ dept.head.last_name|slice:":1" }}{{ dept.head.first_name|slice:":1" }} + {{ dept.head.get_full_name }}
    + {% else %} + — + {% endif %} - 42 - 3 + {{ dept.employees_count }} + {{ dept.posts_count }} -
    -
    - 84% -
    + {% if dept.employees_count %} + Активний + {% else %} + Порожній + {% endif %} - Норма - - - -
    -
    - HR -
    - - -
    - МІ - Марія Іванова -
    - - 12 - 1 - -
    -
    - 80% -
    - - Норма - - - - -
    -
    - Дизайн -
    - - -
    - АК - Анна Кравцова -
    - - 18 - 2 - -
    -
    - 90% -
    - - Увага - - - - -
    -
    - Фінанси -
    - - -
    - ЄС - Олена Смирнова -
    - - 14 - 0 - -
    -
    - 88% -
    - - Укомпл. - - - - -
    -
    - Маркетинг -
    - - -
    - ПС - Павло Сідоров -
    - - 22 - 1 - -
    -
    - 88% -
    - - Норма - - - - -
    -
    - Продажі -
    - - -
    - НБ - Наталя Бєлова -
    - - 28 - 4 - -
    -
    - 80% -
    - - Увага - - - - -
    -
    - Підтримка -
    - - -
    - АЛ - Андрій Лебедєв -
    - - 15 - 1 - -
    -
    - 83% -
    - - Норма - - - - -
    -
    - Юридичний -
    - - -
    - ІФ - Ірина Фьодорова -
    - - 5 - 2 - -
    -
    - 62% -
    - - Нестача - - + {% empty %} + Відділи ще не додані до довідника + {% endfor %}
    @@ -284,119 +121,100 @@
    Аптечних співробітників
    -
    162
    -
    100+ локацій
    +
    {{ pharmacy_employees_count }}
    +
    {{ pharmacies|length }} локацій
    -
    Вакансій
    -
    15
    -
    7 термінових
    +
    Організацій
    +
    {{ organizations_count }}
    +
    юридичних осіб
    -
    Нестача персоналу
    -
    14
    -
    аптек без фармацевта
    +
    Без завідувача
    +
    {{ pharmacies_no_head }}
    + {% if pharmacies_no_head %} +
    потребують призначення
    + {% else %} +
    всі аптеки із завідувачем
    + {% endif %}
    -
    Сер. закриття вакансії
    -
    24 дн.
    -
    офіс: 18 дн.
    +
    Без персоналу
    +
    {{ pharmacies_empty }}
    + {% if pharmacies_empty %} +
    аптек без співробітників
    + {% else %} +
    всі укомплектовані
    + {% endif %}
    -
    +

    Аптечні локації

    -
    - - -
    - - - - + + + + - - + {% for pharmacy in pharmacies %} + - - - - - - - - + + - - - - - - - - + - - - - - - + {% empty %} + + {% endfor %}
    АптекаМістоЗавідуючийФармацевтКасирОрганізаціяРегіонЗавідувачСпівробітників Статус
    -
    А01
    + {% if pharmacy.staff_status == 'danger' %} +
    А{{ pharmacy.pharmacy_number }}
    + {% elif pharmacy.staff_status == 'warning' %} +
    А{{ pharmacy.pharmacy_number }}
    + {% else %} +
    А{{ pharmacy.pharmacy_number }}
    + {% endif %}
    -
    Аптека №1 — Центральна
    -
    вул. Дерибасівська, 12
    +
    Аптека №{{ pharmacy.pharmacy_number }}
    +
    {{ pharmacy.address|default:"адресу не вказано" }}
    Одеса1 / 13 / 32 / 3Норма
    {{ pharmacy.organization.name|default:"—" }}{{ pharmacy.location_region.name|default:"—" }} -
    -
    А07
    -
    -
    Аптека №7 — Черемушки
    -
    вул. Маршала Жукова, 54
    -
    + {% if pharmacy.head_pharmacist %} +
    + {{ pharmacy.head_pharmacist.last_name|slice:":1" }}{{ pharmacy.head_pharmacist.first_name|slice:":1" }} + {{ pharmacy.head_pharmacist.get_full_name }}
    + {% else %} + не призначений + {% endif %}
    Одеса1 / 11 / 22 / 2Увага
    {{ pharmacy.staff_count }} -
    -
    А23
    -
    -
    Аптека №23 — Котовський
    -
    пр. Добровольського, 133
    -
    -
    + {% if pharmacy.staff_status == 'danger' %} + Нестача + {% elif pharmacy.staff_status == 'warning' %} + Увага + {% else %} + Норма + {% endif %}
    Одеса0 / 11 / 21 / 1Нестача
    Аптеки ще не додані
    -
    - Показано 3 з 100+ аптек -
    - - -
    +
    + Всього {{ pharmacies|length }} активних аптек
    diff --git a/templates/pages/task-detail.html b/templates/pages/task-detail.html new file mode 100644 index 0000000..a5eda5d --- /dev/null +++ b/templates/pages/task-detail.html @@ -0,0 +1,221 @@ +
    + + + + + +
    +
    +
    + + В роботі + + Високий + Фінанси · Q4 Звітність +
    +

    Підготувати звіт за квартал

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

    Опис

    +

    Необхідно зібрати фінансові дані по всіх відділах та аптечних точках за IV квартал 2025 року та підготувати зведений звіт для керівництва.

    +

    Звіт має включати: динаміку продажів, порівняння з планом, аналіз витрат та прогноз на I квартал 2026 р.

    +
    + + +
    +
    +

    Чекліст

    + 2 / 4 +
    +
    +
    +
    +
    + + + + +
    +
    + + +
    +
    +

    Коментарі

    + 3 +
    + + +
    +
    +
    ІК
    +
    +
    + Іван Коваленко + 14.04.2026, 09:32 +
    +

    Взяв в роботу. Починаю збирати дані по продажах з відділу аналітики — очікую отримати до кінця дня.

    +
    +
    +
    + + +
    +
    +
    МП
    +
    +
    + Марія Петренко + 14.04.2026, 11:15 +
    +

    Можу допомогти з фінансовою частиною. Вже підготувала зведену таблицю витрат за жовтень–грудень, відправлю на пошту.

    +
    +
    +
    + + +
    +
    +
    ОТ
    +
    +
    + Олексій Ткаченко + 15.04.2026, 08:47 +
    +

    Прошу також підготувати порівняння з аналогічним кварталом минулого року. Це потрібно для презентації правлінню.

    +
    +
    +
    + + +
    +
    +
    ІК
    +
    + +
    + + +
    +
    +
    +
    + +
    +
    + + +
    +
    +

    Деталі задачі

    +
    +
    + +
    + Виконавець +
    +
    ІК
    + Іван Коваленко +
    +
    + +
    + +
    + Відповідальний +
    +
    МП
    + Марія Петренко +
    +
    + +
    + +
    + Дедлайн +
    + + 10.12.2025 +
    +
    + +
    + +
    + Відділ + Фінанси +
    + +
    + +
    + Проект + Q4 Звітність +
    + +
    + +
    + Теги +
    + звіт + фінанси + Q4 +
    +
    + +
    + +
    + Створено + 01.12.2025 · Іван К. +
    + +
    +
    + +
    +
    diff --git a/templates/pages/tasks.html b/templates/pages/tasks.html new file mode 100644 index 0000000..09a737d --- /dev/null +++ b/templates/pages/tasks.html @@ -0,0 +1,241 @@ +
    + + +
    +
    +

    Мої задачі

    +

    Управління вашими задачами та завданнями

    +
    + +
    + + +
    + + + + +
    + + +
    + + +
    + +
    +
    Підготувати звіт за квартал
    +
    Зібрати дані та підготувати фінансовий звіт
    +
    +
    + + В роботі + + + 10.12.2025 + + Високий + + +
    +
    + + +
    + +
    +
    Оновити документацію
    +
    Оновити внутрішню документацію відділу
    +
    +
    + + Очікує + + + 15.12.2025 + + Середній + + +
    +
    + + +
    + +
    +
    Провести інвентаризацію
    +
    Перевірити наявність товарів на складі
    +
    +
    + + Виконано + + + 01.12.2025 + + Низький + + +
    +
    + + +
    + +
    +
    Зустріч з постачальником
    +
    Обговорити умови нового контракту
    +
    +
    + + Очікує + + + 08.12.2025 + + Високий + + +
    +
    + + +
    + +
    +
    Перевірити замовлення
    +
    Підтвердити статус поточних замовлень
    +
    +
    + + В роботі + + + 12.12.2025 + + Середній + + +
    +
    + + +
    + +
    +
    Підготувати презентацію для керівництва
    +
    Підсумки роботи відділу за IV квартал
    +
    +
    + + Очікує + + + 20.12.2025 + + Середній + + +
    +
    + + +
    + +
    +
    Провести навчання персоналу
    +
    Онбординг нових співробітників аптечної мережі
    +
    +
    + + В роботі + + + 18.12.2025 + + Високий + + +
    +
    + + +
    + +
    +
    Аудит витрат за листопад
    +
    Перевірити всі витрати відділу за листопад
    +
    +
    + + Виконано + + + 25.11.2025 + + Низький + + +
    +
    + +
    + +