KINDNICK 9ebf4ad961 v2.4.0: Phase 2 — meal time, past records, goals, CSV import, crash report
- Meal time dialog (right-click lunch/dinner button to enter actual times)
- Calendar right-click context: add/edit/delete past records
- Monthly goal settings + progress widget (overtime cap, avg daily)
- CSV import (our standard format) with conflict policy
- Global crash handler with Gitea Issues auto-report

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-30 18:38:38 +09:00

326 lines
12 KiB
Python

"""
통계 대시보드 - 주간/월간 통계
"""
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QGroupBox, QGridLayout, QTabWidget, QWidget)
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
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(420, 350)
layout = QVBoxLayout()
layout.setSpacing(6)
layout.setContentsMargins(12, 10, 12, 10)
title = QLabel(tr('stats.title'))
title.setObjectName("dialog_title")
title.setAlignment(Qt.AlignCenter)
layout.addWidget(title)
tabs = QTabWidget()
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)
close_button = QPushButton(tr('btn.close'))
close_button.clicked.connect(self.close)
layout.addWidget(close_button)
self.setLayout(layout)
def create_weekly_tab(self) -> QWidget:
"""주간 통계 탭 생성"""
widget = QWidget()
layout = QVBoxLayout()
layout.setSpacing(6)
layout.setContentsMargins(4, 4, 4, 4)
summary_group = QGroupBox(tr('stats.weekly_summary'))
summary_layout = QGridLayout()
summary_layout.setSpacing(4)
summary_layout.setContentsMargins(8, 20, 8, 6)
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)
widget.setLayout(layout)
return widget
def create_monthly_tab(self) -> QWidget:
"""월간 통계 탭 생성"""
widget = QWidget()
layout = QVBoxLayout()
layout.setSpacing(6)
layout.setContentsMargins(4, 4, 4, 4)
# 추정 급여 카드 (옵션 활성 시)
self.salary_label = QLabel("")
self.salary_label.setStyleSheet("font-weight: bold; color: #4caf50; padding: 6px;")
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)
# 목표 진행률
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)
layout.addWidget(self.monthly_chart, 1)
widget.setLayout(layout)
return widget
def create_pattern_tab(self) -> QWidget:
"""패턴 분석 탭 생성"""
widget = QWidget()
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)
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)
layout.addStretch()
widget.setLayout(layout)
return widget
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)}")
avg_hours = weekly_stats.get('avg_hours_per_day', 0) or 0
self.weekly_avg_hours.setText(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}")
# 주간 차트
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'):
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}시간")
work_days = monthly_stats.get('work_days', 0) or 0
self.monthly_work_days.setText(f"{work_days}")
if work_days > 0:
avg = total_hours / work_days
self.monthly_avg_hours.setText(f"{avg:.1f}시간")
else:
self.monthly_avg_hours.setText("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}")
# 월간 차트 (요일별 평균)
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 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_()