Add Dispatch_V0.1.1

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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