From 606da976a0faee4bca3f9fb74f33cbb39cd77cb7 Mon Sep 17 00:00:00 2001 From: KINDNICK Date: Thu, 30 Apr 2026 18:51:47 +0900 Subject: [PATCH] =?UTF-8?q?v2.5.0:=20Phase=203=20=E2=80=94=20weekly=20repo?= =?UTF-8?q?rt,=20chart=20hover,=20clock-in=20distribution,=20leave=20calen?= =?UTF-8?q?dar?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Weekly auto report on Monday (system alert + Discord push, dedupe) - Matplotlib chart hover annotation for daily hours - Clock-in time distribution histogram (30-min bins) with avg line - Leave usage calendar with color-coded full/half/quarter days Fixed: leave_view setLayout indentation regression Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 18 ++++ core/version.py | 2 +- ui/chart_widget.py | 95 +++++++++++++++-- ui/controllers/notification_orchestrator.py | 57 +++++++++- ui/leave_calendar_view.py | 112 ++++++++++++++++++++ ui/leave_view.py | 10 ++ ui/stats_view.py | 11 +- 7 files changed, 294 insertions(+), 11 deletions(-) create mode 100644 ui/leave_calendar_view.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 7defee9..c2d5977 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,24 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [2.5.0] — 2026-04-30 + +### Added — Phase 3 (4종) +- **주간 자동 리포트** — 월요일 첫 출근 시 (또는 첫 5분 tick) 지난주 요약 발송 + - 시스템 알림 + Discord push (옵션) 동시 + - `notification_log`로 중복 발송 방지 + - 항목: 총 근무·일평균·연장근무·가장 긴 날 +- **matplotlib 차트 호버 디테일** — 막대 위에 마우스 올리면 정확한 수치 툴팁 + - 일별 근무 시간 차트(주간 탭)에 적용 +- **출근 시각 분포 차트** — 패턴 분석 탭에 30분 단위 히스토그램 + - 평균 출근 시각 빨간 점선으로 표시 +- **휴가 캘린더 시각화** — 연차 관리 → "📅 캘린더 보기" + - 사용 일자에 종일/반차/반반차별 색상 표시 + - 날짜 클릭 → 사용 내역 표시 + +### Fixed +- `leave_view.py` setLayout 들여쓰기 회귀 수정 + ## [2.4.0] — 2026-04-30 ### Added — Phase 2 (5종) diff --git a/core/version.py b/core/version.py index 25a0759..1b6e4f8 100644 --- a/core/version.py +++ b/core/version.py @@ -4,4 +4,4 @@ 릴리스 시 이 값을 올린 후 git tag → push. CHANGELOG.md의 최상단 항목과 일치시킬 것. """ -__version__ = '2.4.0' +__version__ = '2.5.0' diff --git a/ui/chart_widget.py b/ui/chart_widget.py index abe7bc1..7c12e05 100644 --- a/ui/chart_widget.py +++ b/ui/chart_widget.py @@ -50,12 +50,7 @@ def make_chart_widget(parent=None) -> QWidget: def draw_daily_hours(widget: QWidget, records: List[dict]) -> None: - """일별 근무시간 막대 그래프. - - Args: - widget: make_chart_widget()로 만든 위젯 - records: [{date, total_hours, overtime_minutes}, ...] - """ + """일별 근무시간 막대 그래프 (호버 시 정확한 수치 툴팁).""" if not getattr(widget, '_figure', None): return fig = widget._figure @@ -67,17 +62,101 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None: return dates = [r['date'][5:] for r in records] # MM-DD만 + full_dates = [r['date'] for r in records] hours = [r.get('total_hours', 0) or 0 for r in records] overtimes = [(r.get('overtime_minutes', 0) or 0) / 60 for r in records] base = [max(h - o, 0) for h, o in zip(hours, overtimes)] ax = fig.add_subplot(111) - ax.bar(dates, base, label='정상', color='#4a90e2') - ax.bar(dates, overtimes, bottom=base, label='연장', color='#ff6b6b') + bars_base = ax.bar(dates, base, label='정상', color='#4a90e2') + bars_ot = ax.bar(dates, overtimes, bottom=base, label='연장', color='#ff6b6b') ax.set_ylabel('시간') ax.legend(loc='upper left', fontsize=8) ax.tick_params(axis='x', labelrotation=45, labelsize=8) ax.grid(axis='y', alpha=0.3) + + # 호버 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), + color="white", fontsize=9, + arrowprops=dict(arrowstyle="->", color="#888"), + ) + annot.set_visible(False) + + def on_hover(event): + if event.inaxes != ax: + if annot.get_visible(): + annot.set_visible(False) + widget._canvas.draw_idle() + return + for bars, kind in ((bars_base, 'base'), (bars_ot, 'ot')): + for i, bar in enumerate(bars): + if bar.contains(event)[0]: + h = hours[i]; ot = overtimes[i] + text = f"▼ {full_dates[i]}\n근무 {h:.1f}h" + if ot > 0: + text += f"\n연장 +{ot:.1f}h" + annot.xy = (bar.get_x() + bar.get_width() / 2, bar.get_height() + bar.get_y()) + annot.set_text(text) + annot.set_visible(True) + widget._canvas.draw_idle() + return + if annot.get_visible(): + annot.set_visible(False) + widget._canvas.draw_idle() + + widget._canvas.mpl_connect("motion_notify_event", on_hover) + widget._canvas.draw() + + +def draw_clock_in_distribution(widget: QWidget, records: List[dict]) -> None: + """출근 시각 분포 히스토그램 (30분 빈).""" + if not getattr(widget, '_figure', None): + return + fig = widget._figure + fig.clear() + if not records: + ax = fig.add_subplot(111) + ax.text(0.5, 0.5, '기록 없음', ha='center', va='center', transform=ax.transAxes) + widget._canvas.draw() + return + + # 출근 시각을 분 단위로 (00:00=0) + minutes_list = [] + for r in records: + ci = r.get('clock_in') + if not ci: + continue + parts = ci.split(':') + if len(parts) >= 2: + try: + minutes_list.append(int(parts[0]) * 60 + int(parts[1])) + except ValueError: + pass + + if not minutes_list: + 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') + avg = sum(minutes_list) / len(minutes_list) + ax.axvline(avg, color='#ff6b6b', linestyle='--', + 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) widget._canvas.draw() diff --git a/ui/controllers/notification_orchestrator.py b/ui/controllers/notification_orchestrator.py index 41e486b..1c164e5 100644 --- a/ui/controllers/notification_orchestrator.py +++ b/ui/controllers/notification_orchestrator.py @@ -17,6 +17,58 @@ class NotificationOrchestrator: self.notifier = window.notifier self._last_5min_bucket: int | None = None # now.minute (5의 배수일 때만) + def maybe_send_weekly_report(self, now: datetime) -> None: + """월요일 첫 update_display 호출 시 지난주 요약 발송 (시스템 + Discord). + + notification_log로 중복 가드. 월요일이 아니거나 이미 보냈으면 no-op. + """ + if now.weekday() != 0: # 0=월요일 + return + if self.db.has_notification_today('system', 'weekly_report'): + return + # 지난주 데이터 (월~일) + from datetime import timedelta as _td + last_mon = now.date() - _td(days=7) + last_sun = now.date() - _td(days=1) + records = self.db.get_work_records_by_range(last_mon.isoformat(), last_sun.isoformat()) + closed = [r for r in records if r.get('clock_out')] + if not closed: + return # 지난주 기록 없음 + total_h = sum((r.get('total_hours') or 0) for r in closed) + ot_total = sum((r.get('overtime_minutes') or 0) for r in closed) + ot_h, ot_m = ot_total // 60, ot_total % 60 + avg_h = total_h / len(closed) if closed else 0 + longest = max(closed, key=lambda r: r.get('total_hours') or 0) + longest_str = f"{longest['date']} ({longest.get('total_hours', 0):.1f}h)" + + title = "📊 지난주 요약" + body = (f"기간: {last_mon} ~ {last_sun}\n" + f"총 근무: {total_h:.1f}시간 ({len(closed)}일)\n" + f"일 평균: {avg_h:.1f}시간\n" + f"연장근무: {ot_h}시간 {ot_m}분\n" + f"가장 긴 날: {longest_str}") + self.notifier.notification_signal.emit(title, body) + self.db.log_notification('system', 'weekly_report') + + # Discord 도 옵션 활성 시 push + if self.db.get_setting('discord_notif_clock_out', 'true').lower() == 'true': + url = self.db.get_setting('discord_webhook_url', '') or '' + if url: + try: + from utils.discord_webhook import send, COLOR_BLUE + fields = [ + {"name": "총 근무", "value": f"{total_h:.1f}시간 ({len(closed)}일)", "inline": True}, + {"name": "일 평균", "value": f"{avg_h:.1f}시간", "inline": True}, + {"name": "연장근무", "value": f"{ot_h}시간 {ot_m}분", "inline": True}, + {"name": "가장 긴 날", "value": longest_str, "inline": False}, + ] + ok = send(url, "📊 지난주 요약", + f"기간: {last_mon} ~ {last_sun}", + color=COLOR_BLUE, fields=fields) + self.db.log_notification('discord', 'weekly_report', success=ok) + except Exception: + pass + def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float) -> None: n = self.notifier # 1초마다 체크: 30분 전, 점심 미등록, 연장 적립 @@ -26,10 +78,13 @@ class NotificationOrchestrator: if remaining_seconds < 0: n.check_overtime_earning(abs(int(remaining_seconds / 60))) - # 5분 간격 throttle: 건강/주간/누적/휴식권고 + # 5분 간격 throttle: 건강/주간/누적/휴식권고/주간리포트 if now.minute % 5 == 0 and self._last_5min_bucket != now.minute: self._last_5min_bucket = now.minute + # 월요일 첫 출근 시 지난주 리포트 + self.maybe_send_weekly_report(now) + # 휴식 권고 (장시간 연속 근무) break_minutes = self.db.get_total_break_minutes_today() n.check_health_break(self.window.clock_in_time, break_minutes, now) diff --git a/ui/leave_calendar_view.py b/ui/leave_calendar_view.py new file mode 100644 index 0000000..619bc38 --- /dev/null +++ b/ui/leave_calendar_view.py @@ -0,0 +1,112 @@ +""" +연차 사용 캘린더 시각화. + +QCalendarWidget에 사용 연차일을 색칠로 표시. +- 1.0일: 진한 색 +- 0.5일(반차): 중간 색 +- 0.25일(반반차): 옅은 색 +""" +from __future__ import annotations +from datetime import datetime +from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel, + QPushButton, QCalendarWidget) +from PyQt5.QtCore import Qt, QDate +from PyQt5.QtGui import QTextCharFormat, QColor, QBrush + +from ui.styles import apply_dark_titlebar + + +class LeaveCalendarView(QDialog): + """연차 캘린더 시각화.""" + + def __init__(self, parent=None, db=None): + super().__init__(parent) + self.db = db + self.setWindowTitle("📅 연차 캘린더") + self.setModal(True) + self.setMinimumSize(540, 480) + self._build_ui() + self._mark_dates() + apply_dark_titlebar(self) + + def _build_ui(self): + layout = QVBoxLayout() + + # 헤더: 잔여 + 범례 + header = QHBoxLayout() + balance = float(self.db.get_setting('leave_balance', '0') or 0) + total = float(self.db.get_setting('annual_leave_total', '15') or 15) + used = total - balance + title = QLabel(f"🌴 잔여 {balance:.2f}일 / 총 {total:.0f}일 (사용 {used:.2f}일)") + title.setStyleSheet("font-weight: bold; font-size: 13px;") + header.addWidget(title) + header.addStretch() + layout.addLayout(header) + + # 범례 + legend = QHBoxLayout() + for label, color in [("🟩 종일(1.0)", "#4caf50"), + ("🟨 반차(0.5)", "#ffc107"), + ("🟪 반반차(0.25)", "#9c27b0")]: + l = QLabel(label) + l.setStyleSheet(f"padding: 2px 6px;") + legend.addWidget(l) + legend.addStretch() + layout.addLayout(legend) + + # 캘린더 + self.calendar = QCalendarWidget() + self.calendar.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader) + self.calendar.clicked.connect(self._on_date_click) + layout.addWidget(self.calendar, 1) + + # 선택 일자 정보 + self.detail_label = QLabel("") + self.detail_label.setStyleSheet("padding: 6px; color: #888;") + layout.addWidget(self.detail_label) + + # 닫기 버튼 + btn_row = QHBoxLayout() + btn_row.addStretch() + close_btn = QPushButton("닫기") + close_btn.clicked.connect(self.close) + btn_row.addWidget(close_btn) + layout.addLayout(btn_row) + + self.setLayout(layout) + + def _mark_dates(self): + """연차 사용 일자에 색상 표시.""" + records = self.db.get_all_leave_records(limit=365) + for r in records: + try: + d = datetime.strptime(r['date'], '%Y-%m-%d').date() + except (ValueError, TypeError): + continue + qd = QDate(d.year, d.month, d.day) + days = float(r.get('days') or 0) + if days >= 1.0: + color = QColor("#4caf50") + elif days >= 0.5: + color = QColor("#ffc107") + else: + color = QColor("#9c27b0") + fmt = QTextCharFormat() + fmt.setBackground(QBrush(color)) + fmt.setForeground(QBrush(QColor("white"))) + self.calendar.setDateTextFormat(qd, fmt) + + def _on_date_click(self, qdate): + date_str = qdate.toString('yyyy-MM-dd') + records = self.db.get_all_leave_records(limit=365) + match = [r for r in records if r['date'] == date_str] + if not match: + self.detail_label.setText(f"{date_str} — 연차 사용 없음") + return + parts = [] + for r in match: + t = r.get('leave_type', 'annual') + d = float(r.get('days') or 0) + memo = r.get('memo') or '' + parts.append(f"{t} {d}일" + (f" ({memo})" if memo else "")) + self.detail_label.setText(f"📅 {date_str}: " + ", ".join(parts)) diff --git a/ui/leave_view.py b/ui/leave_view.py index 339b4e5..1fc6916 100644 --- a/ui/leave_view.py +++ b/ui/leave_view.py @@ -80,6 +80,11 @@ class LeaveView(QDialog): add_leave_button = QPushButton("➕ 연차 사용 추가") add_leave_button.clicked.connect(self.add_leave_record) button_layout.addWidget(add_leave_button) + + cal_button = QPushButton("📅 캘린더 보기") + cal_button.clicked.connect(self._show_calendar) + button_layout.addWidget(cal_button) + close_button = QPushButton("닫기") close_button.clicked.connect(self.close) button_layout.addWidget(close_button) @@ -87,6 +92,11 @@ class LeaveView(QDialog): self.setLayout(layout) + def _show_calendar(self): + from ui.leave_calendar_view import LeaveCalendarView + dlg = LeaveCalendarView(self, self.db) + dlg.exec_() + def load_data(self): """데이터 로드""" # 잔액 업데이트 diff --git a/ui/stats_view.py b/ui/stats_view.py index 7525cb4..862d773 100644 --- a/ui/stats_view.py +++ b/ui/stats_view.py @@ -165,7 +165,11 @@ class StatsView(QDialog): pattern_group.setLayout(pattern_layout) layout.addWidget(pattern_group) - layout.addStretch() + # 출근 시각 분포 차트 + from ui.chart_widget import make_chart_widget + self.clock_in_chart = make_chart_widget(widget) + layout.addWidget(self.clock_in_chart, 1) + widget.setLayout(layout) return widget @@ -253,6 +257,11 @@ class StatsView(QDialog): 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