Add Dispatch_V0.1.1

This commit is contained in:
2026-04-29 08:18:54 +04:00
commit a7ede6ded4
404 changed files with 39167 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/__init__.py
"""UI-контур Ticket."""
from .dialogs import AcceptanceDialog, DiagnosticReportDialog, RepairReportDialog, SpecialistDialog
from .pages import ActsPage, ArchivePage, ReportViewer, ReportsPage
from .ticket_board_page import TicketBoardPage
from .ticket_placeholder_page import TicketPlaceholderPage
from .ticket_shell import TicketShell
from .cards import TaskCard, TaskCardView
from .details import TaskDetailsActions, TaskDetailsDialog
__all__ = [
"TicketBoardPage",
"TicketPlaceholderPage",
"TicketShell",
"TaskCard",
"TaskCardView",
"TaskDetailsActions",
"TaskDetailsDialog",
"AcceptanceDialog",
"DiagnosticReportDialog",
"RepairReportDialog",
"SpecialistDialog",
"ActsPage",
"ArchivePage",
"ReportsPage",
"ReportViewer",
]

View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/cards/__init__.py
"""Карточки задач Ticket."""
from .task_card import TaskCard
from .task_card_view import TaskCardView
__all__ = ["TaskCard", "TaskCardView"]

View File

@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/cards/task_card.py
"""Тонкий entry-point карточки Ticket."""
from .task_card_view import TaskCardView
class TaskCard(TaskCardView):
"""Runtime-entry карточки задачи на доске Ticket."""

View File

@@ -0,0 +1,129 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/cards/task_card_pixmap_factory.py
"""Pixmap-хелперы для карточки Ticket."""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtGui import QColor, QFont, QPainter, QPainterPath, QPixmap
def compute_square_size(width: int, height: int, padding: int = 0) -> int:
"""Вернуть квадратный размер по меньшей стороне с учётом внутреннего отступа."""
available = min(width, height) - padding
return max(0, available)
def build_avatar_pixmap(source: QPixmap, size: int) -> QPixmap:
"""Построить круглый avatar-pixmap из исходной фотографии."""
avatar = QPixmap(size, size)
avatar.fill(Qt.GlobalColor.transparent)
painter = QPainter(avatar)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
outer_padding = 1
outer_size = size - (outer_padding * 2)
outer_path = QPainterPath()
outer_path.addEllipse(outer_padding, outer_padding, outer_size, outer_size)
painter.fillPath(outer_path, QColor("#A3A3A3"))
inner_padding = outer_padding + 2
inner_size = size - (inner_padding * 2)
inner_path = QPainterPath()
inner_path.addEllipse(inner_padding, inner_padding, inner_size, inner_size)
painter.setClipPath(inner_path)
scaled = source.scaled(
inner_size,
inner_size,
Qt.AspectRatioMode.KeepAspectRatioByExpanding,
Qt.TransformationMode.SmoothTransformation,
)
source_x = max(0, (scaled.width() - inner_size) // 2)
source_y = max(0, (scaled.height() - inner_size) // 2)
painter.drawPixmap(inner_padding, inner_padding, scaled, source_x, source_y, inner_size, inner_size)
painter.end()
return avatar
def build_placeholder_avatar_pixmap(size: int) -> QPixmap:
"""Построить круглый placeholder-аватар с вопросительным знаком."""
avatar = QPixmap(size, size)
avatar.fill(Qt.GlobalColor.transparent)
painter = QPainter(avatar)
painter.setRenderHint(QPainter.RenderHint.Antialiasing, True)
outer_padding = 1
circle_size = size - (outer_padding * 2)
circle_path = QPainterPath()
circle_path.addEllipse(outer_padding, outer_padding, circle_size, circle_size)
painter.fillPath(circle_path, QColor("#F6A493"))
painter.setPen(QColor("#FFFFFF"))
painter.drawPath(circle_path)
font = QFont()
font.setPixelSize(18)
font.setBold(True)
painter.setFont(font)
painter.setPen(QColor("#172B4D"))
painter.drawText(outer_padding, outer_padding, circle_size, circle_size, Qt.AlignmentFlag.AlignCenter, "?")
painter.end()
return avatar
def load_scaled_icon_pixmap(image_path: str, width: int, height: int, padding: int = 0) -> QPixmap | None:
"""Загрузить и отмасштабировать stage-icon по фактическому размеру ячейки."""
if not image_path:
return None
pixmap = QPixmap(image_path)
if pixmap.isNull():
return None
icon_size = compute_square_size(width, height, padding)
if icon_size <= 0:
return None
return pixmap.scaled(
icon_size,
icon_size,
Qt.AspectRatioMode.KeepAspectRatio,
Qt.TransformationMode.SmoothTransformation,
)
def load_tinted_icon_pixmap(
image_path: str,
width: int,
height: int,
color_hex: str,
padding: int = 0,
) -> QPixmap | None:
"""Загрузить stage-icon и перекрасить его в нужный цвет."""
scaled = load_scaled_icon_pixmap(image_path, width, height, padding)
if scaled is None:
return None
tinted = QPixmap(scaled.size())
tinted.fill(Qt.GlobalColor.transparent)
painter = QPainter(tinted)
painter.drawPixmap(0, 0, scaled)
painter.setCompositionMode(QPainter.CompositionMode.CompositionMode_SourceIn)
painter.fillRect(tinted.rect(), QColor(color_hex))
painter.end()
return tinted
def load_avatar_pixmap(image_path: str, width: int, height: int, padding: int = 0) -> QPixmap | None:
"""Загрузить фото специалиста и подготовить круглый avatar-pixmap."""
if not image_path:
return None
pixmap = QPixmap(image_path)
if pixmap.isNull():
return None
avatar_size = compute_square_size(width, height, padding)
if avatar_size <= 0:
return None
return build_avatar_pixmap(pixmap, avatar_size)

View File

@@ -0,0 +1,209 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/cards/task_card_view.py
"""Ticket task card view."""
from __future__ import annotations
from PySide6.QtCore import Qt, QTimer, Signal
from PySide6.QtWidgets import QSizePolicy
from gui.components import Label
from gui.containers import SContainer, VContainer
from domain import TicketTaskSnapshot
from domain.ticket_constants import (
STATE_COMPLETED,
STATE_CONFIRMATION,
STATE_IN_PROGRESS,
STATE_REFUSED,
STATE_TODO,
)
from ui.task_view_formatters import (
build_specialist_card_text,
build_specialist_photo_path,
build_task_fault_title,
)
from .task_card_pixmap_factory import (
build_placeholder_avatar_pixmap,
compute_square_size,
load_avatar_pixmap,
)
class TaskCardView(SContainer):
"""Compact Ticket card without action logic."""
card_clicked = Signal(object)
card_height_changed = Signal()
_ROOT_STYLE_BY_STATE = {
STATE_TODO: "TICKET_TASK_CARD_ROOT_TODO",
STATE_IN_PROGRESS: "TICKET_TASK_CARD_ROOT_IN_PROGRESS",
STATE_CONFIRMATION: "TICKET_TASK_CARD_ROOT_CONFIRMATION",
STATE_COMPLETED: "TICKET_TASK_CARD_ROOT_COMPLETED",
STATE_REFUSED: "TICKET_TASK_CARD_ROOT_REFUSED",
}
_DARK_TEXT_STATES = {STATE_TODO, STATE_CONFIRMATION, STATE_REFUSED}
def __init__(
self,
task: TicketTaskSnapshot,
parent=None,
):
super().__init__(
width_percent=100,
margin=0,
content_fit=True,
parent=parent,
)
self._card_id = task.task_id
self._fault_title_label: Label | None = None
self._avatar_label: Label | None = None
self._current_task: TicketTaskSnapshot | None = None
self._height_sync_in_progress = False
self._pixmap_refresh_pending = False
self._setup_ui()
self.update_task(task)
@property
def card_id(self) -> object:
return self._card_id
def _setup_ui(self) -> None:
self.setObjectName("ticket_task_card")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.set_size_policy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
# Content-shell: горизонтальное разделение текстовой зоны и аватара.
content = SContainer(
width_percent=100,
height_percent=100,
orientation="h",
margin=8,
spacing=0,
style="TICKET_TASK_CARD_CONTENT",
parent=self,
)
# Text-column: левая вертикальная зона с единственным текстовым полем заголовка.
text_column = VContainer(
width_percent=70,
spacing=0,
parent=content,
)
# Единственное текстовое поле карточки: краткий заголовок неисправности.
self._fault_title_label = Label(
"",
alignment="left",
height_percent=100,
parent=text_column,
)
# Avatar-column: правая зона для фото или плейсхолдера специалиста.
avatar_column = VContainer(
width_percent=26,
spacing=0,
parent=content,
)
self._avatar_label = Label(
"",
alignment="center",
width_percent=100,
height_percent=100,
style="TICKET_TASK_CARD_AVATAR_IMAGE",
parent=avatar_column,
)
def update_task(self, task: TicketTaskSnapshot) -> None:
self._card_id = task.task_id
self._current_task = task
if self._fault_title_label is not None:
self._fault_title_label.set_text(build_task_fault_title(task))
self._fault_title_label.set_tooltip(task.location or "")
self._apply_state_styles(task)
self._update_avatar(task)
self._sync_card_height()
self._schedule_pixmap_refresh()
def _apply_state_styles(self, task: TicketTaskSnapshot) -> None:
style_suffix = "DARK" if task.state_code in self._DARK_TEXT_STATES else "LIGHT"
self.style(self._ROOT_STYLE_BY_STATE.get(task.state_code, "TICKET_TASK_CARD_ROOT_TODO"))
if self._fault_title_label is not None:
self._fault_title_label.style(f"TICKET_TASK_CARD_TITLE_{style_suffix}")
def _update_avatar(self, task: TicketTaskSnapshot) -> None:
if self._avatar_label is None:
return
avatar_path = build_specialist_photo_path(task.assigned_specialist, task.specialist_photo)
self._avatar_label.set_tooltip(task.assigned_specialist or "Специалист не назначен")
pixmap = load_avatar_pixmap(avatar_path, self._avatar_label.width(), self._avatar_label.height(), padding=4)
if pixmap is not None:
self._avatar_label.set_text("")
self._avatar_label.set_pixmap(pixmap)
return
self._avatar_label.set_text("")
placeholder_size = self._label_square_size(self._avatar_label)
if placeholder_size > 0:
self._avatar_label.set_pixmap(build_placeholder_avatar_pixmap(placeholder_size))
@staticmethod
def _label_square_size(label: Label, padding: int = 0) -> int:
return compute_square_size(label.width(), label.height(), padding)
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
self._sync_card_height()
if self._current_task is not None:
self._update_avatar(self._current_task)
self._schedule_pixmap_refresh()
def _schedule_pixmap_refresh(self) -> None:
"""Запланировать отложенный рендер pixmap-контента.
Процентные размеры дочерних Label вычисляются через
QTimer.singleShot(0) в PercentSizedWidget, поэтому при первом
показе карточки (или при динамической вставке из COM-сигнала)
label.width()/height() ещё равны 0 в момент resizeEvent.
Отложенный вызов гарантирует, что к моменту рендера layout
дочерних виджетов уже завершён.
"""
if self._pixmap_refresh_pending:
return
self._pixmap_refresh_pending = True
QTimer.singleShot(0, self._deferred_pixmap_refresh)
def _deferred_pixmap_refresh(self) -> None:
self._pixmap_refresh_pending = False
if self._current_task is not None:
self._update_avatar(self._current_task)
def _sync_card_height(self) -> None:
if self._height_sync_in_progress or self.width() <= 0:
return
target_height = max(1, round(self.width() / 2.745))
if self.height() == target_height:
return
self._height_sync_in_progress = True
self.setFixedHeight(target_height)
self._height_sync_in_progress = False
self.card_height_changed.emit()
def _on_parent_rebuild_finished(self) -> None:
# Фаза 2 каскада percent-sized: родительская колонка завершила
# перестроение, ширина карточки уже стабильная. Только теперь
# пересчитываем собственную высоту по соотношению width/2.745
# и обновляем pixmap-контент.
super()._on_parent_rebuild_finished()
self._sync_card_height()
if self._current_task is not None:
self._schedule_pixmap_refresh()
def mousePressEvent(self, event) -> None:
if event.button() == Qt.MouseButton.LeftButton:
self.card_clicked.emit(self._card_id)
super().mousePressEvent(event)

View File

@@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/details/__init__.py
"""Экран деталей и действия Ticket."""
from .task_details_actions import TaskDetailsActions
from .task_details_dialog import TaskDetailsDialog
__all__ = ["TaskDetailsActions", "TaskDetailsDialog"]

View File

@@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/details/task_details_actions.py
"""Отдельный модуль действий экрана деталей Ticket."""
from __future__ import annotations
from application.ticket_application_api import TicketApplicationApi
from domain import TicketDocumentSnapshot, TicketTaskSnapshot
from ui.dialogs import (
AcceptanceDialog,
DiagnosticReportDialog,
RepairReportDialog,
SpecialistDialog,
TaskRefusalDialog,
)
from ui.ticket_message_dialog import TicketMessageDialog
class TaskDetailsActions:
"""Обёртка над application-командами для details-экрана."""
def __init__(self, application: TicketApplicationApi):
self._application = application
def assign_specialist(
self,
task: TicketTaskSnapshot,
parent=None,
) -> TicketTaskSnapshot | None:
dialog = SpecialistDialog(
specialists=self._application.list_specialists(),
parent=parent,
)
if dialog.exec() != dialog.DialogCode.Accepted:
return None
specialist_name = dialog.selected_specialist.strip()
if not specialist_name:
self._show_warning(parent, "Имя специалиста не указано.")
return None
snapshot = self._application.assign_specialist(task.task_id, specialist_name)
if snapshot is None:
self._show_warning(parent, "Не удалось назначить специалиста.")
return snapshot
def sign_diagnostic(
self,
task: TicketTaskSnapshot,
parent=None,
) -> TicketDocumentSnapshot | None:
dialog = DiagnosticReportDialog(task, parent=parent)
if dialog.exec() != dialog.DialogCode.Accepted:
return None
try:
return self._application.create_diagnostic_report(
task.task_id,
**dialog.build_payload(),
)
except ValueError as exc:
self._show_warning(parent, str(exc))
return None
def sign_repair(
self,
task: TicketTaskSnapshot,
parent=None,
) -> TicketDocumentSnapshot | None:
dialog = RepairReportDialog(task, parent=parent)
if dialog.exec() != dialog.DialogCode.Accepted:
return None
try:
return self._application.create_repair_report(
task.task_id,
**dialog.build_payload(),
)
except ValueError as exc:
self._show_warning(parent, str(exc))
return None
def sign_acceptance(
self,
task: TicketTaskSnapshot,
parent=None,
) -> TicketDocumentSnapshot | None:
dialog = AcceptanceDialog(task, parent=parent)
if dialog.exec() != dialog.DialogCode.Accepted:
return None
try:
return self._application.create_acceptance_report(
task.task_id,
**dialog.build_payload(),
)
except ValueError as exc:
self._show_warning(parent, str(exc))
return None
def archive_task(
self,
task: TicketTaskSnapshot,
parent=None,
) -> TicketTaskSnapshot | None:
answer = TicketMessageDialog.ask_confirmation(
parent=parent,
title="Архивация задачи",
message=f"Переместить задачу #{task.sequence_number or task.task_id} в архив?",
accept_text="В архив",
reject_text="Отмена",
)
if not answer:
return None
snapshot = self._application.archive_task(task.task_id)
if snapshot is None:
self._show_warning(parent, "Не удалось переместить задачу в архив.")
return snapshot
def refuse_task(
self,
task: TicketTaskSnapshot,
parent=None,
) -> TicketTaskSnapshot | None:
dialog = TaskRefusalDialog(task, parent=parent)
if dialog.exec() != dialog.DialogCode.Accepted:
return None
refusal_reason = dialog.refusal_reason
if not refusal_reason:
self._show_warning(parent, "Причина отказа не указана.")
return None
snapshot = self._application.refuse_task(task.task_id, refusal_reason)
if snapshot is None:
self._show_warning(parent, "Не удалось перевести задачу в отказ.")
return snapshot
@staticmethod
def _show_warning(parent, text: str) -> None:
TicketMessageDialog.show_warning(parent, "Ticket", text)

View File

@@ -0,0 +1,279 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/details/task_details_dialog.py
"""Dialog-экран подробностей задачи Ticket в ecco-геометрии."""
from __future__ import annotations
from gui.components import Button, Dialog, Label
from gui.containers import HContainer, VContainer
from application.ticket_application_api import TicketApplicationApi
from domain import TicketTaskSnapshot
from ui.cards.task_card_pixmap_factory import (
build_placeholder_avatar_pixmap,
load_avatar_pixmap,
)
from .task_details_actions import TaskDetailsActions
from .task_details_view_data import (
build_employee_view_data,
build_task_stage_rows,
build_task_summary_rows,
can_archive_task,
can_refuse_task,
)
from .task_stage_action_row import TaskStageActionRow
class TaskDetailsDialog(Dialog):
"""Диалог подробностей задачи и доступных действий по этапам."""
_SUMMARY_TITLES = (
"Учреждение",
"Оборудование",
"Кабинет",
"Назначение",
)
_STAGE_ORDER = (
"specialist",
"diagnostic",
"repair",
"acceptance",
)
def __init__(
self,
application: TicketApplicationApi,
task_id: int,
parent=None,
):
self._application = application
self._task_id = task_id
self._task: TicketTaskSnapshot | None = None
self._actions = TaskDetailsActions(application)
self._summary_value_labels: dict[str, Label] = {}
self._stage_rows: dict[str, TaskStageActionRow] = {}
self._employee_avatar_label: Label | None = None
self._employee_name_label: Label | None = None
self._employee_role_label: Label | None = None
self._refuse_button: Button | None = None
super().__init__(
title="Подробности",
width=360,
height=600,
modal=True,
parent=parent,
)
self._setup_ui()
self._connect_signals()
self._reload_task()
def _setup_ui(self) -> None:
# Root-контейнер окна: раскладывает summary, этапы, сотрудника и footer по вертикали.
root = VContainer(margin=[24, 20, 24, 20], spacing=20)
self.add_widget(root)
root.add_widget(self._build_summary_section())
root.add_widget(Label("", style="TICKET_DETAILS_DIVIDER"))
root.add_widget(self._build_stages_section())
root.add_widget(self._build_employee_section())
root.add_stretch()
root.add_widget(self._build_footer_section())
def _build_summary_section(self) -> VContainer:
# Summary-section: верхняя сводка по задаче с основными идентификационными полями.
summary = VContainer(spacing=12, content_fit=True)
for title in self._SUMMARY_TITLES:
summary.add_widget(self._build_summary_row(title))
return summary
def _build_summary_row(self, title: str) -> HContainer:
# Summary-row: одна горизонтальная строка конкретного поля в блоке сводки.
row = HContainer(spacing=10, content_fit=True)
title_label = Label(
f"{title}:",
alignment="left",
style="TICKET_DETAILS_SUMMARY_TITLE",
)
value_label = Label(
"",
alignment="left",
style="TICKET_DETAILS_SUMMARY_VALUE",
)
self._summary_value_labels[title] = value_label
row.add_widget(title_label)
row.add_widget_with_stretch(value_label, 1)
return row
def _build_stages_section(self) -> VContainer:
# Stages-section: блок со списком шагов выполнения и доступных действий по ним.
section = VContainer(spacing=12, content_fit=True)
section.add_widget(
Label(
"Этапы выполнения заявки",
alignment="left",
style="TICKET_DETAILS_SECTION_TITLE",
)
)
# Stage-list: стек интерактивных строк этапов внутри секции stages.
stage_list = VContainer(spacing=12, content_fit=True)
for stage_key in self._STAGE_ORDER:
stage_row = TaskStageActionRow(stage_key)
stage_row.clicked.connect(self._on_stage_clicked)
self._stage_rows[stage_key] = stage_row
stage_list.add_widget(stage_row)
section.add_widget(stage_list)
return section
def _build_employee_section(self) -> VContainer:
# Employee-section: отдельный блок ответственного сотрудника и его служебной информации.
section = VContainer(spacing=12, content_fit=True)
section.add_widget(
Label(
"Ответственный сотрудник",
alignment="left",
style="TICKET_DETAILS_SECTION_TITLE",
)
)
# Employee-row: горизонтальная строка карточки сотрудника с аватаром и текстом.
employee_row = HContainer(spacing=16, content_fit=True)
self._employee_avatar_label = Label("", style="TICKET_TASK_CARD_AVATAR_IMAGE")
self._employee_avatar_label.set_fixed_size(64, 64)
self._employee_name_label = Label(
"",
alignment="left",
style="TICKET_DETAILS_EMPLOYEE_NAME",
)
self._employee_role_label = Label(
"",
alignment="left",
style="TICKET_DETAILS_EMPLOYEE_ROLE",
)
# Info-column: вертикальный столбец имени и должности рядом с аватаром.
info_column = VContainer(spacing=2, content_fit=True)
info_column.add_widget(self._employee_name_label)
info_column.add_widget(self._employee_role_label)
employee_row.add_widget(self._employee_avatar_label)
employee_row.add_widget_with_stretch(info_column, 1)
section.add_widget(employee_row)
return section
def _build_footer_section(self) -> HContainer:
# Footer-row: нижняя линия действий с центрированными кнопками.
footer = HContainer(spacing=12, content_fit=True)
self._refuse_button = Button(
"Отказать в обслуживании",
style="TICKET_DETAILS_REFUSE_BUTTON",
content_fit=True,
)
self._archive_button = Button(
"В архив",
style="TICKET_DETAILS_REFUSE_BUTTON",
content_fit=True,
)
footer.add_stretch()
footer.add_widget(self._refuse_button)
footer.add_widget(self._archive_button)
footer.add_stretch()
return footer
def _connect_signals(self) -> None:
self._application.task_updated.connect(self._on_task_updated)
self._application.task_removed.connect(self._on_task_removed)
if self._refuse_button is not None:
self._refuse_button.clicked.connect(self._on_refuse_clicked)
if self._archive_button is not None:
self._archive_button.clicked.connect(self._on_archive_clicked)
def _reload_task(self) -> None:
task = self._application.get_task(self._task_id)
if task is None:
self.reject()
return
self._task = task
self._update_view(task)
def _update_view(self, task: TicketTaskSnapshot) -> None:
for title, value in build_task_summary_rows(task):
label = self._summary_value_labels.get(title)
if label is not None:
label.set_text(value)
label.set_tooltip(value)
for stage_view in build_task_stage_rows(task):
stage_row = self._stage_rows.get(stage_view.key)
if stage_row is not None:
stage_row.configure(
text=stage_view.text,
icon_path=stage_view.icon_path,
emphasized=stage_view.emphasized,
clickable=stage_view.clickable,
)
self._update_employee_section(task)
if self._refuse_button is not None:
self._refuse_button.set_visible(can_refuse_task(task))
if self._archive_button is not None:
self._archive_button.set_visible(can_archive_task(task))
def _update_employee_section(self, task: TicketTaskSnapshot) -> None:
employee_data = build_employee_view_data(task)
if self._employee_name_label is not None:
self._employee_name_label.set_text(employee_data["name"])
if self._employee_role_label is not None:
role_text = employee_data["position"].strip()
self._employee_role_label.set_text(role_text)
self._employee_role_label.set_visible(bool(role_text))
if self._employee_avatar_label is None:
return
avatar_pixmap = load_avatar_pixmap(
employee_data["photo_path"],
64,
64,
padding=2,
)
if avatar_pixmap is None:
avatar_pixmap = build_placeholder_avatar_pixmap(64)
self._employee_avatar_label.set_pixmap(avatar_pixmap)
def _on_task_updated(self, task: TicketTaskSnapshot) -> None:
if task.task_id != self._task_id:
return
self._task = task
self._update_view(task)
def _on_task_removed(self, task_id: int) -> None:
if task_id == self._task_id:
self.reject()
def _on_stage_clicked(self, stage_key: str) -> None:
if self._task is None:
return
if stage_key == "specialist":
self._actions.assign_specialist(self._task, self)
return
if stage_key == "diagnostic":
self._actions.sign_diagnostic(self._task, self)
return
if stage_key == "repair":
self._actions.sign_repair(self._task, self)
return
if stage_key == "acceptance":
self._actions.sign_acceptance(self._task, self)
def _on_refuse_clicked(self) -> None:
if self._task is None:
return
snapshot = self._actions.refuse_task(self._task, self)
if snapshot is not None:
self.accept()
def _on_archive_clicked(self) -> None:
if self._task is None:
return
snapshot = self._actions.archive_task(self._task, self)
if snapshot is not None:
self.accept()

View File

@@ -0,0 +1,116 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/details/task_details_view_data.py
"""Форматирование данных для нового dialog-экрана подробностей Ticket."""
from __future__ import annotations
from dataclasses import dataclass
from domain import TicketTaskSnapshot, parse_location_parts
from domain.ticket_constants import STATE_ARCHIVED, STATE_COMPLETED, STATE_REFUSED
from ui.task_view_formatters import (
build_specialist_card_info,
build_specialist_photo_path,
build_stage_icon_path,
can_assign_specialist,
can_sign_acceptance,
can_sign_diagnostic,
can_sign_repair,
)
@dataclass(frozen=True, slots=True)
class TaskStageRowData:
"""View-data одной строки этапа выполнения заявки."""
key: str
text: str
icon_path: str
emphasized: bool
clickable: bool
def build_task_summary_rows(task: TicketTaskSnapshot) -> tuple[tuple[str, str], ...]:
institution, room, device = parse_location_parts(task.location or "")
return (
("Учреждение", institution or "Локация не указана"),
("Оборудование", device or "Аппарат не указан"),
("Кабинет", room or "Кабинет не указан"),
("Назначение", build_task_status_text(task)),
)
def build_task_status_text(task: TicketTaskSnapshot) -> str:
specialist_info = build_specialist_card_info(task.assigned_specialist)
short_name = specialist_info["short_name"].strip()
if not short_name:
return "Специалист не назначен"
return short_name
def build_employee_view_data(task: TicketTaskSnapshot) -> dict[str, str]:
specialist_info = build_specialist_card_info(task.assigned_specialist)
short_name = specialist_info["short_name"].strip()
position = specialist_info["position"].strip()
return {
"name": short_name or "Специалист не назначен",
"position": position if short_name else "Ожидает назначения",
"photo_path": build_specialist_photo_path(
task.assigned_specialist,
task.specialist_photo,
),
}
def build_task_stage_rows(task: TicketTaskSnapshot) -> tuple[TaskStageRowData, ...]:
return (
TaskStageRowData(
key="specialist",
text="Специалист назначен" if task.assigned_specialist.strip() else "Назначить специалиста",
icon_path=build_stage_icon_path("specialist", True),
emphasized=bool(task.assigned_specialist.strip()),
clickable=can_assign_specialist(task),
),
TaskStageRowData(
key="diagnostic",
text=(
"Отчёт диагностики составлен"
if task.diagnostic_report_signed
else "Составить отчёт диагностики"
),
icon_path=build_stage_icon_path("diagnostic", True),
emphasized=bool(task.diagnostic_report_signed),
clickable=can_sign_diagnostic(task),
),
TaskStageRowData(
key="repair",
text=(
"Отчёт по ремонту составлен"
if task.repair_report_signed
else "Составить отчёт по ремонту"
),
icon_path=build_stage_icon_path("repair", True),
emphasized=bool(task.repair_report_signed),
clickable=can_sign_repair(task),
),
TaskStageRowData(
key="acceptance",
text=(
"Акт приёмки работ подписан"
if task.acceptance_report_signed
else "Составить акт приёмки работ"
),
icon_path=build_stage_icon_path("acceptance", True),
emphasized=bool(task.acceptance_report_signed),
clickable=can_sign_acceptance(task),
),
)
def can_refuse_task(task: TicketTaskSnapshot) -> bool:
return task.state_code not in {STATE_COMPLETED, STATE_REFUSED, STATE_ARCHIVED}
def can_archive_task(task: TicketTaskSnapshot) -> bool:
return task.state_code in {STATE_COMPLETED, STATE_REFUSED}

View File

@@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/details/task_stage_action_row.py
"""Clickable task stage row for Ticket details."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal, Slot
from PySide6.QtGui import QMouseEvent
from gui.components import Label
from gui.containers import HContainer
from gui.theme_bus import theme_bus
from ui.cards.task_card_pixmap_factory import load_tinted_icon_pixmap
class TaskStageActionRow(HContainer):
"""Row with icon, text and optional click action."""
clicked = Signal(str)
def __init__(self, stage_key: str, parent=None):
super().__init__(
margin=[4, 2, 4, 2],
spacing=12,
content_fit=True,
parent=parent,
style="TICKET_DETAILS_STAGE_ROW",
active_style="TICKET_DETAILS_STAGE_ROW_ACTIVE",
is_active=False,
)
self._stage_key = stage_key
self._icon_path = ""
self._theme = "dark" if self.palette().window().color().lightness() < 128 else "light"
self._is_clickable = False
self._is_highlighted = False
self._icon_label: Label | None = None
self._text_label: Label | None = None
self._setup_ui()
theme_bus.theme_changed.connect(self.set_theme)
def _setup_ui(self) -> None:
# Root-row этапа: держит иконку, текст этапа и свободное место для выравнивания строки.
self._icon_label = Label("", style="TICKET_TASK_CARD_AVATAR_IMAGE")
self._icon_label.set_fixed_size(22, 22)
self._text_label = Label(
"",
alignment="left",
style="TICKET_DETAILS_STAGE_TEXT_INACTIVE",
active_style="TICKET_DETAILS_STAGE_TEXT_ACTIVE",
is_active=False,
)
self.add_widget(self._icon_label)
self.add_widget(self._text_label)
self.add_stretch()
def configure(
self,
text: str,
icon_path: str,
emphasized: bool,
clickable: bool,
) -> None:
self._icon_path = icon_path
self._is_clickable = bool(clickable)
self._is_highlighted = bool(emphasized) or self._is_clickable
if self._text_label is not None:
self._text_label.set_text(text)
self._text_label.style(is_active=self._is_highlighted)
self.style(is_active=self._is_clickable)
self.setCursor(
Qt.CursorShape.PointingHandCursor
if self._is_clickable
else Qt.CursorShape.ArrowCursor
)
self._refresh_icon()
@Slot(str)
def set_theme(self, theme: str) -> None:
normalized_theme = (theme or "").strip().lower()
if normalized_theme in {"dark", "light"}:
self._theme = normalized_theme
super().set_theme(theme)
self._refresh_icon()
def mousePressEvent(self, event: QMouseEvent) -> None:
if self._is_clickable and event.button() == Qt.MouseButton.LeftButton:
self.clicked.emit(self._stage_key)
super().mousePressEvent(event)
def _refresh_icon(self) -> None:
if self._icon_label is None or not self._icon_path:
return
icon_width = self._icon_label.width() or 22
icon_height = self._icon_label.height() or 22
pixmap = load_tinted_icon_pixmap(
self._icon_path,
icon_width,
icon_height,
self._resolve_icon_color(),
padding=2,
)
if pixmap is not None:
self._icon_label.set_pixmap(pixmap)
def _resolve_icon_color(self) -> str:
if self._is_highlighted:
return "#F5F7FA" if self._theme == "dark" else "#172B4D"
return "#8C9BAB" if self._theme == "dark" else "#6B778C"

View File

@@ -0,0 +1,17 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/dialogs/__init__.py
"""Диалоги Ticket."""
from .acceptance_dialog import AcceptanceDialog
from .specialist_dialog import SpecialistDialog
from .task_refusal_dialog import TaskRefusalDialog
from .report_dialogs import DiagnosticReportDialog, RepairReportDialog
__all__ = [
"AcceptanceDialog",
"DiagnosticReportDialog",
"RepairReportDialog",
"SpecialistDialog",
"TaskRefusalDialog",
]

View File

@@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/dialogs/acceptance_dialog.py
"""UI-диалог акта приёмки Ticket."""
from __future__ import annotations
from gui.containers import SContainer
from domain import TicketTaskSnapshot
from .base_document_dialog import BaseDocumentDialog
class AcceptanceDialog(BaseDocumentDialog):
"""Диалог подписания акта приёмки."""
def __init__(
self,
task: TicketTaskSnapshot,
parent=None,
):
self._work_description = None
self._executor_signature = None
self._customer_signature = None
super().__init__(
task=task,
title="Акт приёмки",
submit_text="Подписать акт",
parent=parent,
)
def build_payload(self) -> dict[str, str]:
return {
"work_description": self._work_description.get_text().strip(),
"executor_signature": self._executor_signature.get_text().strip(),
"customer_signature": self._customer_signature.get_text().strip(),
}
def _is_ready(self) -> bool:
payload = self.build_payload()
return bool(
payload["work_description"]
and payload["executor_signature"]
and payload["customer_signature"]
)
def _build_form(self, container: SContainer) -> None:
work_description_shell = SContainer(
height_percent=40,
orientation="v",
spacing=6,
content_fit=False,
)
self._work_description = self._populate_text_block(
work_description_shell,
"Описание выполненных работ",
"Опишите объём работ, который передаётся заказчику.",
)
container.add_widget(work_description_shell)
executor_signature_shell = SContainer(
height_percent=23,
orientation="v",
spacing=6,
content_fit=False,
)
self._executor_signature = self._populate_text_block(
executor_signature_shell,
"Исполнитель",
"Укажите ФИО и должность исполнителя.",
)
container.add_widget(executor_signature_shell)
customer_signature_shell = SContainer(
height_percent=23,
orientation="v",
spacing=6,
content_fit=False,
)
self._customer_signature = self._populate_text_block(
customer_signature_shell,
"Заказчик",
"Укажите ФИО и должность представителя заказчика.",
)
container.add_widget(customer_signature_shell)
self._refresh_submit_state()

View File

@@ -0,0 +1,218 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/dialogs/base_document_dialog.py
"""Базовый UI-диалог документов Ticket без файловой и доменной логики."""
from __future__ import annotations
from gui.components import Button, Dialog, Label, TextInput
from gui.containers import HContainer, SContainer, VContainer
from domain import TicketTaskSnapshot, parse_location_parts
from ui.task_view_formatters import build_specialist_card_info
class BaseDocumentDialog(Dialog):
"""Базовый modal-диалог для ввода данных документа Ticket."""
def __init__(
self,
task: TicketTaskSnapshot,
title: str,
submit_text: str,
parent=None,
):
self._task = task
self._cancel_button: Button | None = None
self._submit_button: Button | None = None
self._form_text_edits: list[TextInput] = []
super().__init__(
title=title,
width=540,
height=740,
modal=True,
parent=parent,
)
self._setup_ui(submit_text)
self._connect_signals()
self._refresh_submit_state()
def _setup_ui(self, submit_text: str) -> None:
# Root-контейнер документа: собирает все основные зоны окна сверху вниз.
root = VContainer(margin=[24, 20, 24, 20], spacing=0)
self.add_widget(root)
root.add_widget(self._build_summary_shell())
root.add_widget(self._build_divider())
root.add_widget(self._build_form_shell())
root.add_widget(self._build_actions_section(submit_text))
def _build_summary_shell(self) -> SContainer:
# Summary-shell: верхняя область окна с базовой карточкой контекста документа.
summary_shell = SContainer(
height_percent=24,
orientation="v",
content_fit=False,
)
# Summary-content: вертикальный стек строк внутри summary-shell.
summary_content = VContainer(spacing=10, content_fit=True, parent=summary_shell)
for row in self._build_summary_rows():
summary_content.add_widget(row)
return summary_shell
def _build_summary_rows(self) -> list[HContainer]:
institution, room, device = parse_location_parts(self._task.location)
rows: list[HContainer] = []
for title, value in (
("Учреждение", institution or "Локация не указана"),
("Оборудование", device or "Аппарат не указан"),
("Кабинет", room or "Кабинет не указан"),
("Специалист", self._build_specialist_summary()),
):
rows.append(self._build_summary_row(title, value))
return rows
def _build_summary_row(self, title: str, value: str) -> HContainer:
# Summary-row: горизонтальная строка для пары "заголовок поля + значение".
row = HContainer(spacing=10, content_fit=True)
title_label = Label(
f"{title}:",
alignment="left",
style="TICKET_DETAILS_SUMMARY_TITLE",
)
value_label = Label(
value,
alignment="left",
style="TICKET_DETAILS_SUMMARY_VALUE",
)
value_label.set_tooltip(value)
row.add_widget(title_label)
row.add_widget_with_stretch(value_label, 1)
return row
def _build_divider(self) -> Label:
return Label(
"",
style="TICKET_DETAILS_DIVIDER",
height_percent=1,
)
def _build_form_shell(self) -> SContainer:
# Form-shell: центральная рабочая зона, куда наследник добавляет поля документа.
form_shell = SContainer(
height_percent=58,
orientation="v",
spacing=10,
content_fit=False,
)
self._build_form(form_shell)
return form_shell
def _build_actions_section(self, submit_text: str) -> HContainer:
# Actions-row: нижняя линия действий с фиксированной процентной сеткой.
actions = HContainer(
height_percent=11,
spacing=0,
content_fit=False,
)
# Левый spacer-контейнер: формирует стартовый отступ перед кнопкой отмены.
SContainer(
width_percent=10,
height_percent=100,
parent=actions,
)
self._cancel_button = Button(
"Отмена",
width_percent=26,
height_percent=100,
margin=0,
style="TICKET_DOCUMENT_CANCEL_BUTTON",
content_fit=False,
)
actions.add_widget(self._cancel_button)
# Центральный spacer-контейнер: удерживает зазор между двумя action-кнопками.
SContainer(
width_percent=4,
height_percent=100,
parent=actions,
)
self._submit_button = Button(
submit_text,
width_percent=50,
height_percent=100,
margin=0,
style="TICKET_DOCUMENT_SUBMIT_BUTTON",
content_fit=False,
)
actions.add_widget(self._submit_button)
# Правый spacer-контейнер: завершает строку действий симметричным отступом.
SContainer(
width_percent=10,
height_percent=100,
parent=actions,
)
self._submit_button.set_enabled(False)
return actions
def _connect_signals(self) -> None:
if self._cancel_button is not None:
self._cancel_button.clicked.connect(self.reject)
if self._submit_button is not None:
self._submit_button.clicked.connect(self.accept)
for text_edit in self._form_text_edits:
text_edit.text_changed.connect(self._refresh_submit_state)
self._connect_form_signals()
def build_payload(self) -> dict[str, str]:
"""Вернуть данные формы. Реализуется в наследниках."""
raise NotImplementedError
def _build_form(self, container: SContainer) -> None:
"""Построить специфическую часть формы."""
raise NotImplementedError
def _is_ready(self) -> bool:
"""Проверить, что форма готова к отправке."""
raise NotImplementedError
def _connect_form_signals(self) -> None:
"""Подключить сигналы элементов формы, если кроме текстовых полей есть другие элементы."""
return None
def _populate_text_block(
self,
field_shell: SContainer,
title: str,
placeholder: str,
) -> TextInput:
# Field-shell: ожидает внутри себя заголовок поля и текстовую область конкретной секции.
field_shell.add_widget(
Label(
title,
alignment="left",
style="TICKET_DETAILS_SECTION_TITLE",
)
)
text_edit = TextInput(
placeholder=placeholder,
style="TICKET_DOCUMENT_TEXTAREA",
multiline=True,
content_fit=False,
)
self._form_text_edits.append(text_edit)
field_shell.add_widget_with_stretch(text_edit, 1)
return text_edit
def _refresh_submit_state(self) -> None:
if self._submit_button is not None:
self._submit_button.set_enabled(self._is_ready())
def _build_specialist_summary(self) -> str:
specialist_name = self._task.assigned_specialist.strip()
if not specialist_name:
return "Не назначен"
specialist_info = build_specialist_card_info(specialist_name)
short_name = specialist_info["short_name"].strip() or specialist_name
position = specialist_info["position"].strip()
if not position:
return short_name
return f"{short_name}, {position}"

View File

@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/dialogs/report_dialogs/__init__.py
"""Диалоги отчётов Ticket."""
from .report_dialog import DiagnosticReportDialog, RepairReportDialog
__all__ = [
"DiagnosticReportDialog",
"RepairReportDialog",
]

View File

@@ -0,0 +1,137 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/dialogs/report_dialogs/report_dialog.py
"""UI-диалоги отчётов Ticket поверх application document-flow."""
from __future__ import annotations
from gui.containers import SContainer
from domain import TicketTaskSnapshot
from ui.dialogs.base_document_dialog import BaseDocumentDialog
class DiagnosticReportDialog(BaseDocumentDialog):
"""Диалог диагностического отчёта."""
def __init__(
self,
task: TicketTaskSnapshot,
parent=None,
):
self._initial_cause = None
self._actual_cause = None
super().__init__(
task=task,
title="Диагностический отчёт",
submit_text="Подписать диагностику",
parent=parent,
)
def build_payload(self) -> dict[str, str]:
return {
"initial_cause": self._initial_cause.get_text().strip(),
"actual_cause": self._actual_cause.get_text().strip(),
}
def _is_ready(self) -> bool:
payload = self.build_payload()
return bool(payload["initial_cause"] and payload["actual_cause"])
def _build_form(self, container: SContainer) -> None:
initial_cause_shell = SContainer(
height_percent=48,
orientation="v",
spacing=6,
content_fit=False,
)
self._initial_cause = self._populate_text_block(
initial_cause_shell,
"Первичное заключение",
"Кратко опишите исходную причину неисправности.",
)
container.add_widget(initial_cause_shell)
actual_cause_shell = SContainer(
height_percent=48,
orientation="v",
spacing=6,
content_fit=False,
)
self._actual_cause = self._populate_text_block(
actual_cause_shell,
"Вторичное заключение",
"Зафиксируйте подтверждённую причину по итогам диагностики.",
)
container.add_widget(actual_cause_shell)
self._refresh_submit_state()
class RepairReportDialog(BaseDocumentDialog):
"""Диалог ремонтного отчёта."""
def __init__(
self,
task: TicketTaskSnapshot,
parent=None,
):
self._work_done = None
self._used_parts = None
self._recommendations = None
super().__init__(
task=task,
title="Ремонтный отчёт",
submit_text="Подписать ремонт",
parent=parent,
)
def build_payload(self) -> dict[str, str]:
return {
"work_done": self._work_done.get_text().strip(),
"used_parts": self._used_parts.get_text().strip(),
"recommendations": self._recommendations.get_text().strip(),
}
def _is_ready(self) -> bool:
return bool(self.build_payload()["work_done"])
def _build_form(self, container: SContainer) -> None:
work_done_shell = SContainer(
height_percent=31,
orientation="v",
spacing=6,
content_fit=False,
)
self._work_done = self._populate_text_block(
work_done_shell,
"Выполненные работы",
"Опишите фактически выполненные работы.",
)
container.add_widget(work_done_shell)
used_parts_shell = SContainer(
height_percent=31,
orientation="v",
spacing=6,
content_fit=False,
)
self._used_parts = self._populate_text_block(
used_parts_shell,
"Использованные запчасти",
"Перечислите использованные узлы и материалы, если они были.",
)
container.add_widget(used_parts_shell)
recommendations_shell = SContainer(
height_percent=31,
orientation="v",
spacing=6,
content_fit=False,
)
self._recommendations = self._populate_text_block(
recommendations_shell,
"Рекомендации",
"Добавьте рекомендации для следующего обслуживания.",
)
container.add_widget(recommendations_shell)
self._refresh_submit_state()

View File

@@ -0,0 +1,223 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/dialogs/specialist_dialog.py
"""UI-диалог выбора специалиста Ticket."""
from __future__ import annotations
from collections.abc import Sequence
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QMouseEvent
from gui.components import Button, Dialog, Label
from gui.containers import HContainer, ScrollContainer, SContainer, VContainer
from ui.cards.task_card_pixmap_factory import (
build_placeholder_avatar_pixmap,
load_avatar_pixmap,
)
from ui.task_view_formatters import (
build_specialist_card_info,
build_specialist_photo_path,
)
class _SpecialistRow(SContainer):
"""Строка выбора специалиста с фото, именем и должностью."""
clicked = Signal(str)
activated = Signal(str)
def __init__(self, specialist_name: str, parent=None):
super().__init__(
margin=0,
spacing=0,
content_fit=True,
parent=parent,
style="TICKET_SPECIALIST_ITEM",
active_style="TICKET_SPECIALIST_ITEM_SELECTED",
is_active=False,
)
self._specialist_name = specialist_name
self._info = build_specialist_card_info(specialist_name)
self._name_label: Label | None = None
self._role_label: Label | None = None
self._avatar_label: Label | None = None
self.setObjectName("ticket_specialist_row")
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.set_min_height(88)
self._setup_ui()
@property
def specialist_name(self) -> str:
return self._specialist_name
def set_selected(self, selected: bool) -> None:
self.style(is_active=selected)
def mousePressEvent(self, event: QMouseEvent) -> None:
if event.button() == Qt.MouseButton.LeftButton:
self.clicked.emit(self._specialist_name)
super().mousePressEvent(event)
def mouseDoubleClickEvent(self, event: QMouseEvent) -> None:
if event.button() == Qt.MouseButton.LeftButton:
self.clicked.emit(self._specialist_name)
self.activated.emit(self._specialist_name)
super().mouseDoubleClickEvent(event)
def _setup_ui(self) -> None:
# Body-row карточки специалиста: фото слева и текстовый блок справа.
body = HContainer(margin=[12, 10, 12, 10], spacing=16, content_fit=True, parent=self)
body.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
self._avatar_label = Label("", style="TICKET_TASK_CARD_AVATAR_IMAGE")
self._avatar_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
self._avatar_label.set_fixed_size(72, 72)
self._avatar_label.set_pixmap(self._build_avatar_pixmap())
self._name_label = Label(
self._specialist_name,
alignment="left",
style="TICKET_DETAILS_SECTION_TITLE",
)
self._name_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
self._role_label = Label(
self._info.get("position", "").strip() or "Специалист",
alignment="left",
style="TICKET_SPECIALIST_ROLE",
)
self._role_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
# Text-block специалиста: вертикальный контейнер имени и должности.
text_block = VContainer(spacing=2, content_fit=True)
text_block.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
text_block.add_widget(self._name_label)
text_block.add_widget(self._role_label)
body.add_widget(self._avatar_label)
body.add_widget_with_stretch(text_block, 1)
def _build_avatar_pixmap(self):
photo_path = build_specialist_photo_path(
self._specialist_name,
self._info.get("photo", ""),
)
avatar = load_avatar_pixmap(photo_path, 72, 72, padding=2)
if avatar is None:
return build_placeholder_avatar_pixmap(72)
return avatar
class SpecialistDialog(Dialog):
"""Диалог выбора специалиста без application-логики."""
def __init__(
self,
specialists: Sequence[str],
parent=None,
):
self._specialists = [str(item).strip() for item in specialists if str(item).strip()]
self._selected_specialist = ""
self._rows: dict[str, _SpecialistRow] = {}
self._cancel_button: Button | None = None
self._submit_button: Button | None = None
super().__init__(
title="Выбор специалиста",
width=540,
height=740,
modal=True,
parent=parent,
)
self._setup_ui()
self._connect_signals()
self._refresh_submit_state()
@property
def selected_specialist(self) -> str:
return self._selected_specialist
def _setup_ui(self) -> None:
# Root-контейнер диалога: подсказка, список специалистов и action-строка.
main_container = VContainer(margin=[22, 20, 22, 20], spacing=16)
self.add_widget(main_container)
main_container.add_widget(
Label(
"Выберите специалиста для назначения на\nзадачу:",
alignment="left",
style="TICKET_SPECIALIST_HINT",
)
)
main_container.add_widget_with_stretch(self._build_list(), 1)
main_container.add_widget(self._build_actions())
def _build_list(self) -> ScrollContainer:
# Scroll-list специалистов: прокручиваемая область с вариантами назначения.
scroll = ScrollContainer(
spacing=12,
orientation="v",
content_margins=[0, 0, 0, 0],
vertical_scroll_bar_policy="as_needed",
horizontal_scroll_bar_policy="always_off",
style="SCROLL_CONTAINER",
)
for specialist_name in self._specialists:
row = _SpecialistRow(specialist_name)
self._rows[specialist_name] = row
scroll.add_widget(row)
return scroll
def _build_actions(self) -> HContainer:
# Actions-row диалога: выравнивает кнопки отмены и подтверждения выбора.
actions = HContainer(spacing=18, content_fit=True)
actions.add_stretch()
self._cancel_button = Button(
"Отмена",
style="TICKET_DOCUMENT_CANCEL_BUTTON",
content_fit=True,
)
self._cancel_button.set_min_width(160)
self._cancel_button.set_min_height(56)
self._submit_button = Button(
"Выбрать",
style="TICKET_DOCUMENT_SUBMIT_BUTTON",
content_fit=True,
)
self._submit_button.set_min_width(180)
self._submit_button.set_min_height(56)
actions.add_widget(self._cancel_button)
actions.add_widget(self._submit_button)
return actions
def _connect_signals(self) -> None:
for row in self._rows.values():
row.clicked.connect(self._on_row_clicked)
row.activated.connect(self._on_row_activated)
if self._submit_button is not None:
self._submit_button.clicked.connect(self._handle_accept)
if self._cancel_button is not None:
self._cancel_button.clicked.connect(self.reject)
def _refresh_submit_state(self) -> None:
if self._submit_button is not None:
self._submit_button.set_enabled(bool(self._selected_specialist))
def _set_selected_specialist(self, specialist_name: str) -> None:
self._selected_specialist = specialist_name if specialist_name in self._rows else ""
for row_name, row in self._rows.items():
row.set_selected(row_name == self._selected_specialist)
self._refresh_submit_state()
def _handle_accept(self) -> None:
if not self._selected_specialist:
return
self.accept()
def _on_row_clicked(self, specialist_name: str) -> None:
self._set_selected_specialist(specialist_name)
def _on_row_activated(self, specialist_name: str) -> None:
self._set_selected_specialist(specialist_name)
self._handle_accept()

View File

@@ -0,0 +1,135 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/dialogs/task_refusal_dialog.py
"""Диалог подтверждения отказа задачи Ticket."""
from __future__ import annotations
from gui.components import Button, Dialog, Label, TextInput
from gui.containers import HContainer, VContainer
from domain import TicketTaskSnapshot, parse_location_parts
class TaskRefusalDialog(Dialog):
"""Диалог ввода обязательной причины отказа по задаче."""
def __init__(self, task: TicketTaskSnapshot, parent=None):
self._task = task
self._reason_input: TextInput | None = None
self._cancel_button: Button | None = None
self._submit_button: Button | None = None
super().__init__(
title="Отказ в обслуживании",
width=500,
height=460,
modal=True,
parent=parent,
)
self._setup_ui()
self._connect_signals()
self._refresh_submit_state()
@property
def refusal_reason(self) -> str:
if self._reason_input is None:
return ""
return self._reason_input.get_text().strip()
def _setup_ui(self) -> None:
# Root-контейнер окна отказа: предупреждение, контекст задачи, поле причины и actions.
main_container = VContainer(margin=[24, 20, 24, 20], spacing=16)
self.add_widget(main_container)
main_container.add_widget(
Label(
"Вы уверены, что хотите отказать в обслуживании?",
alignment="left",
style="TICKET_REFUSAL_HEADING",
)
)
main_container.add_widget(self._build_location_row())
main_container.add_widget(
Label(
'Задача будет перемещена в колонку "Отказ"',
alignment="left",
style="TICKET_REFUSAL_WARNING",
)
)
main_container.add_widget(
Label(
"Причина отказа",
alignment="left",
style="TICKET_REFUSAL_HEADING",
)
)
main_container.add_widget_with_stretch(self._build_reason_input(), 1)
main_container.add_widget(self._build_actions())
def _build_location_row(self) -> HContainer:
# Location-row: показывает локацию задачи, чтобы отказ происходил в явном контексте.
row = HContainer(spacing=10, content_fit=True)
row.add_widget(
Label(
"Локация и кабинет:",
alignment="left",
style="TICKET_REFUSAL_LOCATION_TITLE",
)
)
value_label = Label(
self._build_location_text(),
alignment="left",
style="TICKET_REFUSAL_LOCATION_VALUE",
)
value_label.set_tooltip(self._build_location_text())
row.add_widget_with_stretch(value_label, 1)
return row
def _build_reason_input(self) -> TextInput:
self._reason_input = TextInput(
placeholder="Укажите причину отказа",
style="TICKET_DOCUMENT_TEXTAREA",
multiline=True,
)
self._reason_input.set_min_height(170)
return self._reason_input
def _build_actions(self) -> HContainer:
# Actions-row окна отказа: собирает кнопки отмены и финального подтверждения.
actions = HContainer(spacing=18, content_fit=True)
actions.add_stretch()
self._cancel_button = Button(
"Отмена",
style="TICKET_DOCUMENT_CANCEL_BUTTON",
content_fit=True,
)
self._submit_button = Button(
"Подтвердить отказ",
style="TICKET_DOCUMENT_SUBMIT_BUTTON",
content_fit=True,
)
actions.add_widget(self._cancel_button)
actions.add_widget(self._submit_button)
return actions
def _connect_signals(self) -> None:
if self._reason_input is not None:
self._reason_input.text_changed.connect(self._refresh_submit_state)
if self._cancel_button is not None:
self._cancel_button.clicked.connect(self.reject)
if self._submit_button is not None:
self._submit_button.clicked.connect(self._handle_accept)
def _refresh_submit_state(self) -> None:
if self._submit_button is not None:
self._submit_button.set_enabled(bool(self.refusal_reason))
def _handle_accept(self) -> None:
if not self.refusal_reason:
return
self.accept()
def _build_location_text(self) -> str:
institution, room, _ = parse_location_parts(self._task.location or "")
normalized_institution = institution or "Локация не указана"
normalized_room = room or "Кабинет не указан"
return f"{normalized_institution}, {normalized_room}"

View File

@@ -0,0 +1,16 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/pages/__init__.py
"""Страницы Ticket."""
from .acts_page import ActsPage
from .archive_page import ArchivePage
from .reports_page import ReportsPage
from .report_viewer import ReportViewer
__all__ = [
"ActsPage",
"ArchivePage",
"ReportsPage",
"ReportViewer",
]

View File

@@ -0,0 +1,335 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/pages/acts_page.py
"""Самостоятельная страница актов Ticket."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QSizePolicy
from gui.components import Label, TextInput
from gui.containers import HContainer, SContainer, ScrollContainer, VContainer
from application import TaskApplicationService
from domain import TicketDocumentSnapshot
# -------------------------------------------------------------------------
# Карточка акта (по образцу _ReportCardView)
# -------------------------------------------------------------------------
class _ActCardView(SContainer):
"""Карточка акта: заголовок, дата+учреждение, аппарат+кабинет."""
card_clicked = Signal(str)
def __init__(self, document: TicketDocumentSnapshot, parent=None):
super().__init__(
width_percent=100,
margin=0,
content_fit=True,
parent=parent,
)
self._document_id = document.document_id
self._title_label: Label | None = None
self._subtitle_label: Label | None = None
self._meta_label: Label | None = None
self._selected = False
self._height_sync_in_progress = False
self.setObjectName("ticket_report_card")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.set_size_policy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self._setup_ui()
self._fill(document)
@property
def document_id(self) -> str:
return self._document_id
def set_selected(self, selected: bool) -> None:
self._selected = selected
self._apply_root_style()
def _setup_ui(self) -> None:
content = SContainer(
width_percent=100,
height_percent=100,
margin=8,
spacing=0,
style="TICKET_REPORT_CARD_CONTENT",
parent=self,
)
text_column = VContainer(
width_percent=100,
spacing=2,
parent=content,
)
self._title_label = Label("", alignment="left", parent=text_column)
self._subtitle_label = Label("", alignment="left", parent=text_column)
self._meta_label = Label("", alignment="left", parent=text_column)
def _fill(self, document: TicketDocumentSnapshot) -> None:
title = document.title or "Акт"
created_at = document.created_at.strftime("%d.%m.%Y %H:%M")
facility = document.payload.get("facility") or document.location or ""
if "(" in facility:
facility = facility[:facility.index("(")].strip()
device = document.payload.get("device") or ""
cabinet = document.payload.get("cabinet") or ""
subtitle_parts = [p for p in (created_at, facility) if p]
meta_parts = [p for p in (device, cabinet) if p]
if self._title_label is not None:
self._title_label.set_text(title)
if self._subtitle_label is not None:
self._subtitle_label.set_text(" , ".join(subtitle_parts))
if self._meta_label is not None:
self._meta_label.set_text(" - ".join(meta_parts))
self._apply_root_style()
self._apply_text_styles()
def _apply_root_style(self) -> None:
if self._selected:
self.style("TICKET_REPORT_CARD_ROOT_SELECTED")
else:
self.style("TICKET_REPORT_CARD_ROOT")
def _apply_text_styles(self) -> None:
if self._title_label is not None:
self._title_label.style("TICKET_REPORT_CARD_TITLE")
if self._subtitle_label is not None:
self._subtitle_label.style("TICKET_REPORT_CARD_SUBTITLE")
if self._meta_label is not None:
self._meta_label.style("TICKET_REPORT_CARD_META")
def mousePressEvent(self, event) -> None:
if event.button() == Qt.MouseButton.LeftButton:
self.card_clicked.emit(self._document_id)
super().mousePressEvent(event)
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
self._sync_card_height()
def _sync_card_height(self) -> None:
if self._height_sync_in_progress or self.width() <= 0:
return
target_height = max(1, round(self.width() / 2.745))
if self.height() == target_height:
return
self._height_sync_in_progress = True
self.setFixedHeight(target_height)
self._height_sync_in_progress = False
# -------------------------------------------------------------------------
# Страница актов
# -------------------------------------------------------------------------
class ActsPage(SContainer):
"""Страница актов, полностью локализованная внутри собственного класса."""
def __init__(self, application: TaskApplicationService, parent=None):
super().__init__(width_percent=100, height_percent=100, parent=parent)
self._application = application
self._documents: dict[str, TicketDocumentSnapshot] = {}
self._selected_document_id: str | None = None
self._card_host: VContainer | None = None
self._preview: TextInput | None = None
self._empty_list_label: Label | None = None
self._cards: dict[str, _ActCardView] = {}
self._setup_ui()
self._connect_signals()
self._apply_initial_state()
self._reload_documents()
# -- UI ----------------------------------------------------------------
def _setup_ui(self) -> None:
board_row = HContainer(
margin=[0, 0, 0, 0],
height_percent=100,
spacing=16,
style="TICKET_SURFACE_HOST",
parent=self,
)
board_row.add_widget(self._build_list_column())
board_row.add_widget(self._build_preview_column())
def _build_list_column(self) -> SContainer:
column = SContainer(spacing=12, parent=None)
header = HContainer(
height_percent=5.37,
margin=0,
spacing=16,
content_fit=False,
style="TICKET_BOARD_COLUMN_HEADER",
)
header.add_widget(
Label("Список документов", margin=[12, 0, 0, 0], style="TICKET_BOARD_COLUMN_TITLE"),
)
header.add_stretch()
body = SContainer(
margin=0,
style="TICKET_REPORT_COLUMN_BODY",
)
scroll = ScrollContainer(
margin=0,
spacing=0,
orientation="v",
vertical_scroll_bar_policy="always_off",
horizontal_scroll_bar_policy="always_off",
style="SCROLL_CONTAINER",
parent=body,
)
scroll.scroll_area.verticalScrollBar().setSingleStep(48)
self._card_host = VContainer(
spacing=12,
content_fit=False,
parent=scroll,
)
self._card_host.set_size_policy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Fixed,
)
column.add_widget(header)
column.add_widget(body)
return column
def _build_preview_column(self) -> SContainer:
column = SContainer(spacing=12, width_percent=79.83, parent=None)
header = HContainer(
height_percent=5.37,
margin=0,
spacing=16,
content_fit=False,
style="TICKET_BOARD_COLUMN_HEADER",
)
header.add_widget(
Label("Просмотр", margin=[12, 0, 0, 0], style="TICKET_BOARD_COLUMN_TITLE"),
)
header.add_stretch()
body = SContainer(
margin=0,
style="TICKET_REPORT_PREVIEW_BODY",
)
preview_inner = VContainer(margin=0, spacing=0, parent=body)
self._preview = TextInput(style="TICKET_REPORT_PREVIEW_AREA", multiline=True)
self._preview.set_read_only(True)
preview_inner.add_widget_with_stretch(self._preview, 1)
column.add_widget(header)
column.add_widget(body)
return column
# -- Signals -----------------------------------------------------------
def _connect_signals(self) -> None:
self._application.task_updated.connect(self._reload_documents)
self._application.state_loaded.connect(self._reload_documents)
def _apply_initial_state(self) -> None:
self._clear_preview()
# -- Data refresh ------------------------------------------------------
def _reload_documents(self, *_args) -> None:
ordered_documents = self._application.list_documents("acceptance")
self._documents = {
document.document_id: document
for document in ordered_documents
}
self._rebuild_cards(ordered_documents)
if self._selected_document_id in self._documents:
self._select_document(self._selected_document_id, update_preview=True)
return
self._selected_document_id = None
self._update_card_selection()
self._clear_preview()
def _rebuild_cards(self, documents: list[TicketDocumentSnapshot]) -> None:
if self._card_host is None:
return
for card in list(self._cards.values()):
self._card_host.remove_widget(card)
card.setParent(None)
self._cards.clear()
if self._empty_list_label is not None:
self._card_host.remove_widget(self._empty_list_label)
self._empty_list_label.setParent(None)
self._empty_list_label = None
if not documents:
self._empty_list_label = Label(
"Подписанные акты пока не созданы.",
alignment="left",
style="TICKET_REPORT_EMPTY_LABEL",
)
self._card_host.insert_widget(0, self._empty_list_label)
return
for index, document in enumerate(documents):
card = _ActCardView(document)
card.card_clicked.connect(self._on_card_clicked)
self._cards[document.document_id] = card
self._card_host.insert_widget(index, card)
self._update_card_selection()
# -- Selection ---------------------------------------------------------
def _select_document(self, document_id: str | None, update_preview: bool) -> None:
normalized = document_id if document_id in self._documents else None
self._selected_document_id = normalized
self._update_card_selection()
if not update_preview:
return
document = self._current_document()
if document is None:
self._clear_preview()
return
self._render_document(document)
def _update_card_selection(self) -> None:
for doc_id, card in self._cards.items():
card.set_selected(doc_id == self._selected_document_id)
def _current_document(self) -> TicketDocumentSnapshot | None:
if self._selected_document_id is None:
return None
return self._documents.get(self._selected_document_id)
def _render_document(self, document: TicketDocumentSnapshot) -> None:
if self._preview is None:
return
self._preview.set_text(document.content or document.summary or document.title)
def _clear_preview(self) -> None:
if self._preview is not None:
self._preview.set_text("")
def _on_card_clicked(self, document_id: str) -> None:
self._select_document(document_id, update_preview=True)
def showEvent(self, event) -> None:
super().showEvent(event)
self._reload_documents()

View File

@@ -0,0 +1,237 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/pages/archive_page.py
"""Самостоятельная страница архива Ticket."""
from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QSizePolicy
from gui.components import Label, TextInput
from gui.containers import HContainer, SContainer, ScrollContainer, VContainer
from application import TaskApplicationService
from domain import ArchiveRecordSnapshot, TicketDocumentSnapshot
from ui.cards import TaskCardView
from .archive_view_helpers import (
build_preview_lines,
cycle_token_from_record,
record_to_task_snapshot,
)
# -------------------------------------------------------------------------
# Страница архива
# -------------------------------------------------------------------------
class ArchivePage(SContainer):
"""Страница архива с карточками в стиле доски задач и панелью просмотра."""
def __init__(self, application: TaskApplicationService, parent=None):
super().__init__(width_percent=100, height_percent=100, parent=parent)
self._application = application
self._records: dict[str, ArchiveRecordSnapshot] = {}
self._selected_record_key: str | None = None
self._card_host: VContainer | None = None
self._preview: TextInput | None = None
self._empty_list_label: Label | None = None
self._cards: dict[str, TaskCardView] = {}
self._card_key_by_task_id: dict[int, str] = {}
self._setup_ui()
self._connect_signals()
self._reload_records()
# -- UI ----------------------------------------------------------------
def _setup_ui(self) -> None:
board_row = HContainer(
margin=[0, 0, 0, 0],
height_percent=100,
spacing=16,
style="TICKET_SURFACE_HOST",
parent=self,
)
board_row.add_widget(self._build_list_column())
board_row.add_widget(self._build_preview_column())
def _build_list_column(self) -> SContainer:
column = SContainer(spacing=12, parent=None)
header = HContainer(
height_percent=5.37,
margin=0,
spacing=16,
content_fit=False,
style="TICKET_BOARD_COLUMN_HEADER",
)
header.add_widget(
Label("Архив задач", margin=[12, 0, 0, 0], style="TICKET_BOARD_COLUMN_TITLE"),
)
header.add_stretch()
body = SContainer(
margin=0,
style="TICKET_REPORT_COLUMN_BODY",
)
scroll = ScrollContainer(
margin=0,
spacing=0,
orientation="v",
vertical_scroll_bar_policy="always_off",
horizontal_scroll_bar_policy="always_off",
style="SCROLL_CONTAINER",
parent=body,
)
scroll.scroll_area.verticalScrollBar().setSingleStep(48)
self._card_host = VContainer(
spacing=12,
content_fit=False,
parent=scroll,
)
self._card_host.set_size_policy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Fixed,
)
column.add_widget(header)
column.add_widget(body)
return column
def _build_preview_column(self) -> SContainer:
column = SContainer(spacing=12, width_percent=79.83, parent=None)
header = HContainer(
height_percent=5.37,
margin=0,
spacing=16,
content_fit=False,
style="TICKET_BOARD_COLUMN_HEADER",
)
header.add_widget(
Label("Просмотр", margin=[12, 0, 0, 0], style="TICKET_BOARD_COLUMN_TITLE"),
)
header.add_stretch()
body = SContainer(
margin=0,
style="TICKET_REPORT_PREVIEW_BODY",
)
preview_inner = VContainer(margin=0, spacing=0, parent=body)
self._preview = TextInput(style="TICKET_REPORT_PREVIEW_AREA", multiline=True)
self._preview.set_read_only(True)
self._preview._input.setVerticalScrollBarPolicy(
Qt.ScrollBarPolicy.ScrollBarAlwaysOff,
)
preview_inner.add_widget_with_stretch(self._preview, 1)
column.add_widget(header)
column.add_widget(body)
return column
# -- Signals -----------------------------------------------------------
def _connect_signals(self) -> None:
self._application.task_updated.connect(self._reload_records)
self._application.task_removed.connect(self._reload_records)
self._application.state_loaded.connect(self._reload_records)
# -- Data refresh ------------------------------------------------------
@staticmethod
def _record_key(record: ArchiveRecordSnapshot) -> str:
token = cycle_token_from_record(record)
return f"{record.task_id}_{token}" if token else str(record.task_id)
def _reload_records(self, *_args) -> None:
records = self._application.list_archive_records()
self._records = {self._record_key(r): r for r in records}
self._rebuild_cards(records)
if self._selected_record_key in self._records:
self._select_record(self._selected_record_key, update_preview=True)
return
self._selected_record_key = None
self._clear_preview()
def _rebuild_cards(self, records: list[ArchiveRecordSnapshot]) -> None:
if self._card_host is None:
return
for card in list(self._cards.values()):
self._card_host.remove_widget(card)
card.setParent(None)
self._cards.clear()
self._card_key_by_task_id.clear()
if self._empty_list_label is not None:
self._card_host.remove_widget(self._empty_list_label)
self._empty_list_label.setParent(None)
self._empty_list_label = None
if not records:
self._empty_list_label = Label(
"Архивные задачи пока отсутствуют.",
alignment="left",
style="TICKET_REPORT_EMPTY_LABEL",
)
self._card_host.insert_widget(0, self._empty_list_label)
return
for index, record in enumerate(records):
key = self._record_key(record)
synthetic_task = record_to_task_snapshot(record)
card = TaskCardView(synthetic_task)
card.card_clicked.connect(self._on_card_clicked)
self._cards[key] = card
self._card_key_by_task_id[record.task_id] = key
self._card_host.insert_widget(index, card)
# -- Selection ---------------------------------------------------------
def _select_record(self, key: str | None, update_preview: bool) -> None:
normalized = key if key in self._records else None
self._selected_record_key = normalized
if not update_preview:
return
record = self._records.get(normalized) if normalized is not None else None
if record is None:
self._clear_preview()
return
self._render_record(record)
def _render_record(self, record: ArchiveRecordSnapshot) -> None:
if self._preview is None:
return
documents = self._load_cycle_documents(record)
lines = build_preview_lines(record, documents)
self._preview.set_text("\n".join(lines))
def _load_cycle_documents(
self, record: ArchiveRecordSnapshot,
) -> list[TicketDocumentSnapshot]:
"""Загрузить только документы текущего цикла задачи."""
cycle_token = cycle_token_from_record(record)
all_docs = self._application.list_documents()
return [
d for d in all_docs
if d.task_id == record.task_id and cycle_token in d.document_id
]
def _clear_preview(self) -> None:
if self._preview is not None:
self._preview.set_text("")
def _on_card_clicked(self, card_id: object) -> None:
if isinstance(card_id, int):
key = self._card_key_by_task_id.get(card_id)
if key is not None:
self._select_record(key, update_preview=True)
def showEvent(self, event) -> None:
super().showEvent(event)
self._reload_records()

View File

@@ -0,0 +1,129 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/pages/archive_view_helpers.py
"""Вспомогательные функции построения представления архива."""
from __future__ import annotations
from domain import ArchiveRecordSnapshot, TicketDocumentSnapshot, TicketTaskSnapshot
from domain.ticket_constants import STATE_REFUSED, TICKET_STATE_COLORS, TICKET_STATE_NAMES
from ui.task_view_formatters import format_datetime, split_task_location
def record_to_task_snapshot(record: ArchiveRecordSnapshot) -> TicketTaskSnapshot:
"""Построить синтетический TicketTaskSnapshot для отрисовки карточки."""
state_code = record.pre_archive_state_code
return TicketTaskSnapshot(
task_id=record.task_id,
location=record.location,
state_code=state_code,
state_name=TICKET_STATE_NAMES.get(state_code, "Архив"),
action_text=record.action_text,
color_hex=TICKET_STATE_COLORS.get(state_code, record.color_hex),
created_at=record.created_at,
completed_at=record.completed_at,
refused_from_state=record.refused_from_state,
refusal_reason=record.refusal_reason,
assigned_specialist=record.assigned_specialist,
specialist_photo=record.specialist_photo,
diagnostic_report_signed=record.diagnostic_report_signed,
repair_report_signed=record.repair_report_signed,
acceptance_report_signed=record.acceptance_report_signed,
sequence_number=record.sequence_number,
)
def cycle_token_from_record(record: ArchiveRecordSnapshot) -> str:
"""Получить cycle_token из created_at архивной записи."""
if record.created_at is not None:
return record.created_at.strftime("%Y%m%d_%H%M%S")
return ""
def build_preview_lines(
record: ArchiveRecordSnapshot,
documents: list[TicketDocumentSnapshot],
) -> list[str]:
"""Собрать текст предпросмотра архивной записи."""
institution, device, room = split_task_location(record.location)
is_refused = record.pre_archive_state_code == STATE_REFUSED
lines: list[str] = []
seq = record.sequence_number or record.task_id
lines.append(f"Задача #{seq}")
lines.append(f"Статус: {record.pre_archive_state_name}")
lines.append("")
lines.append(f"Учреждение: {institution}")
lines.append(f"Оборудование: {device}")
lines.append(f"Кабинет: {room}")
lines.append("")
lines.append(f"Создана: {format_datetime(record.created_at)}")
if record.completed_at is not None:
label = "Отказано" if is_refused else "Завершена"
lines.append(f"{label}: {format_datetime(record.completed_at)}")
lines.append(f"Архивирована: {format_datetime(record.archived_at)}")
lines.append("")
lines.append("─── Ход работ ───")
lines.append("")
specialist = record.assigned_specialist.strip()
lines.append(f"Специалист: {specialist or 'Не назначен'}")
lines.append(
f"Диагностика: {'Подписан' if record.diagnostic_report_signed else 'Не подписан'}"
)
lines.append(
f"Ремонт: {'Подписан' if record.repair_report_signed else 'Не подписан'}"
)
lines.append(
f"Приёмка: {'Подписан' if record.acceptance_report_signed else 'Не подписан'}"
)
lines.append("")
if is_refused and record.refusal_reason:
lines.append("─── Причина отказа ───")
lines.append("")
lines.append(record.refusal_reason)
lines.append("")
if documents:
for doc in documents:
_append_document_block(lines, doc)
return lines
_BASE_PAYLOAD_KEYS = frozenset({
"task_id", "institution", "room", "device", "location", "specialist",
})
_PAYLOAD_KEY_LABELS: dict[str, str] = {
"initial_cause": "Первичное заключение",
"actual_cause": "Вторичное заключение",
"work_done": "Выполненные работы",
"used_parts": "Использованные запчасти",
"recommendations": "Рекомендации",
"work_description": "Описание работ",
"executor_signature": "Исполнитель",
"customer_signature": "Заказчик",
}
def _append_document_block(
lines: list[str], doc: TicketDocumentSnapshot,
) -> None:
"""Добавить блок документа без дублирования данных заголовка записи."""
doc_date = format_datetime(doc.created_at)
lines.append(f"─── {doc.title} ({doc_date}) ───")
lines.append("")
if doc.payload:
for key, value in doc.payload.items():
if not value or key in _BASE_PAYLOAD_KEYS:
continue
label = _PAYLOAD_KEY_LABELS.get(key, key)
lines.append(f"{label}: {value}")
elif doc.summary:
lines.append(doc.summary)
lines.append("")

View File

@@ -0,0 +1,153 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/pages/document_browser_page.py
"""Общая страница просмотра документов Ticket."""
from __future__ import annotations
from gui.components import Button, Label, TextInput
from gui.containers import HContainer, SContainer, VContainer
from application import TaskApplicationService
from domain import TicketDocumentSnapshot
from ui.ticket_selection_list import TicketSelectionEntry, TicketSelectionList
from .report_viewer import ReportViewer
class DocumentBrowserPage(SContainer):
"""Базовая страница списка документов и предпросмотра."""
def __init__(
self,
application: TaskApplicationService,
title: str,
empty_message: str,
document_type: str,
parent=None,
):
super().__init__(width_percent=100, height_percent=100, parent=parent)
self._application = application
self._title = title
self._empty_message = empty_message
self._document_type = document_type
self._documents: dict[str, TicketDocumentSnapshot] = {}
self._list_widget: TicketSelectionList | None = None
self._preview: TextInput | None = None
self._open_button: Button | None = None
self._setup_ui()
self._connect_signals()
self._reload_documents()
def _setup_ui(self) -> None:
# Root-контейнер страницы документов: заголовок плюс двухпанельная область.
main_container = VContainer(margin=12, spacing=10, parent=self)
title_label = Label(self._title, alignment="left", style="TICKET_LIST_HEADER")
# Content-row документов: раскладывает список документов и их предпросмотр.
content_row = HContainer(spacing=10, parent=main_container)
# Левая панель списка документов
list_panel = SContainer(style="TICKET_LIST_CONTAINER")
# List-layout: внутренний контейнер заголовка и списка документов.
list_layout = VContainer(margin=12, spacing=8, parent=list_panel)
list_title_label = Label("Список документов",
margin=[0, 0, 0, 8],
alignment="left",
style="TICKET_LIST_TITLE")
self._list_widget = TicketSelectionList()
list_layout.add_widget(list_title_label)
list_layout.add_widget(self._list_widget)
# Правая панель предпросмотра
preview_panel = SContainer(style="TICKET_LIST_CONTAINER")
# Preview-layout: внутренний контейнер заголовка, preview-поля и кнопки открытия.
preview_layout = VContainer(margin=12, spacing=8, parent=preview_panel)
preview_title_label = Label("Предпросмотр", alignment="left", style="TICKET_LIST_TITLE")
self._preview = TextInput(style="TICKET_PREVIEW_AREA", multiline=True)
self._preview.set_read_only(True)
self._open_button = Button("Открыть документ", style="FILTER_BUTTON", content_fit=True)
self._open_button.set_enabled(False)
preview_layout.add_widget(preview_title_label)
preview_layout.add_widget(self._preview)
preview_layout.add_widget(self._open_button)
content_row.add_widget_with_stretch(list_panel, 4)
content_row.add_widget_with_stretch(preview_panel, 7)
main_container.add_widget(title_label)
main_container.add_widget(content_row)
def _connect_signals(self) -> None:
# Application-состояние
self._application.task_updated.connect(self._reload_documents)
self._application.state_loaded.connect(self._reload_documents)
# UI-события страницы
if self._list_widget is not None:
self._list_widget.selection_changed.connect(self._update_preview)
self._list_widget.item_activated.connect(self._open_current_document)
if self._open_button is not None:
self._open_button.clicked.connect(self._open_current_document)
def _reload_documents(self, *_args) -> None:
if self._list_widget is None or self._preview is None:
return
current_document_id = self._current_document_id()
self._documents = {
document.document_id: document
for document in self._application.list_documents(self._document_type)
}
entries = [
TicketSelectionEntry(
entry_id=document.document_id,
title=document.title,
subtitle=document.created_at.strftime("%d.%m.%Y %H:%M"),
)
for document in self._documents.values()
]
self._list_widget.set_entries(entries)
if not self._documents:
self._preview.set_text(self._empty_message)
if self._open_button is not None:
self._open_button.set_enabled(False)
return
self._restore_selection(current_document_id)
self._update_preview()
def _restore_selection(self, document_id: str | None) -> None:
if self._list_widget is None:
return
self._list_widget.set_current_entry(document_id)
def _update_preview(self, *_args) -> None:
if self._preview is None:
return
document = self._current_document()
if document is None:
self._preview.set_text(self._empty_message)
if self._open_button is not None:
self._open_button.set_enabled(False)
return
self._preview.set_text(document.content or document.summary or document.title)
if self._open_button is not None:
self._open_button.set_enabled(True)
def _current_document(self) -> TicketDocumentSnapshot | None:
document_id = self._current_document_id()
if document_id is None:
return None
return self._documents.get(document_id)
def _current_document_id(self) -> str | None:
if self._list_widget is None:
return None
entry_id = self._list_widget.current_entry_id()
return str(entry_id) if entry_id is not None else None
def _open_current_document(self, *_args) -> None:
document = self._current_document()
if document is not None:
ReportViewer(document, parent=self).exec()
def showEvent(self, event) -> None:
super().showEvent(event)
self._reload_documents()

View File

@@ -0,0 +1,69 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/pages/report_viewer.py
"""Просмотрщик документа Ticket."""
from __future__ import annotations
from gui.components import Button, Dialog, Label, TextInput
from gui.containers import VContainer
from domain import TicketDocumentSnapshot
class ReportViewer(Dialog):
"""Простой viewer сохранённого документа Ticket."""
def __init__(
self,
document: TicketDocumentSnapshot,
parent=None,
):
self._document = document
self._close_button: Button | None = None
super().__init__(
title=document.title,
width=720,
height=760,
modal=True,
parent=parent,
)
self._setup_ui()
self._connect_signals()
def _setup_ui(self) -> None:
# Root-контейнер viewer-окна: заголовок, метаданные, текст документа и кнопка закрытия.
main_container = VContainer(margin=20,
spacing=12)
self.add_widget(main_container)
title_label = Label(
self._document.title,
alignment="left",
style="TICKET_LIST_HEADER",
)
metadata_label = Label(
(
f"Задача #{self._document.payload.get('task_id', self._document.task_id)}\n"
f"{self._document.created_at.strftime('%d.%m.%Y %H:%M')}\n"
f"{self._document.location or 'Локация не указана'}"
),
alignment="left",
style="TICKET_LIST_SUBTITLE",
)
viewer = TextInput(
text=self._document.content or self._document.summary,
style="TICKET_PREVIEW_AREA",
multiline=True,
)
viewer.set_read_only(True)
self._close_button = Button("Закрыть", style="FILTER_BUTTON", content_fit=True)
main_container.add_widget(title_label)
main_container.add_widget(metadata_label)
main_container.add_widget(viewer)
main_container.add_widget(self._close_button)
def _connect_signals(self) -> None:
if self._close_button is not None:
self._close_button.clicked.connect(self.accept)

View File

@@ -0,0 +1,340 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/pages/reports_page.py
"""Самостоятельная страница отчётов Ticket."""
from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import QSizePolicy
from gui.components import Label, TextInput
from gui.containers import HContainer, SContainer, ScrollContainer, VContainer
from application import TaskApplicationService
from domain import TicketDocumentSnapshot
# -------------------------------------------------------------------------
# Карточка отчёта (по образцу TaskCardView)
# -------------------------------------------------------------------------
class _ReportCardView(SContainer):
"""Карточка отчёта: заголовок, дата+учреждение, аппарат+кабинет."""
card_clicked = Signal(str)
def __init__(self, document: TicketDocumentSnapshot, parent=None):
super().__init__(
width_percent=100,
margin=0,
content_fit=True,
parent=parent,
)
self._document_id = document.document_id
self._title_label: Label | None = None
self._subtitle_label: Label | None = None
self._meta_label: Label | None = None
self._selected = False
self._height_sync_in_progress = False
self.setObjectName("ticket_report_card")
self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
self.setCursor(Qt.CursorShape.PointingHandCursor)
self.set_size_policy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed)
self._setup_ui()
self._fill(document)
@property
def document_id(self) -> str:
return self._document_id
def set_selected(self, selected: bool) -> None:
self._selected = selected
self._apply_root_style()
def _setup_ui(self) -> None:
content = SContainer(
width_percent=100,
height_percent=100,
margin=8,
spacing=0,
style="TICKET_REPORT_CARD_CONTENT",
parent=self,
)
text_column = VContainer(
width_percent=100,
spacing=2,
parent=content,
)
self._title_label = Label("", alignment="left", parent=text_column)
self._subtitle_label = Label("", alignment="left", parent=text_column)
self._meta_label = Label("", alignment="left", parent=text_column)
def _fill(self, document: TicketDocumentSnapshot) -> None:
title = document.title or "Отчёт"
created_at = document.created_at.strftime("%d.%m.%Y %H:%M")
facility = document.payload.get("facility") or document.location or ""
# Убрать название аппарата в скобках (дублирует 3-ю строку)
if "(" in facility:
facility = facility[:facility.index("(")].strip()
device = document.payload.get("device") or ""
cabinet = document.payload.get("cabinet") or ""
subtitle_parts = [p for p in (created_at, facility) if p]
meta_parts = [p for p in (device, cabinet) if p]
if self._title_label is not None:
self._title_label.set_text(title)
if self._subtitle_label is not None:
self._subtitle_label.set_text(" , ".join(subtitle_parts))
if self._meta_label is not None:
self._meta_label.set_text(" - ".join(meta_parts))
self._apply_root_style()
self._apply_text_styles()
def _apply_root_style(self) -> None:
if self._selected:
self.style("TICKET_REPORT_CARD_ROOT_SELECTED")
else:
self.style("TICKET_REPORT_CARD_ROOT")
def _apply_text_styles(self) -> None:
if self._title_label is not None:
self._title_label.style("TICKET_REPORT_CARD_TITLE")
if self._subtitle_label is not None:
self._subtitle_label.style("TICKET_REPORT_CARD_SUBTITLE")
if self._meta_label is not None:
self._meta_label.style("TICKET_REPORT_CARD_META")
def mousePressEvent(self, event) -> None:
if event.button() == Qt.MouseButton.LeftButton:
self.card_clicked.emit(self._document_id)
super().mousePressEvent(event)
def resizeEvent(self, event) -> None:
super().resizeEvent(event)
self._sync_card_height()
def _sync_card_height(self) -> None:
if self._height_sync_in_progress or self.width() <= 0:
return
target_height = max(1, round(self.width() / 2.745))
if self.height() == target_height:
return
self._height_sync_in_progress = True
self.setFixedHeight(target_height)
self._height_sync_in_progress = False
# -------------------------------------------------------------------------
# Страница отчётов
# -------------------------------------------------------------------------
class ReportsPage(SContainer):
"""Страница отчётов, полностью локализованная внутри собственного класса."""
def __init__(self, application: TaskApplicationService, parent=None):
super().__init__(width_percent=100, height_percent=100, parent=parent)
self._application = application
self._documents: dict[str, TicketDocumentSnapshot] = {}
self._selected_document_id: str | None = None
self._card_host: VContainer | None = None
self._preview: TextInput | None = None
self._empty_list_label: Label | None = None
self._cards: dict[str, _ReportCardView] = {}
self._setup_ui()
self._connect_signals()
self._apply_initial_state()
self._reload_documents()
# -- UI ----------------------------------------------------------------
def _setup_ui(self) -> None:
board_row = HContainer(
margin=[0, 0, 0, 0],
height_percent=100,
spacing=16,
style="TICKET_SURFACE_HOST",
parent=self,
)
board_row.add_widget(self._build_list_column())
board_row.add_widget(self._build_preview_column())
def _build_list_column(self) -> SContainer:
column = SContainer(spacing=12, parent=None)
# Header (как в _TicketBoardColumn, но без счётчика)
header = HContainer(
height_percent=5.37,
margin=0,
spacing=16,
content_fit=False,
style="TICKET_BOARD_COLUMN_HEADER",
)
header.add_widget(
Label("Список документов", margin=[12, 0, 0, 0], style="TICKET_BOARD_COLUMN_TITLE"),
)
header.add_stretch()
# Body
body = SContainer(
margin=0,
style="TICKET_REPORT_COLUMN_BODY",
)
scroll = ScrollContainer(
margin=0,
spacing=0,
orientation="v",
vertical_scroll_bar_policy="always_off",
horizontal_scroll_bar_policy="always_off",
style="SCROLL_CONTAINER",
parent=body,
)
scroll.scroll_area.verticalScrollBar().setSingleStep(48)
self._card_host = VContainer(
spacing=12,
content_fit=False,
parent=scroll,
)
self._card_host.set_size_policy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Fixed,
)
column.add_widget(header)
column.add_widget(body)
return column
def _build_preview_column(self) -> SContainer:
column = SContainer(spacing=12, width_percent=79.83, parent=None)
# Header
header = HContainer(
height_percent=5.37,
margin=0,
spacing=16,
content_fit=False,
style="TICKET_BOARD_COLUMN_HEADER",
)
header.add_widget(
Label("Просмотр", margin=[12, 0, 0, 0], style="TICKET_BOARD_COLUMN_TITLE"),
)
header.add_stretch()
# Body — preview
body = SContainer(
margin=0,
style="TICKET_REPORT_PREVIEW_BODY",
)
preview_inner = VContainer(margin=0, spacing=0, parent=body)
self._preview = TextInput(style="TICKET_REPORT_PREVIEW_AREA", multiline=True)
self._preview.set_read_only(True)
preview_inner.add_widget_with_stretch(self._preview, 1)
column.add_widget(header)
column.add_widget(body)
return column
# -- Signals -----------------------------------------------------------
def _connect_signals(self) -> None:
self._application.task_updated.connect(self._reload_documents)
self._application.state_loaded.connect(self._reload_documents)
def _apply_initial_state(self) -> None:
self._clear_preview()
# -- Data refresh ------------------------------------------------------
def _reload_documents(self, *_args) -> None:
ordered_documents = self._application.list_documents("report")
self._documents = {
document.document_id: document
for document in ordered_documents
}
self._rebuild_cards(ordered_documents)
if self._selected_document_id in self._documents:
self._select_document(self._selected_document_id, update_preview=True)
return
self._selected_document_id = None
self._update_card_selection()
self._clear_preview()
def _rebuild_cards(self, documents: list[TicketDocumentSnapshot]) -> None:
if self._card_host is None:
return
for card in list(self._cards.values()):
self._card_host.remove_widget(card)
card.setParent(None)
self._cards.clear()
if self._empty_list_label is not None:
self._card_host.remove_widget(self._empty_list_label)
self._empty_list_label.setParent(None)
self._empty_list_label = None
if not documents:
self._empty_list_label = Label(
"Подписанные отчёты пока не созданы.",
alignment="left",
style="TICKET_REPORT_EMPTY_LABEL",
)
self._card_host.insert_widget(0, self._empty_list_label)
return
for index, document in enumerate(documents):
card = _ReportCardView(document)
card.card_clicked.connect(self._on_card_clicked)
self._cards[document.document_id] = card
self._card_host.insert_widget(index, card)
self._update_card_selection()
# -- Selection ---------------------------------------------------------
def _select_document(self, document_id: str | None, update_preview: bool) -> None:
normalized = document_id if document_id in self._documents else None
self._selected_document_id = normalized
self._update_card_selection()
if not update_preview:
return
document = self._current_document()
if document is None:
self._clear_preview()
return
self._render_document(document)
def _update_card_selection(self) -> None:
for doc_id, card in self._cards.items():
card.set_selected(doc_id == self._selected_document_id)
def _current_document(self) -> TicketDocumentSnapshot | None:
if self._selected_document_id is None:
return None
return self._documents.get(self._selected_document_id)
def _render_document(self, document: TicketDocumentSnapshot) -> None:
if self._preview is None:
return
self._preview.set_text(document.content or document.summary or document.title)
def _clear_preview(self) -> None:
if self._preview is not None:
self._preview.set_text("")
def _on_card_clicked(self, document_id: str) -> None:
self._select_document(document_id, update_preview=True)
def showEvent(self, event) -> None:
super().showEvent(event)
self._reload_documents()

View File

@@ -0,0 +1,289 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/task_view_formatters.py
"""Форматирование отображения задач Ticket для карточек и деталей."""
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from domain import TicketTaskSnapshot, parse_location_parts
from domain.ticket_constants import (
STATE_COMPLETED,
STATE_CONFIRMATION,
STATE_IN_PROGRESS,
STATE_REFUSED,
STATE_TODO,
)
_REPO_ROOT = Path(__file__).resolve().parents[3]
_TICKET_ICONS_DIR = _REPO_ROOT / "gui" / "icons"
_TICKET_SPECIALISTS_DIR = _TICKET_ICONS_DIR / "specialists"
_DEFAULT_SPECIALIST_PHOTO = "specialist.png"
_DEFAULT_STAGE_ICON = "close.png"
_STAGE_ICON_FILENAMES = {
"specialist": "face-man-shimmer-outline.png",
"diagnostic": "wrench-outline.png",
"repair": "wrench-outline.png",
"acceptance": "pencil-outline.png",
}
_SPECIALIST_CARD_INFO = {
"Иванов Алексей Сергеевич": ("Иванов А.С.", "Инженер-электроник", "specialist1.png"),
"Петрова Мария Владимировна": ("Петрова М.В.", "Техник-рентгенолог", "specialist2.png"),
"Сидоров Дмитрий Иванович": ("Сидоров Д.И.", "Инженер КИПиА", "specialist3.png"),
"Козлова Анна Петровна": ("Козлова А.П.", "Специалист по ТО", "specialist4.png"),
"Васильев Сергей Николаевич": ("Васильев С.Н.", "Инженер-программист", "specialist5.png"),
"Николаева Ольга Дмитриевна": ("Николаева О.Д.", "Техник-электрик", "specialist6.png"),
"Фёдоров Андрей Викторович": ("Фёдоров А.В.", "Инженер-механик", "specialist7.png"),
"Орлова Екатерина Александровна": ("Орлова Е.А.", "Специалист по диагностике", "specialist8.png"),
}
def build_task_title(task: TicketTaskSnapshot) -> str:
if task.sequence_number:
return f"Задача #{task.sequence_number}"
return f"Задача #{task.task_id}"
def build_task_card_subtitle(task: TicketTaskSnapshot) -> str:
specialist_text = task.assigned_specialist.strip() or "Специалист не назначен"
return "\n".join(
(
task.location or "Локация не указана",
specialist_text,
build_documents_summary(task),
)
)
def build_documents_summary(task: TicketTaskSnapshot) -> str:
return (
f"Д: {_bool_text(task.diagnostic_report_signed)} | "
f"Р: {_bool_text(task.repair_report_signed)} | "
f"А: {_bool_text(task.acceptance_report_signed)}"
)
def split_task_location(location: str) -> tuple[str, str, str]:
normalized_location = (location or "").strip()
if not normalized_location:
return ("Локация не указана", "Аппарат не указан", "Кабинет не указан")
institution, room, device = parse_location_parts(normalized_location)
institution = institution or "Локация не указана"
device = device or "Аппарат не указан"
room = room or "Кабинет не указан"
return (
shorten_card_text(institution, limit=36),
shorten_card_text(device, limit=34),
shorten_card_text(room, limit=24),
)
def build_task_footer_text(task: TicketTaskSnapshot) -> str:
return build_specialist_card_text(task.assigned_specialist)
def build_task_fault_title(task: TicketTaskSnapshot) -> str:
"""Извлечь краткий заголовок неисправности из строки локации задачи.
Формат локации, формируемый формой создания заявки:
«Учреждение (Аппарат — Краткий заголовок, каб. №)».
Парсер `parse_location_parts` возвращает блок «Аппарат — Краткий
заголовок» в позиции `device`. Заголовок неисправности — это
содержимое после разделителя « — ». При его отсутствии (например,
для тестовых задач без формы) возвращается само устройство, а в
предельном случае — оригинальная строка локации.
"""
institution, _device, _room = split_task_location(task.location)
_, _, raw_device = parse_location_parts(task.location or "")
device_text = raw_device.strip() or institution
separator = ""
if separator in device_text:
title = device_text.split(separator, 1)[1].strip()
if title:
return shorten_card_text(title, limit=64)
if device_text:
return shorten_card_text(device_text, limit=64)
return shorten_card_text(task.location or "Заголовок не указан", limit=64)
def build_task_stage_flags(task: TicketTaskSnapshot) -> tuple[tuple[str, bool], ...]:
return (
("specialist", bool(task.assigned_specialist.strip())),
("diagnostic", bool(task.diagnostic_report_signed)),
("repair", bool(task.repair_report_signed)),
("acceptance", bool(task.acceptance_report_signed)),
)
def build_specialist_initials(specialist_name: str) -> str:
normalized_name = (specialist_name or "").strip()
if not normalized_name:
return "?"
parts = [part for part in normalized_name.split() if part]
if len(parts) >= 2:
return f"{parts[0][0]}{parts[1][0]}".upper()
if len(parts[0]) >= 2:
return parts[0][:2].upper()
return parts[0][:1].upper()
def build_specialist_card_text(specialist_name: str) -> str:
specialist_info = build_specialist_card_info(specialist_name)
short_name = specialist_info["short_name"]
position = specialist_info["position"]
if not short_name:
return "Специалист не назначен"
if not position:
return shorten_card_text(short_name, limit=40)
return shorten_card_text(f"{short_name}{position}", limit=42)
def build_specialist_card_info(specialist_name: str) -> dict[str, str]:
normalized_name = (specialist_name or "").strip()
if not normalized_name:
return {"short_name": "", "position": "", "photo": ""}
short_name, position, photo_filename = _SPECIALIST_CARD_INFO.get(
normalized_name,
_fallback_specialist_card_info(normalized_name),
)
return {
"short_name": short_name,
"position": position,
"photo": photo_filename,
}
def build_specialist_photo_path(specialist_name: str, photo_filename: str) -> str:
normalized_filename = (photo_filename or "").strip()
if normalized_filename:
return _resolve_specialist_photo_path(normalized_filename)
specialist_info = build_specialist_card_info(specialist_name)
photo_from_mapping = specialist_info.get("photo", "").strip()
if not photo_from_mapping:
return ""
return _resolve_specialist_photo_path(photo_from_mapping)
def build_stage_icon_path(stage_key: str, is_active: bool) -> str:
filename = _STAGE_ICON_FILENAMES.get(stage_key, _DEFAULT_STAGE_ICON) if is_active else _DEFAULT_STAGE_ICON
icon_path = _TICKET_ICONS_DIR / filename
if not icon_path.exists():
return ""
return str(icon_path)
def shorten_card_text(text: str, limit: int) -> str:
normalized_text = " ".join((text or "").split())
if len(normalized_text) <= limit:
return normalized_text
if limit <= 3:
return normalized_text[:limit]
return f"{normalized_text[:limit - 3].rstrip()}..."
def build_stage_rows(task: TicketTaskSnapshot) -> tuple[tuple[str, str], ...]:
specialist_status = (
f"Назначен: {task.assigned_specialist}"
if task.assigned_specialist.strip()
else "Не назначен"
)
diagnostic_status = (
"Диагностический отчёт подписан"
if task.diagnostic_report_signed
else "Диагностический отчёт ожидается"
)
repair_status = (
"Ремонтный отчёт подписан"
if task.repair_report_signed
else "Ремонтный отчёт ожидается"
)
acceptance_status = (
"Акт приёмки подписан"
if task.acceptance_report_signed
else "Акт приёмки ожидается"
)
return (
("Специалист", specialist_status),
("Диагностика", diagnostic_status),
("Ремонт", repair_status),
("Приёмка", acceptance_status),
)
def build_action_hint(task: TicketTaskSnapshot) -> str:
if task.state_code == STATE_TODO:
return "Назначьте специалиста и переведите задачу аппаратной кнопкой в работу."
if task.state_code == STATE_IN_PROGRESS:
if not task.assigned_specialist.strip():
return "Сначала назначьте специалиста, затем подпишите диагностический и ремонтный отчёты."
if not task.diagnostic_report_signed or not task.repair_report_signed:
return "Для перехода к подтверждению подпишите диагностический и ремонтный отчёты."
return "Отчёты готовы. Следующий аппаратный переход переведёт задачу к подтверждению."
if task.state_code == STATE_CONFIRMATION:
if not task.acceptance_report_signed:
return "Подпишите акт приёмки, чтобы задача могла перейти в выполненные."
return "Акт готов. Следующий аппаратный переход завершит задачу."
if task.state_code == STATE_COMPLETED:
return "Задача завершена. Её можно переместить в архив."
if task.state_code == STATE_REFUSED:
return "Задача завершилась отказом. Её можно переместить в архив."
return "Архивная запись доступна только для просмотра."
def format_datetime(value: datetime | None) -> str:
if value is None:
return ""
return value.strftime("%d.%m.%Y %H:%M")
def can_assign_specialist(task: TicketTaskSnapshot) -> bool:
return task.state_code in {STATE_TODO, STATE_IN_PROGRESS, STATE_CONFIRMATION}
def can_sign_diagnostic(task: TicketTaskSnapshot) -> bool:
return (
task.state_code == STATE_IN_PROGRESS
and bool(task.assigned_specialist.strip())
and not task.diagnostic_report_signed
)
def can_sign_repair(task: TicketTaskSnapshot) -> bool:
return (
task.state_code == STATE_IN_PROGRESS
and bool(task.assigned_specialist.strip())
and not task.repair_report_signed
)
def can_sign_acceptance(task: TicketTaskSnapshot) -> bool:
return task.state_code == STATE_CONFIRMATION and not task.acceptance_report_signed
def can_archive(task: TicketTaskSnapshot) -> bool:
return task.state_code in {STATE_COMPLETED, STATE_REFUSED}
def _bool_text(value: bool) -> str:
return "Да" if value else "Нет"
def _fallback_specialist_card_info(specialist_name: str) -> tuple[str, str, str]:
parts = [part for part in specialist_name.split() if part]
if len(parts) >= 3:
initials = f"{parts[1][0]}.{parts[2][0]}."
return (f"{parts[0]} {initials}", "Специалист", "")
return (specialist_name, "Специалист", "")
def _resolve_specialist_photo_path(photo_filename: str) -> str:
photo_path = _TICKET_SPECIALISTS_DIR / photo_filename
if photo_path.exists():
return str(photo_path)
fallback_path = _TICKET_SPECIALISTS_DIR / _DEFAULT_SPECIALIST_PHOTO
if fallback_path.exists():
return str(fallback_path)
return ""

View File

@@ -0,0 +1,372 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/ticket_board_page.py
"""Ticket board page."""
from __future__ import annotations
from PySide6.QtWidgets import QSizePolicy
from gui.components import Label
from gui.containers import HContainer, ScrollContainer, SContainer, VContainer
from application import TaskApplicationService
from domain import TicketTaskSnapshot
from domain.ticket_constants import (
STATE_COMPLETED,
STATE_CONFIRMATION,
STATE_IN_PROGRESS,
STATE_REFUSED,
STATE_TODO,
)
from .cards import TaskCard
from .details import TaskDetailsDialog
class _TicketBoardColumn(SContainer):
"""Single Ticket board column."""
def __init__(self, parent=None):
super().__init__(spacing=12, parent=parent)
self._cards: dict[object, TaskCard] = {}
self._badge_label: Label | None = None
self._card_host: VContainer | None = None
self._setup_ui()
def _setup_ui(self) -> None:
# Верхний header-контейнер колонки: объединяет счётчик задач и заголовок этапа.
header = HContainer(
height_percent=5.37,
margin=0,
spacing=16,
content_fit=False,
style="TICKET_BOARD_COLUMN_HEADER",
parent=self,
)
# Badge-shell слева в header: резервирует место под цветной счётчик статуса.
header.add_widget(self._build_badge(header))
# Центральный текстовый блок header: показывает название этапа колонки.
header.add_widget(self._build_title_label())
header.add_stretch()
# Body-shell колонки: визуальная подложка под список карточек текущего этапа.
body = SContainer(
margin=0,
style="TICKET_BOARD_COLUMN_BODY",
parent=self,
)
# Scroll-host внутри body: даёт колонке собственную область прокрутки карточек.
scroll = ScrollContainer(
margin=0,
spacing=0,
orientation="v",
vertical_scroll_bar_policy="always_off",
horizontal_scroll_bar_policy="always_off",
style="SCROLL_CONTAINER",
parent=body,
)
scroll.scroll_area.verticalScrollBar().setSingleStep(48)
# Card-host: вертикальный стек карточек, который динамически растёт по содержимому.
self._card_host = VContainer(
spacing=12,
content_fit=False,
parent=scroll,
)
self._card_host.set_size_policy(
QSizePolicy.Policy.Expanding,
QSizePolicy.Policy.Fixed,
)
# Подписка на фазу 2 каскада percent-sized для детей _card_host.
# Срабатывает после того, как все карточки получили parent_resized,
# отработали _on_parent_rebuild_finished -> _sync_card_height ->
# setFixedHeight(target). К этому моменту minimumHeight каждой
# карточки уже отражает её итоговую высоту, и сумма stack_height
# вычисляется корректно за один проход без промежуточных «дёрганий».
self._card_host.on_children_rebuild_finished(self._sync_card_host_height)
self._sync_card_host_height()
def _build_badge(self, parent) -> SContainer:
raise NotImplementedError
def _build_title_label(self) -> Label:
raise NotImplementedError
def add_card(self, card: TaskCard) -> None:
if self._card_host is None:
return
self.remove_card(card.card_id)
# Подписка на per-card сигнал нужна для сценария add без resize
# колонки: _card_host не получает Resize event, его phase-2 emitter
# молчит, поэтому пересчёт стека инициирует сама карточка после
# своего первого _sync_card_height -> setFixedHeight().
card.card_height_changed.connect(self._sync_card_host_height)
self._cards[card.card_id] = card
self._card_host.insert_widget(len(self._cards) - 1, card)
# Синхронный _sync_card_host_height НЕ вызываем: высота карточки
# ещё нулевая (фаза 2 каскада придёт через QTimer.singleShot(0)).
# Пересчёт стека выполнится по card_height_changed (add без resize)
# либо по _card_host.parent_rebuild_finished (add с resize).
self._update_counter()
def remove_card(self, card_id: object) -> TaskCard | None:
card = self._cards.pop(card_id, None)
if card is None or self._card_host is None:
return None
self._card_host.remove_widget(card)
card.setParent(None)
# Удалённая карточка фазу 2 уже не пришлёт; синхронный пересчёт
# обязателен, иначе stack_height застрянет на устаревшей сумме.
self._sync_card_host_height()
self._update_counter()
return card
def clear_cards(self) -> None:
for card in list(self._cards.values()):
if self._card_host is not None:
self._card_host.remove_widget(card)
card.setParent(None)
self._cards.clear()
# Аналогично remove_card: после очистки фазы 2 от детей не будет.
self._sync_card_host_height()
self._update_counter()
def _update_counter(self) -> None:
if self._badge_label is not None:
self._badge_label.set_text(str(len(self._cards)))
def _sync_card_host_height(self) -> None:
if self._card_host is None:
return
# Используем card.minimumHeight(): после _sync_card_height() карточка
# вызывает setFixedHeight(target), который атомарно выставляет
# min == max == target. В отличие от card.height(), это значение
# доступно сразу, до прогона layout-цикла, что гарантирует
# корректный stack_height в момент эмиссии card_height_changed.
stack_height = sum(
max(card.minimumHeight(), card.height())
for card in self._cards.values()
)
card_count = len(self._cards)
if card_count > 1:
stack_height += 12 * (card_count - 1)
self._card_host.set_min_height(stack_height)
self._card_host.set_max_height(stack_height)
class _TodoTicketBoardColumn(_TicketBoardColumn):
"""Ticket board column for TODO state."""
def _build_badge(self, parent) -> SContainer:
# Badge-контейнер колонки TODO: цветной фон и центрирование значения счётчика.
badge_container = SContainer(
width_percent=12,
height_percent=100,
content_fit=False,
style="TICKET_BOARD_COUNTER_SHELL_TODO",
parent=parent,
)
self._badge_label = Label(
"0",
style="TICKET_BOARD_COUNTER_TEXT_WHITE",
parent=badge_container,
)
return badge_container
def _build_title_label(self) -> Label:
return Label("Новая заявка", style="TICKET_BOARD_COLUMN_TITLE")
class _InProgressTicketBoardColumn(_TicketBoardColumn):
"""Ticket board column for IN_PROGRESS state."""
def _build_badge(self, parent) -> SContainer:
# Badge-контейнер колонки IN_PROGRESS: цветовой маркер и число задач в работе.
badge_container = SContainer(
width_percent=12,
height_percent=100,
content_fit=False,
style="TICKET_BOARD_COUNTER_SHELL_IN_PROGRESS",
parent=parent,
)
self._badge_label = Label(
"0",
style="TICKET_BOARD_COUNTER_TEXT_WHITE",
parent=badge_container,
)
return badge_container
def _build_title_label(self) -> Label:
return Label("Заявка принята к работе", style="TICKET_BOARD_COLUMN_TITLE")
class _ConfirmationTicketBoardColumn(_TicketBoardColumn):
"""Ticket board column for CONFIRMATION state."""
def _build_badge(self, parent) -> SContainer:
# Badge-контейнер колонки CONFIRMATION: показывает объём задач на подтверждении.
badge_container = SContainer(
width_percent=12,
height_percent=100,
content_fit=False,
style="TICKET_BOARD_COUNTER_SHELL_CONFIRMATION",
parent=parent,
)
self._badge_label = Label(
"0",
style="TICKET_BOARD_COUNTER_TEXT_WHITE",
parent=badge_container,
)
return badge_container
def _build_title_label(self) -> Label:
return Label("Заявка на подтверждении", style="TICKET_BOARD_COLUMN_TITLE")
class _CompletedTicketBoardColumn(_TicketBoardColumn):
"""Ticket board column for COMPLETED state."""
def _build_badge(self, parent) -> SContainer:
# Badge-контейнер колонки COMPLETED: визуально отделяет завершённые задачи.
badge_container = SContainer(
width_percent=12,
height_percent=100,
content_fit=False,
style="TICKET_BOARD_COUNTER_SHELL_COMPLETED",
parent=parent,
)
self._badge_label = Label(
"0",
style="TICKET_BOARD_COUNTER_TEXT_WHITE",
parent=badge_container,
)
return badge_container
def _build_title_label(self) -> Label:
return Label("Заявка закрыта", style="TICKET_BOARD_COLUMN_TITLE")
class _RefusedTicketBoardColumn(_TicketBoardColumn):
"""Ticket board column for REFUSED state."""
def _build_badge(self, parent) -> SContainer:
# Badge-контейнер колонки REFUSED: выделяет счётчик задач со статусом отказа.
badge_container = SContainer(
width_percent=12,
height_percent=100,
content_fit=False,
style="TICKET_BOARD_COUNTER_SHELL_REFUSED",
parent=parent,
)
self._badge_label = Label(
"0",
style="TICKET_BOARD_COUNTER_TEXT_MUTED",
parent=badge_container,
)
return badge_container
def _build_title_label(self) -> Label:
return Label("Отменённая заявка", style="TICKET_BOARD_COLUMN_TITLE")
class TicketBoardPage(SContainer):
"""Ticket board connected to application signals."""
def __init__(self, application: TaskApplicationService, parent=None):
super().__init__(
width_percent=100,
height_percent=100,
parent=parent,
style="TICKET_SHELL_ROOT",
)
self._application = application
self._columns: dict[int, _TicketBoardColumn] = {}
self._task_columns: dict[int, int] = {}
self._setup_ui()
self._connect_signals()
self._reload_board()
def _setup_ui(self) -> None:
# Главный row-контейнер доски: раскладывает все статусные колонки по горизонтали.
board_row = HContainer(
margin=[0, 0, 0, 0],
height_percent=100,
spacing=16,
style="TICKET_SURFACE_HOST",
parent=self,
)
todo_column = _TodoTicketBoardColumn()
in_progress_column = _InProgressTicketBoardColumn()
confirmation_column = _ConfirmationTicketBoardColumn()
completed_column = _CompletedTicketBoardColumn()
refused_column = _RefusedTicketBoardColumn()
self._columns[STATE_TODO] = todo_column
self._columns[STATE_IN_PROGRESS] = in_progress_column
self._columns[STATE_CONFIRMATION] = confirmation_column
self._columns[STATE_COMPLETED] = completed_column
self._columns[STATE_REFUSED] = refused_column
board_row.add_widget(todo_column)
board_row.add_widget(in_progress_column)
board_row.add_widget(confirmation_column)
board_row.add_widget(completed_column)
board_row.add_widget(refused_column)
def _connect_signals(self) -> None:
self._application.task_updated.connect(self._on_task_updated)
self._application.task_removed.connect(self._on_task_removed)
self._application.state_loaded.connect(self._reload_board)
def _reload_board(self, *_args) -> None:
for column in self._columns.values():
column.clear_cards()
self._task_columns.clear()
for task in self._application.list_active_tasks():
self._upsert_task(task)
def _upsert_task(self, task: TicketTaskSnapshot) -> None:
column = self._columns.get(task.state_code)
if column is None:
self._remove_task(task.task_id)
return
self._remove_task(task.task_id)
card = TaskCard(task)
card.card_clicked.connect(self._on_card_clicked)
column.add_card(card)
self._task_columns[task.task_id] = task.state_code
def _remove_task(self, task_id: int) -> None:
state_code = self._task_columns.pop(task_id, None)
if state_code is not None:
column = self._columns.get(state_code)
if column is not None:
column.remove_card(task_id)
return
for column in self._columns.values():
if column.remove_card(task_id) is not None:
return
def _on_task_updated(self, task: TicketTaskSnapshot) -> None:
if isinstance(task, TicketTaskSnapshot):
self._upsert_task(task)
def _on_task_removed(self, task_id: int) -> None:
self._remove_task(task_id)
def _on_card_clicked(self, task_id: object) -> None:
try:
normalized_task_id = int(task_id)
except (TypeError, ValueError):
return
task = self._application.get_task(normalized_task_id)
if task is None:
return
TaskDetailsDialog(
application=self._application,
task_id=normalized_task_id,
parent=self,
).exec()

View File

@@ -0,0 +1,418 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/ticket_create_page.py
"""Форма создания заявки в составе независимого приложения Dispatch.
Назначение модуля:
Страница «Создать заявку», разделённая на две колонки:
- Левая колонка занимает 70% ширины и содержит выпадающий список
программного продукта, выпадающий список краткого заголовка
неисправности и многострочное поле подробного описания
неисправности.
- Правая колонка занимает 30% ширины и содержит блок действий
формы: «Создать заявку», «Отмена», «Прикрепить файл».
Источники данных:
- `DB_dispatch/3_software_list.py` — справочник программных
продуктов (ключ `software_id` → наименование).
- `DB_dispatch/4_malfunction_list.py` — справочник кратких
заголовков неисправностей (ключ `software_id` → перечень
формулировок).
- `DB_dispatch/0_users.py` и `DB_dispatch/2_customer_facility_list.py`
— учётная запись и место установки заявителя; используются
для формирования строки локации заявки.
Архитектурные ограничения:
- QSS-литералы и фиксированные размеры в прикладном коде не
используются: оформление подаётся через ключи `APP_STYLES`,
геометрия задаётся процентными долями.
- Все элементы построены на канонических обёртках `Button`,
`Label`, `ComboBox`, `TextInput`, `SContainer`, `HContainer`,
`VContainer`.
- Доступ к каталогу `DB_dispatch` осуществляется только через
сервис `hub.my_account.auth_service`.
"""
from __future__ import annotations
from datetime import datetime
from typing import Callable
from gui.components import Button, ComboBox, Label, TextInput
from gui.containers import HContainer, SContainer, VContainer
from application import TaskApplicationService
from domain import TicketTaskSnapshot
from domain.location_catalog import parse_location_parts
from domain.ticket_constants import (
STATE_TODO,
TICKET_STATE_ACTIONS,
TICKET_STATE_COLORS,
TICKET_STATE_NAMES,
)
# Справочные тексты в комбобоксах: используются как placeholder,
# чтобы пользователю было понятно назначение поля до открытия списка.
_PRODUCT_PLACEHOLDER = "Программный продукт"
_SUMMARY_PLACEHOLDER = "Краткий заголовок неисправности"
class TicketCreatePage(SContainer):
"""Двухколоночная форма создания заявки и её отправки на доску `STATE_TODO`."""
def __init__(
self,
application: TaskApplicationService,
on_finish: Callable[[], None],
parent=None,
):
super().__init__(
width_percent=100,
height_percent=100,
margin=0,
parent=parent,
style="TICKET_SHELL_ROOT",
)
self._application = application
self._on_finish = on_finish
# Состояние активной сессии заявителя; используется только для
# формирования строки локации, в интерфейсе не отображается.
self._signed_user: dict | None = None
self._user_institution: str = ""
self._user_room: str = ""
# Справочные таблицы DB_dispatch и индекс «наименование → id»
# для определения списка неисправностей по выбранному продукту.
self._software_by_id: dict[str, str] = {}
self._software_id_by_name: dict[str, str] = {}
self._malfunction_by_software_id: dict[str, list[str]] = {}
# Поля левой колонки.
self._product_combo: ComboBox | None = None
self._summary_combo: ComboBox | None = None
self._description_input: TextInput | None = None
# Действия формы.
self._submit_button: Button | None = None
self._cancel_button: Button | None = None
self._attach_button: Button | None = None
self._error_label: Label | None = None
self._setup_ui()
self._connect_signals()
self._load_reference_data()
# ── Сборка интерфейса ───────────────────────────────────────────────
def _setup_ui(self) -> None:
# Корневой контейнер страницы — горизонтальная компоновка с
# явными долями 70% (левая колонка) и 30% (правая колонка).
# `HContainer` всегда занимает 100% ширины родителя, поэтому
# параметр `width_percent` ему не передаётся.
root = HContainer(
height_percent=100,
margin=[24, 18, 24, 18],
spacing=18,
content_fit=False,
parent=self,
)
# ── Левая колонка: поля заявки (70%). ────────────────────────
# `VContainer` всегда занимает 100% высоты родителя, поэтому
# `height_percent` опускается; ширина колонки задаётся явно.
left = VContainer(
width_percent=70,
margin=0,
spacing=12,
content_fit=False,
parent=root,
)
left.add_widget(Label(
"Создать заявку",
height_percent=8,
style="LOGIN_TITLE",
))
# Комбобокс программного продукта: справочный текст-подсказка
# отображается в редактируемой строке до выбора пункта.
self._product_combo = ComboBox(
width_percent=100,
height_percent=8,
content_fit=False,
)
self._product_combo.set_editable(True)
self._product_combo.set_placeholder_text(_PRODUCT_PLACEHOLDER)
left.add_widget(self._product_combo)
# Комбобокс краткого заголовка неисправности: пункты списка
# обновляются после выбора программного продукта.
self._summary_combo = ComboBox(
width_percent=100,
height_percent=8,
content_fit=False,
)
self._summary_combo.set_editable(True)
self._summary_combo.set_placeholder_text(_SUMMARY_PLACEHOLDER)
left.add_widget(self._summary_combo)
self._description_input = TextInput(
placeholder=(
"Подробно опишите проявления неисправности, шаги "
"воспроизведения, коды ошибок и предпринятые действия."
),
width_percent=100,
height_percent=68,
content_fit=False,
multiline=True,
)
left.add_widget(self._description_input)
# ── Правая колонка: блок действий (30%). ──────────────────────
right = VContainer(
width_percent=30,
margin=0,
spacing=12,
content_fit=False,
parent=root,
)
self._error_label = Label(
"",
height_percent=8,
style="LOGIN_ERROR_LABEL",
)
self._error_label.set_visible(False)
right.add_widget(self._error_label)
right.add_stretch(1)
# Блок действий: «Создать заявку», «Отмена», «Прикрепить файл».
# `VContainer` принимает только ширину в процентах от родителя;
# высота блока определяется собственными процентами кнопок.
actions = VContainer(
width_percent=100,
margin=0,
spacing=8,
content_fit=False,
parent=right,
)
self._submit_button = Button(
"Создать заявку",
width_percent=100,
height_percent=30,
margin=0,
style="LOGIN_SUBMIT_BUTTON",
content_fit=False,
)
self._cancel_button = Button(
"Отмена",
width_percent=100,
height_percent=30,
margin=0,
style="LOGIN_CANCEL_BUTTON",
content_fit=False,
)
self._attach_button = Button(
"Прикрепить файл",
width_percent=100,
height_percent=30,
margin=0,
style="LOGIN_NAV_BUTTON",
content_fit=False,
)
actions.add_widget(self._submit_button)
actions.add_widget(self._cancel_button)
actions.add_widget(self._attach_button)
right.add_widget(actions)
# ── Сигналы и справочные данные ─────────────────────────────────────
def _connect_signals(self) -> None:
if self._submit_button is not None:
self._submit_button.clicked.connect(self._on_submit_clicked)
if self._cancel_button is not None:
self._cancel_button.clicked.connect(self._on_cancel_clicked)
if self._attach_button is not None:
self._attach_button.clicked.connect(self._on_attach_clicked)
if self._product_combo is not None:
self._product_combo.current_text_changed.connect(
self._on_product_changed,
)
def _load_reference_data(self) -> None:
"""Загрузить справочники программных продуктов и неисправностей.
Источники — `DB_dispatch/3_software_list.py` и
`DB_dispatch/4_malfunction_list.py`. Полученные таблицы
используются для наполнения комбобоксов и для определения
перечня неисправностей по выбранному программному продукту.
"""
# Импорт внутри метода исключает циклическую зависимость
# между UI-слоем Ticket и сервисом учётных записей Dispatch.
from auth_service import (
load_malfunction_list,
load_software_list,
)
self._software_by_id = load_software_list()
self._malfunction_by_software_id = load_malfunction_list()
self._software_id_by_name = {
name: software_id
for software_id, name in self._software_by_id.items()
}
self._populate_product_combo()
self._populate_summary_combo("")
def _populate_product_combo(self) -> None:
if self._product_combo is None:
return
names = sorted(self._software_by_id.values())
self._product_combo.set_items(["", *names])
self._product_combo.set_index(0)
def _populate_summary_combo(self, software_name: str) -> None:
if self._summary_combo is None:
return
software_id = self._software_id_by_name.get(software_name, "")
if software_id:
titles = list(self._malfunction_by_software_id.get(software_id, []))
else:
# Пока программный продукт не выбран, показываем полный
# перечень заголовков из `DB_dispatch/4_malfunction_list.py`,
# сохраняя порядок появления и убирая дубликаты.
titles = []
seen: set[str] = set()
for values in self._malfunction_by_software_id.values():
for title in values:
if title in seen:
continue
seen.add(title)
titles.append(title)
self._summary_combo.set_items(["", *titles])
self._summary_combo.set_index(0)
def _on_product_changed(self, text: str) -> None:
"""Обновить набор кратких заголовков под выбранный продукт."""
self._populate_summary_combo((text or "").strip())
def refresh_user_session(self) -> None:
"""Подтянуть реквизиты заявителя из активной сессии Dispatch.
Метод сохраняет данные пользователя только во внутренних полях:
учреждение и кабинет нужны для формирования строки локации
заявки. В интерфейсе реквизиты заявителя не отображаются.
"""
from auth_service import get_user_facility, load_session
user = load_session()
self._signed_user = user
if user is None:
self._user_institution = ""
self._user_room = ""
return
institution, room, _product = parse_location_parts(get_user_facility(user))
self._user_institution = institution
self._user_room = room
# ── Обработчики действий ────────────────────────────────────────────
def _on_cancel_clicked(self, _checked: bool = False) -> None:
"""Сбросить форму и вернуть пользователя на доску заявок."""
self._reset_form()
self._on_finish()
def _on_attach_clicked(self, _checked: bool = False) -> None:
"""Заглушка действия прикрепления файла.
Полнофункциональная загрузка вложений выходит за рамки
текущего этапа; кнопка зарезервирована в разметке и
подключает обработчик-уведомление, чтобы пользователь не
воспринимал отсутствие реакции как сбой.
"""
self._show_error("Прикрепление файла будет доступно на следующем этапе.")
def _on_submit_clicked(self, _checked: bool = False) -> None:
"""Проверить поля, собрать snapshot и зарегистрировать заявку."""
if self._signed_user is None:
self._show_error("Войдите в систему перед созданием заявки.")
return
product = self._read_combo_text(self._product_combo)
summary = self._read_combo_text(self._summary_combo)
description = (
self._description_input.get_text() if self._description_input else ""
).strip()
if not product or not summary or not description:
self._show_error(
"Выберите программный продукт, краткий заголовок и заполните описание.",
)
return
if not self._user_institution:
self._show_error(
"Для учётной записи не задано место установки в DB_dispatch.",
)
return
location = self._compose_location(product, summary)
snapshot = self._build_snapshot(location)
self._application.submit_new_task(snapshot)
self._reset_form()
self._hide_error()
self._on_finish()
@staticmethod
def _read_combo_text(combo: ComboBox | None) -> str:
if combo is None:
return ""
return combo.get_current_text().strip()
def _compose_location(self, product: str, summary: str) -> str:
# Формат локации совпадает с парсером `parse_location_parts`:
# «Учреждение (Аппарат — заголовок, каб. №)». Это даёт корректное
# отображение карточки на доске без дополнительных адаптеров.
device_segment = f"{product}{summary}" if summary else product
if not self._user_room:
return f"{self._user_institution} ({device_segment})"
return f"{self._user_institution} ({device_segment}, {self._user_room})"
def _build_snapshot(self, location: str) -> TicketTaskSnapshot:
action_text = TICKET_STATE_ACTIONS.get(STATE_TODO, "")
return TicketTaskSnapshot(
task_id=self._application.allocate_new_task_id(),
location=location,
state_code=STATE_TODO,
state_name=TICKET_STATE_NAMES.get(STATE_TODO, ""),
action_text=action_text,
color_hex=TICKET_STATE_COLORS.get(STATE_TODO, "#FFFFFF"),
created_at=datetime.now(),
)
def _reset_form(self) -> None:
"""Очистить редактируемые поля левой колонки."""
if self._product_combo is not None:
self._product_combo.set_index(0)
if self._summary_combo is not None:
self._summary_combo.set_index(0)
if self._description_input is not None:
self._description_input.clear()
self._hide_error()
# ── Сообщения об ошибках ────────────────────────────────────────────
def _show_error(self, message: str) -> None:
if self._error_label is None:
return
self._error_label.set_text(message)
self._error_label.set_visible(True)
def _hide_error(self) -> None:
if self._error_label is None:
return
self._error_label.set_text("")
self._error_label.set_visible(False)

View File

@@ -0,0 +1,115 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/ticket_message_dialog.py
"""Простые диалоги Ticket на локальной GUI-библиотеке."""
from __future__ import annotations
from gui.components import Button, Dialog, Label, TextInput
from gui.containers import HContainer, VContainer
class TicketMessageDialog(Dialog):
"""Унифицированный диалог предупреждения или подтверждения."""
def __init__(
self,
title: str,
message: str,
accept_text: str = "ОК",
reject_text: str | None = None,
parent=None,
):
self._title = title
self._message = message
self._accept_text = accept_text
self._reject_text = reject_text
self._accept_button: Button | None = None
self._reject_button: Button | None = None
super().__init__(
title=title,
width=420,
height=220,
modal=True,
parent=parent,
)
self._setup_ui()
self._connect_signals()
@classmethod
def ask_confirmation(
cls,
parent,
title: str,
message: str,
accept_text: str = "Подтвердить",
reject_text: str = "Отмена",
) -> bool:
dialog = cls(
title=title,
message=message,
accept_text=accept_text,
reject_text=reject_text,
parent=parent,
)
return dialog.exec() == cls.DialogCode.Accepted
@classmethod
def show_warning(
cls,
parent,
title: str,
message: str,
) -> None:
cls(
title=title,
message=message,
accept_text="Закрыть",
reject_text=None,
parent=parent,
).exec()
def _setup_ui(self) -> None:
# Root-контейнер диалога сообщения: заголовок, текстовое поле и строка кнопок.
main_container = VContainer(margin=16, spacing=12)
self.add_widget(main_container)
title_label = Label(
self._title,
alignment="left",
style="TICKET_LIST_HEADER",
)
message_view = TextInput(
text=self._message,
style="TICKET_PREVIEW_AREA",
multiline=True,
)
message_view.set_read_only(True)
message_view.set_min_height(96)
# Actions-row диалога: выравнивает кнопки подтверждения и, при необходимости, отмены.
actions = HContainer(spacing=8, content_fit=True)
actions.add_stretch()
if self._reject_text is not None:
self._reject_button = Button(
self._reject_text,
style="FILTER_BUTTON",
content_fit=True,
)
actions.add_widget(self._reject_button)
self._accept_button = Button(
self._accept_text,
style="FILTER_BUTTON_ACTIVE",
content_fit=True,
)
actions.add_widget(self._accept_button)
main_container.add_widget(title_label)
main_container.add_widget(message_view)
main_container.add_widget(actions)
def _connect_signals(self) -> None:
if self._accept_button is not None:
self._accept_button.clicked.connect(self.accept)
if self._reject_button is not None:
self._reject_button.clicked.connect(self.reject)

View File

@@ -0,0 +1,40 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/ticket_placeholder_page.py
"""Временная страница-заглушка Ticket для внутренних разделов shell."""
from gui.components.label import Label
from gui.containers import SContainer, VContainer
class TicketPlaceholderPage(SContainer):
"""Временная страница для разделов, которые ещё будут перенесены."""
def __init__(
self,
title: str,
description: str,
notes: tuple[str, ...] = (),
parent=None,
):
super().__init__(width_percent=100, height_percent=100, parent=parent)
self._title = title
self._description = description
self._notes = notes
self._setup_ui()
def _setup_ui(self) -> None:
"""Показать временную страницу до переноса полноценного UI."""
# Root-контейнер заглушки: показывает название раздела, описание и вспомогательные заметки.
main_container = VContainer(margin=18, spacing=10, parent=self)
title_label = Label(self._title, alignment="left", style="TICKET_LIST_HEADER")
description_label = Label(
self._description,
alignment="left",
style="TICKET_EMPTY_LABEL",
)
main_container.add_widget(title_label)
main_container.add_widget(description_label)
for note in self._notes:
note_label = Label(note, alignment="left", style="TICKET_LIST_SUBTITLE")
main_container.add_widget(note_label)

View File

@@ -0,0 +1,171 @@
# -*- coding: utf-8 -*-
# hub/ticket/ui/ticket_selection_list.py
"""Контейнерный список выбора Ticket на локальной GUI-библиотеке."""
from __future__ import annotations
from dataclasses import dataclass
from PySide6.QtCore import Qt, Signal
from gui.components import Label, VSpring
from gui.containers import ScrollContainer, SContainer, VContainer
@dataclass(frozen=True, slots=True)
class TicketSelectionEntry:
"""Описывает одну запись в контейнерном списке Ticket."""
entry_id: object
title: str
subtitle: str = ""
class _TicketSelectionItem(SContainer):
"""Визуальный элемент выбора записи Ticket."""
clicked = Signal(object)
activated = Signal(object)
def __init__(
self,
entry: TicketSelectionEntry,
parent=None,
):
super().__init__(
margin=0,
spacing=2,
content_fit=True,
parent=parent,
style="TICKET_LIST_ITEM",
active_style="TICKET_LIST_ITEM_SELECTED",
is_active=False,
)
self._entry = entry
self.setCursor(Qt.CursorShape.PointingHandCursor)
self._setup_ui()
@property
def entry_id(self) -> object:
return self._entry.entry_id
def set_selected(self, selected: bool) -> None:
self.style(is_active=selected)
def mousePressEvent(self, event) -> None:
if event.button() == Qt.MouseButton.LeftButton:
self.clicked.emit(self._entry.entry_id)
super().mousePressEvent(event)
def mouseDoubleClickEvent(self, event) -> None:
if event.button() == Qt.MouseButton.LeftButton:
self.clicked.emit(self._entry.entry_id)
self.activated.emit(self._entry.entry_id)
super().mouseDoubleClickEvent(event)
def _setup_ui(self) -> None:
# Body элемента списка: вертикальный блок title/subtitle внутри кликабельной записи.
body = VContainer(margin=10, spacing=2, parent=self)
body.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
title_label = Label(
self._entry.title,
alignment="left",
style="TICKET_LIST_TITLE",
)
title_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
body.add_widget(title_label)
if self._entry.subtitle:
subtitle_label = Label(
self._entry.subtitle,
alignment="left",
style="TICKET_LIST_SUBTITLE",
)
subtitle_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
body.add_widget(subtitle_label)
class TicketSelectionList(SContainer):
"""Переиспользуемый список выбора на контейнерах и локальных компонентах."""
selection_changed = Signal(object)
item_activated = Signal(object)
def __init__(self, parent=None):
super().__init__(spacing=0, parent=parent)
self._items: dict[object, _TicketSelectionItem] = {}
self._current_entry_id: object | None = None
self._items_host: VContainer | None = None
self._setup_ui()
def set_entries(self, entries: list[TicketSelectionEntry]) -> None:
previous_entry_id = self._current_entry_id
self.clear_entries()
for entry in entries:
self._add_entry(entry)
if not self._items:
self._current_entry_id = None
self.selection_changed.emit(None)
return
target_entry_id = previous_entry_id if previous_entry_id in self._items else entries[0].entry_id
self.set_current_entry(target_entry_id)
def clear_entries(self) -> None:
if self._items_host is None:
self._items.clear()
self._current_entry_id = None
return
for item in list(self._items.values()):
self._items_host.remove_widget(item)
item.setParent(None)
self._items.clear()
self._current_entry_id = None
def current_entry_id(self) -> object | None:
return self._current_entry_id
def has_selection(self) -> bool:
return self._current_entry_id is not None
def set_current_entry(self, entry_id: object | None) -> None:
normalized_entry_id = entry_id if entry_id in self._items else None
if normalized_entry_id == self._current_entry_id:
return
self._current_entry_id = normalized_entry_id
for item_entry_id, item in self._items.items():
item.set_selected(item_entry_id == normalized_entry_id)
self.selection_changed.emit(normalized_entry_id)
def _setup_ui(self) -> None:
# Scroll-контейнер списка: внешняя прокручиваемая оболочка всех записей TicketSelectionList.
scroll = ScrollContainer(
margin=0,
content_margins=[0, 0, 0, 0],
spacing=6,
orientation="v",
vertical_scroll_bar_policy="as_needed",
horizontal_scroll_bar_policy="always_off",
style="SCROLL_CONTAINER",
parent=self,
)
# Items-host: вертикальный стек элементов списка с нижней пружиной для прилипания вверх.
self._items_host = VContainer(spacing=6, parent=scroll)
self._items_host.add_widget(VSpring())
def _add_entry(self, entry: TicketSelectionEntry) -> None:
if self._items_host is None:
return
item = _TicketSelectionItem(entry)
item.clicked.connect(self._on_item_clicked)
item.activated.connect(self._on_item_activated)
self._items[entry.entry_id] = item
self._items_host.insert_widget(len(self._items) - 1, item)
def _on_item_clicked(self, entry_id: object) -> None:
self.set_current_entry(entry_id)
def _on_item_activated(self, entry_id: object) -> None:
if entry_id != self._current_entry_id:
self.set_current_entry(entry_id)
self.item_activated.emit(entry_id)

View File

@@ -0,0 +1,400 @@
# -*- 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))