v2.8.0: 도전과제 시스템 + 다크 디자인 리뉴얼 + 안정성 강화
Added — 도전과제 시스템 (153개 자동 평가) - core/achievements.py: 16개 카테고리, 5단계 등급, 시크릿 9개, 메타 도전과제 - ui/achievements_view.py: 4탭 다이얼로그 (전체/진행중/완료/시크릿) - 5분 throttle 자동 평가 + 시스템 알림 + Discord embed push - achievements 테이블 확장 (code/category/tier/is_secret/progress/target) - hire_date 자동 추적, 뷰 진입 카운터 8개 settings 키 Changed — 다크 테마 디자인 리뉴얼 - ui/dark_components.py: 재사용 가능한 다크 컴포넌트 (header/card/button/progress) - 통계/도움말/도전과제 다이얼로그 일관 다크 톤 - matplotlib 차트 다크 테마 적용 (figure/axes/grid/legend) - 등급별 카드 그라디언트 (브론즈/실버/골드/플래티넘/레전드) Fixed — 안정성·일관성 - 타임존 자정 경계 버그 (has_notification_today UTC vs localtime mismatch) - DB 연결 누수: _conn() 컨텍스트 매니저 도입, 40+ 메서드 변환 - DB 이중 부트스트랩 제거 (MainWindow(db=None) 옵션 인자) - crash_handler 다단계 폴백 (DB → 파일 → stderr) - updater PID race: 지수 backoff 재시도 (총 ~9초) - Discord URL 형식 검증 (snowflake regex) - 일일 보고서 저녁 섹션, MealTimeDialog 출/퇴근 범위 검증 - check_dinner_reminder 신규, 알림 임계값 5개 설정화 - closeEvent timer/notifier 정리 (aboutToQuit hook) - 마이그레이션 12개 모두 _conn() + try/finally - DB 인덱스 5개 추가 (break/overtime/leave date) Tests - pytest 116/116 PASS, 통합 시나리오 48/48 PASS Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
ff71886fd7
commit
c5df37ca57
@@ -0,0 +1,484 @@
|
||||
"""
|
||||
도전과제 뷰 — 4탭 (전체 / 진행 중 / 완료 / 시크릿).
|
||||
|
||||
디자인 원칙:
|
||||
- 카드 = 등급별 그라디언트 배경 + 외곽선 빛 (획득 시 강한 색)
|
||||
- 글로벌 QSS와 격리: 모든 sub-label에 명시적 transparent + border:none
|
||||
- 진행 게이지 = 두꺼운 색상 막대 (등급 색)
|
||||
- 카테고리 = 작은 인라인 태그
|
||||
- 시크릿 미발견 = ❓ 처리
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QTabWidget, QWidget, QScrollArea,
|
||||
QProgressBar, QFrame, QGridLayout, QSizePolicy)
|
||||
from PyQt5.QtCore import Qt
|
||||
from PyQt5.QtGui import QFont
|
||||
|
||||
from core.achievements import get_all_with_status, get_stats
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
# 등급별 색상 팔레트 (배경 그라디언트, 외곽선, 강조)
|
||||
TIER_THEMES = {
|
||||
'bronze': {
|
||||
'border': '#cd7f32',
|
||||
'border_strong': '#e09947',
|
||||
'bg_top': '#3a2a18',
|
||||
'bg_bot': '#241810',
|
||||
'text': '#ffd9a8',
|
||||
'label': '🥉',
|
||||
'name': '브론즈',
|
||||
},
|
||||
'silver': {
|
||||
'border': '#a8a8a8',
|
||||
'border_strong': '#d0d0d0',
|
||||
'bg_top': '#2e2e36',
|
||||
'bg_bot': '#1c1c22',
|
||||
'text': '#e8e8f0',
|
||||
'label': '🥈',
|
||||
'name': '실버',
|
||||
},
|
||||
'gold': {
|
||||
'border': '#ffb700',
|
||||
'border_strong': '#ffd24a',
|
||||
'bg_top': '#3a2e10',
|
||||
'bg_bot': '#241c08',
|
||||
'text': '#ffe9a0',
|
||||
'label': '🥇',
|
||||
'name': '골드',
|
||||
},
|
||||
'platinum': {
|
||||
'border': '#7fdbff',
|
||||
'border_strong': '#a8e8ff',
|
||||
'bg_top': '#1a3340',
|
||||
'bg_bot': '#0e1f28',
|
||||
'text': '#c5ecff',
|
||||
'label': '💎',
|
||||
'name': '플래티넘',
|
||||
},
|
||||
'legend': {
|
||||
'border': '#ff6b9d',
|
||||
'border_strong': '#ff90b8',
|
||||
'bg_top': '#3a1a2a',
|
||||
'bg_bot': '#26101a',
|
||||
'text': '#ffc0d4',
|
||||
'label': '🌟',
|
||||
'name': '레전드',
|
||||
},
|
||||
}
|
||||
|
||||
CATEGORY_LABELS = {
|
||||
'streak': '출근 streak', 'punctual': '시간 엄수', 'balance': '워라밸',
|
||||
'ot_bank': '연장 적립', 'ot_use': '연장 사용', 'leave': '연차',
|
||||
'health': '건강', 'special_day': '특별일', 'pattern': '패턴',
|
||||
'milestone': '마일스톤', 'season': '시즌', 'time_slot': '시간대',
|
||||
'meal': '식사', 'break_use': '외출', 'settings': '설정',
|
||||
'stats': '통계', 'secret': '시크릿', 'korea': '한국 문화',
|
||||
'ambition': '야망', 'meta': '메타',
|
||||
}
|
||||
|
||||
|
||||
class AchievementsView(QDialog):
|
||||
"""도전과제 다이얼로그 — 4탭 + 통계 헤더."""
|
||||
|
||||
def __init__(self, db, parent=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle("🏆 도전과제")
|
||||
self.setMinimumSize(960, 720)
|
||||
self.resize(1100, 800)
|
||||
self._increment_view_count()
|
||||
self.setStyleSheet("QDialog { background: #0e0e14; }")
|
||||
self.init_ui()
|
||||
apply_dark_titlebar(self, dark=True)
|
||||
|
||||
def _increment_view_count(self) -> None:
|
||||
try:
|
||||
cur = self.db.get_setting_int('achievements_view_count', 0)
|
||||
self.db.set_setting('achievements_view_count', str(cur + 1))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def init_ui(self) -> None:
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(20, 20, 20, 16)
|
||||
layout.setSpacing(12)
|
||||
|
||||
stats = get_stats(self.db)
|
||||
|
||||
# === 헤더: 큰 숫자 + 그라디언트 진행바 ===
|
||||
layout.addWidget(self._build_header(stats))
|
||||
|
||||
# === 탭 ===
|
||||
self.tabs = QTabWidget()
|
||||
self.tabs.setStyleSheet(self._tabs_qss())
|
||||
|
||||
all_items = get_all_with_status(self.db)
|
||||
earned_items = [a for a in all_items if a['earned_date'] is not None]
|
||||
in_progress = [a for a in all_items
|
||||
if a['earned_date'] is None and not a['is_secret']]
|
||||
secret_items = [a for a in all_items if a['is_secret']]
|
||||
|
||||
self.tabs.addTab(self._build_grid_tab(all_items), f"🌐 전체 · {len(all_items)}")
|
||||
self.tabs.addTab(self._build_grid_tab(in_progress),
|
||||
f"⚡ 진행 중 · {len(in_progress)}")
|
||||
self.tabs.addTab(self._build_grid_tab(earned_items),
|
||||
f"✓ 완료 · {len(earned_items)}")
|
||||
self.tabs.addTab(
|
||||
self._build_grid_tab(secret_items, secret_mode=True),
|
||||
f"🌑 시크릿 · {stats['secret_earned']}/{stats['secret_total']}"
|
||||
)
|
||||
layout.addWidget(self.tabs, 1)
|
||||
|
||||
# === 닫기 버튼 ===
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
close_btn = QPushButton("닫기")
|
||||
close_btn.setMinimumWidth(100)
|
||||
close_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background: #2a2a36; color: #e0e0e8;
|
||||
border: 1px solid #44446a; border-radius: 6px;
|
||||
padding: 8px 20px; font-size: 10pt;
|
||||
}
|
||||
QPushButton:hover { background: #3a3a4a; border-color: #6b9eff; }
|
||||
""")
|
||||
close_btn.clicked.connect(self.accept)
|
||||
btn_row.addWidget(close_btn)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
# ----- 헤더 -----
|
||||
def _build_header(self, stats: dict) -> QWidget:
|
||||
container = QFrame()
|
||||
container.setStyleSheet("""
|
||||
QFrame {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #1a1a30, stop:1 #2a1a3a);
|
||||
border: 1px solid #3a3a5a;
|
||||
border-radius: 12px;
|
||||
}
|
||||
QLabel { background: transparent; border: none; color: #e8e8f4; }
|
||||
""")
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(20, 16, 20, 16)
|
||||
layout.setSpacing(8)
|
||||
|
||||
pct = (stats['earned'] / stats['total'] * 100) if stats['total'] else 0
|
||||
|
||||
# 큰 숫자 행
|
||||
num_row = QHBoxLayout()
|
||||
num_row.setSpacing(24)
|
||||
|
||||
big = QLabel(f"<span style='font-size: 32pt; font-weight: bold; color: #ffd24a;'>{stats['earned']}</span>"
|
||||
f"<span style='font-size: 18pt; color: #888;'> / {stats['total']}</span>")
|
||||
big.setTextFormat(Qt.RichText)
|
||||
num_row.addWidget(big)
|
||||
|
||||
spacer = QFrame()
|
||||
spacer.setFrameShape(QFrame.VLine)
|
||||
spacer.setStyleSheet("color: #3a3a5a;")
|
||||
num_row.addWidget(spacer)
|
||||
|
||||
secret_lbl = QLabel(
|
||||
f"<div style='line-height: 1.3;'>"
|
||||
f"<span style='font-size: 9pt; color: #888;'>🌑 시크릿</span><br>"
|
||||
f"<span style='font-size: 18pt; font-weight: bold; color: #ff90b8;'>"
|
||||
f"{stats['secret_earned']}</span>"
|
||||
f"<span style='font-size: 12pt; color: #888;'> / {stats['secret_total']}</span>"
|
||||
f"</div>"
|
||||
)
|
||||
secret_lbl.setTextFormat(Qt.RichText)
|
||||
num_row.addWidget(secret_lbl)
|
||||
|
||||
num_row.addStretch()
|
||||
|
||||
pct_lbl = QLabel(
|
||||
f"<div style='text-align: right; line-height: 1.3;'>"
|
||||
f"<span style='font-size: 9pt; color: #888;'>달성률</span><br>"
|
||||
f"<span style='font-size: 24pt; font-weight: bold; color: #4adef0;'>"
|
||||
f"{pct:.1f}%</span></div>"
|
||||
)
|
||||
pct_lbl.setTextFormat(Qt.RichText)
|
||||
pct_lbl.setAlignment(Qt.AlignRight)
|
||||
num_row.addWidget(pct_lbl)
|
||||
|
||||
layout.addLayout(num_row)
|
||||
|
||||
# 진행 바
|
||||
bar = QProgressBar()
|
||||
bar.setMaximum(max(stats['total'], 1))
|
||||
bar.setValue(stats['earned'])
|
||||
bar.setTextVisible(False)
|
||||
bar.setMinimumHeight(8)
|
||||
bar.setMaximumHeight(8)
|
||||
bar.setStyleSheet("""
|
||||
QProgressBar {
|
||||
background: #1a1a26;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QProgressBar::chunk {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #4adef0, stop:0.5 #6b9eff, stop:1 #ff90b8);
|
||||
border-radius: 4px;
|
||||
}
|
||||
""")
|
||||
layout.addWidget(bar)
|
||||
|
||||
container.setLayout(layout)
|
||||
return container
|
||||
|
||||
# ----- 탭 그리드 -----
|
||||
def _build_grid_tab(self, items: list, secret_mode: bool = False) -> QWidget:
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setStyleSheet("""
|
||||
QScrollArea { background: transparent; border: none; }
|
||||
QScrollBar:vertical {
|
||||
background: #1a1a24; width: 10px; border-radius: 5px;
|
||||
}
|
||||
QScrollBar::handle:vertical {
|
||||
background: #44446a; border-radius: 5px; min-height: 30px;
|
||||
}
|
||||
QScrollBar::handle:vertical:hover { background: #6b9eff; }
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
|
||||
""")
|
||||
container = QWidget()
|
||||
container.setStyleSheet("background: transparent;")
|
||||
grid = QGridLayout()
|
||||
grid.setSpacing(12)
|
||||
grid.setContentsMargins(8, 8, 8, 8)
|
||||
|
||||
if not items:
|
||||
empty = QLabel("(아직 없음)")
|
||||
empty.setAlignment(Qt.AlignCenter)
|
||||
empty.setStyleSheet(
|
||||
"color: #666; padding: 60px; font-size: 12pt; background: transparent;"
|
||||
)
|
||||
grid.addWidget(empty, 0, 0)
|
||||
else:
|
||||
cols = 3
|
||||
for i, item in enumerate(items):
|
||||
card = self._build_card(item, secret_mode=secret_mode)
|
||||
grid.addWidget(card, i // cols, i % cols)
|
||||
# 빈 컬럼 stretch 방지
|
||||
for c in range(cols):
|
||||
grid.setColumnStretch(c, 1)
|
||||
|
||||
container.setLayout(grid)
|
||||
scroll.setWidget(container)
|
||||
return scroll
|
||||
|
||||
# ----- 단일 카드 -----
|
||||
def _build_card(self, item: dict, secret_mode: bool = False) -> QFrame:
|
||||
is_earned = item['earned_date'] is not None
|
||||
is_locked_secret = item['is_secret'] and not is_earned
|
||||
tier = item['tier'] or 'bronze'
|
||||
theme = TIER_THEMES.get(tier, TIER_THEMES['bronze'])
|
||||
|
||||
# 시크릿 미발견은 회색 톤으로
|
||||
if is_locked_secret:
|
||||
bg_top, bg_bot = '#1a1a26', '#0e0e16'
|
||||
border = '#3a3a4a'
|
||||
text_color = '#666'
|
||||
else:
|
||||
bg_top = theme['bg_top']
|
||||
bg_bot = theme['bg_bot']
|
||||
border = theme['border_strong'] if is_earned else theme['border']
|
||||
text_color = theme['text'] if is_earned else '#c0c0d0'
|
||||
|
||||
# 외곽선 강도: 획득 시 2px + 더 진한 색
|
||||
border_width = 2 if is_earned else 1
|
||||
opacity_overlay = '' if is_earned else 'background-color: rgba(0,0,0,0.25);'
|
||||
|
||||
card = QFrame()
|
||||
card.setFrameShape(QFrame.NoFrame)
|
||||
card.setMinimumHeight(150)
|
||||
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
card.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||
stop:0 {bg_top}, stop:1 {bg_bot});
|
||||
border: {border_width}px solid {border};
|
||||
border-radius: 10px;
|
||||
}}
|
||||
QLabel {{
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: {text_color};
|
||||
}}
|
||||
""")
|
||||
|
||||
outer = QVBoxLayout()
|
||||
outer.setContentsMargins(14, 12, 14, 12)
|
||||
outer.setSpacing(8)
|
||||
|
||||
# 1행: 이모지 + 이름 + 등급 라벨
|
||||
top_row = QHBoxLayout()
|
||||
top_row.setSpacing(10)
|
||||
|
||||
if is_locked_secret:
|
||||
icon_text = "❓"
|
||||
else:
|
||||
icon_text = item['badge_icon'] or '🏆'
|
||||
icon = QLabel(icon_text)
|
||||
icon.setStyleSheet(
|
||||
f"font-size: 32pt; background: transparent; border: none; "
|
||||
f"color: {text_color};"
|
||||
)
|
||||
icon.setMinimumWidth(48)
|
||||
icon.setAlignment(Qt.AlignCenter | Qt.AlignTop)
|
||||
top_row.addWidget(icon)
|
||||
|
||||
# 이름 + 카테고리 (세로 스택)
|
||||
name_box = QVBoxLayout()
|
||||
name_box.setSpacing(2)
|
||||
name_box.setContentsMargins(0, 4, 0, 0)
|
||||
|
||||
name_text = "???" if is_locked_secret else (item['name'] or '')
|
||||
name = QLabel(name_text)
|
||||
name.setStyleSheet(
|
||||
f"font-size: 12pt; font-weight: bold; "
|
||||
f"color: {'#ffffff' if is_earned else '#d0d0e0'}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
name.setWordWrap(True)
|
||||
name_box.addWidget(name)
|
||||
|
||||
cat_text = CATEGORY_LABELS.get(item['category'], item['category'] or '')
|
||||
if not is_locked_secret:
|
||||
cat_label = QLabel(f" {theme['label']} {theme['name']} · {cat_text} ")
|
||||
cat_label.setStyleSheet(
|
||||
f"font-size: 8.5pt; "
|
||||
f"color: {theme['border_strong']}; "
|
||||
f"background: rgba(255,255,255,0.05); "
|
||||
f"border: 1px solid {theme['border']}; "
|
||||
f"border-radius: 8px; "
|
||||
f"padding: 1px 4px;"
|
||||
)
|
||||
cat_label.setMaximumHeight(20)
|
||||
cat_label.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
|
||||
cat_wrap = QHBoxLayout()
|
||||
cat_wrap.setContentsMargins(0, 0, 0, 0)
|
||||
cat_wrap.addWidget(cat_label)
|
||||
cat_wrap.addStretch()
|
||||
name_box.addLayout(cat_wrap)
|
||||
|
||||
top_row.addLayout(name_box, 1)
|
||||
outer.addLayout(top_row)
|
||||
|
||||
# 2행: 설명
|
||||
if is_locked_secret:
|
||||
desc_text = "🔒 달성하면 공개됩니다"
|
||||
else:
|
||||
desc_text = item['description'] or ''
|
||||
desc = QLabel(desc_text)
|
||||
desc.setWordWrap(True)
|
||||
desc.setStyleSheet(
|
||||
f"color: #a0a0b8; font-size: 9.5pt; "
|
||||
f"background: transparent; border: none; padding: 0;"
|
||||
)
|
||||
outer.addWidget(desc)
|
||||
|
||||
# 3행: 진행 게이지 또는 획득 일자
|
||||
if is_earned:
|
||||
earned = QLabel(f" ✓ {item['earned_date']} 달성 ")
|
||||
earned.setStyleSheet(
|
||||
f"color: {theme['border_strong']}; "
|
||||
f"font-weight: bold; font-size: 9.5pt; "
|
||||
f"background: rgba(255,255,255,0.08); "
|
||||
f"border: 1px solid {theme['border']}; "
|
||||
f"border-radius: 6px; padding: 4px 8px;"
|
||||
)
|
||||
earned.setSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)
|
||||
row = QHBoxLayout()
|
||||
row.addWidget(earned)
|
||||
row.addStretch()
|
||||
outer.addLayout(row)
|
||||
elif not is_locked_secret:
|
||||
target = max(1, item.get('target') or 1)
|
||||
progress = item.get('progress') or 0
|
||||
pct = (progress / target * 100) if target else 0
|
||||
|
||||
# 게이지 + 숫자 라벨
|
||||
gauge_row = QHBoxLayout()
|
||||
gauge_row.setSpacing(8)
|
||||
|
||||
pb = QProgressBar()
|
||||
pb.setMaximum(target)
|
||||
pb.setValue(min(progress, target))
|
||||
pb.setTextVisible(False)
|
||||
pb.setMinimumHeight(10)
|
||||
pb.setMaximumHeight(10)
|
||||
pb.setStyleSheet(f"""
|
||||
QProgressBar {{
|
||||
background: rgba(0,0,0,0.4);
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
}}
|
||||
QProgressBar::chunk {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 {theme['border']}, stop:1 {theme['border_strong']});
|
||||
border-radius: 5px;
|
||||
}}
|
||||
""")
|
||||
gauge_row.addWidget(pb, 1)
|
||||
|
||||
num = QLabel(f"{progress} / {target}")
|
||||
num.setStyleSheet(
|
||||
f"color: {theme['border_strong']}; font-size: 9pt; "
|
||||
f"font-weight: bold; background: transparent; border: none;"
|
||||
)
|
||||
num.setMinimumWidth(60)
|
||||
num.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
gauge_row.addWidget(num)
|
||||
|
||||
outer.addLayout(gauge_row)
|
||||
else:
|
||||
# 시크릿 잠금 — 회색 점선 placeholder
|
||||
placeholder = QLabel("· · · · · · · · · ·")
|
||||
placeholder.setStyleSheet(
|
||||
"color: #444; font-size: 12pt; letter-spacing: 4px; "
|
||||
"background: transparent; border: none;"
|
||||
)
|
||||
placeholder.setAlignment(Qt.AlignCenter)
|
||||
outer.addWidget(placeholder)
|
||||
|
||||
outer.addStretch(1)
|
||||
card.setLayout(outer)
|
||||
return card
|
||||
|
||||
# ----- 탭 QSS (다이얼로그 전용) -----
|
||||
def _tabs_qss(self) -> str:
|
||||
return """
|
||||
QTabWidget::pane {
|
||||
background: #14141c;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 10px;
|
||||
top: -1px;
|
||||
}
|
||||
QTabBar::tab {
|
||||
background: #1c1c28;
|
||||
color: #a0a0b8;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-bottom: none;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
margin-right: 3px;
|
||||
font-size: 10pt;
|
||||
}
|
||||
QTabBar::tab:selected {
|
||||
background: #14141c;
|
||||
color: #ffd24a;
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid #ffd24a;
|
||||
}
|
||||
QTabBar::tab:hover:!selected {
|
||||
background: #2a2a36;
|
||||
color: #e0e0e8;
|
||||
}
|
||||
"""
|
||||
+69
-19
@@ -20,6 +20,33 @@ except ImportError:
|
||||
_MPL = False
|
||||
|
||||
|
||||
# 다크 테마 색상 (dark_components 톤과 일치)
|
||||
_CHART_BG = '#14141c'
|
||||
_CHART_GRID = '#2a2a3a'
|
||||
_CHART_TEXT = '#c0c0d0'
|
||||
_CHART_BAR_NORMAL = '#6b9eff' # blue
|
||||
_CHART_BAR_OVERTIME = '#ff90b8' # pink
|
||||
_CHART_BAR_WEEKEND = '#fcd34d' # gold
|
||||
_CHART_AVG_LINE = '#4ade80' # green
|
||||
|
||||
|
||||
def _apply_dark_axes(ax) -> None:
|
||||
"""차트 ax에 다크 테마 적용 — 텍스트, 그리드, spines, 배경."""
|
||||
ax.set_facecolor(_CHART_BG)
|
||||
ax.tick_params(axis='both', colors=_CHART_TEXT)
|
||||
ax.xaxis.label.set_color(_CHART_TEXT)
|
||||
ax.yaxis.label.set_color(_CHART_TEXT)
|
||||
ax.title.set_color(_CHART_TEXT)
|
||||
for spine in ax.spines.values():
|
||||
spine.set_color(_CHART_GRID)
|
||||
ax.grid(axis='y', alpha=0.25, color=_CHART_GRID)
|
||||
|
||||
|
||||
def _apply_dark_figure(fig) -> None:
|
||||
"""figure 배경을 다크 톤으로."""
|
||||
fig.patch.set_facecolor(_CHART_BG)
|
||||
|
||||
|
||||
class _Fallback(QWidget):
|
||||
"""matplotlib 미설치 시 안내."""
|
||||
def __init__(self, message: str):
|
||||
@@ -38,10 +65,12 @@ def make_chart_widget(parent=None) -> QWidget:
|
||||
if not _MPL:
|
||||
return _Fallback("차트 표시에는 matplotlib가 필요합니다.\npip install matplotlib")
|
||||
widget = QWidget(parent)
|
||||
widget.setStyleSheet(f"background: {_CHART_BG}; border-radius: 8px;")
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
fig = Figure(figsize=(5, 3), dpi=100, tight_layout=True)
|
||||
fig = Figure(figsize=(5, 3), dpi=100, tight_layout=True, facecolor=_CHART_BG)
|
||||
canvas = FigureCanvas(fig)
|
||||
canvas.setStyleSheet(f"background: {_CHART_BG};")
|
||||
layout.addWidget(canvas)
|
||||
widget.setLayout(layout)
|
||||
widget._figure = fig
|
||||
@@ -55,9 +84,12 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
|
||||
return
|
||||
fig = widget._figure
|
||||
fig.clear()
|
||||
_apply_dark_figure(fig)
|
||||
if not records:
|
||||
ax = fig.add_subplot(111)
|
||||
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center', transform=ax.transAxes)
|
||||
_apply_dark_axes(ax)
|
||||
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center',
|
||||
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
|
||||
widget._canvas.draw()
|
||||
return
|
||||
|
||||
@@ -68,20 +100,23 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
|
||||
base = [max(h - o, 0) for h, o in zip(hours, overtimes)]
|
||||
|
||||
ax = fig.add_subplot(111)
|
||||
bars_base = ax.bar(dates, base, label='정상', color='#4a90e2')
|
||||
bars_ot = ax.bar(dates, overtimes, bottom=base, label='연장', color='#ff6b6b')
|
||||
bars_base = ax.bar(dates, base, label='정상', color=_CHART_BAR_NORMAL)
|
||||
bars_ot = ax.bar(dates, overtimes, bottom=base, label='연장',
|
||||
color=_CHART_BAR_OVERTIME)
|
||||
ax.set_ylabel('시간')
|
||||
ax.legend(loc='upper left', fontsize=8)
|
||||
legend = ax.legend(loc='upper left', fontsize=8, facecolor=_CHART_BG,
|
||||
edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT)
|
||||
ax.tick_params(axis='x', labelrotation=45, labelsize=8)
|
||||
ax.grid(axis='y', alpha=0.3)
|
||||
_apply_dark_axes(ax)
|
||||
|
||||
# 호버 annotation 설정
|
||||
annot = ax.annotate(
|
||||
"", xy=(0, 0), xytext=(15, 15),
|
||||
textcoords="offset points",
|
||||
bbox=dict(boxstyle="round,pad=0.4", fc="#222", ec="#888", alpha=0.95),
|
||||
bbox=dict(boxstyle="round,pad=0.4", fc="#1a1a26", ec=_CHART_BAR_NORMAL,
|
||||
alpha=0.95),
|
||||
color="white", fontsize=9,
|
||||
arrowprops=dict(arrowstyle="->", color="#888"),
|
||||
arrowprops=dict(arrowstyle="->", color=_CHART_BAR_NORMAL),
|
||||
)
|
||||
annot.set_visible(False)
|
||||
|
||||
@@ -102,6 +137,14 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
|
||||
annot.set_text(text)
|
||||
annot.set_visible(True)
|
||||
widget._canvas.draw_idle()
|
||||
# 도전과제 #stat_chart_hover — 첫 발견 시 1회만 기록
|
||||
db = getattr(widget, '_achievement_db', None)
|
||||
if db is not None:
|
||||
try:
|
||||
if db.get_setting('chart_hover_discovered', 'false').lower() != 'true':
|
||||
db.set_setting('chart_hover_discovered', 'true')
|
||||
except Exception:
|
||||
pass
|
||||
return
|
||||
if annot.get_visible():
|
||||
annot.set_visible(False)
|
||||
@@ -117,13 +160,15 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
|
||||
return
|
||||
fig = widget._figure
|
||||
fig.clear()
|
||||
_apply_dark_figure(fig)
|
||||
if not records:
|
||||
ax = fig.add_subplot(111)
|
||||
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center', transform=ax.transAxes)
|
||||
_apply_dark_axes(ax)
|
||||
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center',
|
||||
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
|
||||
widget._canvas.draw()
|
||||
return
|
||||
|
||||
# 출근 시각을 분 단위로 (00:00=0)
|
||||
minutes_list = []
|
||||
for r in records:
|
||||
ci = r.get('clock_in')
|
||||
@@ -137,26 +182,31 @@ def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None:
|
||||
pass
|
||||
|
||||
if not minutes_list:
|
||||
ax = fig.add_subplot(111)
|
||||
_apply_dark_axes(ax)
|
||||
ax.text(0.5, 0.5, '기록 없음', ha='center', va='center',
|
||||
transform=ax.transAxes, color=_CHART_TEXT, fontsize=11)
|
||||
widget._canvas.draw()
|
||||
return
|
||||
|
||||
# 30분 빈
|
||||
bin_size = 30
|
||||
min_m = (min(minutes_list) // bin_size) * bin_size
|
||||
max_m = ((max(minutes_list) // bin_size) + 1) * bin_size
|
||||
bins = list(range(min_m, max_m + bin_size, bin_size))
|
||||
|
||||
ax = fig.add_subplot(111)
|
||||
ax.hist(minutes_list, bins=bins, color='#4a90e2', edgecolor='white')
|
||||
ax.hist(minutes_list, bins=bins, color=_CHART_BAR_NORMAL,
|
||||
edgecolor=_CHART_BG, linewidth=1)
|
||||
avg = sum(minutes_list) / len(minutes_list)
|
||||
ax.axvline(avg, color='#ff6b6b', linestyle='--',
|
||||
ax.axvline(avg, color=_CHART_AVG_LINE, linestyle='--', linewidth=2,
|
||||
label=f'평균 {int(avg//60):02d}:{int(avg%60):02d}')
|
||||
ax.set_xticks([m for m in bins if m % 60 == 0])
|
||||
ax.set_xticklabels([f"{m//60:02d}:00" for m in bins if m % 60 == 0],
|
||||
rotation=45, fontsize=8)
|
||||
ax.set_ylabel('일수')
|
||||
ax.set_title('출근 시각 분포')
|
||||
ax.legend(loc='upper right', fontsize=8)
|
||||
ax.grid(axis='y', alpha=0.3)
|
||||
legend = ax.legend(loc='upper right', fontsize=8, facecolor=_CHART_BG,
|
||||
edgecolor=_CHART_GRID, labelcolor=_CHART_TEXT)
|
||||
_apply_dark_axes(ax)
|
||||
widget._canvas.draw()
|
||||
|
||||
|
||||
@@ -166,6 +216,7 @@ def draw_weekday_avg(widget: QWidget, records: List[dict]) -> None:
|
||||
return
|
||||
fig = widget._figure
|
||||
fig.clear()
|
||||
_apply_dark_figure(fig)
|
||||
|
||||
from datetime import datetime as _dt
|
||||
weekday_totals = [0.0] * 7
|
||||
@@ -182,9 +233,8 @@ def draw_weekday_avg(widget: QWidget, records: List[dict]) -> None:
|
||||
labels = ['월', '화', '수', '목', '금', '토', '일']
|
||||
|
||||
ax = fig.add_subplot(111)
|
||||
colors = ['#4a90e2'] * 5 + ['#ff6b6b'] * 2 # 주말 강조
|
||||
colors = [_CHART_BAR_NORMAL] * 5 + [_CHART_BAR_WEEKEND] * 2 # 주말 골드 강조
|
||||
ax.bar(labels, avg, color=colors)
|
||||
ax.set_ylabel('평균 시간')
|
||||
ax.set_title('요일별 평균 근무시간')
|
||||
ax.grid(axis='y', alpha=0.3)
|
||||
_apply_dark_axes(ax)
|
||||
widget._canvas.draw()
|
||||
|
||||
@@ -17,12 +17,17 @@ class LockMonitor:
|
||||
self.window = window
|
||||
self.db = window.db
|
||||
self.last_locked: bool = False
|
||||
self._detector_failed_once: bool = False # 첫 실패만 로깅 (5초 폴링 노이즈 방지)
|
||||
|
||||
def tick(self) -> None:
|
||||
try:
|
||||
from utils.lock_detector import is_screen_locked
|
||||
locked = is_screen_locked()
|
||||
except Exception:
|
||||
except Exception as e:
|
||||
if not self._detector_failed_once:
|
||||
self._detector_failed_once = True
|
||||
from utils.debug_log import dlog
|
||||
dlog(f"lock detector failed (silenced after first): {e}")
|
||||
return
|
||||
|
||||
was_locked = self.last_locked
|
||||
|
||||
@@ -2,11 +2,24 @@
|
||||
알림 오케스트레이션.
|
||||
|
||||
5분 가드로 건강/주간/누적 임계 알림을 throttle.
|
||||
notifier.py의 6개 알림 메서드를 적절한 시점에 호출.
|
||||
notifier.py의 알림 메서드를 적절한 시점에 호출.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from datetime import datetime
|
||||
|
||||
from core.settings_keys import (
|
||||
HEALTH_CONSECUTIVE_OT_DAYS, WEEKLY_HOURS_THRESHOLD, OVERTIME_THRESHOLD_HOURS,
|
||||
)
|
||||
from utils.debug_log import dlog
|
||||
|
||||
|
||||
def _get_int(db, key: str, default: int, lo: int, hi: int) -> int:
|
||||
try:
|
||||
v = int(db.get_setting(key, str(default)) or default)
|
||||
except (ValueError, TypeError):
|
||||
v = default
|
||||
return max(lo, min(hi, v))
|
||||
|
||||
|
||||
class NotificationOrchestrator:
|
||||
"""update_display() 1Hz tick에서 호출."""
|
||||
@@ -66,19 +79,23 @@ class NotificationOrchestrator:
|
||||
f"기간: {last_mon} ~ {last_sun}",
|
||||
color=COLOR_BLUE, fields=fields)
|
||||
self.db.log_notification('discord', 'weekly_report', success=ok)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
dlog(f"discord weekly_report failed: {e}")
|
||||
|
||||
def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float) -> None:
|
||||
n = self.notifier
|
||||
# 1초마다 체크: 30분 전, 점심 미등록, 연장 적립
|
||||
# 1초마다 체크: 30분 전, 점심/저녁 미등록, 연장 적립
|
||||
n.check_clock_out_soon(expected_clock_out, now)
|
||||
n.check_lunch_reminder(self.window.clock_in_time,
|
||||
self.window.lunch_break_enabled, now)
|
||||
n.check_dinner_reminder(self.window.clock_in_time,
|
||||
getattr(self.window, 'dinner_break_enabled', False), now)
|
||||
if remaining_seconds < 0:
|
||||
n.check_overtime_earning(abs(int(remaining_seconds / 60)))
|
||||
|
||||
# 5분 간격 throttle: 건강/주간/누적/휴식권고/주간리포트
|
||||
# 임계값 가드(>=3, >52, >=1200)는 notifier 내부에서 설정값으로 재검사하므로
|
||||
# 여기서는 항상 호출 — 설정 변경이 즉시 반영되도록.
|
||||
if now.minute % 5 == 0 and self._last_5min_bucket != now.minute:
|
||||
self._last_5min_bucket = now.minute
|
||||
|
||||
@@ -89,18 +106,73 @@ class NotificationOrchestrator:
|
||||
break_minutes = self.db.get_total_break_minutes_today()
|
||||
n.check_health_break(self.window.clock_in_time, break_minutes, now)
|
||||
|
||||
# 임계값은 한 번만 읽어 시스템 알림과 Discord push에 동일 적용
|
||||
consecutive = self.db.get_consecutive_overtime_days()
|
||||
if consecutive >= 3:
|
||||
consecutive_th = _get_int(self.db, HEALTH_CONSECUTIVE_OT_DAYS, 3, 1, 14)
|
||||
if consecutive >= consecutive_th:
|
||||
n.notify_health_warning(consecutive)
|
||||
self._discord_health(consecutive, break_minutes, now)
|
||||
|
||||
weekly_hours = self.db.get_weekly_stats().get('total_hours', 0)
|
||||
if weekly_hours > 52:
|
||||
weekly_th = _get_int(self.db, WEEKLY_HOURS_THRESHOLD, 52, 20, 168)
|
||||
if weekly_hours > weekly_th:
|
||||
n.notify_weekly_hours(weekly_hours)
|
||||
|
||||
balance_minutes = self.db.get_total_overtime_balance()
|
||||
if balance_minutes >= 1200:
|
||||
ot_threshold_h = _get_int(self.db, OVERTIME_THRESHOLD_HOURS, 20, 1, 200)
|
||||
if balance_minutes / 60.0 >= ot_threshold_h:
|
||||
n.notify_overtime_threshold(balance_minutes / 60.0)
|
||||
|
||||
# 도전과제 평가 (5분 throttle)
|
||||
self._evaluate_achievements(now)
|
||||
|
||||
def _evaluate_achievements(self, now: datetime) -> None:
|
||||
"""도전과제 평가 + 신규 잠금 해제 알림.
|
||||
|
||||
실패는 silent — 도전과제 시스템이 메인 흐름을 막으면 안 됨.
|
||||
"""
|
||||
try:
|
||||
from core.achievements import evaluate_all
|
||||
unlocked = evaluate_all(self.db)
|
||||
except Exception as e:
|
||||
dlog(f"achievement eval failed: {e}")
|
||||
return
|
||||
if not unlocked:
|
||||
return
|
||||
# 시스템 알림 + Discord push (옵션)
|
||||
notif_on = self.db.get_setting('notification_achievement', 'true').lower() == 'true'
|
||||
for a in unlocked:
|
||||
self.db.log_notification('system', f'achievement:{a.code}')
|
||||
if notif_on:
|
||||
title = f"{a.badge_icon} 도전과제 달성!"
|
||||
body = f"{a.name}\n{a.description}"
|
||||
self.notifier.notification_signal.emit(title, body)
|
||||
# Discord 통합 push (여러 개면 묶어서)
|
||||
self._discord_achievements(unlocked)
|
||||
|
||||
def _discord_achievements(self, unlocked: list) -> None:
|
||||
if self.db.get_setting('discord_notif_achievement', 'true').lower() != 'true':
|
||||
return
|
||||
url = self.db.get_setting('discord_webhook_url', '') or ''
|
||||
if not url:
|
||||
return
|
||||
try:
|
||||
from utils import discord_webhook
|
||||
fields = [{"name": f"{a.badge_icon} {a.name}",
|
||||
"value": a.description, "inline": False}
|
||||
for a in unlocked[:10]]
|
||||
extra = (f"\n... 외 {len(unlocked) - 10}개" if len(unlocked) > 10 else '')
|
||||
ok = discord_webhook.send(
|
||||
url,
|
||||
f"🏆 도전과제 {len(unlocked)}개 달성!",
|
||||
f"새로 잠금 해제된 도전과제 입니다.{extra}",
|
||||
color=discord_webhook.COLOR_YELLOW,
|
||||
fields=fields,
|
||||
)
|
||||
self.db.log_notification('discord', 'achievement', success=ok)
|
||||
except Exception as e:
|
||||
dlog(f"discord achievement push failed: {e}")
|
||||
|
||||
def _discord_health(self, days: int, break_minutes: int, now: datetime) -> None:
|
||||
"""건강 경고 Discord push (옵션)."""
|
||||
if self.db.has_notification_today('discord', 'health'):
|
||||
@@ -115,5 +187,5 @@ class NotificationOrchestrator:
|
||||
elapsed = (now - self.window.clock_in_time).total_seconds() / 3600 - break_minutes / 60
|
||||
ok = discord_webhook.send_health_warning(url, elapsed)
|
||||
self.db.log_notification('discord', 'health', success=ok)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
dlog(f"discord health push failed: {e}")
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
"""
|
||||
도전과제 다이얼로그에서 사용한 디자인 톤을 다른 다이얼로그에도 재사용.
|
||||
|
||||
핵심 원칙:
|
||||
- 다이얼로그 배경: #0e0e14 (깊은 다크)
|
||||
- 카드: 그라디언트 (#bg_top → #bg_bot) + 강조 외곽선
|
||||
- 헤더: 큰 숫자 + 그라디언트 progress bar
|
||||
- 탭: 골드 강조 선택
|
||||
- 모든 sub-label은 명시적 transparent + border:none 으로 글로벌 QSS 충돌 회피
|
||||
|
||||
모든 컴포넌트는 stand-alone — 부모가 dark 다이얼로그라고 가정.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from typing import Optional, List, Tuple
|
||||
from PyQt5.QtWidgets import (QFrame, QLabel, QVBoxLayout, QHBoxLayout,
|
||||
QProgressBar, QPushButton, QWidget, QSizePolicy,
|
||||
QTabWidget)
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
|
||||
# ── 색상 팔레트 ────────────────────────────────────────────────
|
||||
DARK_BG = '#0e0e14'
|
||||
DARK_PANEL = '#14141c'
|
||||
DARK_PANEL_2 = '#1c1c28'
|
||||
DARK_BORDER = '#2a2a3a'
|
||||
DARK_BORDER_STRONG = '#44446a'
|
||||
DARK_TEXT = '#e8e8f4'
|
||||
DARK_TEXT_DIM = '#a0a0b8'
|
||||
DARK_TEXT_FAINT = '#666680'
|
||||
|
||||
ACCENT_GOLD = '#ffd24a'
|
||||
ACCENT_BLUE = '#6b9eff'
|
||||
ACCENT_CYAN = '#4adef0'
|
||||
ACCENT_PINK = '#ff90b8'
|
||||
ACCENT_GREEN = '#4ade80'
|
||||
ACCENT_ORANGE = '#fcd34d'
|
||||
ACCENT_RED = '#fb7185'
|
||||
|
||||
# 카드 테마 (등급/상태별)
|
||||
CARD_THEMES = {
|
||||
'gold': {
|
||||
'border': '#ffb700', 'border_strong': '#ffd24a',
|
||||
'bg_top': '#3a2e10', 'bg_bot': '#241c08',
|
||||
'text': '#ffe9a0', 'accent': ACCENT_GOLD,
|
||||
},
|
||||
'blue': {
|
||||
'border': '#5a8eff', 'border_strong': '#6b9eff',
|
||||
'bg_top': '#1a2840', 'bg_bot': '#0e1828',
|
||||
'text': '#c0d8ff', 'accent': ACCENT_BLUE,
|
||||
},
|
||||
'cyan': {
|
||||
'border': '#3acce0', 'border_strong': '#4adef0',
|
||||
'bg_top': '#0e3340', 'bg_bot': '#08222b',
|
||||
'text': '#a8e8f0', 'accent': ACCENT_CYAN,
|
||||
},
|
||||
'green': {
|
||||
'border': '#3ace70', 'border_strong': '#4ade80',
|
||||
'bg_top': '#0e3324', 'bg_bot': '#082218',
|
||||
'text': '#a8e8c0', 'accent': ACCENT_GREEN,
|
||||
},
|
||||
'pink': {
|
||||
'border': '#ff5a8c', 'border_strong': '#ff90b8',
|
||||
'bg_top': '#3a1a2a', 'bg_bot': '#26101a',
|
||||
'text': '#ffc0d4', 'accent': ACCENT_PINK,
|
||||
},
|
||||
'red': {
|
||||
'border': '#ea5566', 'border_strong': '#fb7185',
|
||||
'bg_top': '#3a1620', 'bg_bot': '#260e16',
|
||||
'text': '#ffb8c0', 'accent': ACCENT_RED,
|
||||
},
|
||||
'gray': {
|
||||
'border': '#44446a', 'border_strong': '#666688',
|
||||
'bg_top': '#1c1c28', 'bg_bot': '#14141c',
|
||||
'text': '#c0c0d0', 'accent': DARK_TEXT_DIM,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── QSS 헬퍼 ───────────────────────────────────────────────────
|
||||
|
||||
def dialog_qss() -> str:
|
||||
"""다이얼로그 전체 배경."""
|
||||
return f"QDialog {{ background: {DARK_BG}; }}"
|
||||
|
||||
|
||||
def tabs_qss(accent: str = ACCENT_GOLD) -> str:
|
||||
return f"""
|
||||
QTabWidget::pane {{
|
||||
background: {DARK_PANEL};
|
||||
border: 1px solid {DARK_BORDER};
|
||||
border-radius: 10px;
|
||||
top: -1px;
|
||||
}}
|
||||
QTabBar::tab {{
|
||||
background: {DARK_PANEL_2};
|
||||
color: {DARK_TEXT_DIM};
|
||||
padding: 9px 18px;
|
||||
border: 1px solid {DARK_BORDER};
|
||||
border-bottom: none;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
margin-right: 3px;
|
||||
font-size: 10pt;
|
||||
}}
|
||||
QTabBar::tab:selected {{
|
||||
background: {DARK_PANEL};
|
||||
color: {accent};
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid {accent};
|
||||
}}
|
||||
QTabBar::tab:hover:!selected {{
|
||||
background: #2a2a36;
|
||||
color: {DARK_TEXT};
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def scroll_qss() -> str:
|
||||
return f"""
|
||||
QScrollArea {{ background: transparent; border: none; }}
|
||||
QScrollBar:vertical {{
|
||||
background: {DARK_PANEL_2}; width: 10px; border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::handle:vertical {{
|
||||
background: {DARK_BORDER_STRONG}; border-radius: 5px; min-height: 30px;
|
||||
}}
|
||||
QScrollBar::handle:vertical:hover {{ background: {ACCENT_BLUE}; }}
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
|
||||
QScrollBar:horizontal {{
|
||||
background: {DARK_PANEL_2}; height: 10px; border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::handle:horizontal {{
|
||||
background: {DARK_BORDER_STRONG}; border-radius: 5px; min-width: 30px;
|
||||
}}
|
||||
QScrollBar::handle:horizontal:hover {{ background: {ACCENT_BLUE}; }}
|
||||
"""
|
||||
|
||||
|
||||
def button_qss(variant: str = 'default') -> str:
|
||||
""" variant: default | primary | success | danger | ghost """
|
||||
if variant == 'primary':
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {ACCENT_BLUE}; color: white;
|
||||
border: none; border-radius: 6px;
|
||||
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: #82b0ff; }}
|
||||
QPushButton:pressed {{ background: #5a8eee; }}
|
||||
QPushButton:disabled {{ background: #2a2a3a; color: {DARK_TEXT_FAINT}; }}
|
||||
"""
|
||||
if variant == 'success':
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {ACCENT_GREEN}; color: #0e2a1a;
|
||||
border: none; border-radius: 6px;
|
||||
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: #6ae899; }}
|
||||
"""
|
||||
if variant == 'danger':
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {ACCENT_RED}; color: white;
|
||||
border: none; border-radius: 6px;
|
||||
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: #fc8896; }}
|
||||
"""
|
||||
if variant == 'ghost':
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: transparent; color: {DARK_TEXT_DIM};
|
||||
border: 1px solid {DARK_BORDER_STRONG}; border-radius: 6px;
|
||||
padding: 6px 14px; font-size: 9.5pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: {DARK_PANEL_2}; color: {DARK_TEXT};
|
||||
border-color: {ACCENT_BLUE}; }}
|
||||
"""
|
||||
# default
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {DARK_PANEL_2}; color: {DARK_TEXT};
|
||||
border: 1px solid {DARK_BORDER_STRONG}; border-radius: 6px;
|
||||
padding: 8px 18px; font-size: 10pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: #2a2a36; border-color: {ACCENT_BLUE}; }}
|
||||
"""
|
||||
|
||||
|
||||
# ── 컴포넌트 빌더 ──────────────────────────────────────────────
|
||||
|
||||
def build_gradient_header(title: str, big_value: str, subtitle: str = '',
|
||||
big_color: str = ACCENT_GOLD,
|
||||
extra_widgets: Optional[List[QWidget]] = None) -> QFrame:
|
||||
"""그라디언트 헤더 — 좌측 큰 숫자/제목, 우측에 추가 위젯들.
|
||||
|
||||
Args:
|
||||
title: 큰 숫자 위 작은 라벨 (예: "달성")
|
||||
big_value: 큰 숫자/문자열 (RichText 가능 — HTML 사용)
|
||||
subtitle: 큰 숫자 아래 부제 (예: "/ 153")
|
||||
big_color: 큰 숫자 색
|
||||
extra_widgets: 우측에 배치할 위젯 (예: 추가 통계, 토글)
|
||||
"""
|
||||
container = QFrame()
|
||||
container.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #1a1a30, stop:1 #2a1a3a);
|
||||
border: 1px solid #3a3a5a;
|
||||
border-radius: 12px;
|
||||
}}
|
||||
QLabel {{ background: transparent; border: none; color: {DARK_TEXT}; }}
|
||||
""")
|
||||
layout = QHBoxLayout()
|
||||
layout.setContentsMargins(20, 14, 20, 14)
|
||||
layout.setSpacing(20)
|
||||
|
||||
# 좌측: 제목 + 큰 숫자
|
||||
left = QVBoxLayout()
|
||||
left.setSpacing(2)
|
||||
if title:
|
||||
t = QLabel(title)
|
||||
t.setStyleSheet(
|
||||
f"font-size: 9pt; color: {DARK_TEXT_DIM}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
left.addWidget(t)
|
||||
big = QLabel(
|
||||
f"<span style='font-size: 28pt; font-weight: bold; color: {big_color};'>"
|
||||
f"{big_value}</span>" + (f"<span style='font-size: 14pt; color: #888;'>"
|
||||
f" {subtitle}</span>" if subtitle else '')
|
||||
)
|
||||
big.setTextFormat(Qt.RichText)
|
||||
big.setStyleSheet("background: transparent; border: none;")
|
||||
left.addWidget(big)
|
||||
layout.addLayout(left)
|
||||
|
||||
# 우측: extra widgets
|
||||
if extra_widgets:
|
||||
layout.addStretch()
|
||||
for w in extra_widgets:
|
||||
layout.addWidget(w)
|
||||
else:
|
||||
layout.addStretch()
|
||||
|
||||
container.setLayout(layout)
|
||||
return container
|
||||
|
||||
|
||||
def build_stat_card(title: str, value: str, subtitle: str = '',
|
||||
theme: str = 'blue', icon: str = '') -> QFrame:
|
||||
"""단일 통계 카드 — 제목, 큰 숫자, 부제, 좌측 큰 이모지."""
|
||||
t = CARD_THEMES.get(theme, CARD_THEMES['blue'])
|
||||
card = QFrame()
|
||||
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
card.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||
stop:0 {t['bg_top']}, stop:1 {t['bg_bot']});
|
||||
border: 1px solid {t['border']};
|
||||
border-radius: 10px;
|
||||
}}
|
||||
QLabel {{ background: transparent; border: none; color: {t['text']}; }}
|
||||
""")
|
||||
outer = QHBoxLayout()
|
||||
outer.setContentsMargins(16, 12, 16, 12)
|
||||
outer.setSpacing(12)
|
||||
|
||||
if icon:
|
||||
icon_lbl = QLabel(icon)
|
||||
icon_lbl.setStyleSheet(
|
||||
f"font-size: 28pt; background: transparent; border: none; "
|
||||
f"color: {t['border_strong']};"
|
||||
)
|
||||
icon_lbl.setMinimumWidth(48)
|
||||
icon_lbl.setAlignment(Qt.AlignCenter)
|
||||
outer.addWidget(icon_lbl)
|
||||
|
||||
text_box = QVBoxLayout()
|
||||
text_box.setSpacing(2)
|
||||
|
||||
title_lbl = QLabel(title)
|
||||
title_lbl.setStyleSheet(
|
||||
f"font-size: 9.5pt; color: {DARK_TEXT_DIM}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
text_box.addWidget(title_lbl)
|
||||
|
||||
val_lbl = QLabel(
|
||||
f"<span style='font-size: 18pt; font-weight: bold; color: {t['border_strong']};'>"
|
||||
f"{value}</span>"
|
||||
)
|
||||
val_lbl.setTextFormat(Qt.RichText)
|
||||
val_lbl.setStyleSheet("background: transparent; border: none;")
|
||||
text_box.addWidget(val_lbl)
|
||||
|
||||
if subtitle:
|
||||
sub_lbl = QLabel(subtitle)
|
||||
sub_lbl.setStyleSheet(
|
||||
f"font-size: 9pt; color: {DARK_TEXT_DIM}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
sub_lbl.setWordWrap(True)
|
||||
text_box.addWidget(sub_lbl)
|
||||
|
||||
outer.addLayout(text_box, 1)
|
||||
card.setLayout(outer)
|
||||
return card
|
||||
|
||||
|
||||
def build_section_card(title: str, content: QWidget,
|
||||
theme: str = 'gray', icon: str = '') -> QFrame:
|
||||
"""제목 + 내용 큰 카드 (세로 레이아웃)."""
|
||||
t = CARD_THEMES.get(theme, CARD_THEMES['gray'])
|
||||
card = QFrame()
|
||||
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
card.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||
stop:0 {t['bg_top']}, stop:1 {t['bg_bot']});
|
||||
border: 1px solid {t['border']};
|
||||
border-radius: 10px;
|
||||
}}
|
||||
QLabel {{ background: transparent; border: none; color: {t['text']}; }}
|
||||
""")
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(16, 12, 16, 14)
|
||||
layout.setSpacing(8)
|
||||
|
||||
head = QHBoxLayout()
|
||||
if icon:
|
||||
i = QLabel(icon)
|
||||
i.setStyleSheet(
|
||||
f"font-size: 16pt; color: {t['border_strong']}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
head.addWidget(i)
|
||||
title_lbl = QLabel(title)
|
||||
title_lbl.setStyleSheet(
|
||||
f"font-size: 12pt; font-weight: bold; color: {DARK_TEXT}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
head.addWidget(title_lbl)
|
||||
head.addStretch()
|
||||
layout.addLayout(head)
|
||||
|
||||
layout.addWidget(content, 1)
|
||||
card.setLayout(layout)
|
||||
return card
|
||||
|
||||
|
||||
def style_progressbar(pb: QProgressBar, theme: str = 'blue',
|
||||
height: int = 10) -> None:
|
||||
"""기본 progress bar에 다크 그라디언트 스타일 적용."""
|
||||
t = CARD_THEMES.get(theme, CARD_THEMES['blue'])
|
||||
pb.setMinimumHeight(height)
|
||||
pb.setMaximumHeight(height)
|
||||
pb.setTextVisible(False)
|
||||
pb.setStyleSheet(f"""
|
||||
QProgressBar {{
|
||||
background: rgba(0,0,0,0.4);
|
||||
border: none;
|
||||
border-radius: {height // 2}px;
|
||||
}}
|
||||
QProgressBar::chunk {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 {t['border']}, stop:1 {t['border_strong']});
|
||||
border-radius: {height // 2}px;
|
||||
}}
|
||||
""")
|
||||
|
||||
|
||||
def transparent_label(text: str, size: int = 10, weight: str = 'normal',
|
||||
color: str = DARK_TEXT) -> QLabel:
|
||||
"""글로벌 QSS와 격리된 다크 라벨 (배경 없음, 외곽선 없음)."""
|
||||
lbl = QLabel(text)
|
||||
weight_str = 'bold' if weight == 'bold' else 'normal'
|
||||
lbl.setStyleSheet(
|
||||
f"font-size: {size}pt; font-weight: {weight_str}; color: {color}; "
|
||||
f"background: transparent; border: none; padding: 0; margin: 0;"
|
||||
)
|
||||
return lbl
|
||||
+112
-16
@@ -2,6 +2,7 @@
|
||||
사용 설명 가이드 창.
|
||||
|
||||
i18n 사전(_HELP_HTML)에서 ko/en HTML을 가져와 6개 탭으로 표시.
|
||||
도전과제/통계 다이얼로그와 동일한 다크 톤.
|
||||
"""
|
||||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QWidget, QTabWidget, QTextBrowser)
|
||||
@@ -9,6 +10,10 @@ from PyQt5.QtCore import Qt
|
||||
|
||||
from core.i18n import tr, tr_html
|
||||
from ui.styles import apply_dark_titlebar
|
||||
from ui.dark_components import (
|
||||
dialog_qss, tabs_qss, button_qss,
|
||||
DARK_BG, DARK_PANEL, DARK_BORDER, DARK_TEXT, ACCENT_GOLD,
|
||||
)
|
||||
|
||||
|
||||
class HelpView(QDialog):
|
||||
@@ -28,42 +33,48 @@ class HelpView(QDialog):
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(tr('window.help'))
|
||||
self.setModal(True)
|
||||
self.setMinimumSize(680, 720)
|
||||
|
||||
self.setMinimumSize(720, 720)
|
||||
self.resize(820, 760)
|
||||
self.setStyleSheet(dialog_qss())
|
||||
self.init_ui()
|
||||
apply_dark_titlebar(self)
|
||||
apply_dark_titlebar(self, dark=True)
|
||||
|
||||
def init_ui(self):
|
||||
main_layout = QVBoxLayout()
|
||||
main_layout.setContentsMargins(0, 0, 0, 0)
|
||||
main_layout.setSpacing(0)
|
||||
main_layout.setContentsMargins(20, 16, 20, 14)
|
||||
main_layout.setSpacing(10)
|
||||
|
||||
title = QLabel(tr('window.help'))
|
||||
title.setObjectName("dialog_title")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
# 다크 타이틀
|
||||
title = QLabel(f"📖 {tr('window.help')}")
|
||||
title.setStyleSheet(
|
||||
f"font-size: 18pt; font-weight: bold; color: {DARK_TEXT}; "
|
||||
f"background: transparent; border: none; padding: 4px 0;"
|
||||
)
|
||||
main_layout.addWidget(title)
|
||||
|
||||
tabs = QTabWidget()
|
||||
tabs.setDocumentMode(True)
|
||||
tabs.setStyleSheet(tabs_qss())
|
||||
for html_key, tab_label_key in self._TABS:
|
||||
tabs.addTab(self._make_tab(tr_html(html_key)), tr(tab_label_key))
|
||||
main_layout.addWidget(tabs)
|
||||
main_layout.addWidget(tabs, 1)
|
||||
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.setContentsMargins(20, 10, 20, 20)
|
||||
button_layout.setContentsMargins(0, 6, 0, 0)
|
||||
|
||||
# 온보딩 다시 보기 (왼쪽)
|
||||
# 온보딩 다시 보기 (왼쪽, ghost 스타일)
|
||||
onboarding_button = QPushButton("🚀 온보딩 다시 보기")
|
||||
onboarding_button.setMinimumHeight(40)
|
||||
onboarding_button.setMinimumHeight(36)
|
||||
onboarding_button.setStyleSheet(button_qss('ghost'))
|
||||
onboarding_button.clicked.connect(self._reopen_onboarding)
|
||||
button_layout.addWidget(onboarding_button)
|
||||
|
||||
button_layout.addStretch()
|
||||
|
||||
close_button = QPushButton(tr('btn.close'))
|
||||
close_button.setObjectName("btn_primary")
|
||||
close_button.setMinimumHeight(40)
|
||||
close_button.setMinimumHeight(36)
|
||||
close_button.setMinimumWidth(120)
|
||||
close_button.setStyleSheet(button_qss('primary'))
|
||||
close_button.clicked.connect(self.close)
|
||||
button_layout.addWidget(close_button)
|
||||
main_layout.addLayout(button_layout)
|
||||
@@ -78,15 +89,100 @@ class HelpView(QDialog):
|
||||
|
||||
def _make_tab(self, html: str) -> QWidget:
|
||||
container = QWidget()
|
||||
container.setStyleSheet(f"background: {DARK_PANEL};")
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(16, 12, 16, 12)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
browser = QTextBrowser()
|
||||
browser.setOpenExternalLinks(False)
|
||||
browser.setHtml(html)
|
||||
# HTML 내부에 다크 톤 스타일 주입
|
||||
styled_html = self._inject_dark_styles(html)
|
||||
browser.setHtml(styled_html)
|
||||
browser.setStyleSheet(f"""
|
||||
QTextBrowser {{
|
||||
background: {DARK_PANEL};
|
||||
color: {DARK_TEXT};
|
||||
border: none;
|
||||
padding: 16px 20px;
|
||||
font-size: 10.5pt;
|
||||
selection-background-color: {ACCENT_GOLD};
|
||||
selection-color: #1a1a26;
|
||||
}}
|
||||
QScrollBar:vertical {{
|
||||
background: {DARK_PANEL}; width: 10px; border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::handle:vertical {{
|
||||
background: {DARK_BORDER}; border-radius: 5px; min-height: 30px;
|
||||
}}
|
||||
QScrollBar::handle:vertical:hover {{ background: {ACCENT_GOLD}; }}
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
|
||||
""")
|
||||
layout.addWidget(browser)
|
||||
container.setLayout(layout)
|
||||
return container
|
||||
|
||||
def _inject_dark_styles(self, html: str) -> str:
|
||||
"""HelpHTML 내용에 다크 톤 CSS 주입 (제목/링크/코드/테이블)."""
|
||||
css = f"""
|
||||
<style>
|
||||
body, p, li {{
|
||||
color: #e8e8f4;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
}}
|
||||
h1, h2, h3, h4 {{
|
||||
color: #ffd24a;
|
||||
margin-top: 1.2em;
|
||||
margin-bottom: 0.5em;
|
||||
}}
|
||||
h2 {{ font-size: 16pt; border-bottom: 2px solid #44446a; padding-bottom: 6px; }}
|
||||
h3 {{ font-size: 13pt; color: #6b9eff; }}
|
||||
h4 {{ font-size: 11pt; color: #4ade80; }}
|
||||
b, strong {{ color: #ff90b8; }}
|
||||
code {{
|
||||
background: #1c1c28;
|
||||
color: #ffd24a;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}}
|
||||
pre {{
|
||||
background: #1c1c28;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
color: #e8e8f4;
|
||||
}}
|
||||
ul, ol {{ margin-left: 0; padding-left: 24px; }}
|
||||
li {{ margin-bottom: 4px; }}
|
||||
a {{ color: #4adef0; text-decoration: none; }}
|
||||
a:hover {{ text-decoration: underline; }}
|
||||
table {{ border-collapse: collapse; margin: 10px 0; }}
|
||||
th {{
|
||||
background: #2a2a3a;
|
||||
color: #ffd24a;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #44446a;
|
||||
text-align: left;
|
||||
}}
|
||||
td {{
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #2a2a3a;
|
||||
color: #e8e8f4;
|
||||
}}
|
||||
hr {{ border: none; border-top: 1px solid #2a2a3a; margin: 16px 0; }}
|
||||
blockquote {{
|
||||
border-left: 3px solid #6b9eff;
|
||||
margin-left: 0;
|
||||
padding: 4px 16px;
|
||||
color: #a0a0b8;
|
||||
background: rgba(107, 158, 255, 0.05);
|
||||
}}
|
||||
</style>
|
||||
"""
|
||||
return css + html
|
||||
|
||||
|
||||
# 단독 실행 테스트
|
||||
if __name__ == "__main__":
|
||||
|
||||
+163
-33
@@ -34,25 +34,34 @@ from ui.settings_view import SettingsView
|
||||
from ui.break_view import BreakView
|
||||
from core.notifier import Notifier
|
||||
from utils.system_tray import SystemTrayIcon
|
||||
from utils.time_format import format_hours_minutes
|
||||
from ui.styles import get_theme, ThemeColors, apply_dark_titlebar
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
"""메인 윈도우 클래스"""
|
||||
|
||||
def __init__(self):
|
||||
def __init__(self, db: 'Database' = None):
|
||||
"""
|
||||
Args:
|
||||
db: 사전 초기화된 Database 인스턴스. None이면 자체 부트스트랩.
|
||||
(main.py가 backup/crash_handler용으로 먼저 만들고 전달)
|
||||
"""
|
||||
super().__init__()
|
||||
|
||||
# 테마 적용
|
||||
self.current_theme = 'light' # 설정에서 로드 후 덮어씀
|
||||
|
||||
# 데이터베이스 — db_path_override 설정 시 그 경로 사용 (클라우드 동기화 폴더 등)
|
||||
bootstrap = Database()
|
||||
override_path = bootstrap.get_setting(DB_PATH_OVERRIDE, '') or ''
|
||||
if override_path and os.path.exists(os.path.dirname(override_path) or '.'):
|
||||
self.db = Database(override_path)
|
||||
# 데이터베이스 — main.py가 전달하면 재사용, 아니면 자체 부트스트랩
|
||||
if db is not None:
|
||||
self.db = db
|
||||
else:
|
||||
self.db = bootstrap
|
||||
bootstrap = Database()
|
||||
override_path = bootstrap.get_setting(DB_PATH_OVERRIDE, '') or ''
|
||||
if override_path and os.path.exists(os.path.dirname(override_path) or '.'):
|
||||
self.db = Database(override_path)
|
||||
else:
|
||||
self.db = bootstrap
|
||||
self.event_monitor = EventMonitor()
|
||||
|
||||
# 언어 초기화 (설정값 반영)
|
||||
@@ -81,6 +90,14 @@ class MainWindow(QMainWindow):
|
||||
self.notifier = Notifier(self, db=self.db)
|
||||
self.notifier.notification_signal.connect(self.show_notification)
|
||||
|
||||
# 도전과제 정의 동기화 (실패는 silent — 핵심 기능 아님)
|
||||
try:
|
||||
from core.achievements import sync_definitions_to_db
|
||||
sync_definitions_to_db(self.db)
|
||||
except Exception as e:
|
||||
from utils.debug_log import dlog
|
||||
dlog(f"achievements sync failed: {e}")
|
||||
|
||||
# 책임 분리된 컨트롤러들 (1Hz hot path + 사용자 액션)
|
||||
from ui.controllers.lock_monitor import LockMonitor
|
||||
from ui.controllers.auto_lunch import AutoLunchManager
|
||||
@@ -136,12 +153,41 @@ class MainWindow(QMainWindow):
|
||||
self._lock_timer.timeout.connect(self._check_screen_lock)
|
||||
self._lock_timer.start(5000)
|
||||
|
||||
# 종료 시 타이머 정리 — aboutToQuit은 QApplication 종료 직전에만 1회 fire.
|
||||
# 모든 종료 경로(트레이 메뉴/Discord/언어변경 등)를 한 곳에서 커버.
|
||||
try:
|
||||
qapp = QApplication.instance()
|
||||
if qapp is not None:
|
||||
qapp.aboutToQuit.connect(self._on_app_quit)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 초기 데이터 로드
|
||||
self.load_today_data()
|
||||
|
||||
# 시작 5초 후 백그라운드 업데이트 체크 (실패 시 조용히 무시)
|
||||
QTimer.singleShot(5000, lambda: self.check_for_updates(silent=True))
|
||||
|
||||
def _on_app_quit(self) -> None:
|
||||
"""QApplication.aboutToQuit 핸들러 — 타이머 정지 + 트레이 숨김.
|
||||
|
||||
in-flight 1Hz/5s tick이 부분 파괴된 객체에 대해 fire하는 것 방지.
|
||||
"""
|
||||
try:
|
||||
if hasattr(self, 'timer') and self.timer is not None:
|
||||
self.timer.stop()
|
||||
if hasattr(self, '_lock_timer') and self._lock_timer is not None:
|
||||
self._lock_timer.stop()
|
||||
if hasattr(self, 'notifier') and self.notifier is not None:
|
||||
# Notifier 내부 1분 timer
|
||||
if hasattr(self.notifier, 'timer'):
|
||||
self.notifier.timer.stop()
|
||||
if hasattr(self, 'tray_icon') and self.tray_icon is not None:
|
||||
self.tray_icon.hide()
|
||||
except Exception:
|
||||
# 정리 실패해도 종료는 진행 — Qt가 결국 다 cleanup
|
||||
pass
|
||||
|
||||
def _check_screen_lock(self):
|
||||
"""LockMonitor 컨트롤러로 위임 (5초 polling)."""
|
||||
self._lock_monitor.tick()
|
||||
@@ -329,6 +375,7 @@ class MainWindow(QMainWindow):
|
||||
stats_button = QPushButton(tr('menu.stats'))
|
||||
calendar_button = QPushButton(tr('menu.calendar'))
|
||||
report_button = QPushButton(tr('menu.daily_report'))
|
||||
achievements_button = QPushButton("🏆 도전과제")
|
||||
help_button = QPushButton(tr('menu.help'))
|
||||
settings_button = QPushButton(tr('menu.settings'))
|
||||
|
||||
@@ -340,13 +387,15 @@ class MainWindow(QMainWindow):
|
||||
(settings_button, 'menu.settings')]:
|
||||
register(btn, key)
|
||||
|
||||
for btn in [stats_button, calendar_button, report_button, help_button, settings_button]:
|
||||
for btn in [stats_button, calendar_button, report_button,
|
||||
achievements_button, help_button, settings_button]:
|
||||
bottom_layout.addWidget(btn)
|
||||
|
||||
# 버튼 연결
|
||||
stats_button.clicked.connect(self.show_stats)
|
||||
calendar_button.clicked.connect(self.show_calendar)
|
||||
report_button.clicked.connect(self.generate_daily_report)
|
||||
achievements_button.clicked.connect(self.show_achievements)
|
||||
help_button.clicked.connect(self.show_help)
|
||||
settings_button.clicked.connect(self.show_settings)
|
||||
|
||||
@@ -1130,7 +1179,6 @@ class MainWindow(QMainWindow):
|
||||
# AUTO_OVERTIME 가드: 자동 적립 OFF + 적립할 게 있으면 사용자에게 확인
|
||||
auto_overtime = self.db.get_setting_bool('auto_overtime', True)
|
||||
if not auto_overtime and overtime_earned > 0:
|
||||
from utils.time_format import format_hours_minutes
|
||||
time_str = format_hours_minutes(overtime_earned, omit_zero_minutes=True)
|
||||
actual_str = format_hours_minutes(overtime_actual, omit_zero_minutes=True)
|
||||
ask = QMessageBox.question(
|
||||
@@ -1619,6 +1667,12 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def show_calendar(self):
|
||||
"""캘린더 창 표시"""
|
||||
# 도전과제 카운터
|
||||
try:
|
||||
cur = self.db.get_setting_int('calendar_view_count', 0)
|
||||
self.db.set_setting('calendar_view_count', str(cur + 1))
|
||||
except Exception:
|
||||
pass
|
||||
dialog = CalendarView(self, self.db)
|
||||
dialog.exec_()
|
||||
|
||||
@@ -1652,6 +1706,18 @@ class MainWindow(QMainWindow):
|
||||
dialog = HelpView(self)
|
||||
dialog.exec_()
|
||||
|
||||
def show_achievements(self):
|
||||
"""도전과제 다이얼로그 표시."""
|
||||
from ui.achievements_view import AchievementsView
|
||||
# 진입 시 즉시 한 번 평가 — UI에 최신 진행도 반영
|
||||
try:
|
||||
from core.achievements import evaluate_all
|
||||
evaluate_all(self.db)
|
||||
except Exception:
|
||||
pass
|
||||
dialog = AchievementsView(self.db, self)
|
||||
dialog.exec_()
|
||||
|
||||
def _show_meal_context(self, meal_type: str, button, pos):
|
||||
"""점심/저녁 버튼 우클릭 → 실제 시간 입력 메뉴."""
|
||||
from PyQt5.QtWidgets import QMenu
|
||||
@@ -1669,13 +1735,14 @@ class MainWindow(QMainWindow):
|
||||
default_min = (self.time_calc.lunch_duration_minutes
|
||||
if meal_type == 'lunch'
|
||||
else self.time_calc.dinner_duration_minutes)
|
||||
dialog = MealTimeDialog(self, meal_type=meal_type, default_minutes=default_min)
|
||||
# 식사 시각은 출~퇴근 범위 내여야 함. 호출 시점은 항상 출근 후·미퇴근 상태.
|
||||
dialog = MealTimeDialog(
|
||||
self, meal_type=meal_type, default_minutes=default_min,
|
||||
clock_in_time=self.clock_in_time,
|
||||
)
|
||||
if dialog.exec_() != QDialog.Accepted:
|
||||
return
|
||||
start, end, minutes = dialog.get_times()
|
||||
if minutes <= 0:
|
||||
QMessageBox.warning(self, "입력 오류", "종료 시간이 시작보다 빠릅니다.")
|
||||
return
|
||||
today = datetime.now().date().isoformat()
|
||||
self.db.add_meal_record(today, start, end, meal_type=meal_type)
|
||||
# 자동 토글 ON
|
||||
@@ -1706,8 +1773,10 @@ class MainWindow(QMainWindow):
|
||||
"""퇴근 후 요약 카드 표시. 시급 옵션 활성 시 추정 급여도 포함."""
|
||||
if not hasattr(self, 'today_summary_card'):
|
||||
return
|
||||
# 점심 시간 계산 (lunch_break_enabled면 설정값, 아니면 0)
|
||||
# 점심/저녁 시간 (플래그 ON이면 설정값, 아니면 0)
|
||||
lunch_min = self.time_calc.lunch_duration_minutes if self.lunch_break_enabled else 0
|
||||
dinner_min = (self.time_calc.dinner_duration_minutes
|
||||
if getattr(self, 'dinner_break_enabled', False) else 0)
|
||||
|
||||
# 추정 급여 (옵션)
|
||||
salary_text = ""
|
||||
@@ -1726,6 +1795,7 @@ class MainWindow(QMainWindow):
|
||||
self.today_summary_card.show_summary(
|
||||
total_hours=total_hours,
|
||||
lunch_minutes=lunch_min,
|
||||
dinner_minutes=dinner_min,
|
||||
break_minutes=break_minutes,
|
||||
overtime_actual=overtime_actual,
|
||||
overtime_earned=overtime_earned,
|
||||
@@ -1745,8 +1815,9 @@ class MainWindow(QMainWindow):
|
||||
from utils import discord_webhook
|
||||
ok = discord_webhook.send_clock_in(url, when.strftime('%H:%M:%S'))
|
||||
self.db.log_notification('discord', 'clock_in', success=ok)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
from utils.debug_log import dlog
|
||||
dlog(f"discord clock_in push failed: {e}")
|
||||
|
||||
def _discord_push_clock_out(self, when, total_hours, overtime_actual, overtime_earned):
|
||||
if self.db.get_setting('discord_notif_clock_out', 'true').lower() != 'true':
|
||||
@@ -1761,8 +1832,9 @@ class MainWindow(QMainWindow):
|
||||
total_hours, overtime_actual, overtime_earned,
|
||||
)
|
||||
self.db.log_notification('discord', 'clock_out', success=ok)
|
||||
except Exception:
|
||||
pass
|
||||
except Exception as e:
|
||||
from utils.debug_log import dlog
|
||||
dlog(f"discord clock_out push failed: {e}")
|
||||
|
||||
def check_for_updates(self, silent: bool = False):
|
||||
"""업데이트 확인. silent=True면 새 버전 있을 때만 알림 (시작 시 자동 체크용)."""
|
||||
@@ -2020,10 +2092,52 @@ class MainWindow(QMainWindow):
|
||||
if self.is_clocked_in and self.clock_in_time:
|
||||
self.update_display()
|
||||
|
||||
def _append_meal_section(self, report_lines, today: str, label: str,
|
||||
flag: bool, records: list, default_min: int) -> None:
|
||||
"""일일 보고서의 점심/저녁 섹션 출력.
|
||||
|
||||
실측 기록(`break_records.break_type='lunch'/'dinner'`)이 있으면
|
||||
시작/종료 시각과 실측 분을 표시하고, 플래그만 켜진 경우엔
|
||||
설정값(`lunch_duration_minutes`/`dinner_duration_minutes`)을 표시.
|
||||
"""
|
||||
actual_min = sum((r.get('total_minutes') or 0) for r in records)
|
||||
if records and actual_min > 0:
|
||||
report_lines.append(f"{label}: {format_hours_minutes(actual_min)} (실측)")
|
||||
for r in records:
|
||||
try:
|
||||
start_dt = datetime.fromisoformat(f"{today} {r['break_out']}")
|
||||
except (KeyError, ValueError, TypeError):
|
||||
continue
|
||||
if r.get('break_in'):
|
||||
try:
|
||||
end_dt = datetime.fromisoformat(f"{today} {r['break_in']}")
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
if end_dt < start_dt:
|
||||
end_dt += timedelta(days=1)
|
||||
dur = int((end_dt - start_dt).total_seconds() / 60)
|
||||
report_lines.append(
|
||||
f" - {self.format_time(start_dt)} ~ {self.format_time(end_dt)} ({dur}분)"
|
||||
)
|
||||
else:
|
||||
report_lines.append(f" - {self.format_time(start_dt)} ~ 진행중")
|
||||
elif flag:
|
||||
report_lines.append(f"{label}: 포함 ({format_hours_minutes(default_min)})")
|
||||
else:
|
||||
return
|
||||
report_lines.append("")
|
||||
|
||||
def generate_daily_report(self):
|
||||
"""오늘 하루 근무 내역 보고서 생성 및 클립보드 복사"""
|
||||
from datetime import date
|
||||
|
||||
# 도전과제 카운터 (보고서 생성 횟수)
|
||||
try:
|
||||
cur = self.db.get_setting_int('daily_report_count', 0)
|
||||
self.db.set_setting('daily_report_count', str(cur + 1))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
today = date.today().isoformat()
|
||||
|
||||
# 오늘의 근무 기록 조회
|
||||
@@ -2063,16 +2177,20 @@ class MainWindow(QMainWindow):
|
||||
|
||||
report_lines.append("")
|
||||
|
||||
# 외출 시간
|
||||
break_minutes = self.db.get_total_break_minutes_today()
|
||||
if break_minutes > 0:
|
||||
break_hours = break_minutes // 60
|
||||
break_mins = break_minutes % 60
|
||||
report_lines.append(f"🚶 외출 시간: {break_hours}시간 {break_mins}분")
|
||||
# 외출 / 점심 / 저녁 분리 — break_type 으로 구분 (v2.7.0+)
|
||||
# 'break'(또는 NULL) = 일반 외출, 'lunch'/'dinner' = 실측 식사 기록
|
||||
all_break_records = self.db.get_today_break_records()
|
||||
real_break_records = [b for b in all_break_records
|
||||
if (b.get('break_type') or 'break') == 'break']
|
||||
lunch_records = [b for b in all_break_records if b.get('break_type') == 'lunch']
|
||||
dinner_records = [b for b in all_break_records if b.get('break_type') == 'dinner']
|
||||
|
||||
# 외출 상세 내역
|
||||
break_records = self.db.get_today_break_records()
|
||||
for br in break_records:
|
||||
# 외출 시간 (식사 제외)
|
||||
real_break_minutes = sum((b.get('total_minutes') or 0) for b in real_break_records)
|
||||
has_active_break = any(not b.get('break_in') for b in real_break_records)
|
||||
if real_break_minutes > 0 or has_active_break:
|
||||
report_lines.append(f"🚶 외출 시간: {format_hours_minutes(real_break_minutes)}")
|
||||
for br in real_break_records:
|
||||
break_out_time = datetime.fromisoformat(f"{today} {br['break_out']}")
|
||||
if br['break_in']:
|
||||
break_in_time = datetime.fromisoformat(f"{today} {br['break_in']}")
|
||||
@@ -2080,18 +2198,30 @@ class MainWindow(QMainWindow):
|
||||
if break_in_time < break_out_time:
|
||||
break_in_time += timedelta(days=1)
|
||||
duration = int((break_in_time - break_out_time).total_seconds() / 60)
|
||||
reason = f" ({br['reason']})" if br['reason'] else ""
|
||||
reason = f" ({br['reason']})" if br.get('reason') else ""
|
||||
report_lines.append(f" - {self.format_time(break_out_time)} ~ {self.format_time(break_in_time)} ({duration}분){reason}")
|
||||
else:
|
||||
reason = f" ({br['reason']})" if br['reason'] else ""
|
||||
reason = f" ({br['reason']})" if br.get('reason') else ""
|
||||
report_lines.append(f" - {self.format_time(break_out_time)} ~ 복귀중{reason}")
|
||||
report_lines.append("")
|
||||
|
||||
# 점심시간
|
||||
lunch_break = work_record.get('lunch_break', False)
|
||||
if lunch_break:
|
||||
report_lines.append(f"🍱 점심시간: 포함 (1시간)")
|
||||
report_lines.append("")
|
||||
lunch_flag = bool(work_record.get('lunch_break', False))
|
||||
if lunch_flag or lunch_records:
|
||||
self._append_meal_section(
|
||||
report_lines, today, '🍱 점심시간',
|
||||
lunch_flag, lunch_records,
|
||||
self.time_calc.lunch_duration_minutes,
|
||||
)
|
||||
|
||||
# 저녁시간
|
||||
dinner_flag = bool(work_record.get('dinner_break', False))
|
||||
if dinner_flag or dinner_records:
|
||||
self._append_meal_section(
|
||||
report_lines, today, '🍽️ 저녁시간',
|
||||
dinner_flag, dinner_records,
|
||||
self.time_calc.dinner_duration_minutes,
|
||||
)
|
||||
|
||||
# 추가 근무 적립
|
||||
if work_record['overtime_minutes'] and work_record['overtime_minutes'] > 0:
|
||||
|
||||
+90
-22
@@ -3,44 +3,70 @@
|
||||
|
||||
기본 60분 자동 차감 모드와 별개로, 사용자가 정확한 시작/종료 시각을
|
||||
입력하면 그 값을 break_records.break_type='lunch'/'dinner'로 저장.
|
||||
|
||||
식사 시각은 출근~퇴근 범위 내에서만 의미가 있으므로,
|
||||
clock_in_time이 주어지면 시작이 출근 이전이거나 퇴근 이후로 가는 것을 차단.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QTimeEdit, QMessageBox)
|
||||
from PyQt5.QtCore import QTime, Qt
|
||||
from PyQt5.QtCore import QTime
|
||||
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
class MealTimeDialog(QDialog):
|
||||
"""점심/저녁 실제 시작·종료 시간 입력."""
|
||||
"""점심/저녁 실제 시작·종료 시간 입력.
|
||||
|
||||
def __init__(self, parent=None, meal_type: str = 'lunch', default_minutes: int = 60):
|
||||
Args:
|
||||
parent: 부모 위젯
|
||||
meal_type: 'lunch' 또는 'dinner'
|
||||
default_minutes: 안내 문구에 표시할 기본 차감 분
|
||||
clock_in_time: 출근 datetime (옵션). 주어지면 식사가 출근 이후인지 검증.
|
||||
clock_out_time: 퇴근 datetime (옵션, 보통 미퇴근 상태). 주어지면 퇴근 이전인지 검증.
|
||||
"""
|
||||
|
||||
def __init__(self, parent=None, meal_type: str = 'lunch', default_minutes: int = 60,
|
||||
clock_in_time: Optional[datetime] = None,
|
||||
clock_out_time: Optional[datetime] = None):
|
||||
super().__init__(parent)
|
||||
self.meal_type = meal_type
|
||||
self._clock_in = clock_in_time
|
||||
self._clock_out = clock_out_time
|
||||
title_kr = '점심' if meal_type == 'lunch' else '저녁'
|
||||
self.setWindowTitle(f"{title_kr} 시간 입력")
|
||||
self.setModal(True)
|
||||
self.setFixedSize(360, 220)
|
||||
self.setFixedSize(380, 260)
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(20, 16, 20, 16)
|
||||
|
||||
info = QLabel(f"{title_kr} 시작·종료 시각을 입력하세요.\n자동 적용된 {default_minutes}분 대신 정확한 시간으로 기록됩니다.")
|
||||
info_text = (f"{title_kr} 시작·종료 시각을 입력하세요.\n"
|
||||
f"자동 적용된 {default_minutes}분 대신 정확한 시간으로 기록됩니다.")
|
||||
if clock_in_time is not None:
|
||||
info_text += f"\n출근 {clock_in_time.strftime('%H:%M')} 이후만 입력 가능."
|
||||
info = QLabel(info_text)
|
||||
info.setWordWrap(True)
|
||||
info.setStyleSheet("color: #888; padding-bottom: 6px;")
|
||||
layout.addWidget(info)
|
||||
|
||||
# 합리적 기본값: 출근 이후로 보정
|
||||
default_start_h = 12 if meal_type == 'lunch' else 18
|
||||
default_end_h = 13 if meal_type == 'lunch' else 19
|
||||
if clock_in_time is not None and clock_in_time.hour >= default_start_h:
|
||||
# 출근이 이미 점심/저녁 기본 시각을 지났으면 출근 직후를 기본값으로
|
||||
default_start_h = (clock_in_time.hour + 1) % 24
|
||||
default_end_h = (default_start_h + 1) % 24
|
||||
|
||||
# 시작
|
||||
start_row = QHBoxLayout()
|
||||
start_row.addWidget(QLabel("시작:"))
|
||||
self.start_edit = QTimeEdit()
|
||||
self.start_edit.setDisplayFormat("HH:mm")
|
||||
# 기본값: 점심=12:00, 저녁=18:00
|
||||
default_start = QTime(12, 0) if meal_type == 'lunch' else QTime(18, 0)
|
||||
self.start_edit.setTime(default_start)
|
||||
self.start_edit.setTime(QTime(default_start_h, 0))
|
||||
start_row.addWidget(self.start_edit)
|
||||
start_row.addStretch()
|
||||
layout.addLayout(start_row)
|
||||
@@ -50,8 +76,7 @@ class MealTimeDialog(QDialog):
|
||||
end_row.addWidget(QLabel("종료:"))
|
||||
self.end_edit = QTimeEdit()
|
||||
self.end_edit.setDisplayFormat("HH:mm")
|
||||
default_end = QTime(13, 0) if meal_type == 'lunch' else QTime(19, 0)
|
||||
self.end_edit.setTime(default_end)
|
||||
self.end_edit.setTime(QTime(default_end_h, 0))
|
||||
end_row.addWidget(self.end_edit)
|
||||
end_row.addStretch()
|
||||
layout.addLayout(end_row)
|
||||
@@ -79,30 +104,73 @@ class MealTimeDialog(QDialog):
|
||||
self.setLayout(layout)
|
||||
apply_dark_titlebar(self)
|
||||
|
||||
def _update_preview(self):
|
||||
s = self.start_edit.time()
|
||||
e = self.end_edit.time()
|
||||
start_dt = datetime.combine(datetime.today(), s.toPyTime())
|
||||
end_dt = datetime.combine(datetime.today(), e.toPyTime())
|
||||
# -------- 내부 시각 계산 --------
|
||||
def _resolve_meal_window(self) -> tuple[datetime, datetime, int]:
|
||||
"""현재 위젯 값에서 (start_dt, end_dt, minutes) 계산.
|
||||
|
||||
자정 경계 처리:
|
||||
1) 야간 출근자(clock_in 18시 이후)가 새벽 식사 시각을 입력하면
|
||||
시작이 출근 이전으로 보이는데, 이를 다음날 새벽으로 +1day shift.
|
||||
2) 종료가 시작보다 빠르면 종료에 +1day (점심 12:55→13:30 같은 정상은 영향 X).
|
||||
|
||||
주간 출근자(clock_in 09시)가 08시 입력 시 +1day는 적용하지 않아 검증에서 거절.
|
||||
"""
|
||||
s = self.start_edit.time().toPyTime()
|
||||
e = self.end_edit.time().toPyTime()
|
||||
base_date = (self._clock_in.date() if self._clock_in is not None
|
||||
else datetime.today().date())
|
||||
start_dt = datetime.combine(base_date, s)
|
||||
end_dt = datetime.combine(base_date, e)
|
||||
|
||||
# 야간 출근자 자동 보정
|
||||
if (self._clock_in is not None and start_dt < self._clock_in
|
||||
and self._clock_in.hour >= 18):
|
||||
start_dt += timedelta(days=1)
|
||||
end_dt += timedelta(days=1)
|
||||
|
||||
if end_dt < start_dt:
|
||||
end_dt += timedelta(days=1)
|
||||
minutes = int((end_dt - start_dt).total_seconds() / 60)
|
||||
if minutes <= 0:
|
||||
self.preview.setText("⚠️ 시작이 종료보다 늦습니다")
|
||||
return start_dt, end_dt, minutes
|
||||
|
||||
def _update_preview(self):
|
||||
start_dt, end_dt, minutes = self._resolve_meal_window()
|
||||
ok, reason = self._validate_window(start_dt, end_dt, minutes)
|
||||
if not ok:
|
||||
self.preview.setText(f"⚠️ {reason}")
|
||||
self.preview.setStyleSheet("color: #f44336;")
|
||||
else:
|
||||
self.preview.setText(f"총 {minutes}분")
|
||||
self.preview.setStyleSheet("color: #4caf50; font-weight: bold;")
|
||||
|
||||
def _validate_window(self, start_dt: datetime, end_dt: datetime,
|
||||
minutes: int) -> tuple[bool, str]:
|
||||
"""식사 시각이 출/퇴근 범위와 정합인지 검증."""
|
||||
if minutes <= 0:
|
||||
return False, "시작이 종료보다 늦습니다"
|
||||
if minutes > 8 * 60:
|
||||
# 자정 경계 처리 후 8시간 초과면 사용자 실수일 가능성 높음
|
||||
return False, "식사 시간이 8시간을 초과합니다"
|
||||
if self._clock_in is not None and start_dt < self._clock_in:
|
||||
return False, f"출근({self._clock_in.strftime('%H:%M')}) 이전입니다"
|
||||
if self._clock_out is not None and end_dt > self._clock_out:
|
||||
return False, f"퇴근({self._clock_out.strftime('%H:%M')}) 이후입니다"
|
||||
return True, ""
|
||||
|
||||
def accept(self):
|
||||
"""저장 버튼: 검증 실패 시 다이얼로그 닫지 않고 메시지박스."""
|
||||
start_dt, end_dt, minutes = self._resolve_meal_window()
|
||||
ok, reason = self._validate_window(start_dt, end_dt, minutes)
|
||||
if not ok:
|
||||
QMessageBox.warning(self, "입력 오류", reason)
|
||||
return
|
||||
super().accept()
|
||||
|
||||
def get_times(self) -> tuple[str, str, int]:
|
||||
"""('HH:MM:SS', 'HH:MM:SS', total_minutes) 반환."""
|
||||
s = self.start_edit.time().toPyTime()
|
||||
e = self.end_edit.time().toPyTime()
|
||||
start_str = f"{s.hour:02d}:{s.minute:02d}:00"
|
||||
end_str = f"{e.hour:02d}:{e.minute:02d}:00"
|
||||
s_dt = datetime.combine(datetime.today(), s)
|
||||
e_dt = datetime.combine(datetime.today(), e)
|
||||
if e_dt < s_dt:
|
||||
e_dt += timedelta(days=1)
|
||||
minutes = int((e_dt - s_dt).total_seconds() / 60)
|
||||
_, _, minutes = self._resolve_meal_window()
|
||||
return start_str, end_str, minutes
|
||||
|
||||
+62
-24
@@ -12,16 +12,20 @@ from PyQt5.QtWidgets import (QWizard, QWizardPage, QVBoxLayout, QHBoxLayout,
|
||||
QMessageBox, QGroupBox)
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
# (label, work_minutes, lunch_minutes)
|
||||
# (i18n_key, work_minutes, lunch_minutes, dinner_minutes)
|
||||
# 라벨은 tr()로 런타임 해석 — 언어 전환 후 위저드 다시 열면 새 언어로 표시.
|
||||
# dinner_minutes는 기본 0 — 야근으로 저녁이 자주 발생하는 사용자는
|
||||
# 직접 입력 모드에서 저녁 분(minutes)을 따로 설정.
|
||||
WORK_PRESETS = [
|
||||
("표준 8시간 (점심 60분)", 480, 60),
|
||||
("단축근무 7시간 30분 (점심 30분)", 450, 30),
|
||||
("단축근무 7시간 (점심 60분)", 420, 60),
|
||||
("단축근무 6시간 (점심 30분)", 360, 30),
|
||||
("반일 4시간 (점심 0분)", 240, 0),
|
||||
('onboarding.preset.standard_8h', 480, 60, 0),
|
||||
('onboarding.preset.short_7h30m', 450, 30, 0),
|
||||
('onboarding.preset.short_7h', 420, 60, 0),
|
||||
('onboarding.preset.short_6h', 360, 30, 0),
|
||||
('onboarding.preset.half_4h', 240, 0, 0),
|
||||
]
|
||||
|
||||
|
||||
@@ -52,48 +56,72 @@ class WorkPatternPage(QWizardPage):
|
||||
|
||||
layout = QVBoxLayout()
|
||||
self.button_group = QButtonGroup(self)
|
||||
for i, (label, _, _) in enumerate(WORK_PRESETS):
|
||||
rb = QRadioButton(label)
|
||||
for i, (key, _, _, _) in enumerate(WORK_PRESETS):
|
||||
rb = QRadioButton(tr(key))
|
||||
self.button_group.addButton(rb, i)
|
||||
layout.addWidget(rb)
|
||||
if i == 0:
|
||||
rb.setChecked(True)
|
||||
|
||||
# 사용자 정의
|
||||
custom_box = QGroupBox("사용자 정의")
|
||||
custom_layout = QHBoxLayout()
|
||||
self.custom_radio = QRadioButton("직접 입력:")
|
||||
custom_box = QGroupBox(tr('onboarding.preset.custom_box'))
|
||||
custom_layout = QVBoxLayout()
|
||||
custom_layout.setSpacing(4)
|
||||
|
||||
suffix_h = tr('onboarding.preset.suffix_hours')
|
||||
suffix_m = tr('onboarding.preset.suffix_minutes')
|
||||
|
||||
row1 = QHBoxLayout()
|
||||
self.custom_radio = QRadioButton(tr('onboarding.preset.custom_radio'))
|
||||
self.button_group.addButton(self.custom_radio, len(WORK_PRESETS))
|
||||
self.hours_spin = QSpinBox()
|
||||
self.hours_spin.setRange(0, 12)
|
||||
self.hours_spin.setValue(8)
|
||||
self.hours_spin.setSuffix(" 시간")
|
||||
self.hours_spin.setSuffix(suffix_h)
|
||||
self.minutes_spin = QSpinBox()
|
||||
self.minutes_spin.setRange(0, 59)
|
||||
self.minutes_spin.setSingleStep(15)
|
||||
self.minutes_spin.setSuffix(" 분")
|
||||
self.minutes_spin.setSuffix(suffix_m)
|
||||
row1.addWidget(self.custom_radio)
|
||||
row1.addWidget(self.hours_spin)
|
||||
row1.addWidget(self.minutes_spin)
|
||||
row1.addStretch()
|
||||
custom_layout.addLayout(row1)
|
||||
|
||||
row2 = QHBoxLayout()
|
||||
self.lunch_spin = QSpinBox()
|
||||
self.lunch_spin.setRange(0, 120)
|
||||
self.lunch_spin.setSingleStep(5)
|
||||
self.lunch_spin.setValue(60)
|
||||
self.lunch_spin.setPrefix("점심 ")
|
||||
self.lunch_spin.setSuffix(" 분")
|
||||
custom_layout.addWidget(self.custom_radio)
|
||||
custom_layout.addWidget(self.hours_spin)
|
||||
custom_layout.addWidget(self.minutes_spin)
|
||||
custom_layout.addWidget(self.lunch_spin)
|
||||
custom_layout.addStretch()
|
||||
self.lunch_spin.setPrefix(tr('onboarding.preset.lunch_prefix'))
|
||||
self.lunch_spin.setSuffix(suffix_m)
|
||||
self.dinner_spin = QSpinBox()
|
||||
self.dinner_spin.setRange(0, 120)
|
||||
self.dinner_spin.setSingleStep(5)
|
||||
self.dinner_spin.setValue(0)
|
||||
self.dinner_spin.setPrefix(tr('onboarding.preset.dinner_prefix'))
|
||||
self.dinner_spin.setSuffix(suffix_m)
|
||||
self.dinner_spin.setToolTip(tr('onboarding.preset.dinner_tooltip'))
|
||||
row2.addSpacing(20)
|
||||
row2.addWidget(self.lunch_spin)
|
||||
row2.addWidget(self.dinner_spin)
|
||||
row2.addStretch()
|
||||
custom_layout.addLayout(row2)
|
||||
|
||||
custom_box.setLayout(custom_layout)
|
||||
layout.addWidget(custom_box)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def selected_minutes(self):
|
||||
"""returns (work_minutes, lunch_minutes, dinner_minutes)"""
|
||||
idx = self.button_group.checkedId()
|
||||
if 0 <= idx < len(WORK_PRESETS):
|
||||
_, wm, lm = WORK_PRESETS[idx]
|
||||
return wm, lm
|
||||
return self.hours_spin.value() * 60 + self.minutes_spin.value(), self.lunch_spin.value()
|
||||
_, wm, lm, dm = WORK_PRESETS[idx]
|
||||
return wm, lm, dm
|
||||
return (self.hours_spin.value() * 60 + self.minutes_spin.value(),
|
||||
self.lunch_spin.value(),
|
||||
self.dinner_spin.value())
|
||||
|
||||
|
||||
class ClockInDetectionPage(QWizardPage):
|
||||
@@ -232,6 +260,13 @@ class DiscordPage(QWizardPage):
|
||||
QMessageBox.warning(self, "URL 필요", "웹훅 URL을 먼저 입력해주세요.")
|
||||
return
|
||||
from utils import discord_webhook
|
||||
if not discord_webhook.is_valid_webhook_url(url):
|
||||
QMessageBox.warning(
|
||||
self, "URL 형식 오류",
|
||||
"Discord 웹훅 URL 형식이 아닙니다.\n"
|
||||
"예: https://discord.com/api/webhooks/{ID}/{TOKEN}"
|
||||
)
|
||||
return
|
||||
ok = discord_webhook.send_test(url)
|
||||
if ok:
|
||||
QMessageBox.information(self, "성공", "Discord 채널에서 테스트 메시지를 확인하세요.")
|
||||
@@ -286,7 +321,7 @@ class OnboardingWizard(QWizard):
|
||||
|
||||
def accept(self):
|
||||
# 1. 근무 패턴
|
||||
wm, lm = self.work_page.selected_minutes()
|
||||
wm, lm, dm = self.work_page.selected_minutes()
|
||||
if wm < 30:
|
||||
QMessageBox.warning(self, "입력 오류", "하루 근무는 최소 30분 이상이어야 합니다.")
|
||||
return
|
||||
@@ -294,6 +329,7 @@ class OnboardingWizard(QWizard):
|
||||
settings = {
|
||||
'work_minutes': wm,
|
||||
'lunch_duration_minutes': lm,
|
||||
# 사용자가 0으로 두면 기존 기본값 보존(60) — 단, 명시적 양수 입력만 덮어쓰기
|
||||
'annual_leave_days': self.leave_page.leave_spin.value(),
|
||||
'annual_leave_total': self.leave_page.leave_spin.value(),
|
||||
'salary_enabled': self.leave_page.salary_enabled.isChecked(),
|
||||
@@ -301,6 +337,8 @@ class OnboardingWizard(QWizard):
|
||||
'overtime_rate': self.leave_page.rate_combo.currentData(),
|
||||
'onboarding_completed': True,
|
||||
}
|
||||
if dm > 0:
|
||||
settings['dinner_duration_minutes'] = dm
|
||||
|
||||
# 2. 출근 감지 방식
|
||||
mode = self.detect_page.detection_mode()
|
||||
|
||||
+138
-10
@@ -17,8 +17,11 @@ from core.i18n import tr
|
||||
from core.settings_keys import (
|
||||
WORK_HOURS, WORK_MINUTES, LUNCH_DURATION_MINUTES, DINNER_DURATION_MINUTES,
|
||||
AUTO_LUNCH, AUTO_BREAK_ON_LOCK, AUTO_OVERTIME,
|
||||
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_OVERTIME, NOTIF_HEALTH,
|
||||
NOTIF_CLOCK_OUT, NOTIF_LUNCH, NOTIF_DINNER, NOTIF_OVERTIME, NOTIF_HEALTH,
|
||||
NOTIFICATION_BEFORE_MINUTES,
|
||||
LUNCH_REMINDER_HOURS, DINNER_REMINDER_HOURS,
|
||||
OVERTIME_THRESHOLD_HOURS, WEEKLY_HOURS_THRESHOLD, HEALTH_CONSECUTIVE_OT_DAYS,
|
||||
HEALTH_BREAK_HOURS, HEALTH_BREAK_ENABLED,
|
||||
THEME, TIME_FORMAT, LANGUAGE, OVERTIME_UNIT, ANNUAL_LEAVE_DAYS,
|
||||
INITIAL_OVERTIME_MINUTES, INITIAL_LEAVE_USED_HOURS,
|
||||
DB_PATH_OVERRIDE, CLOCK_IN_ON_UNLOCK,
|
||||
@@ -258,13 +261,50 @@ class SettingsView(QDialog):
|
||||
self.work_preset_combo.setCurrentIndex(custom_index)
|
||||
self.work_preset_combo.blockSignals(False)
|
||||
|
||||
def _load_threshold_safely(self, settings: dict, setting_key: str,
|
||||
attr_name: str, default: int) -> None:
|
||||
"""settings dict에서 임계값을 읽어 SpinBox에 안전하게 적용.
|
||||
|
||||
get_settings() 결과는 이미 타입 변환된 dict이라 추가 DB 호출 없이 사용.
|
||||
"""
|
||||
if not hasattr(self, attr_name):
|
||||
return
|
||||
spin = getattr(self, attr_name)
|
||||
try:
|
||||
val = int(settings.get(setting_key, default))
|
||||
except (ValueError, TypeError):
|
||||
val = default
|
||||
# 이미 설정된 setRange를 존중 — 사용자 옛 값이 범위 밖이면 클램프
|
||||
spin.setValue(max(spin.minimum(), min(spin.maximum(), val)))
|
||||
|
||||
@staticmethod
|
||||
def _make_threshold_spin(lo: int, hi: int, default: int, suffix: str) -> QSpinBox:
|
||||
"""고급 임계값용 표준 SpinBox."""
|
||||
sb = QSpinBox()
|
||||
sb.setRange(lo, hi)
|
||||
sb.setValue(default)
|
||||
sb.setSuffix(suffix)
|
||||
sb.setFixedWidth(110)
|
||||
return sb
|
||||
|
||||
@staticmethod
|
||||
def _labeled_row(label_text: str, widget) -> QHBoxLayout:
|
||||
"""좌측 라벨(고정 폭) + 위젯 + 오른쪽 stretch 한 줄 레이아웃."""
|
||||
row = QHBoxLayout()
|
||||
lbl = QLabel(label_text)
|
||||
lbl.setFixedWidth(140)
|
||||
row.addWidget(lbl)
|
||||
row.addWidget(widget)
|
||||
row.addStretch()
|
||||
return row
|
||||
|
||||
def create_notification_group(self) -> QGroupBox:
|
||||
"""알림 설정 그룹"""
|
||||
group = QGroupBox(tr('group.notification'))
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(6)
|
||||
|
||||
# 알림 체크박스들을 2열로 배치
|
||||
# 알림 체크박스들을 3행으로 배치 (저녁 알림 추가로 5개)
|
||||
check_row1 = QHBoxLayout()
|
||||
self.clock_out_notification_check = QCheckBox("퇴근 30분 전 알림")
|
||||
self.clock_out_notification_check.setChecked(True)
|
||||
@@ -275,13 +315,25 @@ class SettingsView(QDialog):
|
||||
layout.addLayout(check_row1)
|
||||
|
||||
check_row2 = QHBoxLayout()
|
||||
self.dinner_notification_check = QCheckBox("저녁시간 등록 알림")
|
||||
self.dinner_notification_check.setChecked(True)
|
||||
self.overtime_notification_check = QCheckBox("연장근무 적립 알림")
|
||||
self.overtime_notification_check.setChecked(True)
|
||||
check_row2.addWidget(self.dinner_notification_check)
|
||||
check_row2.addWidget(self.overtime_notification_check)
|
||||
layout.addLayout(check_row2)
|
||||
|
||||
check_row3 = QHBoxLayout()
|
||||
self.health_notification_check = QCheckBox("건강 경고 알림")
|
||||
self.health_notification_check.setChecked(True)
|
||||
check_row2.addWidget(self.overtime_notification_check)
|
||||
check_row2.addWidget(self.health_notification_check)
|
||||
layout.addLayout(check_row2)
|
||||
self.health_break_notification_check = QCheckBox("휴식 권고 알림")
|
||||
self.health_break_notification_check.setChecked(True)
|
||||
self.health_break_notification_check.setToolTip(
|
||||
"오랜 시간 자리에서 일하면 스트레칭을 권유 (연속 근무 N시간 기준)"
|
||||
)
|
||||
check_row3.addWidget(self.health_notification_check)
|
||||
check_row3.addWidget(self.health_break_notification_check)
|
||||
layout.addLayout(check_row3)
|
||||
|
||||
# 퇴근 N분 전 알림 시점 설정
|
||||
before_row = QHBoxLayout()
|
||||
@@ -299,6 +351,47 @@ class SettingsView(QDialog):
|
||||
before_row.addStretch()
|
||||
layout.addLayout(before_row)
|
||||
|
||||
# === 고급 임계값 (접이식 그룹박스) ===
|
||||
adv_box = QGroupBox("고급 임계값")
|
||||
adv_box.setCheckable(True)
|
||||
adv_box.setChecked(False) # 기본 접힘
|
||||
adv_box.setToolTip("회사 정책·개인 선호에 맞춰 알림 발생 시점 조정")
|
||||
adv_layout = QVBoxLayout()
|
||||
adv_layout.setSpacing(4)
|
||||
|
||||
# 점심 알림 임계 (출근 후 N시간)
|
||||
self.lunch_reminder_spin = self._make_threshold_spin(1, 12, 4, " 시간")
|
||||
self.lunch_reminder_spin.setToolTip("출근 후 N시간 경과 시 점심 미등록 알림")
|
||||
adv_layout.addLayout(self._labeled_row("점심 알림 (출근 +):", self.lunch_reminder_spin))
|
||||
|
||||
# 저녁 알림 임계 (출근 후 N시간)
|
||||
self.dinner_reminder_spin = self._make_threshold_spin(1, 16, 8, " 시간")
|
||||
self.dinner_reminder_spin.setToolTip("출근 후 N시간 경과 시 저녁 미등록 알림")
|
||||
adv_layout.addLayout(self._labeled_row("저녁 알림 (출근 +):", self.dinner_reminder_spin))
|
||||
|
||||
# 연장근무 누적 임계
|
||||
self.overtime_threshold_spin = self._make_threshold_spin(1, 200, 20, " 시간")
|
||||
self.overtime_threshold_spin.setToolTip("연장근무 잔액이 N시간 이상이면 알림")
|
||||
adv_layout.addLayout(self._labeled_row("연장 누적 알림:", self.overtime_threshold_spin))
|
||||
|
||||
# 주 X시간 임계
|
||||
self.weekly_hours_spin = self._make_threshold_spin(20, 168, 52, " 시간")
|
||||
self.weekly_hours_spin.setToolTip("주간 총 근무가 N시간 초과 시 경고 (한국 노동법 기본 52)")
|
||||
adv_layout.addLayout(self._labeled_row("주간 한도 경고:", self.weekly_hours_spin))
|
||||
|
||||
# 연속 연장근무 일수
|
||||
self.health_consecutive_spin = self._make_threshold_spin(1, 14, 3, " 일")
|
||||
self.health_consecutive_spin.setToolTip("N일 이상 연속 연장근무 시 건강 경고")
|
||||
adv_layout.addLayout(self._labeled_row("연속 연장 경고:", self.health_consecutive_spin))
|
||||
|
||||
# 휴식 권고 (연속 근무 시간)
|
||||
self.health_break_hours_spin = self._make_threshold_spin(1, 12, 4, " 시간")
|
||||
self.health_break_hours_spin.setToolTip("연속 근무 N시간 경과 시 스트레칭 권유")
|
||||
adv_layout.addLayout(self._labeled_row("휴식 권고 시점:", self.health_break_hours_spin))
|
||||
|
||||
adv_box.setLayout(adv_layout)
|
||||
layout.addWidget(adv_box)
|
||||
|
||||
# 시간 형식 + 테마 한 줄에
|
||||
format_row = QHBoxLayout()
|
||||
time_format_label = QLabel("시간 형식:")
|
||||
@@ -578,13 +671,22 @@ class SettingsView(QDialog):
|
||||
self.holiday_count_label.setText(f"{len(holidays)}개 ({current_year}년)")
|
||||
|
||||
def add_korean_holidays_auto(self):
|
||||
"""holidays 패키지로 음력/임시 공휴일 포함 자동 추가"""
|
||||
current_year = datetime.now().year
|
||||
"""holidays 패키지로 음력/임시 공휴일 포함 자동 추가.
|
||||
|
||||
11월 이후 호출 시 자동으로 다음 연도까지 등록 — 연말 경계에서
|
||||
신정 등이 누락되는 것 방지.
|
||||
"""
|
||||
now = datetime.now()
|
||||
current_year = now.year
|
||||
# 11~12월에 호출 시 다음 해 1월 신정·설 연휴 미리 등록 (연말 자정 경계 대응)
|
||||
include_next = now.month >= 11
|
||||
target_label = (f"{current_year}년 + {current_year + 1}년"
|
||||
if include_next else f"{current_year}년")
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"한국 공휴일 자동 추가",
|
||||
f"{current_year}년 한국 공휴일을 자동으로 등록하시겠습니까?\n\n"
|
||||
f"{target_label} 한국 공휴일을 자동으로 등록하시겠습니까?\n\n"
|
||||
"포함:\n"
|
||||
"• 양력 공휴일 (신정/삼일절/어린이날 등)\n"
|
||||
"• 음력 명절 (설날 연휴/추석 연휴/석가탄신일)\n"
|
||||
@@ -595,10 +697,12 @@ class SettingsView(QDialog):
|
||||
if reply != QMessageBox.Yes:
|
||||
return
|
||||
|
||||
added = self.db.add_korean_holidays_auto(current_year)
|
||||
added = self.db.add_korean_holidays_auto(current_year, include_next_year=include_next)
|
||||
if added < 0:
|
||||
# 패키지 미설치 시 고정 공휴일로 폴백
|
||||
self.db.add_korean_holidays(current_year)
|
||||
if include_next:
|
||||
self.db.add_korean_holidays(current_year + 1)
|
||||
self.update_holiday_count()
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
@@ -933,14 +1037,27 @@ class SettingsView(QDialog):
|
||||
# 알림
|
||||
self.clock_out_notification_check.setChecked(settings.get(NOTIF_CLOCK_OUT, True))
|
||||
self.lunch_notification_check.setChecked(settings.get(NOTIF_LUNCH, True))
|
||||
if hasattr(self, 'dinner_notification_check'):
|
||||
self.dinner_notification_check.setChecked(settings.get(NOTIF_DINNER, True))
|
||||
self.overtime_notification_check.setChecked(settings.get(NOTIF_OVERTIME, True))
|
||||
self.health_notification_check.setChecked(settings.get(NOTIF_HEALTH, True))
|
||||
if hasattr(self, 'health_break_notification_check'):
|
||||
self.health_break_notification_check.setChecked(
|
||||
settings.get(HEALTH_BREAK_ENABLED, True))
|
||||
if hasattr(self, 'notif_before_spin'):
|
||||
try:
|
||||
self.notif_before_spin.setValue(int(settings.get(NOTIFICATION_BEFORE_MINUTES, 30)))
|
||||
except (ValueError, TypeError):
|
||||
self.notif_before_spin.setValue(30)
|
||||
|
||||
# 고급 임계값
|
||||
self._load_threshold_safely(settings, LUNCH_REMINDER_HOURS, 'lunch_reminder_spin', 4)
|
||||
self._load_threshold_safely(settings, DINNER_REMINDER_HOURS, 'dinner_reminder_spin', 8)
|
||||
self._load_threshold_safely(settings, OVERTIME_THRESHOLD_HOURS, 'overtime_threshold_spin', 20)
|
||||
self._load_threshold_safely(settings, WEEKLY_HOURS_THRESHOLD, 'weekly_hours_spin', 52)
|
||||
self._load_threshold_safely(settings, HEALTH_CONSECUTIVE_OT_DAYS, 'health_consecutive_spin', 3)
|
||||
self._load_threshold_safely(settings, HEALTH_BREAK_HOURS, 'health_break_hours_spin', 4)
|
||||
|
||||
# 시간 형식 (콤보박스는 문자열로 저장하므로 변환)
|
||||
time_format = settings.get(TIME_FORMAT, '24')
|
||||
if isinstance(time_format, int):
|
||||
@@ -1067,11 +1184,22 @@ class SettingsView(QDialog):
|
||||
NOTIF_OVERTIME: self.overtime_notification_check.isChecked(),
|
||||
NOTIF_HEALTH: self.health_notification_check.isChecked(),
|
||||
NOTIFICATION_BEFORE_MINUTES: self.notif_before_spin.value(),
|
||||
# 고급 임계값
|
||||
LUNCH_REMINDER_HOURS: self.lunch_reminder_spin.value(),
|
||||
DINNER_REMINDER_HOURS: self.dinner_reminder_spin.value(),
|
||||
OVERTIME_THRESHOLD_HOURS: self.overtime_threshold_spin.value(),
|
||||
WEEKLY_HOURS_THRESHOLD: self.weekly_hours_spin.value(),
|
||||
HEALTH_CONSECUTIVE_OT_DAYS: self.health_consecutive_spin.value(),
|
||||
HEALTH_BREAK_HOURS: self.health_break_hours_spin.value(),
|
||||
TIME_FORMAT: self.time_format_combo.currentData(),
|
||||
OVERTIME_UNIT: self.overtime_unit_combo.currentData(),
|
||||
AUTO_OVERTIME: self.auto_overtime_check.isChecked(),
|
||||
ANNUAL_LEAVE_DAYS: self.annual_leave_days.value(),
|
||||
}
|
||||
if hasattr(self, 'dinner_notification_check'):
|
||||
settings[NOTIF_DINNER] = self.dinner_notification_check.isChecked()
|
||||
if hasattr(self, 'health_break_notification_check'):
|
||||
settings[HEALTH_BREAK_ENABLED] = self.health_break_notification_check.isChecked()
|
||||
if hasattr(self, 'auto_break_check'):
|
||||
settings[AUTO_BREAK_ON_LOCK] = self.auto_break_check.isChecked()
|
||||
if hasattr(self, 'clock_in_unlock_check'):
|
||||
@@ -1243,7 +1371,7 @@ class SettingsView(QDialog):
|
||||
|
||||
if filename:
|
||||
try:
|
||||
saved_path = CSVExporter.export_work_records(records, filename)
|
||||
saved_path = CSVExporter.export_work_records(records, filename, db=self.db)
|
||||
QMessageBox.information(
|
||||
self,
|
||||
"내보내기 완료",
|
||||
|
||||
+142
-92
@@ -2,7 +2,8 @@
|
||||
통계 대시보드 - 주간/월간 통계
|
||||
"""
|
||||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QGroupBox, QGridLayout, QTabWidget, QWidget)
|
||||
QPushButton, QGroupBox, QGridLayout, QTabWidget, QWidget,
|
||||
QFrame)
|
||||
from PyQt5.QtCore import Qt
|
||||
from datetime import datetime, timedelta
|
||||
import sys
|
||||
@@ -12,6 +13,10 @@ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
from core.database import Database
|
||||
from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
from ui.dark_components import (
|
||||
dialog_qss, tabs_qss, button_qss, build_stat_card, build_section_card,
|
||||
transparent_label, ACCENT_GOLD, ACCENT_GREEN, DARK_TEXT, DARK_TEXT_DIM,
|
||||
)
|
||||
|
||||
|
||||
class StatsView(QDialog):
|
||||
@@ -22,72 +27,91 @@ class StatsView(QDialog):
|
||||
self.db = db if db else Database()
|
||||
self.init_ui()
|
||||
self.load_stats()
|
||||
apply_dark_titlebar(self)
|
||||
apply_dark_titlebar(self, dark=True)
|
||||
|
||||
def init_ui(self):
|
||||
"""UI 초기화"""
|
||||
self.setWindowTitle(tr('window.stats'))
|
||||
self.setModal(True)
|
||||
self.setMinimumSize(420, 350)
|
||||
self.setMinimumSize(720, 600)
|
||||
self.resize(900, 720)
|
||||
self.setStyleSheet(dialog_qss())
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(6)
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(20, 16, 20, 14)
|
||||
|
||||
title = QLabel(tr('stats.title'))
|
||||
title.setObjectName("dialog_title")
|
||||
title.setAlignment(Qt.AlignCenter)
|
||||
# 다크 톤 타이틀
|
||||
title = QLabel(f"📊 {tr('stats.title')}")
|
||||
title.setStyleSheet(
|
||||
f"font-size: 18pt; font-weight: bold; color: {DARK_TEXT}; "
|
||||
f"background: transparent; border: none; padding: 4px 0;"
|
||||
)
|
||||
layout.addWidget(title)
|
||||
|
||||
tabs = QTabWidget()
|
||||
tabs.setStyleSheet(tabs_qss())
|
||||
tabs.addTab(self.create_weekly_tab(), tr('stats.tab_weekly'))
|
||||
tabs.addTab(self.create_monthly_tab(), tr('stats.tab_monthly'))
|
||||
tabs.addTab(self.create_pattern_tab(), tr('stats.tab_pattern'))
|
||||
layout.addWidget(tabs)
|
||||
# 도전과제용 탭 진입 카운터
|
||||
tabs.currentChanged.connect(self._on_tab_changed)
|
||||
self._on_tab_changed(0)
|
||||
layout.addWidget(tabs, 1)
|
||||
|
||||
# 닫기 버튼 — 우측 정렬
|
||||
btn_row = QHBoxLayout()
|
||||
btn_row.addStretch()
|
||||
close_button = QPushButton(tr('btn.close'))
|
||||
close_button.setMinimumWidth(100)
|
||||
close_button.setStyleSheet(button_qss('ghost'))
|
||||
close_button.clicked.connect(self.close)
|
||||
layout.addWidget(close_button)
|
||||
btn_row.addWidget(close_button)
|
||||
layout.addLayout(btn_row)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def _on_tab_changed(self, idx: int) -> None:
|
||||
"""탭별 진입 카운터 (도전과제 시스템용). 실패는 silent."""
|
||||
keys = ['stat_weekly_view_count', 'stat_monthly_view_count',
|
||||
'stat_pattern_view_count']
|
||||
if 0 <= idx < len(keys):
|
||||
try:
|
||||
cur = self.db.get_setting_int(keys[idx], 0)
|
||||
self.db.set_setting(keys[idx], str(cur + 1))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def create_weekly_tab(self) -> QWidget:
|
||||
"""주간 통계 탭 생성"""
|
||||
widget = QWidget()
|
||||
widget.setStyleSheet("background: transparent;")
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(6)
|
||||
layout.setContentsMargins(4, 4, 4, 4)
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(8, 12, 8, 8)
|
||||
|
||||
summary_group = QGroupBox(tr('stats.weekly_summary'))
|
||||
summary_layout = QGridLayout()
|
||||
summary_layout.setSpacing(4)
|
||||
summary_layout.setContentsMargins(8, 20, 8, 6)
|
||||
# 카드 4개 가로 배치 (총근무 / 출근일 / 평균 / 연장)
|
||||
cards_row = QHBoxLayout()
|
||||
cards_row.setSpacing(10)
|
||||
self.weekly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 주",
|
||||
theme='blue', icon='⏱️')
|
||||
self.weekly_days_card = build_stat_card("근무 일수", "0일", "이번 주",
|
||||
theme='cyan', icon='📅')
|
||||
self.weekly_avg_card = build_stat_card("일평균", "0시간", "이번 주",
|
||||
theme='green', icon='📊')
|
||||
self.weekly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 주",
|
||||
theme='gold', icon='🔥')
|
||||
for c in (self.weekly_total_card, self.weekly_days_card,
|
||||
self.weekly_avg_card, self.weekly_ot_card):
|
||||
cards_row.addWidget(c, 1)
|
||||
layout.addLayout(cards_row)
|
||||
|
||||
self.weekly_total_hours = QLabel("0")
|
||||
self.weekly_total_hours.setObjectName("stat_value")
|
||||
self.weekly_work_days = QLabel("0")
|
||||
self.weekly_work_days.setObjectName("stat_value")
|
||||
self.weekly_avg_hours = QLabel("0")
|
||||
self.weekly_avg_hours.setObjectName("stat_value")
|
||||
self.weekly_overtime = QLabel("0")
|
||||
self.weekly_overtime.setObjectName("stat_value")
|
||||
|
||||
summary_layout.addWidget(QLabel(tr('stats.total_hours')), 0, 0)
|
||||
summary_layout.addWidget(self.weekly_total_hours, 0, 1)
|
||||
summary_layout.addWidget(QLabel(tr('stats.work_days')), 1, 0)
|
||||
summary_layout.addWidget(self.weekly_work_days, 1, 1)
|
||||
summary_layout.addWidget(QLabel(tr('stats.avg_hours')), 2, 0)
|
||||
summary_layout.addWidget(self.weekly_avg_hours, 2, 1)
|
||||
summary_layout.addWidget(QLabel(tr('stats.total_overtime')), 3, 0)
|
||||
summary_layout.addWidget(self.weekly_overtime, 3, 1)
|
||||
|
||||
summary_group.setLayout(summary_layout)
|
||||
layout.addWidget(summary_group)
|
||||
|
||||
# 주간 차트 (일별 근무시간)
|
||||
# 주간 차트 (일별 근무시간) — 카드 안에
|
||||
from ui.chart_widget import make_chart_widget
|
||||
self.weekly_chart = make_chart_widget(widget)
|
||||
layout.addWidget(self.weekly_chart, 1)
|
||||
chart_card = build_section_card("일별 근무 시간", self.weekly_chart,
|
||||
theme='gray', icon='📈')
|
||||
layout.addWidget(chart_card, 1)
|
||||
|
||||
widget.setLayout(layout)
|
||||
return widget
|
||||
@@ -95,40 +119,36 @@ class StatsView(QDialog):
|
||||
def create_monthly_tab(self) -> QWidget:
|
||||
"""월간 통계 탭 생성"""
|
||||
widget = QWidget()
|
||||
widget.setStyleSheet("background: transparent;")
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(6)
|
||||
layout.setContentsMargins(4, 4, 4, 4)
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(8, 12, 8, 8)
|
||||
|
||||
# 추정 급여 카드 (옵션 활성 시)
|
||||
# 카드 4개
|
||||
cards_row = QHBoxLayout()
|
||||
cards_row.setSpacing(10)
|
||||
self.monthly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 달",
|
||||
theme='blue', icon='⏱️')
|
||||
self.monthly_days_card = build_stat_card("근무 일수", "0일", "이번 달",
|
||||
theme='cyan', icon='📅')
|
||||
self.monthly_avg_card = build_stat_card("일평균", "0시간", "이번 달",
|
||||
theme='green', icon='📊')
|
||||
self.monthly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 달",
|
||||
theme='gold', icon='🔥')
|
||||
for c in (self.monthly_total_card, self.monthly_days_card,
|
||||
self.monthly_avg_card, self.monthly_ot_card):
|
||||
cards_row.addWidget(c, 1)
|
||||
layout.addLayout(cards_row)
|
||||
|
||||
# 추정 급여 (옵션 활성 시)
|
||||
self.salary_label = QLabel("")
|
||||
self.salary_label.setStyleSheet("font-weight: bold; color: #4caf50; padding: 6px;")
|
||||
self.salary_label.setStyleSheet(
|
||||
f"background: rgba(74, 222, 128, 0.12); "
|
||||
f"border: 1px solid {ACCENT_GREEN}; border-radius: 8px; "
|
||||
f"color: {ACCENT_GREEN}; font-weight: bold; "
|
||||
f"padding: 10px 14px; font-size: 11pt;"
|
||||
)
|
||||
self.salary_label.setVisible(False)
|
||||
|
||||
summary_group = QGroupBox(tr('stats.monthly_summary'))
|
||||
summary_layout = QGridLayout()
|
||||
summary_layout.setSpacing(4)
|
||||
summary_layout.setContentsMargins(8, 20, 8, 6)
|
||||
|
||||
self.monthly_total_hours = QLabel("0")
|
||||
self.monthly_total_hours.setObjectName("stat_value")
|
||||
self.monthly_work_days = QLabel("0")
|
||||
self.monthly_work_days.setObjectName("stat_value")
|
||||
self.monthly_avg_hours = QLabel("0")
|
||||
self.monthly_avg_hours.setObjectName("stat_value")
|
||||
self.monthly_overtime = QLabel("0")
|
||||
self.monthly_overtime.setObjectName("stat_value")
|
||||
|
||||
summary_layout.addWidget(QLabel(tr('stats.total_hours')), 0, 0)
|
||||
summary_layout.addWidget(self.monthly_total_hours, 0, 1)
|
||||
summary_layout.addWidget(QLabel(tr('stats.work_days')), 1, 0)
|
||||
summary_layout.addWidget(self.monthly_work_days, 1, 1)
|
||||
summary_layout.addWidget(QLabel(tr('stats.avg_hours')), 2, 0)
|
||||
summary_layout.addWidget(self.monthly_avg_hours, 2, 1)
|
||||
summary_layout.addWidget(QLabel(tr('stats.total_overtime')), 3, 0)
|
||||
summary_layout.addWidget(self.monthly_overtime, 3, 1)
|
||||
|
||||
summary_group.setLayout(summary_layout)
|
||||
layout.addWidget(summary_group)
|
||||
layout.addWidget(self.salary_label)
|
||||
|
||||
# 목표 진행률
|
||||
@@ -139,7 +159,9 @@ class StatsView(QDialog):
|
||||
# 월간 차트
|
||||
from ui.chart_widget import make_chart_widget
|
||||
self.monthly_chart = make_chart_widget(widget)
|
||||
layout.addWidget(self.monthly_chart, 1)
|
||||
chart_card = build_section_card("요일별 평균", self.monthly_chart,
|
||||
theme='gray', icon='📊')
|
||||
layout.addWidget(chart_card, 1)
|
||||
|
||||
widget.setLayout(layout)
|
||||
return widget
|
||||
@@ -147,46 +169,71 @@ class StatsView(QDialog):
|
||||
def create_pattern_tab(self) -> QWidget:
|
||||
"""패턴 분석 탭 생성"""
|
||||
widget = QWidget()
|
||||
widget.setStyleSheet("background: transparent;")
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(6)
|
||||
layout.setContentsMargins(4, 4, 4, 4)
|
||||
|
||||
pattern_group = QGroupBox(tr('stats.pattern_insights'))
|
||||
pattern_layout = QVBoxLayout()
|
||||
pattern_layout.setSpacing(4)
|
||||
pattern_layout.setContentsMargins(8, 20, 8, 6)
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(8, 12, 8, 8)
|
||||
|
||||
# 패턴 텍스트 카드
|
||||
self.pattern_text = QLabel(tr('stats.analyzing'))
|
||||
self.pattern_text.setWordWrap(True)
|
||||
self.pattern_text.setAlignment(Qt.AlignTop | Qt.AlignLeft)
|
||||
|
||||
pattern_layout.addWidget(self.pattern_text)
|
||||
|
||||
pattern_group.setLayout(pattern_layout)
|
||||
layout.addWidget(pattern_group)
|
||||
self.pattern_text.setStyleSheet(
|
||||
f"font-size: 11pt; color: {DARK_TEXT}; "
|
||||
f"background: transparent; border: none; padding: 4px 0;"
|
||||
)
|
||||
layout.addWidget(build_section_card("패턴 인사이트", self.pattern_text,
|
||||
theme='cyan', icon='🔍'))
|
||||
|
||||
# 출근 시각 분포 차트
|
||||
from ui.chart_widget import make_chart_widget
|
||||
self.clock_in_chart = make_chart_widget(widget)
|
||||
layout.addWidget(self.clock_in_chart, 1)
|
||||
layout.addWidget(build_section_card("출근 시각 분포", self.clock_in_chart,
|
||||
theme='gray', icon='⏰'), 1)
|
||||
|
||||
widget.setLayout(layout)
|
||||
return widget
|
||||
|
||||
def _set_card_value(self, card, value: str) -> None:
|
||||
"""build_stat_card로 만든 카드의 큰 숫자 라벨 업데이트.
|
||||
|
||||
카드 구조: QFrame > QHBoxLayout > [icon QLabel] [text QVBoxLayout > title, value, subtitle]
|
||||
value는 두 번째 QLabel.
|
||||
"""
|
||||
# text_box는 outer hbox의 마지막 layout
|
||||
outer = card.layout()
|
||||
if outer is None or outer.count() == 0:
|
||||
return
|
||||
# text_box 찾기 (마지막 item, layout)
|
||||
text_item = outer.itemAt(outer.count() - 1)
|
||||
text_box = text_item.layout() if text_item else None
|
||||
if text_box is None or text_box.count() < 2:
|
||||
return
|
||||
val_lbl = text_box.itemAt(1).widget() # 두 번째가 큰 숫자
|
||||
if val_lbl is None:
|
||||
return
|
||||
# 큰 숫자 RichText 형식 유지
|
||||
from ui.dark_components import CARD_THEMES
|
||||
# tier color는 카드 자체에 알 방법이 없으니 기본 골드 톤
|
||||
val_lbl.setText(
|
||||
f"<span style='font-size: 18pt; font-weight: bold; color: #ffd24a;'>"
|
||||
f"{value}</span>"
|
||||
)
|
||||
|
||||
def load_stats(self):
|
||||
"""통계 로드"""
|
||||
# 주간 통계
|
||||
weekly_stats = self.db.get_weekly_stats()
|
||||
total_hours = weekly_stats.get('total_hours', 0) or 0
|
||||
self.weekly_total_hours.setText(f"{total_hours:.1f}시간")
|
||||
self.weekly_work_days.setText(f"{weekly_stats.get('work_days', 0)}일")
|
||||
self._set_card_value(self.weekly_total_card, f"{total_hours:.1f}시간")
|
||||
self._set_card_value(self.weekly_days_card, f"{weekly_stats.get('work_days', 0)}일")
|
||||
avg_hours = weekly_stats.get('avg_hours_per_day', 0) or 0
|
||||
self.weekly_avg_hours.setText(f"{avg_hours:.1f}시간")
|
||||
self._set_card_value(self.weekly_avg_card, f"{avg_hours:.1f}시간")
|
||||
|
||||
overtime_minutes = weekly_stats.get('total_overtime_minutes', 0) or 0
|
||||
overtime_hours = overtime_minutes // 60
|
||||
overtime_mins = overtime_minutes % 60
|
||||
self.weekly_overtime.setText(f"{overtime_hours}시간 {overtime_mins}분")
|
||||
self._set_card_value(self.weekly_ot_card, f"{overtime_hours}시간 {overtime_mins}분")
|
||||
|
||||
# 주간 차트
|
||||
from ui.chart_widget import draw_daily_hours, draw_weekday_avg
|
||||
@@ -196,26 +243,29 @@ class StatsView(QDialog):
|
||||
(today - _td(days=6)).isoformat(), today.isoformat()
|
||||
)
|
||||
if hasattr(self, 'weekly_chart'):
|
||||
# 도전과제 chart_hover 감지를 위해 db 참조 attach
|
||||
self.weekly_chart._achievement_db = self.db
|
||||
draw_daily_hours(self.weekly_chart, week_records)
|
||||
|
||||
# 월간 통계
|
||||
now = datetime.now()
|
||||
monthly_stats = self.db.get_monthly_stats(now.year, now.month)
|
||||
total_hours = monthly_stats.get('total_hours', 0) or 0
|
||||
self.monthly_total_hours.setText(f"{total_hours:.1f}시간")
|
||||
self._set_card_value(self.monthly_total_card, f"{total_hours:.1f}시간")
|
||||
work_days = monthly_stats.get('work_days', 0) or 0
|
||||
self.monthly_work_days.setText(f"{work_days}일")
|
||||
self._set_card_value(self.monthly_days_card, f"{work_days}일")
|
||||
|
||||
if work_days > 0:
|
||||
avg = total_hours / work_days
|
||||
self.monthly_avg_hours.setText(f"{avg:.1f}시간")
|
||||
self._set_card_value(self.monthly_avg_card, f"{avg:.1f}시간")
|
||||
else:
|
||||
self.monthly_avg_hours.setText("0시간")
|
||||
self._set_card_value(self.monthly_avg_card, "0시간")
|
||||
|
||||
overtime_minutes = monthly_stats.get('total_overtime_minutes', 0) or 0
|
||||
overtime_hours = overtime_minutes // 60
|
||||
overtime_mins = overtime_minutes % 60
|
||||
self.monthly_overtime.setText(f"{overtime_hours}시간 {overtime_mins}분")
|
||||
self._set_card_value(self.monthly_ot_card,
|
||||
f"{overtime_hours}시간 {overtime_mins}분")
|
||||
|
||||
# 월간 차트 (요일별 평균)
|
||||
if hasattr(self, 'monthly_chart'):
|
||||
|
||||
+6
-2
@@ -55,16 +55,18 @@ class TodaySummaryCard(QFrame):
|
||||
|
||||
def show_summary(self, total_hours: float, lunch_minutes: int,
|
||||
break_minutes: int, overtime_actual: int,
|
||||
overtime_earned: int, salary_text: str = "") -> None:
|
||||
overtime_earned: int, salary_text: str = "",
|
||||
dinner_minutes: int = 0) -> None:
|
||||
"""카드 내용 채우고 표시.
|
||||
|
||||
Args:
|
||||
total_hours: 총 근무시간(시간)
|
||||
lunch_minutes: 점심 시간(분)
|
||||
break_minutes: 외출 시간(분)
|
||||
break_minutes: 외출 시간(분, 식사 제외)
|
||||
overtime_actual: 실제 연장근무(분)
|
||||
overtime_earned: 적립 연장근무(분)
|
||||
salary_text: 추정 급여 표시 문자열 (옵션 활성 시)
|
||||
dinner_minutes: 저녁 시간(분), 0이면 표시 안 함
|
||||
"""
|
||||
h = int(total_hours)
|
||||
m = int((total_hours - h) * 60)
|
||||
@@ -73,6 +75,8 @@ class TodaySummaryCard(QFrame):
|
||||
details = []
|
||||
if lunch_minutes > 0:
|
||||
details.append(f"점심 {lunch_minutes}분")
|
||||
if dinner_minutes > 0:
|
||||
details.append(f"저녁 {dinner_minutes}분")
|
||||
if break_minutes > 0:
|
||||
details.append(f"외출 {break_minutes}분")
|
||||
if overtime_actual > 0:
|
||||
|
||||
Reference in New Issue
Block a user