handOver2/ui/widgets/todo_list.py

495 lines
16 KiB
Python

# -*- coding: utf-8 -*-
"""
할일 목록 위젯 모듈
할일 목록을 표시하고 관리하는 위젯입니다.
"""
from datetime import date, datetime
from typing import List, Dict
from PySide6.QtWidgets import (
QWidget, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QFrame, QScrollArea, QCheckBox, QDialog,
QMenu
)
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QFont
from ui.base.base_widget import BaseWidget, CardWidget
from ui.components.custom_button import CustomButton, IconButton
from ui.dialogs.todo_input_dialog import TodoInputDialog
from database.crud import CRUDManager
from database.models import Todo, TodoCategory
from core.config import ConfigManager
from core.signals import GlobalSignals
from core.logger import get_logger
logger = get_logger(__name__)
# 카테고리별 색상 및 아이콘
CATEGORY_STYLES = {
TodoCategory.GENERAL: {
"icon": "📝",
"color": "#3b82f6",
"label": "일반",
},
TodoCategory.ARRIVAL_INSPECTION: {
"icon": "🚃",
"color": "#8b5cf6",
"label": "도착검수",
},
TodoCategory.TASK: {
"icon": "🔧",
"color": "#f59e0b",
"label": "작업",
},
}
class TodoItem(QWidget):
"""
할일 항목 위젯
할일 하나를 표시하는 위젯입니다.
"""
status_changed = Signal(int, bool) # todo_id, is_completed
item_clicked = Signal(int) # todo_id
category_changed = Signal(int, str) # todo_id, new_category
def __init__(self, todo: Todo, parent=None):
super().__init__(parent)
self.config = ConfigManager()
self.todo = todo
self._setup_ui()
def _setup_ui(self):
"""UI 설정"""
layout = QHBoxLayout(self)
layout.setContentsMargins(8, 8, 8, 8)
layout.setSpacing(12)
# 카테고리 아이콘
category = self.todo.category or TodoCategory.GENERAL
style = CATEGORY_STYLES.get(category, CATEGORY_STYLES[TodoCategory.GENERAL])
category_label = QLabel(style["icon"])
category_label.setFixedWidth(24)
category_label.setFont(QFont("Segoe UI Emoji", 14))
layout.addWidget(category_label)
# 체크박스
self.checkbox = QCheckBox()
self.checkbox.setChecked(self.todo.is_completed)
self.checkbox.stateChanged.connect(self._on_checkbox_changed)
layout.addWidget(self.checkbox)
# 내용
content_widget = QWidget()
content_widget.setCursor(Qt.PointingHandCursor)
content_layout = QVBoxLayout(content_widget)
content_layout.setContentsMargins(0, 0, 0, 0)
content_layout.setSpacing(2)
# 할일 내용
self.content_label = QLabel(self.todo.content or "")
self.content_label.setFont(QFont("GmarketSans", 12))
self.content_label.setWordWrap(True)
# 부가 정보
info_parts = []
if self.todo.target_train:
info_parts.append(f"편성: {self.todo.target_train}")
if self.todo.schedule:
info_parts.append(self.todo.schedule)
self.info_label = QLabel(" | ".join(info_parts) if info_parts else "")
self.info_label.setFont(QFont("GmarketSans", 10))
content_layout.addWidget(self.content_label)
if info_parts:
content_layout.addWidget(self.info_label)
layout.addWidget(content_widget, 1)
# 카테고리 태그
if category != TodoCategory.GENERAL:
tag = QLabel(style["label"])
tag.setFont(QFont("GmarketSans", 9))
tag.setStyleSheet(f"""
background-color: {style['color']};
color: white;
padding: 2px 6px;
border-radius: 4px;
""")
layout.addWidget(tag)
# 클릭 이벤트
content_widget.mousePressEvent = lambda e: self.item_clicked.emit(self.todo.id)
self._apply_style()
def _apply_style(self):
"""스타일 적용"""
theme = self.config.theme
category = self.todo.category or TodoCategory.GENERAL
cat_style = CATEGORY_STYLES.get(category, CATEGORY_STYLES[TodoCategory.GENERAL])
if theme == 'dark':
bg = "#1e293b"
text = "#f8fafc"
secondary = "#64748b"
completed_text = "#475569"
else:
bg = "#ffffff"
text = "#1e293b"
secondary = "#94a3b8"
completed_text = "#cbd5e1"
if self.todo.is_completed:
text = completed_text
self.content_label.setStyleSheet(f"color: {text}; text-decoration: line-through;")
else:
self.content_label.setStyleSheet(f"color: {text};")
self.info_label.setStyleSheet(f"color: {secondary};")
# 카테고리에 따른 왼쪽 테두리 색상
border_color = cat_style["color"]
self.setStyleSheet(f"""
TodoItem {{
background-color: {bg};
border-radius: 8px;
border-left: 3px solid {border_color};
}}
QCheckBox::indicator {{
width: 18px;
height: 18px;
border-radius: 4px;
}}
QCheckBox::indicator:unchecked {{
border: 2px solid #64748b;
background-color: transparent;
}}
QCheckBox::indicator:checked {{
border: 2px solid #22c55e;
background-color: #22c55e;
}}
""")
def _on_checkbox_changed(self, state: int):
"""체크박스 상태 변경"""
is_completed = state == Qt.Checked
self.todo.is_completed = is_completed
self._apply_style()
self.status_changed.emit(self.todo.id, is_completed)
def contextMenuEvent(self, event):
"""컨텍스트 메뉴 이벤트"""
menu = QMenu(self)
# 스타일 설정
theme = self.config.theme
bg_color = "#334155" if theme == 'dark' else "#ffffff"
text_color = "#f8fafc" if theme == 'dark' else "#1e293b"
border_color = "#475569" if theme == 'dark' else "#e2e8f0"
menu.setStyleSheet(f"""
QMenu {{
background-color: {bg_color};
color: {text_color};
border: 1px solid {border_color};
border-radius: 8px;
padding: 4px;
}}
QMenu::item {{
padding: 6px 24px;
border-radius: 4px;
}}
QMenu::item:selected {{
background-color: #3b82f6;
color: white;
}}
""")
# 이동 가능한 카테고리
# '고장', '지시', '작업', '기타'는 TodoCategory에 정의된 상수나 문자열이어야 함
# models.py의 TodoCategory는 Enum이 아닐 수도 있음. 확인 필요하지만 일단 문자열로 처리
# TodoCategory.GENERAL="일반", ARRIVAL="도착검수", TASK="작업"
# 사용자가 요청한 "고장", "지시", "기타"는 TodoCategory에 없을 수 있음.
# 하지만 models.py를 보면 TodoCategory는 클래스로 정의되어 있고 상수가 있음.
# 일단 요청대로 문자열을 사용.
actions = [
("🔧 작업으로 이동", "작업"),
("⚠️ 고장으로 이동", "고장"),
("📢 지시로 이동", "지시"),
("📝 기타로 이동", "기타"),
("📄 일반으로 이동", "일반"),
]
current_category = self.todo.category or "일반"
for label, category in actions:
if category != current_category:
action = menu.addAction(label)
# lambda capture issue fix
action.triggered.connect(lambda checked=False, c=category: self.category_changed.emit(self.todo.id, c))
menu.exec(event.globalPos())
class CategoryFilterChip(QPushButton):
"""카테고리 필터 칩"""
filter_changed = Signal(str, bool) # category, is_active
def __init__(self, category: str, parent=None):
super().__init__(parent)
self.config = ConfigManager()
self.category = category
self._is_active = True
style = CATEGORY_STYLES.get(category, CATEGORY_STYLES[TodoCategory.GENERAL])
self.setText(f"{style['icon']} {style['label']}")
self.icon_color = style["color"]
self.setFont(QFont("GmarketSans", 10))
self.setCursor(Qt.PointingHandCursor)
self.setFixedHeight(28)
self.clicked.connect(self._on_clicked)
self._apply_style()
def _on_clicked(self):
"""클릭 이벤트"""
self._is_active = not self._is_active
self._apply_style()
self.filter_changed.emit(self.category, self._is_active)
def _apply_style(self):
"""스타일 적용"""
theme = self.config.theme
if self._is_active:
self.setStyleSheet(f"""
QPushButton {{
background-color: {self.icon_color};
color: white;
border: none;
border-radius: 4px;
padding: 0 8px;
}}
QPushButton:hover {{
opacity: 0.8;
}}
""")
else:
if theme == 'dark':
bg = "#334155"
text = "#64748b"
else:
bg = "#e2e8f0"
text = "#94a3b8"
self.setStyleSheet(f"""
QPushButton {{
background-color: {bg};
color: {text};
border: none;
border-radius: 4px;
padding: 0 8px;
}}
QPushButton:hover {{
color: {self.icon_color};
}}
""")
class TodoListWidget(CardWidget):
"""
할일 목록 위젯
할일 목록을 표시하고 관리합니다.
카테고리별로 필터링할 수 있습니다.
Examples:
>>> widget = TodoListWidget()
>>> widget.load_data()
"""
def __init__(self, parent=None):
super().__init__(parent, padding=12, radius=12)
self.crud = CRUDManager()
self._current_date = date.today()
self._todo_items: List[TodoItem] = []
self._active_filters: Dict[str, bool] = {
TodoCategory.GENERAL: True,
TodoCategory.ARRIVAL_INSPECTION: True,
TodoCategory.TASK: True,
}
self._setup_ui()
self._connect_signals()
logger.info("할일 목록 위젯 초기화 완료")
def _setup_ui(self):
"""UI 설정"""
# 헤더
header = QWidget()
header_layout = QHBoxLayout(header)
header_layout.setContentsMargins(0, 0, 0, 0)
title = QLabel("✅ 할일 목록")
title.setFont(QFont("GmarketSans", 14, QFont.Bold))
theme = self.config.theme
text_color = "#f8fafc" if theme == 'dark' else "#1e293b"
title.setStyleSheet(f"color: {text_color};")
header_layout.addWidget(title)
header_layout.addStretch()
# 추가 버튼
add_btn = IconButton("+", size=28, tooltip="할일 추가")
add_btn.clicked.connect(self._on_add_clicked)
header_layout.addWidget(add_btn)
self.layout.addWidget(header)
# 카테고리 필터
filter_container = QWidget()
filter_layout = QHBoxLayout(filter_container)
filter_layout.setContentsMargins(0, 4, 0, 4)
filter_layout.setSpacing(6)
self._filter_chips = []
for category in [TodoCategory.GENERAL, TodoCategory.ARRIVAL_INSPECTION, TodoCategory.TASK]:
chip = CategoryFilterChip(category)
chip.filter_changed.connect(self._on_filter_changed)
self._filter_chips.append(chip)
filter_layout.addWidget(chip)
filter_layout.addStretch()
self.layout.addWidget(filter_container)
# 구분선
separator = QFrame()
separator.setFrameShape(QFrame.HLine)
separator.setStyleSheet("color: #334155;" if theme == 'dark' else "color: #e2e8f0;")
self.layout.addWidget(separator)
# 스크롤 영역
scroll = QScrollArea()
scroll.setWidgetResizable(True)
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scroll.setFrameShape(QFrame.NoFrame)
scroll.setStyleSheet("background: transparent;")
# 할일 컨테이너
self.todo_container = QWidget()
self.todo_layout = QVBoxLayout(self.todo_container)
self.todo_layout.setContentsMargins(0, 0, 0, 0)
self.todo_layout.setSpacing(8)
self.todo_layout.addStretch()
scroll.setWidget(self.todo_container)
self.layout.addWidget(scroll, 1)
def _connect_signals(self):
"""시그널 연결"""
self.signals.todo_added.connect(self._on_todo_added)
self.signals.todo_status_changed.connect(self._on_todo_status_changed)
def _on_filter_changed(self, category: str, is_active: bool):
"""필터 변경"""
self._active_filters[category] = is_active
self._apply_filter()
def _apply_filter(self):
"""필터 적용"""
for item in self._todo_items:
category = item.todo.category or TodoCategory.GENERAL
should_show = self._active_filters.get(category, True)
item.setVisible(should_show)
def load_data(self):
"""데이터 로드"""
# 기존 아이템 제거
for item in self._todo_items:
self.todo_layout.removeWidget(item)
item.deleteLater()
self._todo_items.clear()
# 할일 조회
todos = self.crud.get_todos_by_date(self._current_date, include_incomplete=True)
# 카테고리별로 정렬 (도착검수 > 작업 > 일반)
category_order = {
TodoCategory.ARRIVAL_INSPECTION: 0,
TodoCategory.TASK: 1,
TodoCategory.GENERAL: 2,
}
todos.sort(key=lambda t: (t.is_completed, category_order.get(t.category or TodoCategory.GENERAL, 2)))
# 할일 아이템 추가
for todo in todos:
item = TodoItem(todo)
item.status_changed.connect(self._on_item_status_changed)
item.item_clicked.connect(self._on_item_clicked)
item.category_changed.connect(self._on_category_changed)
# stretch 앞에 삽입
self.todo_layout.insertWidget(self.todo_layout.count() - 1, item)
self._todo_items.append(item)
# 필터 적용
self._apply_filter()
def _on_add_clicked(self):
"""추가 버튼 클릭"""
dialog = TodoInputDialog(self, self._current_date)
if dialog.exec() == QDialog.Accepted:
data = dialog.get_data()
self.crud.create_todo(**data)
self.load_data()
def _on_item_status_changed(self, todo_id: int, is_completed: bool):
"""할일 상태 변경"""
completed_at = datetime.now().isoformat() if is_completed else None
self.crud.update_todo(todo_id, is_completed=is_completed, completed_at=completed_at)
def _on_item_clicked(self, todo_id: int):
"""할일 항목 클릭 (편집)"""
todo = self.crud.get_todo(todo_id)
if todo:
dialog = TodoInputDialog(self, self._current_date, todo)
if dialog.exec() == QDialog.Accepted:
data = dialog.get_data()
self.crud.update_todo(todo_id, **data)
self.load_data()
def _on_todo_added(self, todo_id: int):
"""할일 추가 시그널"""
self.load_data()
def _on_todo_status_changed(self, todo_id: int, is_completed: bool):
"""할일 상태 변경 시그널"""
# 이미 처리됨
pass
def _on_category_changed(self, todo_id: int, new_category: str):
"""카테고리 변경"""
self.crud.update_todo(todo_id, category=new_category)
self.load_data()