# -*- coding: utf-8 -*- # hub/ticket/ui/cards/task_card_view.py """Ticket task card view.""" from __future__ import annotations from PySide6.QtCore import Qt, QTimer, Signal from PySide6.QtWidgets import QSizePolicy from gui.components import Label from gui.containers import SContainer, VContainer from domain import TicketTaskSnapshot from domain.ticket_constants import ( STATE_COMPLETED, STATE_CONFIRMATION, STATE_IN_PROGRESS, STATE_REFUSED, STATE_TODO, ) from ui.task_view_formatters import ( build_specialist_card_text, build_specialist_photo_path, build_task_fault_title, ) from .task_card_pixmap_factory import ( build_placeholder_avatar_pixmap, compute_square_size, load_avatar_pixmap, ) class TaskCardView(SContainer): """Compact Ticket card without action logic.""" card_clicked = Signal(object) card_height_changed = Signal() _ROOT_STYLE_BY_STATE = { STATE_TODO: "TICKET_TASK_CARD_ROOT_TODO", STATE_IN_PROGRESS: "TICKET_TASK_CARD_ROOT_IN_PROGRESS", STATE_CONFIRMATION: "TICKET_TASK_CARD_ROOT_CONFIRMATION", STATE_COMPLETED: "TICKET_TASK_CARD_ROOT_COMPLETED", STATE_REFUSED: "TICKET_TASK_CARD_ROOT_REFUSED", } _DARK_TEXT_STATES = {STATE_TODO, STATE_CONFIRMATION, STATE_REFUSED} def __init__( self, task: TicketTaskSnapshot, parent=None, ): super().__init__( width_percent=100, margin=0, content_fit=True, parent=parent, ) self._card_id = task.task_id self._fault_title_label: Label | None = None self._avatar_label: Label | None = None self._current_task: TicketTaskSnapshot | None = None self._height_sync_in_progress = False self._pixmap_refresh_pending = False self._setup_ui() self.update_task(task) @property def card_id(self) -> object: return self._card_id def _setup_ui(self) -> None: self.setObjectName("ticket_task_card") self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True) self.setCursor(Qt.CursorShape.PointingHandCursor) self.set_size_policy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed) # Content-shell: горизонтальное разделение текстовой зоны и аватара. content = SContainer( width_percent=100, height_percent=100, orientation="h", margin=8, spacing=0, style="TICKET_TASK_CARD_CONTENT", parent=self, ) # Text-column: левая вертикальная зона с единственным текстовым полем заголовка. text_column = VContainer( width_percent=70, spacing=0, parent=content, ) # Единственное текстовое поле карточки: краткий заголовок неисправности. self._fault_title_label = Label( "", alignment="left", height_percent=100, parent=text_column, ) # Avatar-column: правая зона для фото или плейсхолдера специалиста. avatar_column = VContainer( width_percent=26, spacing=0, parent=content, ) self._avatar_label = Label( "", alignment="center", width_percent=100, height_percent=100, style="TICKET_TASK_CARD_AVATAR_IMAGE", parent=avatar_column, ) def update_task(self, task: TicketTaskSnapshot) -> None: self._card_id = task.task_id self._current_task = task if self._fault_title_label is not None: self._fault_title_label.set_text(build_task_fault_title(task)) self._fault_title_label.set_tooltip(task.location or "") self._apply_state_styles(task) self._update_avatar(task) self._sync_card_height() self._schedule_pixmap_refresh() def _apply_state_styles(self, task: TicketTaskSnapshot) -> None: style_suffix = "DARK" if task.state_code in self._DARK_TEXT_STATES else "LIGHT" self.style(self._ROOT_STYLE_BY_STATE.get(task.state_code, "TICKET_TASK_CARD_ROOT_TODO")) if self._fault_title_label is not None: self._fault_title_label.style(f"TICKET_TASK_CARD_TITLE_{style_suffix}") def _update_avatar(self, task: TicketTaskSnapshot) -> None: if self._avatar_label is None: return avatar_path = build_specialist_photo_path(task.assigned_specialist, task.specialist_photo) self._avatar_label.set_tooltip(task.assigned_specialist or "Специалист не назначен") pixmap = load_avatar_pixmap(avatar_path, self._avatar_label.width(), self._avatar_label.height(), padding=4) if pixmap is not None: self._avatar_label.set_text("") self._avatar_label.set_pixmap(pixmap) return self._avatar_label.set_text("") placeholder_size = self._label_square_size(self._avatar_label) if placeholder_size > 0: self._avatar_label.set_pixmap(build_placeholder_avatar_pixmap(placeholder_size)) @staticmethod def _label_square_size(label: Label, padding: int = 0) -> int: return compute_square_size(label.width(), label.height(), padding) def resizeEvent(self, event) -> None: super().resizeEvent(event) self._sync_card_height() if self._current_task is not None: self._update_avatar(self._current_task) self._schedule_pixmap_refresh() def _schedule_pixmap_refresh(self) -> None: """Запланировать отложенный рендер pixmap-контента. Процентные размеры дочерних Label вычисляются через QTimer.singleShot(0) в PercentSizedWidget, поэтому при первом показе карточки (или при динамической вставке из COM-сигнала) label.width()/height() ещё равны 0 в момент resizeEvent. Отложенный вызов гарантирует, что к моменту рендера layout дочерних виджетов уже завершён. """ if self._pixmap_refresh_pending: return self._pixmap_refresh_pending = True QTimer.singleShot(0, self._deferred_pixmap_refresh) def _deferred_pixmap_refresh(self) -> None: self._pixmap_refresh_pending = False if self._current_task is not None: self._update_avatar(self._current_task) def _sync_card_height(self) -> None: if self._height_sync_in_progress or self.width() <= 0: return target_height = max(1, round(self.width() / 2.745)) if self.height() == target_height: return self._height_sync_in_progress = True self.setFixedHeight(target_height) self._height_sync_in_progress = False self.card_height_changed.emit() def _on_parent_rebuild_finished(self) -> None: # Фаза 2 каскада percent-sized: родительская колонка завершила # перестроение, ширина карточки уже стабильная. Только теперь # пересчитываем собственную высоту по соотношению width/2.745 # и обновляем pixmap-контент. super()._on_parent_rebuild_finished() self._sync_card_height() if self._current_task is not None: self._schedule_pixmap_refresh() def mousePressEvent(self, event) -> None: if event.button() == Qt.MouseButton.LeftButton: self.card_clicked.emit(self._card_id) super().mousePressEvent(event)