# -*- coding: utf-8 -*- # hub/ticket/ui/ticket_shell.py """Оболочка модуля Ticket в составе независимого приложения Dispatch. Назначение модуля: Минимальная shell-страница Dispatch: - Единая горизонтальная шапка из трёх равных по ширине кнопок: кнопка-логотип (открывает контактный диалог), кнопка раздела «Ваши заявки», кнопка авторизации `Log In` / `Log Out`. - Центральная область с доской задач Ticket. - Модуль работы с COM-портом в Dispatch отключён, поэтому строка состояния COM-канала и подписки на сигналы шлюза удалены. Архитектурные ограничения: - Стилевые ключи берутся только из внешнего реестра `APP_STYLES`; локальные QSS-литералы не используются. - Все три элемента шапки — экземпляры локальной обёртки `Button` и распределяют пространство равными долями `stretch=1`. - Логотип реализован как канонический `Button` с иконкой (`icon_path`), без кастомного raw-Qt компонента и без inline QSS. """ from __future__ import annotations import os from functools import partial from gui.components.button import Button from gui.components.dialog import Dialog from gui.components.label import Label from gui.containers import HContainer, SContainer, StackContainer, VContainer from gui.theme_bus import theme_bus from application import TaskApplicationService from .pages import ArchivePage from .ticket_board_page import TicketBoardPage from .ticket_create_page import TicketCreatePage # Контактные телефоны для модального окна. Текст вынесен в константы, # чтобы единственное место правки находилось в верхней части файла. _CONTACT_PHONE_GENERAL = "+7 (000) 000-00-00" _CONTACT_PHONE_DISPATCHER = "+7 (000) 000-00-00" _CONTACT_PHONE_SERVICE_HEAD = "+7 (000) 000-00-00" class _ContactsDialog(Dialog): """Простое модальное окно с контактными данными организации.""" def __init__(self, parent=None): super().__init__( title="Контактные данные", width=420, height=260, modal=True, parent=parent, ) self._setup_ui() def _setup_ui(self) -> None: body = VContainer( margin=[24, 20, 24, 20], spacing=12, parent=None, ) self.add_widget(body) body.add_widget(Label( "Контакты службы сервисного обслуживания", height_percent=20, style="LOGIN_TITLE", )) body.add_widget(Label( f"Общий телефон: {_CONTACT_PHONE_GENERAL}", height_percent=20, style="LOGIN_FIELD_LABEL", )) body.add_widget(Label( f"Диспетчер: {_CONTACT_PHONE_DISPATCHER}", height_percent=20, style="LOGIN_FIELD_LABEL", )) body.add_widget(Label( f"Руководитель службы: {_CONTACT_PHONE_SERVICE_HEAD}", height_percent=20, style="LOGIN_FIELD_LABEL", )) actions = SContainer( height_percent=20, orientation="h", content_fit=False, ) actions.add_stretch(1) close_button = Button( text="Закрыть", height_percent=100, margin=0, content_fit=False, ) close_button.clicked.connect(self.accept) actions.add_widget(close_button) body.add_widget(actions) class TicketShell(SContainer): """Корневая оболочка Dispatch: шапка из трёх кнопок и доска задач.""" def __init__( self, application: TaskApplicationService, parent=None, ): super().__init__( width_percent=100, height_percent=100, margin=0, spacing=0, parent=parent, style="TICKET_SHELL_ROOT", ) self._application = application self._page_stack: StackContainer | None = None self._theme = "dark" if self.palette().window().color().lightness() < 128 else "light" self._active_view_name = "board" self._page_index_by_name: dict[str, int] = {} self._nav_buttons: dict[str, Button] = {} self._login_button: Button | None = None self._logo_button: Button | None = None self._is_logged_in: bool = False self._logged_in_user: dict | None = None self._create_page: TicketCreatePage | None = None self._logo_dark_path, self._logo_light_path = self._resolve_logo_paths() self._setup_ui() self._connect_signals() self._sync_from_application() self._restore_session() # ── разметка интерфейса ── def _setup_ui(self) -> None: # Единая шапка: один HContainer, три равные по ширине кнопки. # Высота шапки — 6% высоты shell. Распределение долей # выполняется через `add_widget_with_stretch(button, 1)` для # каждой кнопки, что даёт ровно одинаковые сегменты. top_bar = HContainer( height_percent=6, margin=[0, 0, 0, 12], spacing=16, content_fit=False, style="TICKET_SURFACE_HOST", parent=self, ) # Кнопка-логотип: при нажатии открывается модальный диалог с # контактными данными. Иконка логотипа подбирается под тему. self._logo_button = Button( text="", height_percent=100, margin=0, is_active=False, content_fit=False, icon_path=self._logo_dark_path, icon_size=40, ) board_button = Button( text="Ваши заявки", height_percent=100, margin=0, is_active=True, content_fit=False, ) self._nav_buttons["board"] = board_button create_button = Button( text="Создать заявку", height_percent=100, margin=0, is_active=False, content_fit=False, ) self._nav_buttons["create"] = create_button archive_button = Button( text="Архив моих заявок", height_percent=100, margin=0, is_active=False, content_fit=False, ) self._nav_buttons["archive"] = archive_button self._login_button = Button( text="Log In", height_percent=100, margin=0, is_active=False, content_fit=False, style="LOGIN_NAV_BUTTON", ) top_bar.add_widget_with_stretch(self._logo_button, 1) top_bar.add_widget_with_stretch(board_button, 1) top_bar.add_widget_with_stretch(create_button, 1) top_bar.add_widget_with_stretch(archive_button, 1) top_bar.add_widget_with_stretch(self._login_button, 1) # Центральный stack-контейнер: в Dispatch удерживает три страницы — доску, создание и архив. self._page_stack = StackContainer(margin=0, parent=self) board_page = TicketBoardPage(application=self._application) create_page = TicketCreatePage( application=self._application, on_finish=self._on_create_form_finished, ) self._create_page = create_page archive_page = ArchivePage(application=self._application) self._page_index_by_name["board"] = self._page_stack.add_widget(board_page) self._page_index_by_name["create"] = self._page_stack.add_widget(create_page) self._page_index_by_name["archive"] = self._page_stack.add_widget(archive_page) self._page_stack.set_current_index(self._page_index_by_name["board"]) def _connect_signals(self) -> None: self._nav_buttons["board"].clicked.connect(partial(self._on_navigation_requested, "board")) self._nav_buttons["create"].clicked.connect(partial(self._on_navigation_requested, "create")) self._nav_buttons["archive"].clicked.connect(partial(self._on_navigation_requested, "archive")) if self._login_button is not None: self._login_button.clicked.connect(self._on_login_button_clicked) if self._logo_button is not None: self._logo_button.clicked.connect(self._on_logo_button_clicked) self._application.active_view_changed.connect(self._on_active_view_changed) theme_bus.theme_changed.connect(self._on_theme_changed) def _sync_from_application(self) -> None: self._set_active_page(self._application.get_active_view()) # ── навигация и страницы ── def _set_active_page(self, view_name: str) -> None: if self._page_stack is None: return normalized_name = view_name if view_name in self._page_index_by_name else "board" self._active_view_name = normalized_name target_index = self._page_index_by_name.get(normalized_name) if target_index is None: return if self._page_stack.current_index() != target_index: self._page_stack.set_current_index(target_index) self._apply_navigation_theme() def _on_active_view_changed(self, view_name: str) -> None: self._set_active_page(view_name) def _on_navigation_requested(self, view_name: str, _checked: bool = False) -> None: if view_name == "create" and self._create_page is not None: # При каждом входе на страницу форма подтягивает актуальные данные заявителя. self._create_page.refresh_user_session() self._application.set_active_view(view_name) def _on_create_form_finished(self) -> None: """После сохранения или отмены формы вернуть пользователя на доску заявок.""" self._application.set_active_view("board") # ── авторизация ── def _on_login_button_clicked(self, _checked: bool = False) -> None: """Переключить состояние сессии: вход через диалог или выход. Источник учётных данных — каталог `DB_dispatch`. Сценарий полностью повторяет схему USMS: при отсутствии активного пользователя открывается модальный диалог авторизации, иначе выполняется выход и очистка файла активной сессии. """ if self._login_button is None: return if self._logged_in_user is not None: self._do_logout() else: self._do_login() def _do_login(self) -> None: """Открыть диалог авторизации и при успехе записать сессию.""" # Импорты вынесены в момент вызова, чтобы избежать циклической # загрузки модулей при старте приложения Dispatch. from gui.login_dialog import LoginDialog from auth_service import write_session dialog = LoginDialog(parent=self) if dialog.exec() != LoginDialog.DialogCode.Accepted: return user = dialog.get_authenticated_user() if user is None: return write_session(user) self._apply_logged_in_state(user) def _do_logout(self) -> None: """Очистить запись активной сессии и вернуть кнопку в Log In.""" from auth_service import clear_session clear_session() self._apply_logged_out_state() def _restore_session(self) -> None: """Восстановить состояние входа из `DB_dispatch/1_actual_state.py`.""" from auth_service import load_session user = load_session() if user is not None: self._apply_logged_in_state(user) def _apply_logged_in_state(self, user: dict) -> None: """Перевести кнопку входа в состояние Log Out для активного пользователя.""" self._logged_in_user = user self._is_logged_in = True if self._login_button is None: return self._login_button.set_text("Log Out") self._login_button.style(is_active=True) def _apply_logged_out_state(self) -> None: """Перевести кнопку входа обратно в состояние Log In.""" self._logged_in_user = None self._is_logged_in = False if self._login_button is None: return self._login_button.set_text("Log In") self._login_button.style(is_active=False) # ── контактный диалог ── def _on_logo_button_clicked(self, _checked: bool = False) -> None: """Открыть модальное окно с контактными телефонами организации.""" dialog = _ContactsDialog(parent=self) dialog.exec() # ── оформление и тема ── def _on_theme_changed(self, theme: str) -> None: self._apply_theme(theme) def _apply_theme(self, theme: str) -> None: normalized_theme = (theme or "").strip().lower() if normalized_theme not in {"dark", "light"}: return self._theme = normalized_theme self._apply_navigation_theme() self._update_logo_icon(normalized_theme) def _apply_navigation_theme(self) -> None: normal_key, active_key = self._navigation_style_keys() for page_name, button in self._nav_buttons.items(): button.style( style_key=normal_key, active_key=active_key, is_active=page_name == self._active_view_name, ) def _navigation_style_keys(self) -> tuple[str, str]: if self._theme == "light": return ("TAB_BUTTON_NORMAL_LIGHT", "TAB_BUTTON_ACTIVE_LIGHT") return ("TAB_BUTTON_NORMAL", "TAB_BUTTON_ACTIVE") def _resolve_logo_paths(self) -> tuple[str, str]: """Вычислить пути файлов логотипа из локального каталога `gui/components/logo`.""" # __file__ = .../dispatch/hub/ticket/ui/ticket_shell.py # project_root = .../dispatch project_root = os.path.dirname( os.path.dirname( os.path.dirname( os.path.dirname(os.path.abspath(__file__)) ) ) ) logo_dir = os.path.join(project_root, "gui", "components", "logo") return ( os.path.join(logo_dir, "Nutshell_Logo_ENG_White.png"), os.path.join(logo_dir, "Nutshell_Logo_ENG_Black.png"), ) def _update_logo_icon(self, theme: str) -> None: """Подобрать иконку кнопки-логотипа под текущую тему оформления.""" if self._logo_button is None: return is_light = str(theme or "").strip().lower() == "light" path = self._logo_light_path if is_light else self._logo_dark_path from PySide6.QtCore import QSize from PySide6.QtGui import QIcon # Используем штатное API QPushButton, доступ к которому Button предоставляет # как делегат через свойство `clicked`. Иконку обновляем в обход публичного API, # потому что Button не имеет канонического `set_icon` метода в текущем wrapper-слое. inner_button = getattr(self._logo_button, "_button", None) if inner_button is not None: inner_button.setIcon(QIcon(path)) inner_button.setIconSize(QSize(40, 40))