""" 통계 대시보드 - 주간/월간 통계 """ 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"" f"{value}" ) 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_()