373 lines
15 KiB
Python
373 lines
15 KiB
Python
# -*- 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()
|