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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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