- fix: main.exe에서 통계 차트 안 뜨던 문제 (backend_qt5agg→backend_qtagg 우선 import + spec 보강 + 실패 로깅) - fix: 통계/도움말/도전과제 + 차트가 라이트 테마에서도 다크 고정 → 현재 테마(ThemeColors) 추종 - dark_components/chart_widget를 테마 인식형으로 리팩터 (등급 카드·차트 막대 등 강조색은 유지) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
385 lines
16 KiB
Python
385 lines
16 KiB
Python
"""
|
|
통계 대시보드 - 주간/월간 통계
|
|
"""
|
|
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
|
QPushButton, QGroupBox, QGridLayout, QTabWidget, QWidget,
|
|
QFrame)
|
|
from PyQt5.QtCore import Qt
|
|
from datetime import datetime, timedelta
|
|
import sys
|
|
import os
|
|
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, tc,
|
|
)
|
|
|
|
|
|
class StatsView(QDialog):
|
|
"""통계 뷰 다이얼로그"""
|
|
|
|
def __init__(self, parent=None, db=None):
|
|
super().__init__(parent)
|
|
self.db = db if db else Database()
|
|
self.init_ui()
|
|
self.load_stats()
|
|
apply_dark_titlebar(self) # 현재 테마에 맞춰 타이틀바
|
|
|
|
def init_ui(self):
|
|
"""UI 초기화"""
|
|
self.setWindowTitle(tr('window.stats'))
|
|
self.setModal(True)
|
|
self.setMinimumSize(720, 600)
|
|
self.resize(900, 720)
|
|
self.setStyleSheet(dialog_qss())
|
|
|
|
layout = QVBoxLayout()
|
|
layout.setSpacing(10)
|
|
layout.setContentsMargins(20, 16, 20, 14)
|
|
|
|
# 다크 톤 타이틀
|
|
title = QLabel(f"{tr('stats.title')}")
|
|
title.setStyleSheet(
|
|
f"font-size: 18pt; font-weight: bold; color: {tc('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'))
|
|
# 도전과제용 탭 진입 카운터
|
|
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)
|
|
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(10)
|
|
layout.setContentsMargins(8, 12, 8, 8)
|
|
|
|
# 카드 4개 가로 배치 (총근무 / 출근일 / 평균 / 연장)
|
|
cards_row = QHBoxLayout()
|
|
cards_row.setSpacing(10)
|
|
self.weekly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 주",
|
|
theme='blue', icon='clock')
|
|
self.weekly_days_card = build_stat_card("근무 일수", "0일", "이번 주",
|
|
theme='cyan', icon='calendar')
|
|
self.weekly_avg_card = build_stat_card("일평균", "0시간", "이번 주",
|
|
theme='green', icon='chart')
|
|
self.weekly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 주",
|
|
theme='gold', icon='flame')
|
|
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)
|
|
|
|
# 주간 차트 (일별 근무시간) — 카드 안에
|
|
from ui.chart_widget import make_chart_widget
|
|
self.weekly_chart = make_chart_widget(widget)
|
|
chart_card = build_section_card("일별 근무 시간", self.weekly_chart,
|
|
theme='gray', icon='trending-up')
|
|
layout.addWidget(chart_card, 1)
|
|
|
|
widget.setLayout(layout)
|
|
return widget
|
|
|
|
def create_monthly_tab(self) -> QWidget:
|
|
"""월간 통계 탭 생성"""
|
|
widget = QWidget()
|
|
widget.setStyleSheet("background: transparent;")
|
|
layout = QVBoxLayout()
|
|
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='clock')
|
|
self.monthly_days_card = build_stat_card("근무 일수", "0일", "이번 달",
|
|
theme='cyan', icon='calendar')
|
|
self.monthly_avg_card = build_stat_card("일평균", "0시간", "이번 달",
|
|
theme='green', icon='chart')
|
|
self.monthly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 달",
|
|
theme='gold', icon='flame')
|
|
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(
|
|
f"background: rgba(81, 207, 102, 0.12); "
|
|
f"border: 1px solid {tc('green')}; border-radius: 8px; "
|
|
f"color: {tc('green')}; font-weight: bold; "
|
|
f"padding: 10px 14px; font-size: 11pt;"
|
|
)
|
|
self.salary_label.setVisible(False)
|
|
layout.addWidget(self.salary_label)
|
|
|
|
# 목표 진행률
|
|
from ui.goal_widget import GoalWidget
|
|
self.goal_widget = GoalWidget(self.db)
|
|
layout.addWidget(self.goal_widget)
|
|
|
|
# 월간 차트
|
|
from ui.chart_widget import make_chart_widget
|
|
self.monthly_chart = make_chart_widget(widget)
|
|
chart_card = build_section_card("요일별 평균", self.monthly_chart,
|
|
theme='gray', icon='chart')
|
|
layout.addWidget(chart_card, 1)
|
|
|
|
widget.setLayout(layout)
|
|
return widget
|
|
|
|
def create_pattern_tab(self) -> QWidget:
|
|
"""패턴 분석 탭 생성"""
|
|
widget = QWidget()
|
|
widget.setStyleSheet("background: transparent;")
|
|
layout = QVBoxLayout()
|
|
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)
|
|
self.pattern_text.setStyleSheet(
|
|
f"font-size: 11pt; color: {tc('text')}; "
|
|
f"background: transparent; border: none; padding: 4px 0;"
|
|
)
|
|
layout.addWidget(build_section_card("패턴 인사이트", self.pattern_text,
|
|
theme='cyan', icon='search'))
|
|
|
|
# 출근 시각 분포 차트
|
|
from ui.chart_widget import make_chart_widget
|
|
self.clock_in_chart = make_chart_widget(widget)
|
|
layout.addWidget(build_section_card("출근 시각 분포", self.clock_in_chart,
|
|
theme='gray', icon='clock'), 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._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._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._set_card_value(self.weekly_ot_card, f"{overtime_hours}시간 {overtime_mins}분")
|
|
|
|
# 주간 차트
|
|
from ui.chart_widget import draw_daily_hours, draw_weekday_avg
|
|
from datetime import timedelta as _td
|
|
today = datetime.now().date()
|
|
week_records = self.db.get_work_records_by_range(
|
|
(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._set_card_value(self.monthly_total_card, f"{total_hours:.1f}시간")
|
|
work_days = monthly_stats.get('work_days', 0) or 0
|
|
self._set_card_value(self.monthly_days_card, f"{work_days}일")
|
|
|
|
if work_days > 0:
|
|
avg = total_hours / work_days
|
|
self._set_card_value(self.monthly_avg_card, f"{avg:.1f}시간")
|
|
else:
|
|
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._set_card_value(self.monthly_ot_card,
|
|
f"{overtime_hours}시간 {overtime_mins}분")
|
|
|
|
# 월간 차트 (요일별 평균)
|
|
if hasattr(self, 'monthly_chart'):
|
|
draw_weekday_avg(self.monthly_chart, monthly_stats.get('records', []))
|
|
|
|
# 추정 급여 (옵션 활성 시)
|
|
self._update_salary_estimate(monthly_stats.get('records', []))
|
|
|
|
# 목표 진행률
|
|
if hasattr(self, 'goal_widget'):
|
|
self.goal_widget.refresh()
|
|
|
|
# 패턴 분석
|
|
self.analyze_patterns(monthly_stats.get('records', []))
|
|
|
|
def _update_salary_estimate(self, records):
|
|
"""월간 추정 급여 표시 (SALARY_ENABLED=true 일 때만)."""
|
|
if not hasattr(self, 'salary_label'):
|
|
return
|
|
if self.db.get_setting('salary_enabled', 'false').lower() != 'true':
|
|
self.salary_label.setVisible(False)
|
|
return
|
|
try:
|
|
wage = float(self.db.get_setting('hourly_wage', '0') or 0)
|
|
rate = float(self.db.get_setting('overtime_rate', '1.5') or 1.5)
|
|
except (ValueError, TypeError):
|
|
self.salary_label.setVisible(False)
|
|
return
|
|
if wage <= 0:
|
|
self.salary_label.setVisible(False)
|
|
return
|
|
from core.salary import estimate_pay, format_won
|
|
result = estimate_pay(records, wage, rate)
|
|
self.salary_label.setText(
|
|
f"💰 이번 달 추정 급여: {format_won(result['total'])} "
|
|
f"(기본 {format_won(result['base'])} + 연장 {format_won(result['overtime'])})"
|
|
)
|
|
self.salary_label.setVisible(True)
|
|
|
|
def analyze_patterns(self, records):
|
|
"""패턴 분석"""
|
|
# 출근 분포 차트는 데이터 유무와 무관하게 갱신 (빈 차트 표시)
|
|
if hasattr(self, 'clock_in_chart'):
|
|
from ui.chart_widget import draw_clock_in_distribution
|
|
draw_clock_in_distribution(self.clock_in_chart, records or [])
|
|
|
|
if not records:
|
|
self.pattern_text.setText(tr('stats.no_data'))
|
|
return
|
|
|
|
insights = []
|
|
|
|
# 평균 출근 시간
|
|
clock_in_times = []
|
|
for record in records:
|
|
if record.get('clock_in'):
|
|
try:
|
|
time_parts = record['clock_in'].split(':')
|
|
hour = int(time_parts[0])
|
|
minute = int(time_parts[1])
|
|
clock_in_times.append(hour * 60 + minute)
|
|
except (ValueError, IndexError):
|
|
continue
|
|
|
|
if clock_in_times:
|
|
avg_minutes = sum(clock_in_times) / len(clock_in_times)
|
|
avg_hour = int(avg_minutes // 60)
|
|
avg_min = int(avg_minutes % 60)
|
|
insights.append(f"📌 평균 출근시간: {avg_hour:02d}:{avg_min:02d}")
|
|
|
|
# 연장근무 빈도
|
|
overtime_days = len([r for r in records if (r.get('overtime_earned') or 0) > 0])
|
|
total_days = len([r for r in records if r.get('clock_out')])
|
|
|
|
if total_days > 0:
|
|
overtime_rate = (overtime_days / total_days) * 100
|
|
insights.append(f"📌 연장근무 빈도: {overtime_rate:.0f}% ({overtime_days}/{total_days}일)")
|
|
|
|
# 가장 긴 근무일
|
|
records_with_hours = [r for r in records if (r.get('total_hours') or 0) > 0]
|
|
if records_with_hours:
|
|
longest_work = max(records_with_hours, key=lambda x: x.get('total_hours', 0))
|
|
if longest_work.get('total_hours', 0) > 0:
|
|
insights.append(f"📌 최장 근무: {longest_work['date']} ({longest_work['total_hours']:.1f}시간)")
|
|
|
|
# 건강 경고
|
|
recent_records = records[-7:] # 최근 7일
|
|
consecutive_overtime = 0
|
|
max_consecutive = 0
|
|
|
|
for record in recent_records:
|
|
if (record.get('overtime_earned') or 0) > 0:
|
|
consecutive_overtime += 1
|
|
max_consecutive = max(max_consecutive, consecutive_overtime)
|
|
else:
|
|
consecutive_overtime = 0
|
|
|
|
if max_consecutive >= 3:
|
|
insights.append(f"⚠️ 최근 {max_consecutive}일 연속 연장근무 발생!")
|
|
|
|
# 주 52시간 체크
|
|
if len(recent_records) >= 7:
|
|
week_total = sum((r.get('total_hours') or 0) for r in recent_records[-7:])
|
|
if week_total > 52:
|
|
insights.append(f"🚨 주 52시간 초과: {week_total:.1f}시간")
|
|
|
|
self.pattern_text.setText("\n\n".join(insights) if insights else "패턴을 분석할 데이터가 부족합니다.")
|
|
|
|
|
|
# 테스트 코드
|
|
if __name__ == "__main__":
|
|
from PyQt5.QtWidgets import QApplication
|
|
|
|
app = QApplication(sys.argv)
|
|
dialog = StatsView()
|
|
dialog.exec_()
|