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,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()