Files
Dispatch/Dispatch_V0.1.1/ui/ticket_board_page.py
2026-04-29 08:18:54 +04:00

373 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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()