v2.5.0: Phase 3 — weekly report, chart hover, clock-in distribution, leave calendar
- 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) <noreply@anthropic.com>
This commit is contained in:
parent
9ebf4ad961
commit
606da976a0
18
CHANGELOG.md
18
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종)
|
||||
|
||||
@ -4,4 +4,4 @@
|
||||
릴리스 시 이 값을 올린 후 git tag → push.
|
||||
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
||||
"""
|
||||
__version__ = '2.4.0'
|
||||
__version__ = '2.5.0'
|
||||
|
||||
@ -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()
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
112
ui/leave_calendar_view.py
Normal file
112
ui/leave_calendar_view.py
Normal file
@ -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))
|
||||
@ -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):
|
||||
"""데이터 로드"""
|
||||
# 잔액 업데이트
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user