From 78475b58e9bd13a343521c90bae606b87c9c2fd7 Mon Sep 17 00:00:00 2001 From: lokses Date: Wed, 22 Jul 2026 17:31:01 +0300 Subject: [PATCH] =?UTF-8?q?=D0=98=D0=BD=D1=84=D1=80=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80=D0=B0:=20Celery,=20=D0=BE?= =?UTF-8?q?=D0=B1=D1=89=D0=B8=D0=B5=20=D1=83=D1=82=D0=B8=D0=BB=D0=B8=D1=82?= =?UTF-8?q?=D1=8B,=20=D0=B2=D0=B8=D0=B4=D0=B6=D0=B5=D1=82=D1=8B,=20=D1=88?= =?UTF-8?q?=D0=B5=D0=BB=D0=BB=20=D0=BF=D0=BE=D1=80=D1=82=D0=B0=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - acore: Celery-приложение (брокер из env, eager без брокера), настройки CELERY_*, регистрация всех новых приложений в INSTALLED_APPS, маршруты - core/utils: render_page (HTMX-фрагмент/полный layout), is_owner/ is_owner_or_staff; templatetags common_tags (initials, clean_excerpt, is_it_staff); UpdateLastSeenMiddleware - static: people-picker (дерево отделов с аватарками), searchable-select (одиночный выбор с поиском), directory-quick-add, it-manager, pagination; тема Tailwind v4 пересобрана, лого - templates: новый шелл index.html (мобильный сайдбар, темы, фон, user-dropdown) и login с брендингом Co-Authored-By: Claude Opus 4.8 --- .gitignore | 7 + acore/settings/base.py | 33 + acore/urls.py | 19 +- core/middleware.py | 34 + core/migrations/0003_directory_ad_ou_dn.py | 18 + core/templatetags/__init__.py | 0 core/templatetags/common_tags.py | 43 + core/utils/htmx.py | 15 + core/utils/permissions.py | 8 + requirements.txt | 5 + static/assets/css/input.css | 213 +++++ static/assets/css/main.css | 786 ++++++++++++++++++ static/assets/js/admin_leaflet_resize_fix.js | 37 + static/assets/js/chat.js | 827 +++++++++++++++++++ static/assets/js/directory-quick-add.js | 48 ++ static/assets/js/hr-portal.js | 239 ++++++ static/assets/js/it-manager.js | 90 ++ static/assets/js/pagination.js | 11 + static/assets/js/people-picker.js | 342 ++++++++ static/assets/js/searchable-select.js | 161 ++++ static/assets/js/structure.js | 15 - templates/index.html | 67 +- 22 files changed, 2996 insertions(+), 22 deletions(-) create mode 100644 core/middleware.py create mode 100644 core/migrations/0003_directory_ad_ou_dn.py create mode 100644 core/templatetags/__init__.py create mode 100644 core/templatetags/common_tags.py create mode 100644 core/utils/htmx.py create mode 100644 core/utils/permissions.py create mode 100644 static/assets/js/admin_leaflet_resize_fix.js create mode 100644 static/assets/js/chat.js create mode 100644 static/assets/js/directory-quick-add.js create mode 100644 static/assets/js/hr-portal.js create mode 100644 static/assets/js/it-manager.js create mode 100644 static/assets/js/pagination.js create mode 100644 static/assets/js/people-picker.js create mode 100644 static/assets/js/searchable-select.js delete mode 100644 static/assets/js/structure.js diff --git a/.gitignore b/.gitignore index 3671433..a6be283 100644 --- a/.gitignore +++ b/.gitignore @@ -152,3 +152,10 @@ packets.txt .claude/settings.local.json # Шаблон-референс дизайна (не часть Django-проекта) ACORE-template/ + +# Рабочая директория Claude Code (кроме уже указанного settings.local.json) +.claude/ +# Дубль шаблона портала с node_modules (референс, не часть проекта) +osc/ +# Дамп сотрудников с персональными данными — не в репозиторий +employees.json diff --git a/acore/settings/base.py b/acore/settings/base.py index 31e3da3..f746a29 100644 --- a/acore/settings/base.py +++ b/acore/settings/base.py @@ -10,6 +10,15 @@ environ.Env.read_env(BASE_DIR / '.env') SECRET_KEY = env('SECRET_KEY') ALLOWED_HOSTS = env.list('ALLOWED_HOSTS', default=[]) +# Базовый URL портала — используется для абсолютных ссылок в уведомлениях +# (например, Mattermost-бот шлёт ссылку на задачу) +PORTAL_BASE_URL = env('PORTAL_BASE_URL', default='http://localhost:8000') + +# Токен слэш-команды Mattermost (System Console/Team → Integrations → Slash Commands). +# Пустая строка по умолчанию — запросы без совпадающего токена всегда отклоняются (fail-closed), +# пока команда реально не создана в Mattermost и токен не прописан в .env. +MATTERMOST_SLASH_COMMAND_TOKEN = env('MATTERMOST_SLASH_COMMAND_TOKEN', default='') + INSTALLED_APPS = [ # Admin "unfold", # before django.contrib.admin @@ -43,7 +52,13 @@ INSTALLED_APPS = [ 'apps.locations', 'apps.employees', 'apps.visit_manager', + 'tinymce', + 'apps.it_manager', + 'apps.feed', + 'apps.tasks', + 'apps.hiring', + 'apps.utilities', 'apps.ad_connector', 'apps.mail_connector', 'apps.mailparser', @@ -59,6 +74,7 @@ MIDDLEWARE = [ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'core.middleware.UpdateLastSeenMiddleware', ] ROOT_URLCONF = 'acore.urls' @@ -422,3 +438,20 @@ 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) + +# --- TinyMCE (редактор постов ленты) --- +TINYMCE_DEFAULT_CONFIG = { + 'height': 360, + 'menubar': False, + 'plugins': 'lists link table autolink image charmap', + 'toolbar': ( + 'undo redo | blocks | bold italic underline strikethrough | ' + 'forecolor | alignleft aligncenter alignright | ' + 'bullist numlist | link table | removeformat' + ), + 'language': 'uk', + 'license_key': 'gpl', + 'promotion': False, + 'branding': False, + 'convert_urls': False, +} diff --git a/acore/urls.py b/acore/urls.py index 9fa4600..6a476ee 100644 --- a/acore/urls.py +++ b/acore/urls.py @@ -26,13 +26,21 @@ urlpatterns = [ path('api/attendance/', include('apps.visit_manager.api.urls')), path('', include('apps.employees.urls')), path('', include('apps.ad_connector.urls')), + path('', include('apps.feed.urls')), + path('', include('apps.tasks.urls')), + path('', include('apps.it_manager.urls')), + path('', include('apps.mattermost_connector.urls')), + path('', include('apps.utilities.urls')), + path('tinymce/', include('tinymce.urls')), path('', views.index_view, name='index'), path('accounts/login/', views.login_view, name='login'), path('login/', views.login_view, name='login'), path('logout/', views.logout_view, name='logout'), path('dashboard/', views.dashboard_view, name='dashboard'), path('employees/', views.employees_view, name='employees'), + path('employees//', views.employee_detail_view, name='employee_detail'), path('pharmacies/', views.pharmacies_view, name='pharmacies'), + path('dislocation/', views.dislocation_view, name='dislocation'), path('calendar/', views.calendar_view, name='calendar'), path('timetrack/', views.timetrack_view, name='timetrack'), path('reports/', views.reports_view, name='reports'), @@ -40,17 +48,20 @@ 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('profile/', views.profile_view, name='profile'), path('email/', views.email_view, name='email'), path('chat/', views.chat_view, name='chat'), + path('chat/sync/', views.chat_sync_now_view, name='chat_sync_now'), 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('hiring/', views.hiring_create_view, name='hiring_create'), + path('hiring//approve/', views.hiring_approve_view, name='hiring_approve'), + path('hiring//reject/', views.hiring_reject_view, name='hiring_reject'), path('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('directory/quick-add/', views.directory_quick_add_view, name='directory_quick_add'), + path('pharmacies//', views.pharmacy_detail_view, name='pharmacy_detail'), path('modal-add-employee/', views.modal_add_employee_view, name='modal_add_employee'), ] diff --git a/core/middleware.py b/core/middleware.py new file mode 100644 index 0000000..4b7c74a --- /dev/null +++ b/core/middleware.py @@ -0,0 +1,34 @@ +from datetime import timedelta + +from django.utils import timezone + +# Не пишем last_seen на каждый запрос — обновляем не чаще раза в этот интервал. +LAST_SEEN_WRITE_THROTTLE = timedelta(seconds=60) + +# Порог, после которого сотрудник считается офлайн (используется в core.views.dashboard_view). +ONLINE_WINDOW = timedelta(minutes=5) + + +class UpdateLastSeenMiddleware: + """ + Обновляет Employee.last_seen для авторизованных пользователей — основа для + метрики «онлайн зараз» на дашборде. Пишет через .update(), а не save(), + чтобы не дёргать post_save-сигналы синхронизации с AD/Mattermost на каждый запрос. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + + user = getattr(request, 'user', None) + if user is not None and user.is_authenticated: + now = timezone.now() + if not user.last_seen or now - user.last_seen >= LAST_SEEN_WRITE_THROTTLE: + # user — SimpleLazyObject; user.__class__ (в отличие от type(user)) + # корректно возвращает класс обёрнутого объекта (Employee). + user.__class__.objects.filter(pk=user.pk).update(last_seen=now) + user.last_seen = now + + return response diff --git a/core/migrations/0003_directory_ad_ou_dn.py b/core/migrations/0003_directory_ad_ou_dn.py new file mode 100644 index 0000000..ff6e3f6 --- /dev/null +++ b/core/migrations/0003_directory_ad_ou_dn.py @@ -0,0 +1,18 @@ +# Generated by Django 6.0.3 on 2026-07-12 20:00 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0002_connector_config'), + ] + + operations = [ + migrations.AddField( + model_name='directory', + name='ad_ou_dn', + field=models.CharField(blank=True, help_text='Distinguished Name организационного юнита (OU) в Active Directory, куда переносятся учётные записи сотрудников этого отдела (например: OU=IT,OU=Users,DC=company,DC=local). Только для отделов. Если не задано — используется корневая OU коннектора AD.', max_length=500, verbose_name='AD OU DN'), + ), + ] diff --git a/core/templatetags/__init__.py b/core/templatetags/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/templatetags/common_tags.py b/core/templatetags/common_tags.py new file mode 100644 index 0000000..2bd06e9 --- /dev/null +++ b/core/templatetags/common_tags.py @@ -0,0 +1,43 @@ +import re +from html import unescape as html_unescape + +from django import template +from django.utils.html import strip_tags + +register = template.Library() + +_WHITESPACE_RE = re.compile(r'\s+') + + +@register.filter +def initials(user): + """Инициалы для аватара-кружка: 'ІВ' из фамилии и имени""" + if not user: + return '' + last = (user.last_name or '')[:1] + first = (user.first_name or '')[:1] + return f'{last}{first}' + + +@register.filter +def clean_excerpt(html_text, length=140): + """Чистий текстовий уривок з HTML (тіло поста тощо): знімає теги, розкодовує + HTML-сутності (  та інше — інакше після strip_tags вони лишаються як + текст і показуються буквально через автоекранування шаблонів), схлопує + пробіли/невидимі nbsp і обрізає до length символів з «…».""" + if not html_text: + return '' + text = html_unescape(strip_tags(html_text)).replace('\xa0', ' ') + text = _WHITESPACE_RE.sub(' ', text).strip() + length = int(length) + if len(text) > length: + text = text[:length].rstrip() + '…' + return text + + +@register.filter +def is_it_staff(user): + """Чи входить користувач до групи IT (або суперюзер) — доступ до /it-manager/.""" + if not user or not user.is_authenticated: + return False + return user.is_superuser or user.groups.filter(name='IT').exists() diff --git a/core/utils/htmx.py b/core/utils/htmx.py new file mode 100644 index 0000000..033d354 --- /dev/null +++ b/core/utils/htmx.py @@ -0,0 +1,15 @@ +from django.shortcuts import render + + +def is_htmx_request(request) -> bool: + """Запрос прислан HTMX (hx-get/hx-post), а не обычной навигацией браузера""" + return request.headers.get('HX-Request') == 'true' + + +def render_page(request, page_template: str, context: dict = None): + """Страница портала: HTMX-фрагмент из pages/ или полный + layout (index.html) с этим же фрагментом внутри — по типу запроса.""" + context = context or {} + if is_htmx_request(request): + return render(request, f'pages/{page_template}', context) + return render(request, 'index.html', {'page_template': f'pages/{page_template}', **context}) diff --git a/core/utils/permissions.py b/core/utils/permissions.py new file mode 100644 index 0000000..e8124f8 --- /dev/null +++ b/core/utils/permissions.py @@ -0,0 +1,8 @@ +def is_owner_or_staff(user, obj, owner_field: str = 'author_id') -> bool: + """Редактировать может администратор (is_staff) или владелец объекта""" + return user.is_staff or getattr(obj, owner_field) == user.pk + + +def is_owner(user, obj, owner_field: str = 'author_id') -> bool: + """Удалять с портала может только владелец (staff — через админку)""" + return getattr(obj, owner_field) == user.pk diff --git a/requirements.txt b/requirements.txt index b7fa6d2..11fda16 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,12 +8,14 @@ attrs==26.1.0 billiard==4.2.4 celery==5.5.1 certifi==2026.2.25 +cffi==2.1.0 charset-normalizer==3.4.7 click-didyoumean==0.3.1 click-plugins==1.1.1.2 click-repl==0.3.0 click==8.3.2 cron-descriptor==1.4.5 +cryptography==49.0.0 diff-match-patch==20241021 django-celery-beat==2.9.0 django-celery-results==2.5.1 @@ -22,6 +24,7 @@ django-fast-treenode==3.2.10 django-import-export==4.4.0 django-location-field==2.7.3 django-timezone-field==7.2.1 +django-tinymce==5.0.0 django-unfold==0.87.0 Django==6.0.3 djangorestframework==3.15.2 @@ -37,12 +40,14 @@ ldap3==2.9.1 mattermostautodriver==11.7.2 msgpack==1.1.2 multidict==6.7.1 +nh3==0.3.6 openpyxl==3.1.5 packaging==26.0 pillow==12.2.0 prompt_toolkit==3.0.52 propcache==0.5.2 psycopg2-binary==2.9.11 +pycparser==3.0 PyJWT==2.12.1 python-crontab==3.3.0 python-dateutil==2.9.0.post0 diff --git a/static/assets/css/input.css b/static/assets/css/input.css index c82ab30..25e7a4f 100644 --- a/static/assets/css/input.css +++ b/static/assets/css/input.css @@ -328,6 +328,219 @@ .badge-info { background: hsl(205 80% 55% / 0.15); color: hsl(205 80% 35%); } .badge-muted { background: var(--color-muted); color: var(--color-muted-foreground); } + /* ─── People picker: вибір людей/відділів чіпами (форми задач) ─── */ + .people-picker { + position: relative; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-card); + padding: 0.5rem 0.625rem; + } + + .people-picker-ui { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; + } + + .people-picker-chips { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + } + + .people-picker-chip { + display: inline-flex; + align-items: center; + gap: 0.3125rem; + background: color-mix(in srgb, var(--color-primary) 12%, transparent); + color: var(--color-primary); + font-size: 0.8125rem; + font-weight: 500; + padding: 0.1875rem 0.375rem 0.1875rem 0.625rem; + border-radius: 999px; + white-space: nowrap; + } + + .people-picker-chip-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.125rem; + height: 1.125rem; + border: none; + border-radius: 50%; + background: transparent; + color: inherit; + cursor: pointer; + font-size: 0.9375rem; + line-height: 1; + padding: 0; + } + + .people-picker-chip-remove:hover { + background: color-mix(in srgb, var(--color-primary) 20%, transparent); + } + + .people-picker-search { + border: none !important; + box-shadow: none !important; + padding: 0.1875rem 0 !important; + min-width: 8rem; + flex: 1; + background: transparent !important; + } + + /* ─── Searchable select: одиночний вибір з пошуком (локації тощо) ─── */ + .searchable-select { + position: relative; + } + + .searchable-select-search { + width: 100%; + padding-right: 2rem !important; + } + + .searchable-select-clear { + position: absolute; + top: 50%; + right: 0.5rem; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + border: none; + border-radius: 50%; + background: transparent; + color: var(--color-muted-foreground); + cursor: pointer; + font-size: 1rem; + line-height: 1; + padding: 0; + } + + .searchable-select-clear:hover { + background: var(--color-muted); + color: var(--color-foreground); + } + + .people-picker-dropdown { + position: absolute; + top: calc(100% + 8px); + left: 0; + right: 0; + width: 100%; + max-height: 18rem; + overflow-y: auto; + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius); + box-shadow: 0 8px 24px rgba(0,0,0,0.15); + z-index: 1000; + padding: 0.25rem; + } + + /* ─── Рядок людини (у дереві та в результатах пошуку) ─── */ + .people-picker-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.5rem; + border-radius: calc(var(--radius) - 4px); + cursor: pointer; + } + + .people-picker-row:hover { + background: var(--color-muted); + } + + .people-picker-row.is-selected { + background: color-mix(in srgb, var(--color-primary) 8%, transparent); + } + + .people-picker-row-info { + min-width: 0; + flex: 1; + } + + .people-picker-row-name { + font-size: 0.8125rem; + color: var(--color-foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .people-picker-row-position { + font-size: 0.6875rem; + color: var(--color-muted-foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .people-picker-row-check { + color: var(--color-primary); + font-size: 0.9375rem; + flex-shrink: 0; + } + + .people-picker-avatar { + width: 1.75rem; + height: 1.75rem; + border-radius: 50%; + flex-shrink: 0; + object-fit: cover; + } + + .people-picker-avatar-fallback { + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, var(--color-primary) 12%, transparent); + color: var(--color-primary); + font-size: 0.625rem; + font-weight: 600; + } + + /* ─── Дерево відділів у пікері ─── */ + .people-picker-dept { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.5rem; + border-radius: calc(var(--radius) - 4px); + cursor: pointer; + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-foreground); + } + + .people-picker-dept:hover { + background: var(--color-muted); + } + + .people-picker-dept-toggle { + width: 0.75rem; + flex-shrink: 0; + color: var(--color-muted-foreground); + font-size: 0.625rem; + } + + .people-picker-dept-icon { + flex-shrink: 0; + font-size: 0.8125rem; + } + + .people-picker-dept-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + /* ─── Кнопки ─── */ .btn { display: inline-flex; diff --git a/static/assets/css/main.css b/static/assets/css/main.css index c97803b..b89fd79 100644 --- a/static/assets/css/main.css +++ b/static/assets/css/main.css @@ -7,7 +7,27 @@ --font-sans: 'Inter', sans-serif; --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + --color-red-100: oklch(93.6% 0.032 17.717); + --color-red-400: oklch(70.4% 0.191 22.216); + --color-red-500: oklch(63.7% 0.237 25.331); + --color-red-600: oklch(57.7% 0.245 27.325); + --color-red-700: oklch(50.5% 0.213 27.518); + --color-orange-100: oklch(95.4% 0.038 75.164); + --color-orange-400: oklch(75% 0.183 55.934); + --color-orange-500: oklch(70.5% 0.213 47.604); + --color-orange-700: oklch(55.3% 0.195 38.402); + --color-white: #fff; --spacing: 0.25rem; + --container-xs: 20rem; + --container-2xl: 42rem; + --text-sm: 0.875rem; + --text-sm--line-height: calc(1.25 / 0.875); + --text-2xl: 1.5rem; + --text-2xl--line-height: calc(2 / 1.5); + --font-weight-medium: 500; + --font-weight-semibold: 600; + --tracking-tight: -0.025em; + --leading-relaxed: 1.625; --default-transition-duration: 150ms; --default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); --default-font-family: var(--font-sans); @@ -189,34 +209,271 @@ } } @layer utilities { + .collapse { + visibility: collapse; + } + .visible { + visibility: visible; + } + .fixed { + position: fixed; + } + .relative { + position: relative; + } + .static { + position: static; + } .start { inset-inline-start: var(--spacing); } + .end { + inset-inline-end: var(--spacing); + } + .container { + width: 100%; + @media (width >= 40rem) { + max-width: 40rem; + } + @media (width >= 48rem) { + max-width: 48rem; + } + @media (width >= 64rem) { + max-width: 64rem; + } + @media (width >= 80rem) { + max-width: 80rem; + } + @media (width >= 96rem) { + max-width: 96rem; + } + } + .mb-1 { + margin-bottom: calc(var(--spacing) * 1); + } + .mb-2 { + margin-bottom: calc(var(--spacing) * 2); + } + .mb-3 { + margin-bottom: calc(var(--spacing) * 3); + } + .mb-4 { + margin-bottom: calc(var(--spacing) * 4); + } + .mb-6 { + margin-bottom: calc(var(--spacing) * 6); + } .block { display: block; } .flex { display: flex; } + .flow-root { + display: flow-root; + } .hidden { display: none; } + .inline { + display: inline; + } + .inline-flex { + display: inline-flex; + } .table { display: table; } + .table-row { + display: table-row; + } + .h-4 { + height: calc(var(--spacing) * 4); + } + .w-4 { + width: calc(var(--spacing) * 4); + } + .w-full { + width: 100%; + } + .max-w-2xl { + max-width: var(--container-2xl); + } + .max-w-xs { + max-width: var(--container-xs); + } + .min-w-4 { + min-width: calc(var(--spacing) * 4); + } .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,); } + .cursor-pointer { + cursor: pointer; + } .resize { resize: both; } + .list-disc { + list-style-type: disc; + } + .appearance-none { + appearance: none; + } + .flex-row { + flex-direction: row; + } + .flex-wrap { + flex-wrap: wrap; + } + .items-center { + align-items: center; + } + .items-end { + align-items: flex-end; + } + .gap-1 { + gap: calc(var(--spacing) * 1); + } + .gap-2 { + gap: calc(var(--spacing) * 2); + } + .gap-3 { + gap: calc(var(--spacing) * 3); + } + .space-y-1 { + :where(& > :not(:last-child)) { + --tw-space-y-reverse: 0; + margin-block-start: calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse)); + margin-block-end: calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse))); + } + } + .rounded-\[4px\] { + border-radius: 4px; + } .border { border-style: var(--tw-border-style); border-width: 1px; } + .border-t { + border-top-style: var(--tw-border-style); + border-top-width: 1px; + } + .border-b { + border-bottom-style: var(--tw-border-style); + border-bottom-width: 1px; + } + .border-transparent { + border-color: transparent; + } + .bg-orange-100 { + background-color: var(--color-orange-100); + } + .bg-red-100 { + background-color: var(--color-red-100); + } + .bg-red-600 { + background-color: var(--color-red-600); + } + .bg-white { + background-color: var(--color-white); + } + .p-1 { + padding: calc(var(--spacing) * 1); + } + .p-4 { + padding: calc(var(--spacing) * 4); + } + .p-6 { + padding: calc(var(--spacing) * 6); + } + .px-3 { + padding-inline: calc(var(--spacing) * 3); + } + .px-4 { + padding-inline: calc(var(--spacing) * 4); + } + .py-2 { + padding-block: calc(var(--spacing) * 2); + } + .py-2\.5 { + padding-block: calc(var(--spacing) * 2.5); + } + .py-3 { + padding-block: calc(var(--spacing) * 3); + } + .pl-5 { + padding-left: calc(var(--spacing) * 5); + } + .text-center { + text-align: center; + } + .text-left { + text-align: left; + } + .text-2xl { + font-size: var(--text-2xl); + line-height: var(--tw-leading, var(--text-2xl--line-height)); + } + .text-sm { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + .text-\[15px\] { + font-size: 15px; + } + .leading-\[18px\] { + --tw-leading: 18px; + line-height: 18px; + } + .leading-relaxed { + --tw-leading: var(--leading-relaxed); + line-height: var(--leading-relaxed); + } + .font-medium { + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + } + .font-semibold { + --tw-font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-semibold); + } + .tracking-tight { + --tw-tracking: var(--tracking-tight); + letter-spacing: var(--tracking-tight); + } + .whitespace-nowrap { + white-space: nowrap; + } + .text-orange-700 { + color: var(--color-orange-700); + } + .text-red-700 { + color: var(--color-red-700); + } + .text-white { + color: var(--color-white); + } + .italic { + font-style: italic; + } + .underline { + text-decoration-line: underline; + } + .shadow-xs { + --tw-shadow: 0 1px 2px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.05)); + box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow); + } + .blur { + --tw-blur: blur(8px); + 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,); + } .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,); } @@ -225,6 +482,228 @@ transition-timing-function: var(--tw-ease, var(--default-transition-timing-function)); transition-duration: var(--tw-duration, var(--default-transition-duration)); } + .\*\:flex { + :is(& > *) { + display: flex; + } + } + .\*\:flex-row { + :is(& > *) { + flex-direction: row; + } + } + .\*\:items-center { + :is(& > *) { + align-items: center; + } + } + .\*\:gap-1 { + :is(& > *) { + gap: calc(var(--spacing) * 1); + } + } + .\*\:px-2\.5 { + :is(& > *) { + padding-inline: calc(var(--spacing) * 2.5); + } + } + .\*\:py-\[5px\] { + :is(& > *) { + padding-block: 5px; + } + } + .\*\:font-medium { + :is(& > *) { + --tw-font-weight: var(--font-weight-medium); + font-weight: var(--font-weight-medium); + } + } + .\*\:whitespace-nowrap { + :is(& > *) { + white-space: nowrap; + } + } + .after\:absolute { + &::after { + content: var(--tw-content); + position: absolute; + } + } + .after\:-mt-px { + &::after { + content: var(--tw-content); + margin-top: -1px; + } + } + .after\:-ml-px { + &::after { + content: var(--tw-content); + margin-left: -1px; + } + } + .after\:flex\! { + &::after { + content: var(--tw-content); + display: flex !important; + } + } + .after\:h-4 { + &::after { + content: var(--tw-content); + height: calc(var(--spacing) * 4); + } + } + .after\:w-4 { + &::after { + content: var(--tw-content); + width: calc(var(--spacing) * 4); + } + } + .after\:items-center { + &::after { + content: var(--tw-content); + align-items: center; + } + } + .after\:justify-center { + &::after { + content: var(--tw-content); + justify-content: center; + } + } + .after\:leading-none { + &::after { + content: var(--tw-content); + --tw-leading: 1; + line-height: 1; + } + } + .after\:text-white { + &::after { + content: var(--tw-content); + color: var(--color-white); + } + } + .after\:content-\[\'check\\_small\'\] { + &::after { + --tw-content: 'check_small'; + content: var(--tw-content); + } + } + .hover\:bg-red-600\/80 { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, oklch(57.7% 0.245 27.325) 80%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-red-600) 80%, transparent); + } + } + } + } + .hover\:underline { + &:hover { + @media (hover: hover) { + text-decoration-line: underline; + } + } + } + .focus\:outline-2 { + &:focus { + outline-style: var(--tw-outline-style); + outline-width: 2px; + } + } + .focus\:-outline-offset-2 { + &:focus { + outline-offset: calc(2px * -1); + } + } + .disabled\:cursor-not-allowed { + &:disabled { + cursor: not-allowed; + } + } + .disabled\:opacity-50 { + &:disabled { + opacity: 50%; + } + } + .md\:w-auto { + @media (width >= 48rem) { + width: auto; + } + } + .dark\:bg-orange-500\/20 { + @media (prefers-color-scheme: dark) { + background-color: color-mix(in srgb, oklch(70.5% 0.213 47.604) 20%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-orange-500) 20%, transparent); + } + } + } + .dark\:bg-red-500\/20 { + @media (prefers-color-scheme: dark) { + background-color: color-mix(in srgb, oklch(63.7% 0.237 25.331) 20%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-red-500) 20%, transparent); + } + } + } + .dark\:bg-white\/\[\.06\] { + @media (prefers-color-scheme: dark) { + background-color: color-mix(in srgb, #fff 6%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-white) 6%, transparent); + } + } + } + .dark\:text-orange-400 { + @media (prefers-color-scheme: dark) { + color: var(--color-orange-400); + } + } + .dark\:text-red-400 { + @media (prefers-color-scheme: dark) { + color: var(--color-red-400); + } + } + .dark\:scheme-dark { + @media (prefers-color-scheme: dark) { + color-scheme: dark; + } + } + .dark\:after\:text-transparent { + @media (prefers-color-scheme: dark) { + &::after { + content: var(--tw-content); + color: transparent; + } + } + } + .dark\:checked\:after\:text-white { + @media (prefers-color-scheme: dark) { + &:checked { + &::after { + content: var(--tw-content); + color: var(--color-white); + } + } + } + } + .\*\:dark\:hover\:bg-white\/\[\.06\] { + :is(& > *) { + @media (prefers-color-scheme: dark) { + &:hover { + @media (hover: hover) { + background-color: color-mix(in srgb, #fff 6%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background-color: color-mix(in oklab, var(--color-white) 6%, transparent); + } + } + } + } + } + } } @layer base { [data-theme="dark"] { @@ -503,6 +982,201 @@ background: var(--color-muted); color: var(--color-muted-foreground); } + .people-picker { + position: relative; + border: 1px solid var(--color-border); + border-radius: var(--radius); + background: var(--color-card); + padding: 0.5rem 0.625rem; + } + .people-picker-ui { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.375rem; + } + .people-picker-chips { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + } + .people-picker-chip { + display: inline-flex; + align-items: center; + gap: 0.3125rem; + background: color-mix(in srgb, hsl(224.8, 81%, 60.8%) 12%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background: color-mix(in srgb, var(--color-primary) 12%, transparent); + } + color: var(--color-primary); + font-size: 0.8125rem; + font-weight: 500; + padding: 0.1875rem 0.375rem 0.1875rem 0.625rem; + border-radius: 999px; + white-space: nowrap; + } + .people-picker-chip-remove { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.125rem; + height: 1.125rem; + border: none; + border-radius: 50%; + background: transparent; + color: inherit; + cursor: pointer; + font-size: 0.9375rem; + line-height: 1; + padding: 0; + } + .people-picker-chip-remove:hover { + background: color-mix(in srgb, hsl(224.8, 81%, 60.8%) 20%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background: color-mix(in srgb, var(--color-primary) 20%, transparent); + } + } + .people-picker-search { + border: none !important; + box-shadow: none !important; + padding: 0.1875rem 0 !important; + min-width: 8rem; + flex: 1; + background: transparent !important; + } + .searchable-select { + position: relative; + } + .searchable-select-search { + width: 100%; + padding-right: 2rem !important; + } + .searchable-select-clear { + position: absolute; + top: 50%; + right: 0.5rem; + transform: translateY(-50%); + display: flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + border: none; + border-radius: 50%; + background: transparent; + color: var(--color-muted-foreground); + cursor: pointer; + font-size: 1rem; + line-height: 1; + padding: 0; + } + .searchable-select-clear:hover { + background: var(--color-muted); + color: var(--color-foreground); + } + .people-picker-dropdown { + position: absolute; + top: calc(100% + 8px); + left: 0; + right: 0; + width: 100%; + max-height: 18rem; + overflow-y: auto; + background: var(--color-card); + border: 1px solid var(--color-border); + border-radius: var(--radius); + box-shadow: 0 8px 24px rgba(0,0,0,0.15); + z-index: 1000; + padding: 0.25rem; + } + .people-picker-row { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.375rem 0.5rem; + border-radius: calc(var(--radius) - 4px); + cursor: pointer; + } + .people-picker-row:hover { + background: var(--color-muted); + } + .people-picker-row.is-selected { + background: color-mix(in srgb, hsl(224.8, 81%, 60.8%) 8%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background: color-mix(in srgb, var(--color-primary) 8%, transparent); + } + } + .people-picker-row-info { + min-width: 0; + flex: 1; + } + .people-picker-row-name { + font-size: 0.8125rem; + color: var(--color-foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .people-picker-row-position { + font-size: 0.6875rem; + color: var(--color-muted-foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + .people-picker-row-check { + color: var(--color-primary); + font-size: 0.9375rem; + flex-shrink: 0; + } + .people-picker-avatar { + width: 1.75rem; + height: 1.75rem; + border-radius: 50%; + flex-shrink: 0; + object-fit: cover; + } + .people-picker-avatar-fallback { + display: flex; + align-items: center; + justify-content: center; + background: color-mix(in srgb, hsl(224.8, 81%, 60.8%) 12%, transparent); + @supports (color: color-mix(in lab, red, red)) { + background: color-mix(in srgb, var(--color-primary) 12%, transparent); + } + color: var(--color-primary); + font-size: 0.625rem; + font-weight: 600; + } + .people-picker-dept { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.5rem; + border-radius: calc(var(--radius) - 4px); + cursor: pointer; + font-size: 0.8125rem; + font-weight: 500; + color: var(--color-foreground); + } + .people-picker-dept:hover { + background: var(--color-muted); + } + .people-picker-dept-toggle { + width: 0.75rem; + flex-shrink: 0; + color: var(--color-muted-foreground); + font-size: 0.625rem; + } + .people-picker-dept-icon { + flex-shrink: 0; + font-size: 0.8125rem; + } + .people-picker-dept-label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } .btn { display: inline-flex; align-items: center; @@ -805,11 +1479,93 @@ syntax: "*"; inherits: false; } +@property --tw-space-y-reverse { + syntax: "*"; + inherits: false; + initial-value: 0; +} @property --tw-border-style { syntax: "*"; inherits: false; initial-value: solid; } +@property --tw-leading { + syntax: "*"; + inherits: false; +} +@property --tw-font-weight { + syntax: "*"; + inherits: false; +} +@property --tw-tracking { + syntax: "*"; + inherits: false; +} +@property --tw-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-inset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-inset-ring-color { + syntax: "*"; + inherits: false; +} +@property --tw-inset-ring-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} +@property --tw-ring-inset { + syntax: "*"; + inherits: false; +} +@property --tw-ring-offset-width { + syntax: ""; + inherits: false; + initial-value: 0px; +} +@property --tw-ring-offset-color { + syntax: "*"; + inherits: false; + initial-value: #fff; +} +@property --tw-ring-offset-shadow { + syntax: "*"; + inherits: false; + initial-value: 0 0 #0000; +} @property --tw-blur { syntax: "*"; inherits: false; @@ -863,6 +1619,16 @@ syntax: "*"; inherits: false; } +@property --tw-content { + syntax: "*"; + initial-value: ""; + inherits: false; +} +@property --tw-outline-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} @layer properties { @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { *, ::before, ::after, ::backdrop { @@ -871,7 +1637,25 @@ --tw-rotate-z: initial; --tw-skew-x: initial; --tw-skew-y: initial; + --tw-space-y-reverse: 0; --tw-border-style: solid; + --tw-leading: initial; + --tw-font-weight: initial; + --tw-tracking: initial; + --tw-shadow: 0 0 #0000; + --tw-shadow-color: initial; + --tw-shadow-alpha: 100%; + --tw-inset-shadow: 0 0 #0000; + --tw-inset-shadow-color: initial; + --tw-inset-shadow-alpha: 100%; + --tw-ring-color: initial; + --tw-ring-shadow: 0 0 #0000; + --tw-inset-ring-color: initial; + --tw-inset-ring-shadow: 0 0 #0000; + --tw-ring-inset: initial; + --tw-ring-offset-width: 0px; + --tw-ring-offset-color: #fff; + --tw-ring-offset-shadow: 0 0 #0000; --tw-blur: initial; --tw-brightness: initial; --tw-contrast: initial; @@ -885,6 +1669,8 @@ --tw-drop-shadow-color: initial; --tw-drop-shadow-alpha: 100%; --tw-drop-shadow-size: initial; + --tw-content: ""; + --tw-outline-style: solid; } } } diff --git a/static/assets/js/admin_leaflet_resize_fix.js b/static/assets/js/admin_leaflet_resize_fix.js new file mode 100644 index 0000000..9dde7b2 --- /dev/null +++ b/static/assets/js/admin_leaflet_resize_fix.js @@ -0,0 +1,37 @@ +(function () { + 'use strict'; + + // django-location-field создаёт Leaflet-карту в момент рендера поля, когда + // ширина контейнера в unfold-форме (двухколоночная grid-раскладка, сайдбар) + // ещё не устаканилась — Leaflet один раз считает tile bounds по этой ширине + // и подгружает тайлы только под неё, поэтому справа остаётся серая пустота. + // Leaflet сам слушает window 'resize' и пересчитывает размер (invalidateSize) — + // просто нужно реально прислать это событие после того, как раскладка осядет. + + function fireResize() { + window.dispatchEvent(new Event('resize')); + } + + function watchLeafletContainers() { + var containers = document.querySelectorAll('.leaflet-container'); + if (!containers.length || typeof ResizeObserver === 'undefined') { + return; + } + var observer = new ResizeObserver(function () { + fireResize(); + }); + containers.forEach(function (el) { + observer.observe(el.parentElement || el); + }); + } + + document.addEventListener('DOMContentLoaded', function () { + // Даём вебшрифтам/grid-раскладці unfold час "устаканитись", потім тригеримо + // resize кілька разів з наростаючою затримкою — карта може ще не встигнути + // ініціалізуватись на перших тіках (location_field вантажить Leaflet асинхронно) + [50, 200, 500, 1000].forEach(function (delay) { + setTimeout(fireResize, delay); + }); + setTimeout(watchLeafletContainers, 1000); + }); +})(); diff --git a/static/assets/js/chat.js b/static/assets/js/chat.js new file mode 100644 index 0000000..34ba850 --- /dev/null +++ b/static/assets/js/chat.js @@ -0,0 +1,827 @@ +var CHAT_QUICK_EMOJIS = [ + ['👍', 'thumbsup'], ['❤️', 'heart'], ['😂', 'joy'], + ['😮', 'open_mouth'], ['😢', 'cry'], ['🙏', 'pray'], +]; + +var chatConfig = null; +var chatColleagues = []; +var chatChannels = []; +var chatDmChannels = []; +var chatDmLabels = {}; +var chatTeamsById = {}; +var chatUserNames = {}; +var chatPostsById = {}; +var chatCurrentChannelId = null; +var chatCurrentThreadRootId = null; +var chatSocket = null; +var chatReconnectTimer = null; +var chatRenderedPostIds = {}; +var chatThreadRenderedPostIds = {}; +var chatComposerFileIds = []; +var chatThreadComposerFileIds = []; +var chatUploadedFileNames = {}; +var chatFileBlobCache = {}; +var chatDmPickerSelected = {}; +var chatReactionPickerEl = null; + +function chatPanelOpen(id) { + var panel = document.getElementById(id); + var overlay = document.getElementById(id + '-overlay'); + if (overlay) overlay.style.display = 'block'; + if (panel) panel.style.transform = 'translateX(0)'; + document.body.style.overflow = 'hidden'; +} + +function chatPanelClose(id) { + var panel = document.getElementById(id); + var overlay = document.getElementById(id + '-overlay'); + if (overlay) overlay.style.display = 'none'; + if (panel) panel.style.transform = 'translateX(100%)'; + document.body.style.overflow = ''; +} + +function chatInit() { + var configEl = document.getElementById('chat-config-data'); + if (!configEl) return; + + if (chatSocket) { + chatSocket.onclose = null; + chatSocket.close(); + chatSocket = null; + } + if (chatReconnectTimer) { + clearTimeout(chatReconnectTimer); + chatReconnectTimer = null; + } + + chatConfig = JSON.parse(configEl.textContent); + var colleaguesEl = document.getElementById('chat-colleagues-data'); + chatColleagues = colleaguesEl ? JSON.parse(colleaguesEl.textContent) : []; + + chatChannels = []; + chatDmChannels = []; + chatDmLabels = {}; + chatTeamsById = {}; + chatUserNames = {}; + chatPostsById = {}; + chatCurrentChannelId = null; + chatCurrentThreadRootId = null; + chatComposerFileIds = []; + chatThreadComposerFileIds = []; + chatUploadedFileNames = {}; + chatFileBlobCache = {}; + chatDmPickerSelected = {}; + + chatLoadTeams().then(chatLoadChannels); + chatConnectSocket(); +} + +function chatApiFetch(path, options) { + options = options || {}; + options.headers = Object.assign({'Authorization': 'Bearer ' + chatConfig.token}, options.headers || {}); + return fetch(chatConfig.apiBase + path, options).then(function (res) { + if (!res.ok) throw new Error('Mattermost API ' + path + ' -> ' + res.status); + return res.json(); + }); +} + +function chatApiFetchBlob(path) { + return fetch(chatConfig.apiBase + path, {headers: {'Authorization': 'Bearer ' + chatConfig.token}}).then(function (res) { + if (!res.ok) throw new Error('Mattermost API ' + path + ' -> ' + res.status); + return res.blob(); + }); +} + +function chatResolveUserIds(ids) { + var missing = ids.filter(function (id) { return id && !chatUserNames[id]; }); + missing = missing.filter(function (id, i) { return missing.indexOf(id) === i; }); + if (!missing.length) return Promise.resolve(); + + return chatApiFetch('/users/ids', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(missing), + }).then(function (users) { + users.forEach(function (u) { + chatUserNames[u.id] = (u.first_name || u.last_name) + ? (u.first_name + ' ' + u.last_name).trim() + : u.username; + }); + }).catch(function (e) { console.error(e); }); +} + +function chatResolveUsernames(posts) { + var ids = posts.map(function (p) { return p.user_id; }); + return chatResolveUserIds(ids); +} + +function chatLoadTeams() { + return chatApiFetch('/users/me/teams').then(function (teams) { + teams.forEach(function (team) { chatTeamsById[team.id] = team; }); + }).catch(function (e) { console.error(e); }); +} + +function chatLoadChannels() { + return chatApiFetch('/users/me/channels').then(function (channels) { + chatChannels = channels + .filter(function (ch) { return ch.type === 'O' || ch.type === 'P'; }) + .sort(function (a, b) { return a.display_name.localeCompare(b.display_name); }); + chatDmChannels = channels.filter(function (ch) { return ch.type === 'D' || ch.type === 'G'; }); + chatRenderChannelList(); + chatRenderDmList(); + chatDmChannels.forEach(chatResolveDmLabel); + }).catch(function (e) { + console.error(e); + var el = document.getElementById('chat-channel-list'); + if (el) el.innerHTML = '
Не вдалося завантажити канали
'; + }); +} + +function chatResolveDmLabel(channel) { + var promise; + if (channel.type === 'D') { + var otherId = channel.name.split('__').filter(function (id) { return id !== chatConfig.currentUserId; })[0]; + promise = chatResolveUserIds([otherId]).then(function () { + return chatUserNames[otherId] || otherId; + }); + } else { + promise = chatApiFetch('/channels/' + channel.id + '/members').then(function (members) { + var ids = members.map(function (m) { return m.user_id; }).filter(function (id) { return id !== chatConfig.currentUserId; }); + return chatResolveUserIds(ids).then(function () { + return ids.map(function (id) { return chatUserNames[id] || id; }).join(', '); + }); + }); + } + return promise.then(function (label) { + chatDmLabels[channel.id] = label; + var labelEl = document.getElementById('chat-dm-label-' + channel.id); + if (labelEl) labelEl.textContent = label; + if (chatCurrentChannelId === channel.id) chatUpdateHeader(channel); + }).catch(function (e) { console.error(e); }); +} + +function chatRenderChannelList() { + var el = document.getElementById('chat-channel-list'); + if (!el) return; + + if (!chatChannels.length) { + el.innerHTML = '
Немає каналів
'; + return; + } + + el.innerHTML = ''; + chatChannels.forEach(function (ch) { + var team = chatTeamsById[ch.team_id]; + var row = chatBuildChannelRow(ch.id, '#' + ch.display_name, team ? team.display_name : ''); + el.appendChild(row); + }); +} + +function chatRenderDmList() { + var el = document.getElementById('chat-dm-list'); + if (!el) return; + + if (!chatDmChannels.length) { + el.innerHTML = '
Немає повідомлень
'; + return; + } + + el.innerHTML = ''; + chatDmChannels.forEach(function (ch) { + var row = chatBuildChannelRow(ch.id, chatDmLabels[ch.id] || '…', ch.type === 'G' ? 'Групова розмова' : ''); + el.appendChild(row); + }); +} + +function chatBuildChannelRow(channelId, label, subLabel) { + var row = document.createElement('div'); + row.id = 'chat-channel-row-' + channelId; + row.style.cssText = 'padding:0.5rem 0.625rem; border-bottom:1px solid var(--color-border); cursor:pointer;' + + (channelId === chatCurrentChannelId ? ' background:hsl(0 72% 44% / 0.06);' : ''); + row.onmouseover = function () { if (channelId !== chatCurrentChannelId) row.style.background = 'var(--color-muted)'; }; + row.onmouseout = function () { if (channelId !== chatCurrentChannelId) row.style.background = ''; }; + row.onclick = function () { chatSelectChannel(channelId); }; + + var name = document.createElement('div'); + name.id = 'chat-dm-label-' + channelId; + name.style.cssText = 'font-size:0.8125rem; font-weight:500; color:var(--color-foreground);'; + name.textContent = label; + row.appendChild(name); + + if (subLabel) { + var sub = document.createElement('div'); + sub.style.cssText = 'font-size:0.6875rem; color:var(--color-muted-foreground);'; + sub.textContent = subLabel; + row.appendChild(sub); + } + + return row; +} + +function chatFindChannel(channelId) { + return chatChannels.filter(function (c) { return c.id === channelId; })[0] + || chatDmChannels.filter(function (c) { return c.id === channelId; })[0]; +} + +function chatUpdateHeader(ch) { + var header = document.getElementById('chat-header'); + if (!header || !ch) return; + header.innerHTML = ''; + var title = document.createElement('span'); + title.style.cssText = 'font-size:0.875rem; font-weight:600; color:var(--color-foreground);'; + title.textContent = (ch.type === 'O' || ch.type === 'P') ? '#' + ch.display_name : (chatDmLabels[ch.id] || '…'); + header.appendChild(title); +} + +function chatSelectChannel(channelId) { + var prevRow = chatCurrentChannelId ? document.getElementById('chat-channel-row-' + chatCurrentChannelId) : null; + if (prevRow) prevRow.style.background = ''; + + chatCurrentChannelId = channelId; + chatPanelClose('chat-thread-panel'); + chatCurrentThreadRootId = null; + + var newRow = document.getElementById('chat-channel-row-' + channelId); + if (newRow) newRow.style.background = 'hsl(0 72% 44% / 0.06)'; + + chatUpdateHeader(chatFindChannel(channelId)); + + var input = document.getElementById('chat-composer-input'); + var send = document.getElementById('chat-composer-send'); + var attach = document.getElementById('chat-composer-attach'); + if (input) input.disabled = false; + if (send) send.disabled = false; + if (attach) attach.disabled = false; + + chatComposerFileIds = []; + chatRenderComposerFileChips('chat-composer-files', chatComposerFileIds); + + var messagesEl = document.getElementById('chat-messages'); + if (messagesEl) messagesEl.innerHTML = '
Завантаження…
'; + chatRenderedPostIds = {}; + + chatApiFetch('/channels/' + channelId + '/posts?per_page=30').then(function (data) { + var ids = (data.order || []).slice().reverse(); + var posts = ids.map(function (id) { return data.posts[id]; }).filter(function (p) { return !p.root_id; }); + return chatResolveUsernames(posts).then(function () { + if (chatCurrentChannelId !== channelId) return; + chatRenderMessages(posts, true); + }); + }).catch(function (e) { + console.error(e); + if (messagesEl) messagesEl.innerHTML = '
Не вдалося завантажити повідомлення
'; + }); +} + +function chatAppendPosts(containerEl, posts, renderedIds, context) { + if (!containerEl) return; + posts.forEach(function (post) { chatPostsById[post.id] = post; }); + + posts = posts.filter(function (post) { + if (renderedIds[post.id]) return false; + renderedIds[post.id] = true; + return true; + }); + + posts.forEach(function (post) { + containerEl.appendChild(chatBuildMessageEl(post, context)); + }); + + containerEl.scrollTop = containerEl.scrollHeight; +} + +function chatRenderMessages(posts, replace) { + var el = document.getElementById('chat-messages'); + if (!el) return; + if (replace) el.innerHTML = ''; + chatAppendPosts(el, posts, chatRenderedPostIds, 'feed'); +} + +function chatRenderThreadMessages(posts, replace) { + var el = document.getElementById('chat-thread-messages'); + if (!el) return; + if (replace) el.innerHTML = ''; + chatAppendPosts(el, posts, chatThreadRenderedPostIds, 'thread'); +} + +function chatBuildMessageEl(post, context) { + var isMine = post.user_id === chatConfig.currentUserId; + var wrap = document.createElement('div'); + wrap.id = 'chat-message-' + context + '-' + post.id; + wrap.style.cssText = 'display:flex; gap:0.625rem; align-items:flex-start;' + (isMine ? ' flex-direction:row-reverse;' : ''); + + var avatar = document.createElement('div'); + avatar.style.cssText = 'width:2rem; height:2rem; border-radius:50%; background:hsl(205 80% 55% / 0.15); color:hsl(205 80% 45%); display:flex; align-items:center; justify-content:center; font-size:0.625rem; font-weight:700; flex-shrink:0;'; + var senderName = chatUserNames[post.user_id] || post.user_id; + avatar.textContent = senderName.slice(0, 2).toUpperCase(); + wrap.appendChild(avatar); + + var body = document.createElement('div'); + body.style.cssText = (isMine ? 'align-items:flex-end;' : 'align-items:flex-start;') + ' display:flex; flex-direction:column; max-width:28rem;'; + + var meta = document.createElement('div'); + meta.style.cssText = 'display:flex; align-items:baseline; gap:0.5rem; margin-bottom:0.25rem;' + (isMine ? ' flex-direction:row-reverse;' : ''); + var nameEl = document.createElement('span'); + nameEl.style.cssText = 'font-size:0.8125rem; font-weight:600; color:var(--color-foreground);'; + nameEl.textContent = isMine ? 'Ви' : senderName; + var timeEl = document.createElement('span'); + timeEl.style.cssText = 'font-size:0.6875rem; color:var(--color-muted-foreground);'; + timeEl.textContent = post.create_at ? new Date(post.create_at).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'}) : ''; + meta.appendChild(nameEl); + meta.appendChild(timeEl); + body.appendChild(meta); + + if (post.message) { + var bubble = document.createElement('div'); + bubble.style.cssText = isMine + ? 'background:var(--color-primary); color:white; border-radius:0.75rem 0 0.75rem 0.75rem; padding:0.625rem 0.875rem; font-size:0.8125rem; white-space:pre-wrap; word-break:break-word;' + : 'background:var(--color-muted); border-radius:0 0.75rem 0.75rem 0.75rem; padding:0.625rem 0.875rem; font-size:0.8125rem; color:var(--color-foreground); white-space:pre-wrap; word-break:break-word;'; + bubble.textContent = post.message; + body.appendChild(bubble); + } + + var files = post.metadata && post.metadata.files; + if (files && files.length) { + var attachments = document.createElement('div'); + attachments.style.cssText = 'display:flex; flex-wrap:wrap; gap:0.375rem; margin-top:0.375rem;'; + chatRenderAttachments(attachments, files); + body.appendChild(attachments); + } + + var reactions = document.createElement('div'); + reactions.id = 'chat-reactions-' + context + '-' + post.id; + reactions.style.cssText = 'display:flex; flex-wrap:wrap; gap:0.25rem; margin-top:0.375rem;'; + chatRenderReactions(reactions, post); + body.appendChild(reactions); + + var actions = document.createElement('div'); + actions.style.cssText = 'display:flex; gap:0.625rem; margin-top:0.25rem;'; + + var reactBtn = document.createElement('button'); + reactBtn.type = 'button'; + reactBtn.style.cssText = 'background:none; border:none; cursor:pointer; font-size:0.6875rem; color:var(--color-muted-foreground); padding:0;'; + reactBtn.textContent = '🙂 Реакція'; + reactBtn.onclick = function (e) { chatToggleReactionPicker(e, post, context); }; + actions.appendChild(reactBtn); + + if (context === 'feed') { + var replyBtn = document.createElement('button'); + replyBtn.type = 'button'; + replyBtn.id = 'chat-reply-count-' + post.id; + replyBtn.style.cssText = 'background:none; border:none; cursor:pointer; font-size:0.6875rem; color:var(--color-primary); padding:0;'; + replyBtn.textContent = post.reply_count ? ('💬 ' + post.reply_count + ' відповідей') : '↩ Відповісти'; + replyBtn.onclick = function () { chatOpenThread(post.id); }; + actions.appendChild(replyBtn); + } + + body.appendChild(actions); + wrap.appendChild(body); + return wrap; +} + +function chatRenderAttachments(container, files) { + files.forEach(function (file) { + if (file.mime_type && file.mime_type.indexOf('image/') === 0) { + var img = document.createElement('img'); + img.style.cssText = 'max-width:10rem; max-height:8rem; border-radius:0.5rem; cursor:pointer; display:block;'; + img.alt = file.name; + chatFetchFileBlobUrl(file.id, 'thumbnail').then(function (url) { img.src = url; }); + img.onclick = function () { + chatFetchFileBlobUrl(file.id, '').then(function (url) { window.open(url, '_blank'); }); + }; + container.appendChild(img); + } else { + var chip = document.createElement('a'); + chip.style.cssText = 'display:inline-flex; align-items:center; gap:0.375rem; background:var(--color-muted); border-radius:0.5rem; padding:0.375rem 0.625rem; font-size:0.75rem; color:var(--color-foreground); text-decoration:none; cursor:pointer;'; + chip.textContent = '📎 ' + file.name; + chip.onclick = function (e) { + e.preventDefault(); + chatFetchFileBlobUrl(file.id, '').then(function (url) { + var a = document.createElement('a'); + a.href = url; + a.download = file.name; + a.click(); + }); + }; + container.appendChild(chip); + } + }); +} + +function chatFetchFileBlobUrl(fileId, variant) { + var key = fileId + ':' + variant; + if (chatFileBlobCache[key]) return Promise.resolve(chatFileBlobCache[key]); + var path = '/files/' + fileId + (variant ? '/' + variant : ''); + return chatApiFetchBlob(path).then(function (blob) { + var url = URL.createObjectURL(blob); + chatFileBlobCache[key] = url; + return url; + }); +} + +function chatRenderReactions(container, post) { + container.innerHTML = ''; + var reactions = (post.metadata && post.metadata.reactions) || []; + if (!reactions.length) return; + + var byEmoji = {}; + reactions.forEach(function (r) { + if (!byEmoji[r.emoji_name]) byEmoji[r.emoji_name] = []; + byEmoji[r.emoji_name].push(r); + }); + + Object.keys(byEmoji).forEach(function (emojiName) { + var list = byEmoji[emojiName]; + var mine = list.some(function (r) { return r.user_id === chatConfig.currentUserId; }); + var quick = CHAT_QUICK_EMOJIS.filter(function (q) { return q[1] === emojiName; })[0]; + var pill = document.createElement('button'); + pill.type = 'button'; + pill.style.cssText = 'display:inline-flex; align-items:center; gap:0.25rem; border-radius:999px; padding:0.0625rem 0.5rem; font-size:0.75rem; cursor:pointer; border:1px solid ' + + (mine ? 'var(--color-primary)' : 'var(--color-border)') + '; background:' + (mine ? 'hsl(0 72% 44% / 0.08)' : 'var(--color-muted)') + '; color:var(--color-foreground);'; + pill.textContent = (quick ? quick[0] : ':' + emojiName + ':') + ' ' + list.length; + pill.onclick = function () { chatToggleReaction(post, emojiName); }; + container.appendChild(pill); + }); +} + +function chatToggleReactionPicker(event, post, context) { + event.stopPropagation(); + if (chatReactionPickerEl) { + chatReactionPickerEl.remove(); + chatReactionPickerEl = null; + return; + } + + var picker = document.createElement('div'); + picker.id = 'chat-reaction-picker'; + picker.style.cssText = 'position:fixed; z-index:2000; top:0; left:0; background:var(--color-card); border:1px solid var(--color-border); border-radius:0.625rem; box-shadow:0 4px 16px rgba(0,0,0,0.15); padding:0.375rem; display:flex; gap:0.25rem;'; + + CHAT_QUICK_EMOJIS.forEach(function (pair) { + var btn = document.createElement('button'); + btn.type = 'button'; + btn.style.cssText = 'background:none; border:none; cursor:pointer; font-size:1.125rem; padding:0.125rem;'; + btn.textContent = pair[0]; + btn.onclick = function () { + chatToggleReaction(post, pair[1]); + picker.remove(); + chatReactionPickerEl = null; + }; + picker.appendChild(btn); + }); + + document.body.appendChild(picker); + chatReactionPickerEl = picker; + + var rect = event.currentTarget.getBoundingClientRect(); + var pickerRect = picker.getBoundingClientRect(); + var left = Math.min(rect.left, window.innerWidth - pickerRect.width - 8); + var top = rect.bottom + 4; + if (top + pickerRect.height > window.innerHeight) top = rect.top - pickerRect.height - 4; + picker.style.left = Math.max(8, left) + 'px'; + picker.style.top = Math.max(8, top) + 'px'; + + setTimeout(function () { + document.addEventListener('click', function closePicker() { + if (chatReactionPickerEl) { chatReactionPickerEl.remove(); chatReactionPickerEl = null; } + document.removeEventListener('click', closePicker); + }, {once: true}); + }, 0); +} + +function chatToggleReaction(post, emojiName) { + var cached = chatPostsById[post.id] || post; + if (!cached.metadata) cached.metadata = {}; + if (!cached.metadata.reactions) cached.metadata.reactions = []; + + var existing = cached.metadata.reactions.filter(function (r) { + return r.user_id === chatConfig.currentUserId && r.emoji_name === emojiName; + })[0]; + + var request; + if (existing) { + cached.metadata.reactions = cached.metadata.reactions.filter(function (r) { return r !== existing; }); + request = chatApiFetch('/users/' + chatConfig.currentUserId + '/posts/' + post.id + '/reactions/' + emojiName, {method: 'DELETE'}); + } else { + cached.metadata.reactions.push({user_id: chatConfig.currentUserId, post_id: post.id, emoji_name: emojiName}); + request = chatApiFetch('/reactions', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({user_id: chatConfig.currentUserId, post_id: post.id, emoji_name: emojiName}), + }); + } + + chatRerenderReactions(post.id); + request.catch(function (e) { console.error(e); }); +} + +function chatRerenderReactions(postId) { + var post = chatPostsById[postId]; + if (!post) return; + ['feed', 'thread'].forEach(function (context) { + var el = document.getElementById('chat-reactions-' + context + '-' + postId); + if (el) chatRenderReactions(el, post); + }); +} + +function chatOpenThread(postId) { + chatCurrentThreadRootId = postId; + chatThreadRenderedPostIds = {}; + chatThreadComposerFileIds = []; + chatRenderComposerFileChips('chat-thread-composer-files', chatThreadComposerFileIds); + document.getElementById('chat-thread-composer-input').value = ''; + + var el = document.getElementById('chat-thread-messages'); + if (el) el.innerHTML = '
Завантаження…
'; + + chatApiFetch('/posts/' + postId + '/thread?perPage=60').then(function (data) { + var ids = (data.order || []).slice().reverse(); + var posts = ids.map(function (id) { return data.posts[id]; }); + return chatResolveUsernames(posts).then(function () { + if (chatCurrentThreadRootId !== postId) return; + chatRenderThreadMessages(posts, true); + chatPanelOpen('chat-thread-panel'); + }); + }).catch(function (e) { + console.error(e); + if (el) el.innerHTML = '
Не вдалося завантажити тред
'; + }); +} + +function chatThreadSendReply() { + var input = document.getElementById('chat-thread-composer-input'); + var rootId = chatCurrentThreadRootId; + if (!input || !rootId) return; + var message = input.value.trim(); + if (!message) return; + + var rootPost = chatPostsById[rootId]; + if (!rootPost) return; + + input.disabled = true; + chatApiFetch('/posts', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({channel_id: rootPost.channel_id, message: message, root_id: rootId, file_ids: chatThreadComposerFileIds}), + }).then(function (post) { + input.value = ''; + chatThreadComposerFileIds = []; + chatRenderComposerFileChips('chat-thread-composer-files', chatThreadComposerFileIds); + if (chatCurrentThreadRootId === rootId) chatRenderThreadMessages([post], false); + chatBumpReplyCount(rootId); + }).catch(function (e) { + console.error(e); + }).finally(function () { + input.disabled = false; + input.focus(); + }); +} + +function chatBumpReplyCount(rootId) { + var root = chatPostsById[rootId]; + if (!root) return; + root.reply_count = (root.reply_count || 0) + 1; + var btn = document.getElementById('chat-reply-count-' + rootId); + if (btn) btn.textContent = '💬 ' + root.reply_count + ' відповідей'; +} + +function chatSendMessage() { + var input = document.getElementById('chat-composer-input'); + if (!input || !chatCurrentChannelId) return; + var message = input.value.trim(); + if (!message) return; + + var channelId = chatCurrentChannelId; + var fileIds = chatComposerFileIds.slice(); + input.disabled = true; + chatApiFetch('/posts', { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({channel_id: channelId, message: message, file_ids: fileIds}), + }).then(function (post) { + input.value = ''; + chatComposerFileIds = []; + chatRenderComposerFileChips('chat-composer-files', chatComposerFileIds); + if (chatCurrentChannelId === channelId) chatRenderMessages([post], false); + }).catch(function (e) { + console.error(e); + }).finally(function () { + input.disabled = false; + input.focus(); + }); +} + +function chatUploadFiles(fileList, channelId) { + var uploads = Array.prototype.map.call(fileList, function (file) { + var formData = new FormData(); + formData.append('files', file); + formData.append('channel_id', channelId); + return chatApiFetch('/files', {method: 'POST', body: formData}).then(function (data) { + return data.file_infos || []; + }); + }); + return Promise.all(uploads).then(function (results) { + var infos = [].concat.apply([], results); + infos.forEach(function (info) { chatUploadedFileNames[info.id] = info.name; }); + return infos.map(function (info) { return info.id; }); + }); +} + +function chatComposerFilesSelected(inputEl) { + if (!chatCurrentChannelId || !inputEl.files.length) return; + chatUploadFiles(inputEl.files, chatCurrentChannelId).then(function (ids) { + chatComposerFileIds = chatComposerFileIds.concat(ids); + chatRenderComposerFileChips('chat-composer-files', chatComposerFileIds); + }).catch(function (e) { console.error(e); }); + inputEl.value = ''; +} + +function chatThreadComposerFilesSelected(inputEl) { + var rootPost = chatPostsById[chatCurrentThreadRootId]; + if (!rootPost || !inputEl.files.length) return; + chatUploadFiles(inputEl.files, rootPost.channel_id).then(function (ids) { + chatThreadComposerFileIds = chatThreadComposerFileIds.concat(ids); + chatRenderComposerFileChips('chat-thread-composer-files', chatThreadComposerFileIds); + }).catch(function (e) { console.error(e); }); + inputEl.value = ''; +} + +function chatRenderComposerFileChips(containerId, fileIds) { + var container = document.getElementById(containerId); + if (!container) return; + container.innerHTML = ''; + container.style.display = fileIds.length ? 'flex' : 'none'; + + fileIds.forEach(function (fileId) { + var chip = document.createElement('span'); + chip.style.cssText = 'display:inline-flex; align-items:center; gap:0.375rem; background:var(--color-muted); border-radius:0.5rem; padding:0.25rem 0.5rem; font-size:0.75rem; color:var(--color-foreground); margin:0.375rem 0;'; + chip.textContent = '📎 ' + (chatUploadedFileNames[fileId] || fileId); + + var remove = document.createElement('button'); + remove.type = 'button'; + remove.style.cssText = 'background:none; border:none; cursor:pointer; color:var(--color-muted-foreground); font-size:0.75rem; padding:0 0 0 0.25rem;'; + remove.textContent = '✕'; + remove.onclick = function () { + var idx = fileIds.indexOf(fileId); + if (idx !== -1) fileIds.splice(idx, 1); + chatRenderComposerFileChips(containerId, fileIds); + }; + chip.appendChild(remove); + container.appendChild(chip); + }); +} + +function chatOpenNewDmPicker() { + chatDmPickerSelected = {}; + var search = document.getElementById('chat-dm-picker-search'); + if (search) search.value = ''; + chatRenderDmPickerList(); + chatPanelOpen('chat-dm-picker-panel'); +} + +function chatRenderDmPickerList() { + var container = document.getElementById('chat-dm-picker-list'); + if (!container) return; + var search = (document.getElementById('chat-dm-picker-search').value || '').toLowerCase(); + + var filtered = chatColleagues.filter(function (c) { return c.name.toLowerCase().indexOf(search) !== -1; }); + + container.innerHTML = ''; + if (!filtered.length) { + container.innerHTML = '
Нікого не знайдено
'; + return; + } + + filtered.forEach(function (c) { + var row = document.createElement('label'); + row.style.cssText = 'display:flex; align-items:center; gap:0.5rem; padding:0.5rem 0.625rem; border-radius:0.5rem; cursor:pointer; font-size:0.8125rem; color:var(--color-foreground);'; + var checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.checked = !!chatDmPickerSelected[c.mattermostId]; + checkbox.onchange = function () { + if (checkbox.checked) { + if (Object.keys(chatDmPickerSelected).length >= 7) { + checkbox.checked = false; + return; + } + chatDmPickerSelected[c.mattermostId] = c.name; + } else { + delete chatDmPickerSelected[c.mattermostId]; + } + }; + row.appendChild(checkbox); + var name = document.createElement('span'); + name.textContent = c.name; + row.appendChild(name); + container.appendChild(row); + }); +} + +function chatStartNewDm() { + var selectedIds = Object.keys(chatDmPickerSelected); + if (!selectedIds.length) return; + + var path = selectedIds.length === 1 ? '/channels/direct' : '/channels/group'; + var body = selectedIds.length === 1 + ? [chatConfig.currentUserId, selectedIds[0]] + : [chatConfig.currentUserId].concat(selectedIds); + + chatApiFetch(path, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify(body), + }).then(function (channel) { + if (!chatDmChannels.some(function (c) { return c.id === channel.id; })) { + chatDmChannels.push(channel); + } + chatDmLabels[channel.id] = selectedIds.length === 1 + ? chatDmPickerSelected[selectedIds[0]] + : selectedIds.map(function (id) { return chatDmPickerSelected[id]; }).join(', '); + chatRenderDmList(); + chatPanelClose('chat-dm-picker-panel'); + chatSelectChannel(channel.id); + }).catch(function (e) { console.error(e); }); +} + +function chatConnectSocket() { + if (!chatConfig) return; + var socket = new WebSocket(chatConfig.wsUrl); + chatSocket = socket; + + socket.onopen = function () { + socket.send(JSON.stringify({seq: 1, action: 'authentication_challenge', data: {token: chatConfig.token}})); + }; + + socket.onmessage = function (evt) { + var msg; + try { msg = JSON.parse(evt.data); } catch (e) { return; } + if (msg.event === 'posted') return chatHandleWsPosted(msg); + if (msg.event === 'reaction_added' || msg.event === 'reaction_removed') return chatHandleWsReaction(msg); + }; + + socket.onclose = function () { + if (!document.getElementById('chat-app')) return; + chatReconnectTimer = setTimeout(chatConnectSocket, 3000); + }; + + socket.onerror = function () { + socket.close(); + }; +} + +function chatHandleWsPosted(msg) { + if (!msg.data || !msg.data.post) return; + var post; + try { post = JSON.parse(msg.data.post); } catch (e) { return; } + + if (post.channel_id !== chatCurrentChannelId) return; + + if (!post.root_id) { + chatResolveUsernames([post]).then(function () { chatRenderMessages([post], false); }); + return; + } + + chatResolveUsernames([post]).then(function () { + chatPostsById[post.id] = post; + if (chatCurrentThreadRootId === post.root_id) { + chatRenderThreadMessages([post], false); + } else { + chatBumpReplyCount(post.root_id); + } + }); +} + +function chatHandleWsReaction(msg) { + if (!msg.data || !msg.data.reaction) return; + var reaction; + try { reaction = JSON.parse(msg.data.reaction); } catch (e) { return; } + + var post = chatPostsById[reaction.post_id]; + if (!post) return; + if (!post.metadata) post.metadata = {}; + if (!post.metadata.reactions) post.metadata.reactions = []; + + var exists = post.metadata.reactions.some(function (r) { + return r.user_id === reaction.user_id && r.emoji_name === reaction.emoji_name; + }); + + if (msg.event === 'reaction_added' && !exists) { + post.metadata.reactions.push(reaction); + } else if (msg.event === 'reaction_removed') { + post.metadata.reactions = post.metadata.reactions.filter(function (r) { + return !(r.user_id === reaction.user_id && r.emoji_name === reaction.emoji_name); + }); + } + + chatRerenderReactions(reaction.post_id); +} + +document.addEventListener('keydown', function (e) { + if (!e.target || e.key !== 'Enter' || e.shiftKey) return; + if (e.target.id === 'chat-composer-input') { + e.preventDefault(); + chatSendMessage(); + } else if (e.target.id === 'chat-thread-composer-input') { + e.preventDefault(); + chatThreadSendReply(); + } +}); diff --git a/static/assets/js/directory-quick-add.js b/static/assets/js/directory-quick-add.js new file mode 100644 index 0000000..f27ffeb --- /dev/null +++ b/static/assets/js/directory-quick-add.js @@ -0,0 +1,48 @@ +/** + * "Додати новий..." прямо в — + * він лишається джерелом істини для відправки форми, віджет лише малює UI. + * + * Розмітка (одна секція): + *
+ * + *
+ * Дерево відділів передається один раз на сторінку через + * {{ department_tree|json_script:"department-tree-data" }}. + * + * Використання: initPeoplePickers(scopeEl) після вставки розмітки в DOM + * (в т.ч. після HTMX-свапу фрагмента). + */ +(function () { + function options(select) { + return Array.prototype.slice.call(select.options); + } + + function initialsOf(label) { + var parts = (label || '').trim().split(/\s+/).filter(Boolean); + return parts.slice(0, 2).map(function (w) { return w[0]; }).join('').toUpperCase(); + } + + function buildAvatar(photo, label, size) { + size = size || '1.75rem'; + if (photo) { + var img = document.createElement('img'); + img.src = photo; + img.className = 'people-picker-avatar'; + img.style.width = size; + img.style.height = size; + return img; + } + var span = document.createElement('span'); + span.className = 'people-picker-avatar people-picker-avatar-fallback'; + span.style.width = size; + span.style.height = size; + span.textContent = initialsOf(label); + return span; + } + + function findOption(select, pk) { + return options(select).filter(function (o) { return o.value === String(pk); })[0]; + } + + function renderChips(picker) { + var select = picker._select; + var chips = picker._chips; + chips.innerHTML = ''; + options(select).filter(function (o) { return o.selected; }).forEach(function (opt) { + var chip = document.createElement('span'); + chip.className = 'people-picker-chip'; + chip.textContent = opt.dataset.label || opt.textContent; + var remove = document.createElement('button'); + remove.type = 'button'; + remove.className = 'people-picker-chip-remove'; + remove.innerHTML = '×'; + remove.setAttribute('aria-label', 'Видалити'); + remove.addEventListener('click', function () { + opt.selected = false; + select.dispatchEvent(new Event('change')); + renderChips(picker); + if (picker._refreshTree) picker._refreshTree(); + }); + chip.appendChild(remove); + chips.appendChild(chip); + }); + } + + function toggleOption(picker, pk) { + var opt = findOption(picker._select, pk); + if (!opt) return; + opt.selected = !opt.selected; + picker._select.dispatchEvent(new Event('change')); + renderChips(picker); + if (picker._refreshTree) picker._refreshTree(); + } + + function renderSearchResults(picker, query) { + var select = picker._select; + var results = picker._results; + var q = query.trim().toLowerCase(); + var matches = options(select).filter(function (o) { + if (o.selected) return false; + var label = (o.dataset.label || o.textContent).toLowerCase(); + return label.indexOf(q) !== -1; + }).slice(0, 30); + + results.innerHTML = ''; + matches.forEach(function (opt) { + var row = document.createElement('div'); + row.className = 'people-picker-row'; + row.appendChild(buildAvatar(opt.dataset.photo, opt.dataset.label)); + var info = document.createElement('div'); + info.className = 'people-picker-row-info'; + var name = document.createElement('div'); + name.className = 'people-picker-row-name'; + name.textContent = opt.dataset.label || opt.textContent; + info.appendChild(name); + if (opt.dataset.position) { + var pos = document.createElement('div'); + pos.className = 'people-picker-row-position'; + pos.textContent = opt.dataset.position; + info.appendChild(pos); + } + row.appendChild(info); + row.addEventListener('mousedown', function (e) { + e.preventDefault(); + toggleOption(picker, opt.value); + picker._search.value = ''; + showTree(picker); + }); + results.appendChild(row); + }); + } + + function renderEmployeeRow(picker, emp, container) { + var opt = findOption(picker._select, emp.pk); + var row = document.createElement('div'); + row.className = 'people-picker-row'; + if (opt && opt.selected) row.classList.add('is-selected'); + row.appendChild(buildAvatar(emp.photo, emp.label)); + var info = document.createElement('div'); + info.className = 'people-picker-row-info'; + var name = document.createElement('div'); + name.className = 'people-picker-row-name'; + name.textContent = emp.label; + info.appendChild(name); + if (emp.position) { + var pos = document.createElement('div'); + pos.className = 'people-picker-row-position'; + pos.textContent = emp.position; + info.appendChild(pos); + } + row.appendChild(info); + if (opt && opt.selected) { + var check = document.createElement('span'); + check.className = 'people-picker-row-check'; + check.innerHTML = '✓'; + row.appendChild(check); + } + row.addEventListener('mousedown', function (e) { + e.preventDefault(); + toggleOption(picker, emp.pk); + row.classList.toggle('is-selected'); + var existingCheck = row.querySelector('.people-picker-row-check'); + if (row.classList.contains('is-selected') && !existingCheck) { + var c = document.createElement('span'); + c.className = 'people-picker-row-check'; + c.innerHTML = '✓'; + row.appendChild(c); + } else if (!row.classList.contains('is-selected') && existingCheck) { + existingCheck.remove(); + } + }); + container.appendChild(row); + } + + function renderDeptNode(picker, node, container, depth, expanded) { + var row = document.createElement('div'); + row.className = 'people-picker-dept'; + row.style.paddingLeft = (depth * 0.875) + 'rem'; + + var toggle = document.createElement('span'); + toggle.className = 'people-picker-dept-toggle'; + toggle.textContent = expanded ? '▾' : '▸'; + row.appendChild(toggle); + + var folder = document.createElement('span'); + folder.className = 'people-picker-dept-icon'; + folder.innerHTML = '📁'; + row.appendChild(folder); + + var label = document.createElement('span'); + label.className = 'people-picker-dept-label'; + label.textContent = node.name; + row.appendChild(label); + + container.appendChild(row); + + var childrenWrap = document.createElement('div'); + childrenWrap.style.display = expanded ? 'block' : 'none'; + container.appendChild(childrenWrap); + + var built = false; + function build() { + if (built) return; + node.employees.forEach(function (emp) { renderEmployeeRow(picker, emp, childrenWrap); }); + node.children.forEach(function (child) { renderDeptNode(picker, child, childrenWrap, depth + 1, false); }); + built = true; + } + if (expanded) build(); + + row.addEventListener('click', function () { + build(); + var isOpen = childrenWrap.style.display !== 'none'; + childrenWrap.style.display = isOpen ? 'none' : 'block'; + toggle.textContent = isOpen ? '▸' : '▾'; + }); + } + + function showTree(picker) { + picker._search.value = ''; + picker._results.style.display = 'none'; + picker._results.innerHTML = ''; + picker._tree.style.display = 'block'; + if (picker._refreshTree) picker._refreshTree(); + } + + function buildTreeView(picker, treeData) { + var tree = picker._tree; + + function draw() { + tree.innerHTML = ''; + treeData.tree.forEach(function (node, i) { + renderDeptNode(picker, node, tree, 0, true); + }); + if (treeData.no_department && treeData.no_department.length) { + var wrap = document.createElement('div'); + var header = document.createElement('div'); + header.className = 'people-picker-dept-label'; + header.style.padding = '0.375rem 0.5rem'; + header.style.opacity = '0.7'; + header.textContent = 'Без відділу'; + wrap.appendChild(header); + treeData.no_department.forEach(function (emp) { renderEmployeeRow(picker, emp, wrap); }); + tree.appendChild(wrap); + } + } + picker._refreshTree = draw; + draw(); + } + + function loadTreeData(picker) { + var sourceId = picker.dataset.treeSource; + if (!sourceId) return null; + var el = document.getElementById(sourceId); + if (!el) return null; + try { + return JSON.parse(el.textContent); + } catch (e) { + return null; + } + } + + function build(picker) { + var select = picker.querySelector('select[multiple]'); + if (!select) return; + picker._select = select; + + var wrap = document.createElement('div'); + wrap.className = 'people-picker-ui'; + + var chips = document.createElement('div'); + chips.className = 'people-picker-chips'; + picker._chips = chips; + + var search = document.createElement('input'); + search.type = 'text'; + search.className = 'input people-picker-search'; + search.placeholder = picker.dataset.placeholder || '+ Додати...'; + picker._search = search; + + var dropdown = document.createElement('div'); + dropdown.className = 'people-picker-dropdown'; + dropdown.style.display = 'none'; + + var results = document.createElement('div'); + results.className = 'people-picker-results'; + results.style.display = 'none'; + picker._results = results; + + var tree = document.createElement('div'); + tree.className = 'people-picker-tree'; + picker._tree = tree; + + dropdown.appendChild(results); + dropdown.appendChild(tree); + picker._dropdown = dropdown; + + var treeData = loadTreeData(picker); + if (treeData) buildTreeView(picker, treeData); + + function openDropdown() { + dropdown.style.display = 'block'; + if (search.value.trim()) { + results.style.display = 'block'; + tree.style.display = 'none'; + renderSearchResults(picker, search.value); + } else { + showTree(picker); + } + } + + search.addEventListener('focus', openDropdown); + search.addEventListener('input', function () { + if (search.value.trim()) { + results.style.display = 'block'; + tree.style.display = 'none'; + renderSearchResults(picker, search.value); + } else { + showTree(picker); + } + }); + search.addEventListener('keydown', function (e) { + if (e.key === 'Backspace' && !search.value) { + var selected = options(select).filter(function (o) { return o.selected; }); + var last = selected[selected.length - 1]; + if (last) { + last.selected = false; + select.dispatchEvent(new Event('change')); + renderChips(picker); + if (picker._refreshTree) picker._refreshTree(); + } + } else if (e.key === 'Escape') { + dropdown.style.display = 'none'; + search.blur(); + } + }); + document.addEventListener('click', function (e) { + if (!picker.contains(e.target)) dropdown.style.display = 'none'; + }); + + wrap.appendChild(chips); + wrap.appendChild(search); + picker.appendChild(wrap); + picker.appendChild(dropdown); + + renderChips(picker); + } + + window.initPeoplePickers = function (scope) { + (scope || document).querySelectorAll('.people-picker:not([data-picker-init])').forEach(function (picker) { + picker.setAttribute('data-picker-init', '1'); + build(picker); + }); + }; +})(); diff --git a/static/assets/js/searchable-select.js b/static/assets/js/searchable-select.js new file mode 100644 index 0000000..52cf52f --- /dev/null +++ b/static/assets/js/searchable-select.js @@ -0,0 +1,161 @@ +/** + * Одиночний select із пошуком по введенню тексту — заміна звичайного + * + * + * + * + * + * + * Використання: initSearchableSelects(scopeEl) після вставки розмітки + * в DOM (в т.ч. після HTMX-свапу фрагмента). + */ +(function () { + function options(select) { + return Array.prototype.slice.call(select.options).filter(function (o) { return o.value; }); + } + + function currentLabel(select) { + var opt = select.options[select.selectedIndex]; + return opt && opt.value ? opt.textContent : ''; + } + + function renderResults(widget, query) { + var select = widget._select; + var results = widget._results; + var q = query.trim().toLowerCase(); + var matches = options(select).filter(function (o) { + return !q || o.textContent.toLowerCase().indexOf(q) !== -1; + }).slice(0, 50); + + results.innerHTML = ''; + matches.forEach(function (opt) { + var row = document.createElement('div'); + row.className = 'people-picker-row'; + if (opt.value === select.value) row.classList.add('is-selected'); + var info = document.createElement('div'); + info.className = 'people-picker-row-info'; + var name = document.createElement('div'); + name.className = 'people-picker-row-name'; + name.textContent = opt.textContent; + info.appendChild(name); + row.appendChild(info); + row.addEventListener('mousedown', function (e) { + e.preventDefault(); + select.value = opt.value; + select.dispatchEvent(new Event('change')); + widget._search.value = opt.textContent; + widget._dropdown.style.display = 'none'; + updateClearButton(widget); + }); + results.appendChild(row); + }); + if (!matches.length) { + var empty = document.createElement('div'); + empty.className = 'people-picker-row'; + empty.style.cursor = 'default'; + empty.style.color = 'var(--color-muted-foreground)'; + empty.textContent = 'Нічого не знайдено'; + results.appendChild(empty); + } + } + + function updateClearButton(widget) { + widget._clear.style.display = widget._select.value ? 'flex' : 'none'; + } + + function build(widget) { + var select = widget.querySelector('select'); + if (!select) return; + widget._select = select; + + var searchWrap = document.createElement('div'); + searchWrap.style.position = 'relative'; + + var search = document.createElement('input'); + search.type = 'text'; + search.className = 'input searchable-select-search'; + search.placeholder = widget.dataset.placeholder || 'Пошук...'; + search.value = currentLabel(select); + search.autocomplete = 'off'; + // required на hidden-select браузер ігнорує (barred from constraint + // validation) — переносимо на видимий інпут, щоб підказка лишилась + if (select.required) search.required = true; + widget._search = search; + + var clearBtn = document.createElement('button'); + clearBtn.type = 'button'; + clearBtn.className = 'searchable-select-clear'; + clearBtn.innerHTML = '×'; + clearBtn.style.display = select.value ? 'flex' : 'none'; + clearBtn.setAttribute('aria-label', 'Очистити'); + clearBtn.addEventListener('mousedown', function (e) { + e.preventDefault(); + select.value = ''; + select.dispatchEvent(new Event('change')); + search.value = ''; + clearBtn.style.display = 'none'; + search.focus(); + }); + widget._clear = clearBtn; + + var dropdown = document.createElement('div'); + dropdown.className = 'people-picker-dropdown'; + dropdown.style.display = 'none'; + widget._dropdown = dropdown; + + var results = document.createElement('div'); + results.className = 'people-picker-results'; + dropdown.appendChild(results); + widget._results = results; + + function open() { + dropdown.style.display = 'block'; + renderResults(widget, search.value === currentLabel(select) ? '' : search.value); + } + + search.addEventListener('focus', function () { + search.select(); + open(); + }); + search.addEventListener('input', function () { + renderResults(widget, search.value); + dropdown.style.display = 'block'; + }); + search.addEventListener('keydown', function (e) { + if (e.key === 'Escape') { + dropdown.style.display = 'none'; + search.value = currentLabel(select); + search.blur(); + } + }); + search.addEventListener('blur', function () { + // Якщо користувач не обрав опцію — повертаємо текст обраного значення + setTimeout(function () { + if (dropdown.style.display === 'none') search.value = currentLabel(select); + }, 150); + }); + document.addEventListener('click', function (e) { + if (!widget.contains(e.target)) { + dropdown.style.display = 'none'; + search.value = currentLabel(select); + } + }); + + searchWrap.appendChild(search); + searchWrap.appendChild(clearBtn); + searchWrap.appendChild(dropdown); + widget.appendChild(searchWrap); + } + + window.initSearchableSelects = function (scope) { + (scope || document).querySelectorAll('.searchable-select:not([data-searchable-init])').forEach(function (widget) { + widget.setAttribute('data-searchable-init', '1'); + build(widget); + }); + }; +})(); diff --git a/static/assets/js/structure.js b/static/assets/js/structure.js deleted file mode 100644 index 33ea6f9..0000000 --- a/static/assets/js/structure.js +++ /dev/null @@ -1,15 +0,0 @@ -function switchTab(tab) { - var isOffice = tab === 'office'; - var secOffice = document.getElementById('section-office'); - var secPharma = document.getElementById('section-pharma'); - if (!secOffice) return; - secOffice.style.display = isOffice ? '' : 'none'; - secPharma.style.display = isOffice ? 'none' : ''; - - var tabOffice = document.getElementById('tab-office'); - var tabPharma = document.getElementById('tab-pharma'); - tabOffice.style.background = isOffice ? 'var(--color-card)' : 'transparent'; - tabOffice.style.color = isOffice ? 'var(--color-foreground)' : 'var(--color-muted-foreground)'; - tabPharma.style.background = isOffice ? 'transparent' : 'var(--color-card)'; - tabPharma.style.color = isOffice ? 'var(--color-muted-foreground)' : 'var(--color-foreground)'; -} diff --git a/templates/index.html b/templates/index.html index 0cb8807..20d0652 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,3 +1,4 @@ +{% load common_tags %} {% load static %} @@ -8,11 +9,35 @@ + + + - + + + + + + @@ -327,6 +352,15 @@