495 lines
16 KiB
Python
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()
|