From d07b9c6e97e04c3f17b0593f43551ecadbfe73a1 Mon Sep 17 00:00:00 2001 From: lokses Date: Mon, 21 Sep 2026 18:42:21 +0300 Subject: [PATCH] =?UTF-8?q?dashboard:=20=D0=BA=D0=B0=D1=81=D1=82=D0=BE?= =?UTF-8?q?=D0=BC=D0=B8=D0=B7=D0=B8=D1=80=D1=83=D0=B5=D0=BC=D1=8B=D0=B9=20?= =?UTF-8?q?=D0=B4=D0=B0=D1=88=D0=B1=D0=BE=D1=80=D0=B4=20=D0=B8=D0=B7=20?= =?UTF-8?q?=D0=B2=D0=B8=D0=B4=D0=B6=D0=B5=D1=82=D0=BE=D0=B2=20+=20=D1=88?= =?UTF-8?q?=D0=B0=D0=B1=D0=BB=D0=BE=D0=BD=D1=8B=20=D0=BF=D0=BE=20=D1=83?= =?UTF-8?q?=D0=BC=D0=BE=D0=BB=D1=87=D0=B0=D0=BD=D0=B8=D1=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Дашборд теперь собирается из виджетов (приветствие, метрики, мои задачи, новости, быстрый доступ). Сотрудник настраивает состав и порядок под себя (вкл/выкл + перемещение ↑↓), а для тех, кто не настраивал, действует набор по умолчанию по типу локации (Офис/Аптека/Склад), задаётся в админке. - core/dashboard.py: реестр виджетов + сборка раскладки - core/models/dashboard.py: DashboardLayout (персональная), DashboardDefaults (синглтон по типу локации) - core/views.py: dashboard_view через реестр + dashboard_customize_view - templates/pages/dashboard_widgets/*: виджеты-партиалы; dashboard.html — цикл по виджетам; dashboard_customize.html — настройка (HTMX + ↑↓) - admin: DashboardDefaults (чекбоксами по локации) + read-only раскладки; пункт меню «Дашборд по умолчанию» - core/tests_dashboard.py: 11 тестов (логика раскладки + view) Co-Authored-By: Claude Opus 4.8 --- acore/settings/base.py | 5 + acore/urls.py | 1 + core/admin.py | 73 ++++++- core/dashboard.py | 184 ++++++++++++++++ .../0021_dashboarddefaults_dashboardlayout.py | 42 ++++ core/models/__init__.py | 3 + core/models/dashboard.py | 64 ++++++ core/tests_dashboard.py | 120 +++++++++++ core/views.py | 39 ++-- templates/pages/dashboard.html | 197 ++---------------- templates/pages/dashboard_customize.html | 64 ++++++ .../pages/dashboard_widgets/metrics.html | 59 ++++++ .../pages/dashboard_widgets/my_tasks.html | 29 +++ templates/pages/dashboard_widgets/news.html | 33 +++ .../pages/dashboard_widgets/quick_access.html | 83 ++++++++ .../pages/dashboard_widgets/welcome.html | 9 + 16 files changed, 804 insertions(+), 201 deletions(-) create mode 100644 core/dashboard.py create mode 100644 core/migrations/0021_dashboarddefaults_dashboardlayout.py create mode 100644 core/models/dashboard.py create mode 100644 core/tests_dashboard.py create mode 100644 templates/pages/dashboard_customize.html create mode 100644 templates/pages/dashboard_widgets/metrics.html create mode 100644 templates/pages/dashboard_widgets/my_tasks.html create mode 100644 templates/pages/dashboard_widgets/news.html create mode 100644 templates/pages/dashboard_widgets/quick_access.html create mode 100644 templates/pages/dashboard_widgets/welcome.html diff --git a/acore/settings/base.py b/acore/settings/base.py index aeed9fc..1cfb353 100644 --- a/acore/settings/base.py +++ b/acore/settings/base.py @@ -288,6 +288,11 @@ UNFOLD = { "icon": "tune", "link": reverse_lazy("admin:employees_employeedefaults_changelist"), }, + { + "title": "Дашборд по умолчанию", + "icon": "dashboard_customize", + "link": reverse_lazy("admin:core_dashboarddefaults_changelist"), + }, ], }, { diff --git a/acore/urls.py b/acore/urls.py index 10017ea..fe43868 100644 --- a/acore/urls.py +++ b/acore/urls.py @@ -63,6 +63,7 @@ urlpatterns = [ path('2fa/backup-codes/', views.twofa_backup_codes_view, name='twofa_backup_codes'), path('2fa/verify/', views.twofa_verify_view, name='twofa_verify'), path('dashboard/', views.dashboard_view, name='dashboard'), + path('dashboard/customize/', views.dashboard_customize_view, name='dashboard_customize'), path('employees/', views.employees_view, name='employees'), path('employees//', views.employee_detail_view, name='employee_detail'), path('pharmacies/', views.pharmacies_view, name='pharmacies'), diff --git a/core/admin.py b/core/admin.py index 40de151..a3eb116 100644 --- a/core/admin.py +++ b/core/admin.py @@ -11,7 +11,11 @@ from unfold.contrib.filters.admin import DropdownFilter from treenode.forms import TreeNodeForm from django.forms import TextInput, Textarea from django.db.models import Count -from core.models import DirectoryType, Directory, ConnectorConfig, AccessSystem +from django import forms +from core.models import ( + DirectoryType, Directory, ConnectorConfig, AccessSystem, + DashboardDefaults, DashboardLayout, +) from import_export.admin import ImportExportModelAdmin from unfold.contrib.import_export.forms import ExportForm, ImportForm @@ -289,4 +293,69 @@ class ConnectorConfigAdmin(ModelAdmin): color, obj.get_connector_type_display() ) connector_type_display.short_description = _('Коннектор') - connector_type_display.admin_order_field = 'connector_type' \ No newline at end of file + connector_type_display.admin_order_field = 'connector_type' + + +class DashboardDefaultsForm(forms.ModelForm): + """Наборы виджетов дашборда по умолчанию — чекбоксами по типу локации.""" + + class Meta: + model = DashboardDefaults + fields = ('office', 'pharmacy', 'warehouse') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + from core.dashboard import DASHBOARD_WIDGETS, valid_keys + choices = [(w.key, f'{w.label} — {w.description}') for w in DASHBOARD_WIDGETS] + for field in ('office', 'pharmacy', 'warehouse'): + self.fields[field] = forms.MultipleChoiceField( + label=self._meta.model._meta.get_field(field).verbose_name, + required=False, choices=choices, widget=forms.CheckboxSelectMultiple, + ) + if self.instance and self.instance.pk: + self.initial[field] = valid_keys(getattr(self.instance, field) or []) + + def clean(self): + from core.dashboard import DEFAULT_WIDGET_KEYS + cleaned = super().clean() + for field in ('office', 'pharmacy', 'warehouse'): + selected = set(cleaned.get(field) or []) + # сохраняем в порядке реестра виджетов + cleaned[field] = [k for k in DEFAULT_WIDGET_KEYS if k in selected] + return cleaned + + +@admin.register(DashboardDefaults) +class DashboardDefaultsAdmin(ModelAdmin): + form = DashboardDefaultsForm + fieldsets = ( + (_('Виджеты дашборда по типу локации'), { + 'fields': ('office', 'pharmacy', 'warehouse'), + 'description': _( + 'Набор виджетов, который видит сотрудник, ещё не настроивший ' + 'свой дашборд. Порядок — как в списке ниже; пользователь может ' + 'переопределить состав и порядок под себя.' + ), + }), + ) + + def has_add_permission(self, request): + return not DashboardDefaults.objects.exists() + + def has_delete_permission(self, request, obj=None): + return False + + +@admin.register(DashboardLayout) +class DashboardLayoutAdmin(ModelAdmin): + """Персональные раскладки (read-only; удалить = сбросить к дефолту).""" + list_display = ('employee', 'widgets_display', 'updated_at') + search_fields = ('employee__username', 'employee__last_name', 'employee__first_name') + readonly_fields = ('employee', 'widgets', 'updated_at') + + def has_add_permission(self, request): + return False + + def widgets_display(self, obj): + return ', '.join(obj.widgets or []) or '—' + widgets_display.short_description = _('Виджеты') \ No newline at end of file diff --git a/core/dashboard.py b/core/dashboard.py new file mode 100644 index 0000000..f1f80dd --- /dev/null +++ b/core/dashboard.py @@ -0,0 +1,184 @@ +"""Реестр виджетов дашборда и сборка персональной раскладки. + +Дашборд собирается из виджетов (core.dashboard.DASHBOARD_WIDGETS). Порядок и +состав берутся из персональной раскладки сотрудника (DashboardLayout), а если +её нет — из набора по умолчанию для типа локации (DashboardDefaults). +""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import Callable + + +@dataclass(frozen=True) +class DashboardWidget: + key: str + label: str + description: str + template_name: str # pages/dashboard_widgets/.html + context_builder: Callable | None = None # (request) -> dict | None + + @property + def template(self) -> str: + return f'pages/dashboard_widgets/{self.template_name}.html' + + +# ── Построители контекста виджетов ─────────────────────────────────────────── + +def _ctx_metrics(request) -> dict: + from django.utils import timezone + from apps.employees.models import Employee + from apps.it_manager.models import Ticket + from apps.locations.models import Pharmacy + from core.models import Directory, DirectoryType + from core.middleware import ONLINE_WINDOW + + active = Employee.objects.filter(is_active=True) + return { + 'employees_online': active.filter( + last_seen__gte=timezone.now() - ONLINE_WINDOW).count(), + 'employees_active': active.count(), + 'pharmacies_active': Pharmacy.objects.filter(is_active=True).count(), + 'open_tickets': Ticket.objects.filter( + status__code__in=['new', 'in_progress', 'waiting']).count(), + 'directory_types_count': DirectoryType.objects.filter(is_active=True).count(), + 'directories_count': Directory.objects.count(), + } + + +def _ctx_news(request) -> dict: + from apps.feed.models import Post + return { + 'latest_posts': Post.objects.filter( + is_published=True).select_related('author')[:3], + } + + +def _ctx_my_tasks(request) -> dict: + from apps.tasks.models import Task + tasks = ( + Task.objects.for_user(request.user) + .exclude(status__in=['done', 'cancelled']) + .order_by('due_date', '-created_at')[:6] + ) + return {'my_tasks': tasks} + + +# ── Реестр ─────────────────────────────────────────────────────────────────── + +DASHBOARD_WIDGETS: tuple[DashboardWidget, ...] = ( + DashboardWidget( + 'welcome', 'Приветствие', + 'Приветственный баннер с именем, датой и кнопками быстрых действий.', + 'welcome', + ), + DashboardWidget( + 'metrics', 'Метрики', + 'Сводка: сотрудники онлайн, аптеки, открытые IT-заявки, справочники.', + 'metrics', _ctx_metrics, + ), + DashboardWidget( + 'my_tasks', 'Мои задачи', + 'Мои активные задачи (где я автор, ответственный, соисполнитель).', + 'my_tasks', _ctx_my_tasks, + ), + DashboardWidget( + 'news', 'Последние новости', + 'Свежие публикации и объявления компании.', + 'news', _ctx_news, + ), + DashboardWidget( + 'quick_access', 'Быстрый доступ', + 'Плитки со ссылками на почту, документы, задачи и другие разделы.', + 'quick_access', + ), +) + +WIDGETS_BY_KEY = {w.key: w for w in DASHBOARD_WIDGETS} + +# Полный набор в порядке реестра (fallback, если ничего не настроено). +DEFAULT_WIDGET_KEYS = [w.key for w in DASHBOARD_WIDGETS] + + +def valid_keys(keys) -> list: + """Только существующие ключи, без дублей, с сохранением порядка.""" + seen = set() + result = [] + for k in keys or []: + if k in WIDGETS_BY_KEY and k not in seen: + seen.add(k) + result.append(k) + return result + + +def default_keys_for(location_type: str | None) -> list: + from core.models import DashboardDefaults + keys = valid_keys(DashboardDefaults.load().keys_for(location_type or '')) + return keys or list(DEFAULT_WIDGET_KEYS) + + +def resolve_layout_keys(user) -> list: + """Ключи виджетов для сотрудника: своя раскладка или дефолт по локации.""" + from core.models import DashboardLayout + + layout = DashboardLayout.objects.filter(employee=user).first() + if layout is not None and layout.widgets: + keys = valid_keys(layout.widgets) + if keys: + return keys + return default_keys_for(getattr(user, 'user_location_type', None)) + + +def build_dashboard_context(request) -> dict: + """Плоский контекст всех включённых виджетов + список для отрисовки.""" + keys = resolve_layout_keys(request.user) + merged: dict = {} + widgets = [] + for key in keys: + widget = WIDGETS_BY_KEY[key] + if widget.context_builder is not None: + extra = widget.context_builder(request) or {} + merged.update(extra) + widgets.append({ + 'key': widget.key, + 'label': widget.label, + 'template': widget.template, + }) + merged['dashboard_widgets'] = widgets + merged['dashboard_has_custom_layout'] = _has_custom_layout(request.user) + return merged + + +def _has_custom_layout(user) -> bool: + from core.models import DashboardLayout + layout = DashboardLayout.objects.filter(employee=user).first() + return bool(layout and layout.widgets) + + +def customize_rows(user) -> list: + """Строки для страницы настройки: включённые (в порядке) + выключенные.""" + enabled = resolve_layout_keys(user) + enabled_set = set(enabled) + rows = [{'widget': WIDGETS_BY_KEY[k], 'enabled': True} for k in enabled] + for widget in DASHBOARD_WIDGETS: + if widget.key not in enabled_set: + rows.append({'widget': widget, 'enabled': False}) + return rows + + +def save_layout(user, ordered_keys, enabled_keys) -> list: + """Сохранить раскладку: включённые ключи в заданном порядке.""" + from core.models import DashboardLayout + + enabled_set = set(enabled_keys) + result = [k for k in valid_keys(ordered_keys) if k in enabled_set] + DashboardLayout.objects.update_or_create( + employee=user, defaults={'widgets': result}, + ) + return result + + +def reset_layout(user) -> None: + """Удалить персональную раскладку — вернуться к дефолту по локации.""" + from core.models import DashboardLayout + DashboardLayout.objects.filter(employee=user).delete() diff --git a/core/migrations/0021_dashboarddefaults_dashboardlayout.py b/core/migrations/0021_dashboarddefaults_dashboardlayout.py new file mode 100644 index 0000000..0289b41 --- /dev/null +++ b/core/migrations/0021_dashboarddefaults_dashboardlayout.py @@ -0,0 +1,42 @@ +# Generated by Django 6.0.3 on 2026-09-21 15:36 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0020_alter_connectorconfig_extra_config'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='DashboardDefaults', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('office', models.JSONField(blank=True, default=list, verbose_name='Офис')), + ('pharmacy', models.JSONField(blank=True, default=list, verbose_name='Аптека')), + ('warehouse', models.JSONField(blank=True, default=list, verbose_name='Склад')), + ], + options={ + 'verbose_name': 'Дашборд по умолчанию', + 'verbose_name_plural': 'Дашборд по умолчанию', + }, + ), + migrations.CreateModel( + name='DashboardLayout', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('widgets', models.JSONField(blank=True, default=list, verbose_name='Виджеты (порядок)')), + ('updated_at', models.DateTimeField(auto_now=True)), + ('employee', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='dashboard_layout', to=settings.AUTH_USER_MODEL, verbose_name='Сотрудник')), + ], + options={ + 'verbose_name': 'Раскладка дашборда', + 'verbose_name_plural': 'Раскладки дашборда', + }, + ), + ] diff --git a/core/models/__init__.py b/core/models/__init__.py index 2a53d26..22c1f6f 100644 --- a/core/models/__init__.py +++ b/core/models/__init__.py @@ -2,6 +2,7 @@ from .base import BaseModel from .directories import DirectoryType, Directory, AccessSystem from .connector_config import ConnectorConfig from .portal_access import PortalAccess +from .dashboard import DashboardLayout, DashboardDefaults __all__ = [ 'BaseModel', @@ -10,4 +11,6 @@ __all__ = [ 'AccessSystem', 'ConnectorConfig', 'PortalAccess', + 'DashboardLayout', + 'DashboardDefaults', ] diff --git a/core/models/dashboard.py b/core/models/dashboard.py new file mode 100644 index 0000000..4ba7bc9 --- /dev/null +++ b/core/models/dashboard.py @@ -0,0 +1,64 @@ +from django.conf import settings +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class DashboardLayout(models.Model): + """Персональная раскладка дашборда сотрудника. + + widgets — упорядоченный список ключей включённых виджетов (см. + core.dashboard.DASHBOARD_WIDGETS). Отсутствие записи = дашборд по + умолчанию для типа локации сотрудника. + """ + + employee = models.OneToOneField( + settings.AUTH_USER_MODEL, on_delete=models.CASCADE, + related_name='dashboard_layout', verbose_name=_('Сотрудник'), + ) + widgets = models.JSONField(_('Виджеты (порядок)'), default=list, blank=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + verbose_name = _('Раскладка дашборда') + verbose_name_plural = _('Раскладки дашборда') + + def __str__(self): + return f'{self.employee}: {len(self.widgets or [])} виджетов' + + +class DashboardDefaults(models.Model): + """Синглтон: наборы виджетов дашборда по умолчанию для типа локации. + + Применяются к сотруднику, который ещё не настраивал свой дашборд. + Каждое поле — упорядоченный список ключей виджетов. + """ + + office = models.JSONField(_('Офис'), default=list, blank=True) + pharmacy = models.JSONField(_('Аптека'), default=list, blank=True) + warehouse = models.JSONField(_('Склад'), default=list, blank=True) + + class Meta: + verbose_name = _('Дашборд по умолчанию') + verbose_name_plural = _('Дашборд по умолчанию') + + def __str__(self): + return str(_('Дашборд по умолчанию')) + + def save(self, *args, **kwargs): + self.pk = 1 + super().save(*args, **kwargs) + + def delete(self, *args, **kwargs): + pass # синглтон нельзя удалить + + @classmethod + def load(cls): + obj, _created = cls.objects.get_or_create(pk=1) + return obj + + def keys_for(self, location_type: str) -> list: + return { + 'office': self.office, + 'pharmacy': self.pharmacy, + 'warehouse': self.warehouse, + }.get(location_type) or [] diff --git a/core/tests_dashboard.py b/core/tests_dashboard.py new file mode 100644 index 0000000..e5459bf --- /dev/null +++ b/core/tests_dashboard.py @@ -0,0 +1,120 @@ +from datetime import date + +from django.test import TestCase + +from apps.employees.models import Employee +from core import dashboard as dash +from core.models import DashboardDefaults, DashboardLayout +from core.test_utils import login_2fa + + +def make_employee(**overrides): + n = Employee.objects.count() + 1 + defaults = { + 'username': f'dash.user{n}', + 'email': f'dash.user{n}@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) + emp = Employee.objects.create(**defaults) + from django.contrib.auth.models import Group + emp.groups.add(Group.objects.get_or_create(name='Сотрудники (тест)')[0]) + return emp + + +class DashboardLayoutLogicTests(TestCase): + def test_default_fallback_all_widgets(self): + user = make_employee() + self.assertEqual(dash.resolve_layout_keys(user), dash.DEFAULT_WIDGET_KEYS) + + def test_default_per_location_type(self): + d = DashboardDefaults.load() + d.office = ['metrics', 'news'] + d.pharmacy = ['my_tasks'] + d.save() + office = make_employee(user_location_type='office') + pharmacy = make_employee(user_location_type='pharmacy') + self.assertEqual(dash.resolve_layout_keys(office), ['metrics', 'news']) + self.assertEqual(dash.resolve_layout_keys(pharmacy), ['my_tasks']) + + def test_personal_layout_overrides_default(self): + d = DashboardDefaults.load() + d.office = ['metrics'] + d.save() + user = make_employee(user_location_type='office') + dash.save_layout(user, ['news', 'welcome', 'metrics'], ['news', 'welcome']) + # только включённые, в заданном порядке + self.assertEqual(dash.resolve_layout_keys(user), ['news', 'welcome']) + + def test_valid_keys_drops_unknown_and_dups(self): + self.assertEqual( + dash.valid_keys(['news', 'bogus', 'news', 'metrics']), + ['news', 'metrics'], + ) + + def test_save_layout_filters_disabled(self): + user = make_employee() + saved = dash.save_layout(user, ['welcome', 'news', 'metrics'], ['welcome', 'metrics']) + self.assertEqual(saved, ['welcome', 'metrics']) + self.assertEqual(DashboardLayout.objects.get(employee=user).widgets, ['welcome', 'metrics']) + + def test_reset_layout_returns_to_default(self): + user = make_employee(user_location_type='office') + dash.save_layout(user, ['news'], ['news']) + self.assertTrue(DashboardLayout.objects.filter(employee=user).exists()) + dash.reset_layout(user) + self.assertFalse(DashboardLayout.objects.filter(employee=user).exists()) + self.assertEqual(dash.resolve_layout_keys(user), dash.DEFAULT_WIDGET_KEYS) + + def test_customize_rows_enabled_first(self): + user = make_employee() + dash.save_layout(user, ['news', 'metrics'], ['news', 'metrics']) + rows = dash.customize_rows(user) + keys = [r['widget'].key for r in rows] + # включённые первыми в своём порядке, затем остальные + self.assertEqual(keys[:2], ['news', 'metrics']) + self.assertEqual(set(keys), set(dash.DEFAULT_WIDGET_KEYS)) + self.assertTrue(rows[0]['enabled']) + self.assertFalse(rows[2]['enabled']) + + +class DashboardViewTests(TestCase): + def test_dashboard_renders_widgets(self): + user = make_employee() + login_2fa(self.client, user) + resp = self.client.get('/dashboard/', HTTP_HX_REQUEST='true') + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'Настроить дашборд') + + def test_customize_get(self): + user = make_employee() + login_2fa(self.client, user) + resp = self.client.get('/dashboard/customize/', HTTP_HX_REQUEST='true') + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'Настройка дашборда') + + def test_customize_post_saves(self): + user = make_employee() + login_2fa(self.client, user) + resp = self.client.post( + '/dashboard/customize/', + {'order': 'news,metrics,welcome', 'enabled': ['news', 'metrics']}, + HTTP_HX_REQUEST='true', + ) + self.assertEqual(resp.status_code, 302) + self.assertEqual(DashboardLayout.objects.get(employee=user).widgets, ['news', 'metrics']) + + def test_customize_post_reset(self): + user = make_employee() + dash.save_layout(user, ['news'], ['news']) + login_2fa(self.client, user) + resp = self.client.post( + '/dashboard/customize/', {'reset': '1'}, HTTP_HX_REQUEST='true', + ) + self.assertEqual(resp.status_code, 302) + self.assertFalse(DashboardLayout.objects.filter(employee=user).exists()) diff --git a/core/views.py b/core/views.py index 393ccda..856df5b 100644 --- a/core/views.py +++ b/core/views.py @@ -58,25 +58,32 @@ def access_preview_view(request): @login_required def dashboard_view(request): from django.utils import timezone as dj_timezone - from core.middleware import ONLINE_WINDOW - from core.models.directories import DirectoryType - from apps.feed.models import Post - from apps.it_manager.models import Ticket + from core.dashboard import build_dashboard_context - employees_active = Employee.objects.filter(is_active=True) - - context = { - 'today': dj_timezone.localdate(), - 'latest_posts': Post.objects.filter(is_published=True).select_related('author')[:3], - 'employees_online': employees_active.filter(last_seen__gte=dj_timezone.now() - ONLINE_WINDOW).count(), - 'employees_active': employees_active.count(), - 'pharmacies_active': Pharmacy.objects.filter(is_active=True).count(), - 'open_tickets': Ticket.objects.filter(status__code__in=['new', 'in_progress', 'waiting']).count(), - 'directory_types_count': DirectoryType.objects.filter(is_active=True).count(), - 'directories_count': Directory.objects.count(), - } + context = {'today': dj_timezone.localdate()} + context.update(build_dashboard_context(request)) return render_page(request, 'dashboard.html', context) + +@login_required +def dashboard_customize_view(request): + """Настройка персональной раскладки дашборда: вкл/выкл + порядок виджетов.""" + from core import dashboard as dash + + if request.method == 'POST': + if request.POST.get('reset'): + dash.reset_layout(request.user) + return redirect('dashboard') + order = [k.strip() for k in (request.POST.get('order') or '').split(',') if k.strip()] + enabled = request.POST.getlist('enabled') + if not order: # JS отключён — берём порядок чекбоксов + order = enabled + dash.save_layout(request.user, order, enabled) + return redirect('dashboard') + + context = {'rows': dash.customize_rows(request.user)} + return render_page(request, 'dashboard_customize.html', context) + @login_required def employees_view(request): """Список перенесён в HR-портал (read-only для всех, управление — для HR).""" diff --git a/templates/pages/dashboard.html b/templates/pages/dashboard.html index a3b2653..7f2a15a 100644 --- a/templates/pages/dashboard.html +++ b/templates/pages/dashboard.html @@ -1,192 +1,23 @@ -{% load common_tags %} {% load i18n %}
- -
-

{% trans "Добрый день" %}, {{ request.user.first_name|default:request.user.username }}

-

{% trans "Добро пожаловать в корпоративный портал АКС." %} {% trans "Сегодня" %} {{ today|date:"j E Y" }} {% trans "г." %}

-
- - -
+
+
- -
- -
-
-
{% trans "Сотрудников онлайн" %}
-
{{ employees_online }}
-
{% trans "из" %} {{ employees_active }} {% trans "активных" %}
+
+ {% for w in dashboard_widgets %} + {% include w.template %} + {% empty %} +
+ {% trans "Все виджеты скрыты." %} + {% trans "Настроить дашборд" %}
-
- - - - - -
-
- -
-
-
{% trans "Аптек в сети" %}
-
{{ pharmacies_active }}
-
{% trans "активных точек" %}
-
-
- - - -
-
- -
-
-
{% trans "Открытых IT-заявок" %}
-
{{ open_tickets }}
-
{% trans "в работе и новых" %}
-
-
- - - - -
-
- -
-
-
{% trans "Справочников" %}
-
{{ directory_types_count }}
-
{{ directories_count }} {% trans "записей" %}
-
-
- - - -
-
- -
- - -
- - -
-
-

{% trans "Последние новости" %}

-

{% trans "Объявления и важные события компании" %}

-
-
- {% for post in latest_posts %} - -
- {% if post.first_image_url %} - - {% endif %} -
-
-
{{ post.title }}
-
{{ post.body|clean_excerpt:160 }}
-
- {{ post.created_at|date:"d.m.Y" }} -
-
-
- {% empty %} -
{% trans "Публикаций пока нет" %}
- {% endfor %} -
- -
- - - - + {% endfor %}
diff --git a/templates/pages/dashboard_customize.html b/templates/pages/dashboard_customize.html new file mode 100644 index 0000000..3052157 --- /dev/null +++ b/templates/pages/dashboard_customize.html @@ -0,0 +1,64 @@ +{% load i18n %} +
+ +
+
+

{% trans "Настройка дашборда" %}

+

{% trans "Включите нужные виджеты и расположите их в удобном порядке." %}

+
+ +
+ +
+ {% csrf_token %} + + +
    + {% for row in rows %} +
  • + +
    + + +
    +
  • + {% endfor %} +
+ +
+ + +
+
+
+ + diff --git a/templates/pages/dashboard_widgets/metrics.html b/templates/pages/dashboard_widgets/metrics.html new file mode 100644 index 0000000..b383c8e --- /dev/null +++ b/templates/pages/dashboard_widgets/metrics.html @@ -0,0 +1,59 @@ +{% load i18n %} +
+ +
+
+
{% trans "Сотрудников онлайн" %}
+
{{ employees_online }}
+
{% trans "из" %} {{ employees_active }} {% trans "активных" %}
+
+
+ + + + + +
+
+ +
+
+
{% trans "Аптек в сети" %}
+
{{ pharmacies_active }}
+
{% trans "активных точек" %}
+
+
+ + + +
+
+ +
+
+
{% trans "Открытых IT-заявок" %}
+
{{ open_tickets }}
+
{% trans "в работе и новых" %}
+
+
+ + + + +
+
+ +
+
+
{% trans "Справочников" %}
+
{{ directory_types_count }}
+
{{ directories_count }} {% trans "записей" %}
+
+
+ + + +
+
+ +
diff --git a/templates/pages/dashboard_widgets/my_tasks.html b/templates/pages/dashboard_widgets/my_tasks.html new file mode 100644 index 0000000..02cfb14 --- /dev/null +++ b/templates/pages/dashboard_widgets/my_tasks.html @@ -0,0 +1,29 @@ +{% load i18n %} +
+
+

{% trans "Мои задачи" %}

+

{% trans "Активные задачи, где вы участник" %}

+
+ + +
diff --git a/templates/pages/dashboard_widgets/news.html b/templates/pages/dashboard_widgets/news.html new file mode 100644 index 0000000..499aadc --- /dev/null +++ b/templates/pages/dashboard_widgets/news.html @@ -0,0 +1,33 @@ +{% load common_tags %} +{% load i18n %} +
+
+

{% trans "Последние новости" %}

+

{% trans "Объявления и важные события компании" %}

+
+
+ {% for post in latest_posts %} + +
+ {% if post.first_image_url %} + + {% endif %} +
+
+
{{ post.title }}
+
{{ post.body|clean_excerpt:160 }}
+
+ {{ post.created_at|date:"d.m.Y" }} +
+
+
+ {% empty %} +
{% trans "Публикаций пока нет" %}
+ {% endfor %} +
+ +
diff --git a/templates/pages/dashboard_widgets/quick_access.html b/templates/pages/dashboard_widgets/quick_access.html new file mode 100644 index 0000000..277280d --- /dev/null +++ b/templates/pages/dashboard_widgets/quick_access.html @@ -0,0 +1,83 @@ +{% load common_tags %} +{% load i18n %} +
+
+

{% trans "Быстрый доступ" %}

+

{% trans "Полезные ссылки и инструменты" %}

+
+ +
diff --git a/templates/pages/dashboard_widgets/welcome.html b/templates/pages/dashboard_widgets/welcome.html new file mode 100644 index 0000000..a56189c --- /dev/null +++ b/templates/pages/dashboard_widgets/welcome.html @@ -0,0 +1,9 @@ +{% load i18n %} +
+

{% trans "Добрый день" %}, {{ request.user.first_name|default:request.user.username }}

+

{% trans "Добро пожаловать в корпоративный портал АКС." %} {% trans "Сегодня" %} {{ today|date:"j E Y" }} {% trans "г." %}

+
+ + +
+