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,209 @@
# -*- 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)