# -*- 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()