"""Bảng Kanban 7 cột kéo thả — R08-T11. Bảy trạng thái task xếp thành bảy cột. Kéo thẻ sang cột khác là **đổi trạng thái thật**, không phải chỉ dời chỗ trên màn hình — thả vào cột "Đang chạy" là task chạy ngay. ``_DropZone`` là vùng nhận file kéo vào, dùng chung với hộp thoại nhập task. """ from __future__ import annotations import copy from pathlib import Path from typing import Dict, List, Optional from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox, QPlainTextEdit, QPushButton, QScrollArea, QSizePolicy, QStackedWidget, QTabBar, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, ) from ...core import tasks as taskrepo from ...core.projects import list_projects from ...core.tasks import STATUSES, chain_error, duplicate_task, new_task from ...core.worker import AgentWorker from ...i18n import on_language_changed, tr from ...state import AppContext from ...theme import current_palette from ...ui.calendar_view import CalendarView from ...ui.icons import icon from ...ui.osutil import open_path class _KanbanColumn(QListWidget): """One status lane. Accepts drops from sibling columns; a drop means 'move this task to my status'.""" task_dropped = Signal(str, str) # task_id, new_status def __init__(self, status: str): super().__init__() self.status = status self.setDragDropMode(QAbstractItemView.DragDrop) self.setDefaultDropAction(Qt.MoveAction) # Shift/Ctrl-click several cards in the SAME column, then right-click # → "Delete N selected" to bulk-remove tasks instead of one at a time. self.setSelectionMode(QAbstractItemView.ExtendedSelection) self.setWordWrap(True) # Cards wrap, so there is never anything to reach by scrolling sideways # — but QListWidget's own column hint runs 1-6px past the viewport, and # a lane sprouted a horizontal scrollbar at 36 of 38 window widths I # measured. Which lanes grew one changed with the width, which is why it # looked like it depended on the screen. self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.setResizeMode(QListWidget.Adjust) # re-wrap on every resize # No pixel floor here. A fixed one is always wrong on some screen: # 190 lost the seventh lane, 150 still wanted 1242px where a 1280 # window leaves 1091 — so the 1280 monitor scrolled sideways and the # 1920 one did not, same app, same build. The board divides whatever # width it has by seven instead; see _fit_lanes(). def dropEvent(self, event): # noqa: N802 source = event.source() if isinstance(source, _KanbanColumn) and source is not self: item = source.currentItem() tid = item.data(Qt.UserRole) if item else None if tid: event.acceptProposedAction() self.task_dropped.emit(tid, self.status) return event.ignore() class _DropZone(QLabel): """Drag-an-.xlsx-here area for the Import tab.""" file_dropped = Signal(str) def __init__(self): super().__init__() self.setAlignment(Qt.AlignCenter) self.setMinimumHeight(70) _p = current_palette() self.setStyleSheet( f"QLabel {{ border: 1px dashed {_p.border_strong};" f" border-radius: {_p.radius_lg}px;" f" color: {_p.text_muted}; padding: 10px; }}") self.setAcceptDrops(True) def dragEnterEvent(self, event): # noqa: N802 urls = event.mimeData().urls() if urls and urls[0].toLocalFile().lower().endswith( (".xlsx", ".xlsm", ".xls", ".csv", ".json")): event.acceptProposedAction() def dropEvent(self, event): # noqa: N802 urls = event.mimeData().urls() if urls: self.file_dropped.emit(urls[0].toLocalFile())