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/).
|
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
|
## [2.4.0] — 2026-04-30
|
||||||
|
|
||||||
### Added — Phase 2 (5종)
|
### Added — Phase 2 (5종)
|
||||||
|
|||||||
@ -4,4 +4,4 @@
|
|||||||
릴리스 시 이 값을 올린 후 git tag → push.
|
릴리스 시 이 값을 올린 후 git tag → push.
|
||||||
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
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:
|
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):
|
if not getattr(widget, '_figure', None):
|
||||||
return
|
return
|
||||||
fig = widget._figure
|
fig = widget._figure
|
||||||
@ -67,17 +62,101 @@ def draw_daily_hours(widget: QWidget, records: List[dict]) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
dates = [r['date'][5:] for r in records] # MM-DD만
|
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]
|
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]
|
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)]
|
base = [max(h - o, 0) for h, o in zip(hours, overtimes)]
|
||||||
|
|
||||||
ax = fig.add_subplot(111)
|
ax = fig.add_subplot(111)
|
||||||
ax.bar(dates, base, label='정상', color='#4a90e2')
|
bars_base = ax.bar(dates, base, label='정상', color='#4a90e2')
|
||||||
ax.bar(dates, overtimes, bottom=base, label='연장', color='#ff6b6b')
|
bars_ot = ax.bar(dates, overtimes, bottom=base, label='연장', color='#ff6b6b')
|
||||||
ax.set_ylabel('시간')
|
ax.set_ylabel('시간')
|
||||||
ax.legend(loc='upper left', fontsize=8)
|
ax.legend(loc='upper left', fontsize=8)
|
||||||
ax.tick_params(axis='x', labelrotation=45, labelsize=8)
|
ax.tick_params(axis='x', labelrotation=45, labelsize=8)
|
||||||
ax.grid(axis='y', alpha=0.3)
|
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()
|
widget._canvas.draw()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -17,6 +17,58 @@ class NotificationOrchestrator:
|
|||||||
self.notifier = window.notifier
|
self.notifier = window.notifier
|
||||||
self._last_5min_bucket: int | None = None # now.minute (5의 배수일 때만)
|
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:
|
def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float) -> None:
|
||||||
n = self.notifier
|
n = self.notifier
|
||||||
# 1초마다 체크: 30분 전, 점심 미등록, 연장 적립
|
# 1초마다 체크: 30분 전, 점심 미등록, 연장 적립
|
||||||
@ -26,10 +78,13 @@ class NotificationOrchestrator:
|
|||||||
if remaining_seconds < 0:
|
if remaining_seconds < 0:
|
||||||
n.check_overtime_earning(abs(int(remaining_seconds / 60)))
|
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:
|
if now.minute % 5 == 0 and self._last_5min_bucket != now.minute:
|
||||||
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()
|
break_minutes = self.db.get_total_break_minutes_today()
|
||||||
n.check_health_break(self.window.clock_in_time, break_minutes, now)
|
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 = QPushButton("➕ 연차 사용 추가")
|
||||||
add_leave_button.clicked.connect(self.add_leave_record)
|
add_leave_button.clicked.connect(self.add_leave_record)
|
||||||
button_layout.addWidget(add_leave_button)
|
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 = QPushButton("닫기")
|
||||||
close_button.clicked.connect(self.close)
|
close_button.clicked.connect(self.close)
|
||||||
button_layout.addWidget(close_button)
|
button_layout.addWidget(close_button)
|
||||||
@ -87,6 +92,11 @@ class LeaveView(QDialog):
|
|||||||
|
|
||||||
self.setLayout(layout)
|
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):
|
def load_data(self):
|
||||||
"""데이터 로드"""
|
"""데이터 로드"""
|
||||||
# 잔액 업데이트
|
# 잔액 업데이트
|
||||||
|
|||||||
@ -165,7 +165,11 @@ class StatsView(QDialog):
|
|||||||
pattern_group.setLayout(pattern_layout)
|
pattern_group.setLayout(pattern_layout)
|
||||||
layout.addWidget(pattern_group)
|
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)
|
widget.setLayout(layout)
|
||||||
return widget
|
return widget
|
||||||
|
|
||||||
@ -253,6 +257,11 @@ class StatsView(QDialog):
|
|||||||
|
|
||||||
def analyze_patterns(self, records):
|
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:
|
if not records:
|
||||||
self.pattern_text.setText(tr('stats.no_data'))
|
self.pattern_text.setText(tr('stats.no_data'))
|
||||||
return
|
return
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user