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