Add Dispatch_V0.1.1
This commit is contained in:
30
Dispatch_V0.1.1/ui/__init__.py
Normal file
30
Dispatch_V0.1.1/ui/__init__.py
Normal 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",
|
||||
]
|
||||
BIN
Dispatch_V0.1.1/ui/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/ui/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
Dispatch_V0.1.1/ui/__pycache__/ticket_board_page.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/ui/__pycache__/ticket_board_page.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Dispatch_V0.1.1/ui/__pycache__/ticket_shell.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/ui/__pycache__/ticket_shell.cpython-313.pyc
Normal file
Binary file not shown.
9
Dispatch_V0.1.1/ui/cards/__init__.py
Normal file
9
Dispatch_V0.1.1/ui/cards/__init__.py
Normal 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"]
|
||||
BIN
Dispatch_V0.1.1/ui/cards/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/ui/cards/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
Dispatch_V0.1.1/ui/cards/__pycache__/task_card.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/ui/cards/__pycache__/task_card.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
11
Dispatch_V0.1.1/ui/cards/task_card.py
Normal file
11
Dispatch_V0.1.1/ui/cards/task_card.py
Normal 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."""
|
||||
|
||||
129
Dispatch_V0.1.1/ui/cards/task_card_pixmap_factory.py
Normal file
129
Dispatch_V0.1.1/ui/cards/task_card_pixmap_factory.py
Normal 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)
|
||||
209
Dispatch_V0.1.1/ui/cards/task_card_view.py
Normal file
209
Dispatch_V0.1.1/ui/cards/task_card_view.py
Normal 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)
|
||||
9
Dispatch_V0.1.1/ui/details/__init__.py
Normal file
9
Dispatch_V0.1.1/ui/details/__init__.py
Normal 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"]
|
||||
BIN
Dispatch_V0.1.1/ui/details/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/ui/details/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
135
Dispatch_V0.1.1/ui/details/task_details_actions.py
Normal file
135
Dispatch_V0.1.1/ui/details/task_details_actions.py
Normal 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)
|
||||
279
Dispatch_V0.1.1/ui/details/task_details_dialog.py
Normal file
279
Dispatch_V0.1.1/ui/details/task_details_dialog.py
Normal 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()
|
||||
116
Dispatch_V0.1.1/ui/details/task_details_view_data.py
Normal file
116
Dispatch_V0.1.1/ui/details/task_details_view_data.py
Normal 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}
|
||||
112
Dispatch_V0.1.1/ui/details/task_stage_action_row.py
Normal file
112
Dispatch_V0.1.1/ui/details/task_stage_action_row.py
Normal 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"
|
||||
17
Dispatch_V0.1.1/ui/dialogs/__init__.py
Normal file
17
Dispatch_V0.1.1/ui/dialogs/__init__.py
Normal 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",
|
||||
]
|
||||
BIN
Dispatch_V0.1.1/ui/dialogs/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/ui/dialogs/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
86
Dispatch_V0.1.1/ui/dialogs/acceptance_dialog.py
Normal file
86
Dispatch_V0.1.1/ui/dialogs/acceptance_dialog.py
Normal 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()
|
||||
218
Dispatch_V0.1.1/ui/dialogs/base_document_dialog.py
Normal file
218
Dispatch_V0.1.1/ui/dialogs/base_document_dialog.py
Normal 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}"
|
||||
11
Dispatch_V0.1.1/ui/dialogs/report_dialogs/__init__.py
Normal file
11
Dispatch_V0.1.1/ui/dialogs/report_dialogs/__init__.py
Normal 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",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
137
Dispatch_V0.1.1/ui/dialogs/report_dialogs/report_dialog.py
Normal file
137
Dispatch_V0.1.1/ui/dialogs/report_dialogs/report_dialog.py
Normal 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()
|
||||
223
Dispatch_V0.1.1/ui/dialogs/specialist_dialog.py
Normal file
223
Dispatch_V0.1.1/ui/dialogs/specialist_dialog.py
Normal 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()
|
||||
135
Dispatch_V0.1.1/ui/dialogs/task_refusal_dialog.py
Normal file
135
Dispatch_V0.1.1/ui/dialogs/task_refusal_dialog.py
Normal 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}"
|
||||
16
Dispatch_V0.1.1/ui/pages/__init__.py
Normal file
16
Dispatch_V0.1.1/ui/pages/__init__.py
Normal 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",
|
||||
]
|
||||
BIN
Dispatch_V0.1.1/ui/pages/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/ui/pages/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
Dispatch_V0.1.1/ui/pages/__pycache__/acts_page.cpython-313.pyc
Normal file
BIN
Dispatch_V0.1.1/ui/pages/__pycache__/acts_page.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
335
Dispatch_V0.1.1/ui/pages/acts_page.py
Normal file
335
Dispatch_V0.1.1/ui/pages/acts_page.py
Normal 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()
|
||||
237
Dispatch_V0.1.1/ui/pages/archive_page.py
Normal file
237
Dispatch_V0.1.1/ui/pages/archive_page.py
Normal 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()
|
||||
129
Dispatch_V0.1.1/ui/pages/archive_view_helpers.py
Normal file
129
Dispatch_V0.1.1/ui/pages/archive_view_helpers.py
Normal 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("")
|
||||
153
Dispatch_V0.1.1/ui/pages/document_browser_page.py
Normal file
153
Dispatch_V0.1.1/ui/pages/document_browser_page.py
Normal 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()
|
||||
69
Dispatch_V0.1.1/ui/pages/report_viewer.py
Normal file
69
Dispatch_V0.1.1/ui/pages/report_viewer.py
Normal 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)
|
||||
340
Dispatch_V0.1.1/ui/pages/reports_page.py
Normal file
340
Dispatch_V0.1.1/ui/pages/reports_page.py
Normal 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()
|
||||
289
Dispatch_V0.1.1/ui/task_view_formatters.py
Normal file
289
Dispatch_V0.1.1/ui/task_view_formatters.py
Normal 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 ""
|
||||
372
Dispatch_V0.1.1/ui/ticket_board_page.py
Normal file
372
Dispatch_V0.1.1/ui/ticket_board_page.py
Normal 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()
|
||||
418
Dispatch_V0.1.1/ui/ticket_create_page.py
Normal file
418
Dispatch_V0.1.1/ui/ticket_create_page.py
Normal 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)
|
||||
115
Dispatch_V0.1.1/ui/ticket_message_dialog.py
Normal file
115
Dispatch_V0.1.1/ui/ticket_message_dialog.py
Normal 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)
|
||||
40
Dispatch_V0.1.1/ui/ticket_placeholder_page.py
Normal file
40
Dispatch_V0.1.1/ui/ticket_placeholder_page.py
Normal 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)
|
||||
171
Dispatch_V0.1.1/ui/ticket_selection_list.py
Normal file
171
Dispatch_V0.1.1/ui/ticket_selection_list.py
Normal 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)
|
||||
400
Dispatch_V0.1.1/ui/ticket_shell.py
Normal file
400
Dispatch_V0.1.1/ui/ticket_shell.py
Normal 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))
|
||||
Reference in New Issue
Block a user