Initial release v2.2.0
CI / test (push) Has been cancelled

핵심 기능:
- 단축근무·표준·반일 등 다양한 근무 패턴 (5개 프리셋 + 사용자 정의)
- Windows 이벤트 뷰어 자동 출퇴근 감지
- 30분 단위 연장근무 적립/사용 시스템
- 1.0/0.5/0.25일 연차·반차·반반차
- 자동 점심·저녁·외출·자동 백업·화면 잠금 자동 외출
- 한국 공휴일 자동 등록 (음력 포함, holidays 패키지)
- matplotlib 차트 기반 주간/월간/패턴 통계
- 미니 위젯 + 시스템 트레이 통합
- 한국어/English i18n
- 자가 업데이트 (updater.exe + Gitea Releases)

아키텍처:
- core/ (db, time_calculator, notifier, i18n, version, settings_keys)
- ui/ (main_window + 9 dialogs + 3 controllers)
- utils/ (backup, lock_detector, debug_log, updater_client, time_format)
- tests/ (66 pytest 단위) + 통합/i18n GUI 검증

CI/CD:
- .gitea/workflows/ci.yml: push 시 pytest + 통합 테스트
- .gitea/workflows/release.yml: v* 태그 push 시 두 .exe 자동 빌드 + Releases 첨부

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
KINDNICK
2026-04-30 12:54:40 +09:00
co-authored by Claude Opus 4.7
commit bedbb1e9ec
63 changed files with 13353 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
# ui 모듈
+293
View File
@@ -0,0 +1,293 @@
"""
외출 관리 화면
"""
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QTableWidget, QTableWidgetItem,
QHeaderView, QMessageBox, QTimeEdit, QLineEdit, QWidget)
from PyQt5.QtCore import Qt, QTime
from datetime import datetime
from core.i18n import tr
from ui.styles import apply_dark_titlebar
class BreakEditDialog(QDialog):
"""외출 기록 수정 다이얼로그"""
def __init__(self, parent=None, break_record=None):
super().__init__(parent)
self.break_record = break_record
self.init_ui()
apply_dark_titlebar(self)
def init_ui(self):
"""UI 초기화"""
self.setWindowTitle("외출 기록 수정")
self.setFixedSize(380, 180)
layout = QVBoxLayout()
layout.setSpacing(8)
layout.setContentsMargins(12, 10, 12, 10)
# 외출 시간
out_layout = QHBoxLayout()
out_label = QLabel("외출 시간:")
out_label.setFixedWidth(80)
self.out_time_edit = QTimeEdit()
self.out_time_edit.setDisplayFormat("HH:mm:ss")
out_layout.addWidget(out_label)
out_layout.addWidget(self.out_time_edit)
layout.addLayout(out_layout)
# 복귀 시간
in_layout = QHBoxLayout()
in_label = QLabel("복귀 시간:")
in_label.setFixedWidth(80)
self.in_time_edit = QTimeEdit()
self.in_time_edit.setDisplayFormat("HH:mm:ss")
in_layout.addWidget(in_label)
in_layout.addWidget(self.in_time_edit)
layout.addLayout(in_layout)
# 사유
reason_layout = QHBoxLayout()
reason_label = QLabel("사유:")
reason_label.setFixedWidth(80)
self.reason_edit = QLineEdit()
reason_layout.addWidget(reason_label)
reason_layout.addWidget(self.reason_edit)
layout.addLayout(reason_layout)
# 기존 데이터 로드
if self.break_record:
break_out = self.break_record.get('break_out', '00:00:00')
h, m, s = map(int, break_out.split(':'))
self.out_time_edit.setTime(QTime(h, m, s))
break_in = self.break_record.get('break_in')
if break_in:
h, m, s = map(int, break_in.split(':'))
self.in_time_edit.setTime(QTime(h, m, s))
reason = self.break_record.get('reason', '')
if reason:
self.reason_edit.setText(reason)
# 버튼
button_layout = QHBoxLayout()
save_button = QPushButton("저장")
cancel_button = QPushButton("취소")
save_button.clicked.connect(self.accept)
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(save_button)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
self.setLayout(layout)
def get_data(self):
"""입력된 데이터 반환"""
out_time = self.out_time_edit.time()
in_time = self.in_time_edit.time()
reason = self.reason_edit.text()
# 복귀 시간 처리: 기존 기록에 복귀 시간이 없으면 None 유지
# 자정(00:00:00)도 유효한 시간으로 처리
break_in_str = in_time.toString("HH:mm:ss")
if self.break_record and not self.break_record.get('break_in'):
# 기존에 복귀 시간이 없었고, 수정에서도 00:00:00이면 아직 복귀 안 한 것으로 간주
if break_in_str == "00:00:00":
break_in_str = None
return {
'break_out': out_time.toString("HH:mm:ss"),
'break_in': break_in_str,
'reason': reason
}
class BreakView(QDialog):
"""외출 관리 창"""
def __init__(self, parent=None, db=None):
super().__init__(parent)
self.db = db
self.parent_window = parent
self.init_ui()
self.load_break_records()
apply_dark_titlebar(self)
def init_ui(self):
"""UI 초기화"""
self.setWindowTitle(tr('window.break_view'))
self.setGeometry(200, 200, 550, 350)
layout = QVBoxLayout()
layout.setSpacing(6)
layout.setContentsMargins(12, 10, 12, 10)
# 제목
title = QLabel("오늘의 외출 기록")
title.setObjectName("dialog_subtitle")
title.setAlignment(Qt.AlignCenter)
layout.addWidget(title)
# 외출 리스트 테이블
self.table = QTableWidget()
self.table.setColumnCount(5)
self.table.setHorizontalHeaderLabels(["외출 시간", "복귀 시간", "소요 시간", "사유", ""])
# 테이블 설정
header = self.table.horizontalHeader()
header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
header.setSectionResizeMode(1, QHeaderView.ResizeToContents)
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
header.setSectionResizeMode(3, QHeaderView.Stretch)
header.setSectionResizeMode(4, QHeaderView.ResizeToContents)
self.table.setSelectionBehavior(QTableWidget.SelectRows)
self.table.setEditTriggers(QTableWidget.NoEditTriggers)
layout.addWidget(self.table)
# 총 외출 시간 표시
self.total_label = QLabel("총 외출 시간: 0분")
self.total_label.setObjectName("section_title")
self.total_label.setAlignment(Qt.AlignRight)
layout.addWidget(self.total_label)
# 버튼
button_layout = QHBoxLayout()
self.refresh_button = QPushButton("새로고침")
close_button = QPushButton("닫기")
self.refresh_button.clicked.connect(self.load_break_records)
close_button.clicked.connect(self.accept)
button_layout.addStretch()
button_layout.addWidget(self.refresh_button)
button_layout.addWidget(close_button)
layout.addLayout(button_layout)
self.setLayout(layout)
def load_break_records(self):
"""외출 기록 로드"""
records = self.db.get_today_break_records()
self.table.setRowCount(len(records))
for i, record in enumerate(records):
# 외출 시간
break_out = record.get('break_out', '')
self.table.setItem(i, 0, QTableWidgetItem(break_out))
# 복귀 시간
break_in = record.get('break_in', '')
if break_in:
self.table.setItem(i, 1, QTableWidgetItem(break_in))
else:
item = QTableWidgetItem("진행중")
item.setForeground(Qt.red)
self.table.setItem(i, 1, item)
# 소요 시간
total_minutes = record.get('total_minutes')
if total_minutes:
hours = total_minutes // 60
minutes = total_minutes % 60
duration_str = f"{hours}시간 {minutes}분" if hours > 0 else f"{minutes}분"
self.table.setItem(i, 2, QTableWidgetItem(duration_str))
else:
self.table.setItem(i, 2, QTableWidgetItem("-"))
# 사유
reason = record.get('reason', '')
self.table.setItem(i, 3, QTableWidgetItem(reason))
# 액션 버튼
action_widget = QWidget()
action_layout = QHBoxLayout()
action_layout.setContentsMargins(0, 0, 0, 0)
action_layout.setSpacing(5)
edit_button = QPushButton("수정")
delete_button = QPushButton("삭제")
edit_button.setFixedSize(50, 25)
delete_button.setFixedSize(50, 25)
# 클릭 이벤트에 record id 전달
record_id = record['id']
edit_button.clicked.connect(lambda checked, rid=record_id: self.edit_record(rid))
delete_button.clicked.connect(lambda checked, rid=record_id: self.delete_record(rid))
action_layout.addWidget(edit_button)
action_layout.addWidget(delete_button)
action_widget.setLayout(action_layout)
self.table.setCellWidget(i, 4, action_widget)
# 총 외출 시간 업데이트
total_minutes = self.db.get_total_break_minutes_today()
hours = total_minutes // 60
minutes = total_minutes % 60
if hours > 0:
self.total_label.setText(f"총 외출 시간: {hours}시간 {minutes}분")
else:
self.total_label.setText(f"총 외출 시간: {minutes}분")
def edit_record(self, record_id):
"""외출 기록 수정"""
# 기존 기록 조회
records = self.db.get_today_break_records()
record = next((r for r in records if r['id'] == record_id), None)
if not record:
return
# 수정 다이얼로그 표시
dialog = BreakEditDialog(self, record)
if dialog.exec_() == QDialog.Accepted:
data = dialog.get_data()
# DB 업데이트
self.db.update_break_record(
record_id,
data['break_out'],
data['break_in'],
data['reason']
)
# 리스트 새로고침
self.load_break_records()
# 부모 창 업데이트
if self.parent_window and hasattr(self.parent_window, 'update_break_status'):
self.parent_window.update_break_status()
if self.parent_window and hasattr(self.parent_window, 'update_times'):
self.parent_window.update_times()
def delete_record(self, record_id):
"""외출 기록 삭제"""
reply = QMessageBox.question(
self,
"삭제 확인",
"이 외출 기록을 삭제하시겠습니까?",
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
self.db.delete_break_record(record_id)
self.load_break_records()
# 부모 창 업데이트
if self.parent_window and hasattr(self.parent_window, 'update_break_status'):
self.parent_window.update_break_status()
if self.parent_window and hasattr(self.parent_window, 'update_times'):
self.parent_window.update_times()
+523
View File
@@ -0,0 +1,523 @@
"""
캘린더 뷰 - 월간 근무 기록 조회
"""
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QCalendarWidget, QTextEdit, QGroupBox,
QMessageBox)
from PyQt5.QtCore import QDate, Qt
from PyQt5.QtGui import QTextCharFormat, QColor
from datetime import datetime, date
import sys
import os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from core.database import Database
from ui.styles import ThemeColors, apply_dark_titlebar
class CalendarView(QDialog):
"""캘린더 뷰 다이얼로그"""
def __init__(self, parent=None, db=None):
super().__init__(parent)
self.db = db if db else Database()
self.init_ui()
self.load_calendar_data()
apply_dark_titlebar(self)
def init_ui(self):
"""UI 초기화"""
from core.i18n import tr
self.setWindowTitle(tr('window.calendar'))
self.setModal(True)
self.setMinimumSize(520, 820)
layout = QVBoxLayout()
layout.setSpacing(6)
layout.setContentsMargins(12, 10, 12, 10)
# 제목
title = QLabel("월간 근무 기록")
title.setObjectName("dialog_title")
title.setAlignment(Qt.AlignCenter)
layout.addWidget(title)
# 캘린더
self.calendar = QCalendarWidget()
self.calendar.setMinimumHeight(280)
self.calendar.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader)
self.calendar.clicked.connect(self.date_selected)
layout.addWidget(self.calendar, 1)
# 범례
legend_layout = QHBoxLayout()
legend_layout.setSpacing(12)
legend_layout.addWidget(QLabel("🟢 정상"))
legend_layout.addWidget(QLabel("🔴 연장"))
legend_layout.addWidget(QLabel("🟡 휴가"))
legend_layout.addWidget(QLabel("⚪ 없음"))
legend_layout.addStretch()
layout.addLayout(legend_layout)
# 선택된 날짜 상세 정보
detail_group = QGroupBox("선택된 날짜 정보")
detail_layout = QVBoxLayout()
detail_layout.setSpacing(6)
detail_layout.setContentsMargins(10, 20, 10, 8)
self.detail_text = QTextEdit()
self.detail_text.setReadOnly(True)
self.detail_text.setMaximumHeight(100)
detail_layout.addWidget(self.detail_text)
# 버튼 레이아웃
button_layout = QHBoxLayout()
button_layout.setSpacing(6)
self.edit_time_button = QPushButton("✏️ 시간 수정")
self.edit_time_button.setObjectName("btn_primary")
self.edit_time_button.setEnabled(False)
self.edit_time_button.clicked.connect(self.edit_work_time)
button_layout.addWidget(self.edit_time_button)
self.delete_record_button = QPushButton("🗑️ 기록 삭제")
self.delete_record_button.setObjectName("btn_danger")
self.delete_record_button.setEnabled(False)
self.delete_record_button.clicked.connect(self.delete_selected_record)
button_layout.addWidget(self.delete_record_button)
detail_layout.addLayout(button_layout)
detail_group.setLayout(detail_layout)
layout.addWidget(detail_group)
# 메모 그룹
memo_group = QGroupBox("메모")
memo_layout = QVBoxLayout()
memo_layout.setSpacing(6)
memo_layout.setContentsMargins(10, 20, 10, 8)
self.memo_edit = QTextEdit()
self.memo_edit.setMaximumHeight(70)
self.memo_edit.setPlaceholderText("추가근무 사유, 특이사항 등...")
memo_layout.addWidget(self.memo_edit)
self.save_memo_button = QPushButton("💾 메모 저장")
self.save_memo_button.setObjectName("btn_primary")
self.save_memo_button.setEnabled(False)
self.save_memo_button.clicked.connect(self.save_memo)
memo_layout.addWidget(self.save_memo_button)
memo_group.setLayout(memo_layout)
layout.addWidget(memo_group)
# 선택된 날짜 저장용
self.selected_date_str = None
# 닫기 버튼
close_button = QPushButton(tr('btn.close'))
close_button.clicked.connect(self.close)
layout.addWidget(close_button)
self.setLayout(layout)
def load_calendar_data(self):
"""캘린더 데이터 로드"""
# 현재 표시된 월의 데이터 가져오기
current_date = self.calendar.selectedDate()
year = current_date.year()
month = current_date.month()
# 월간 통계 가져오기
stats = self.db.get_monthly_stats(year, month)
records = stats.get('records', [])
# 캘린더에 마킹
for record in records:
record_date = datetime.strptime(record['date'], '%Y-%m-%d').date()
qdate = QDate(record_date.year, record_date.month, record_date.day)
# 포맷 설정
fmt = QTextCharFormat()
if record.get('overtime_earned', 0) > 0:
# 연장근무
fmt.setBackground(QColor(ThemeColors.get('cal_overtime')))
elif record.get('clock_out'):
# 정상 근무
fmt.setBackground(QColor(ThemeColors.get('cal_normal')))
else:
# 출근만 있음
fmt.setBackground(QColor(ThemeColors.get('cal_incomplete')))
fmt.setForeground(QColor(ThemeColors.get('text_primary')))
self.calendar.setDateTextFormat(qdate, fmt)
def date_selected(self, qdate):
"""날짜 선택 시"""
selected_date = qdate.toPyDate()
date_str = selected_date.isoformat()
self.selected_date_str = date_str
# 해당 날짜 기록 조회
record = self.db.get_work_record(date_str)
if record:
# 상세 정보 표시
detail = f"📅 {selected_date.strftime('%Y년 %m월 %d일')}\n\n"
detail += f"출근: {record['clock_in']}\n"
if record.get('clock_out'):
detail += f"퇴근: {record['clock_out']}\n"
detail += f"총 근무시간: {record.get('total_hours', 0):.1f}시간\n"
if record.get('lunch_break'):
detail += f"점심시간: 사용함\n"
else:
detail += f"점심시간: 미사용\n"
if record.get('dinner_break'):
detail += f"저녁시간: 사용함\n"
else:
detail += f"저녁시간: 미사용\n"
if record.get('overtime_earned', 0) > 0:
earned_min = record['overtime_earned']
earned_hours = earned_min // 60
earned_mins = earned_min % 60
detail += f"\n🔥 연장근무 적립: {earned_hours}시간 {earned_mins}분\n"
else:
detail += f"퇴근: 미기록\n"
if record.get('memo'):
detail += f"\n메모: {record['memo']}\n"
self.detail_text.setText(detail)
self.edit_time_button.setEnabled(True)
self.delete_record_button.setEnabled(True)
# 메모 필드 업데이트
self.memo_edit.setPlainText(record.get('memo', ''))
self.save_memo_button.setEnabled(True)
else:
self.detail_text.setText(f"📅 {selected_date.strftime('%Y년 %m월 %d일')}\n\n기록이 없습니다.")
self.edit_time_button.setEnabled(False)
self.delete_record_button.setEnabled(False)
self.memo_edit.setPlainText('')
self.save_memo_button.setEnabled(False)
def delete_selected_record(self):
"""선택된 날짜의 출근 기록 삭제"""
if not self.selected_date_str:
return
reply = QMessageBox.question(
self,
"출근 기록 삭제",
f"{self.selected_date_str}의 출근 기록을 삭제하시겠습니까?\n\n"
f"※ 연관된 연장근무 적립/사용 기록도 함께 삭제됩니다.\n"
f"※ 이 작업은 되돌릴 수 없습니다.",
QMessageBox.Yes | QMessageBox.No,
QMessageBox.No
)
if reply == QMessageBox.Yes:
self.db.delete_work_record(self.selected_date_str)
QMessageBox.information(
self,
"삭제 완료",
f"{self.selected_date_str}의 출근 기록이 삭제되었습니다."
)
# 캘린더 새로고침
self.load_calendar_data()
self.detail_text.clear()
self.delete_record_button.setEnabled(False)
def save_memo(self):
"""메모 저장"""
if not self.selected_date_str:
return
memo = self.memo_edit.toPlainText().strip()
# 메모 업데이트
self.db.update_work_memo(self.selected_date_str, memo)
QMessageBox.information(
self,
"메모 저장",
f"{self.selected_date_str}의 메모가 저장되었습니다."
)
# 상세 정보 새로고침
qdate = self.calendar.selectedDate()
self.date_selected(qdate)
def edit_work_time(self):
"""출퇴근 시간 수정"""
if not self.selected_date_str:
return
# 기존 기록 조회
record = self.db.get_work_record(self.selected_date_str)
if not record:
return
# 수정 다이얼로그 표시
dialog = EditWorkTimeDialog(self, self.db, self.selected_date_str, record)
if dialog.exec_():
# 수정 성공 시 캘린더 새로고침
self.load_calendar_data()
qdate = self.calendar.selectedDate()
self.date_selected(qdate)
# 부모 윈도우 업데이트
if self.parent() and hasattr(self.parent(), 'update_overtime_balance'):
self.parent().update_overtime_balance()
class EditWorkTimeDialog(QDialog):
"""출퇴근 시간 수정 다이얼로그"""
def __init__(self, parent, db, date_str, record):
super().__init__(parent)
self.db = db
self.date_str = date_str
self.record = record
self.init_ui()
apply_dark_titlebar(self)
def init_ui(self):
"""UI 초기화"""
from PyQt5.QtWidgets import QTimeEdit
from PyQt5.QtCore import QTime
self.setWindowTitle("출퇴근 시간 수정")
self.setModal(True)
self.setMinimumWidth(420)
layout = QVBoxLayout()
layout.setSpacing(8)
layout.setContentsMargins(12, 10, 12, 10)
# 제목
title = QLabel(f"📅 {self.date_str} 출퇴근 시간 수정")
title.setObjectName("dialog_subtitle")
layout.addWidget(title)
# 출근 시간
clock_in_layout = QHBoxLayout()
clock_in_layout.setSpacing(4)
clock_in_label = QLabel("출근:")
clock_in_label.setObjectName("field_label")
clock_in_label.setFixedWidth(40)
clock_in_layout.addWidget(clock_in_label)
clock_in_minus_btn = QPushButton("-30분")
clock_in_minus_btn.setFixedWidth(55)
clock_in_minus_btn.clicked.connect(lambda: self.adjust_time(self.clock_in_edit, -30))
clock_in_layout.addWidget(clock_in_minus_btn)
self.clock_in_edit = QTimeEdit()
self.clock_in_edit.setDisplayFormat("HH:mm:ss")
clock_in_time = QTime.fromString(self.record['clock_in'], "HH:mm:ss")
self.clock_in_edit.setTime(clock_in_time)
clock_in_layout.addWidget(self.clock_in_edit)
clock_in_plus_btn = QPushButton("+30분")
clock_in_plus_btn.setFixedWidth(55)
clock_in_plus_btn.clicked.connect(lambda: self.adjust_time(self.clock_in_edit, 30))
clock_in_layout.addWidget(clock_in_plus_btn)
layout.addLayout(clock_in_layout)
# 퇴근 시간
clock_out_layout = QHBoxLayout()
clock_out_layout.setSpacing(4)
clock_out_label = QLabel("퇴근:")
clock_out_label.setObjectName("field_label")
clock_out_label.setFixedWidth(40)
clock_out_layout.addWidget(clock_out_label)
clock_out_minus_btn = QPushButton("-30분")
clock_out_minus_btn.setFixedWidth(55)
clock_out_minus_btn.clicked.connect(lambda: self.adjust_time(self.clock_out_edit, -30))
clock_out_layout.addWidget(clock_out_minus_btn)
self.clock_out_edit = QTimeEdit()
self.clock_out_edit.setDisplayFormat("HH:mm:ss")
if self.record.get('clock_out'):
clock_out_time = QTime.fromString(self.record['clock_out'], "HH:mm:ss")
self.clock_out_edit.setTime(clock_out_time)
clock_out_layout.addWidget(self.clock_out_edit)
clock_out_plus_btn = QPushButton("+30분")
clock_out_plus_btn.setFixedWidth(55)
clock_out_plus_btn.clicked.connect(lambda: self.adjust_time(self.clock_out_edit, 30))
clock_out_layout.addWidget(clock_out_plus_btn)
layout.addLayout(clock_out_layout)
# 점심/저녁 체크박스 - 한 줄에
from PyQt5.QtWidgets import QCheckBox
check_layout = QHBoxLayout()
self.lunch_check = QCheckBox("점심 (1시간)")
self.lunch_check.setChecked(bool(self.record.get('lunch_break', False)))
check_layout.addWidget(self.lunch_check)
self.dinner_check = QCheckBox("저녁 (1시간)")
self.dinner_check.setChecked(bool(self.record.get('dinner_break', False)))
check_layout.addWidget(self.dinner_check)
layout.addLayout(check_layout)
# 안내 메시지
note = QLabel("※ 수정 시 연장근무 내역이 재계산됩니다.")
note.setObjectName("note_text")
layout.addWidget(note)
# 버튼
button_layout = QHBoxLayout()
save_button = QPushButton("저장")
save_button.setObjectName("btn_success")
save_button.clicked.connect(self.save_changes)
cancel_button = QPushButton("취소")
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(save_button)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
self.setLayout(layout)
def adjust_time(self, time_edit, minutes: int):
"""시간 조정 (±분)"""
current_time = time_edit.time()
new_time = current_time.addSecs(minutes * 60)
time_edit.setTime(new_time)
def save_changes(self):
"""변경사항 저장"""
clock_in = self.clock_in_edit.time().toString("HH:mm:ss")
clock_out = self.clock_out_edit.time().toString("HH:mm:ss")
lunch_break = self.lunch_check.isChecked()
dinner_break = self.dinner_check.isChecked()
# 퇴근 시간이 출근 시간보다 빠른지 확인
if clock_out <= clock_in:
QMessageBox.warning(
self,
"시간 오류",
"퇴근 시간은 출근 시간보다 늦어야 합니다."
)
return
# 근무 시간 계산
from datetime import datetime, timedelta
from core.time_calculator import TimeCalculator
# 해당 날짜의 datetime 객체 생성
date_obj = datetime.strptime(self.date_str, "%Y-%m-%d").date()
clock_in_dt = datetime.combine(date_obj, datetime.strptime(clock_in, "%H:%M:%S").time())
clock_out_dt = datetime.combine(date_obj, datetime.strptime(clock_out, "%H:%M:%S").time())
# 총 근무시간 계산
total_hours = (clock_out_dt - clock_in_dt).total_seconds() / 3600
from core.settings_keys import (
WORK_MINUTES, WORK_HOURS, LUNCH_DURATION_MINUTES, DINNER_DURATION_MINUTES,
)
settings = self.db.get_settings()
work_minutes = settings.get(WORK_MINUTES)
if work_minutes is None:
# 레거시 DB: work_hours만 있고 마이그레이션 전인 경우 폴백
try:
work_minutes = int(round(float(settings.get(WORK_HOURS, 8)) * 60))
except (ValueError, TypeError):
work_minutes = 480
time_calc = TimeCalculator(
work_minutes=int(work_minutes),
lunch_duration_minutes=int(settings.get(LUNCH_DURATION_MINUTES, 60)),
dinner_duration_minutes=int(settings.get(DINNER_DURATION_MINUTES, 60)),
)
# 해당 날짜의 외출 시간 조회
break_records = self.db.get_break_records_by_date(self.date_str)
break_minutes = sum(r.get('total_minutes', 0) or 0 for r in break_records)
# calculate_overtime 호출
overtime_actual, overtime_earned = time_calc.calculate_overtime(
clock_in_dt, clock_out_dt,
include_lunch=lunch_break,
include_dinner=dinner_break,
break_minutes=break_minutes
)
# DB 업데이트
conn = None
try:
conn = self.db.get_connection()
cursor = conn.cursor()
# 기존 overtime_earned 값 조회
old_overtime_earned = self.record.get('overtime_earned', 0) or 0
# work_records 업데이트 (dinner_break 포함)
cursor.execute('''
UPDATE work_records
SET clock_in = ?, clock_out = ?, lunch_break = ?, dinner_break = ?,
total_hours = ?, overtime_minutes = ?, overtime_earned = ?
WHERE date = ?
''', (clock_in, clock_out, lunch_break, dinner_break, total_hours, overtime_actual, overtime_earned, self.date_str))
# overtime_bank 테이블도 업데이트 (연장근무 적립 내역)
work_record_id = self.record.get('id')
if work_record_id:
# 기존 적립 내역 삭제
cursor.execute('''
DELETE FROM overtime_bank
WHERE work_record_id = ? AND date = ?
''', (work_record_id, self.date_str))
# 새로운 적립 내역 추가 (0보다 클 때만)
if overtime_earned > 0:
cursor.execute('''
INSERT INTO overtime_bank (work_record_id, earned_minutes, date)
VALUES (?, ?, ?)
''', (work_record_id, overtime_earned, self.date_str))
conn.commit()
QMessageBox.information(
self,
"수정 완료",
f"{self.date_str}의 출퇴근 시간이 수정되었습니다.\n\n"
f"출근: {clock_in}\n"
f"퇴근: {clock_out}\n"
f"점심시간: {'사용' if lunch_break else '미사용'}\n"
f"저녁시간: {'사용' if dinner_break else '미사용'}\n"
f"외출시간: {break_minutes}분\n"
f"총 근무시간: {total_hours:.1f}시간\n"
f"연장근무: {overtime_earned}분 적립"
)
self.accept()
except Exception as e:
QMessageBox.critical(
self,
"오류",
f"수정 중 오류가 발생했습니다:\n{str(e)}"
)
finally:
if conn:
conn.close()
# 테스트 코드
if __name__ == "__main__":
from PyQt5.QtWidgets import QApplication
app = QApplication(sys.argv)
dialog = CalendarView()
dialog.exec_()
+111
View File
@@ -0,0 +1,111 @@
"""
matplotlib 기반 차트 위젯.
stats_view에서 주간/월간 추세를 시각화. matplotlib 미설치 시
ImportError 안내 라벨로 fallback.
"""
from typing import List, Tuple
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
from PyQt5.QtCore import Qt
try:
from matplotlib.figure import Figure
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
import matplotlib
matplotlib.rcParams['font.family'] = ['Malgun Gothic', 'Apple SD Gothic Neo', 'sans-serif']
matplotlib.rcParams['axes.unicode_minus'] = False
_MPL = True
except ImportError:
_MPL = False
class _Fallback(QWidget):
"""matplotlib 미설치 시 안내."""
def __init__(self, message: str):
super().__init__()
layout = QVBoxLayout()
label = QLabel(message)
label.setAlignment(Qt.AlignCenter)
label.setWordWrap(True)
label.setStyleSheet("color: #888; padding: 20px;")
layout.addWidget(label)
self.setLayout(layout)
def make_chart_widget(parent=None) -> QWidget:
"""차트가 그려질 빈 캔버스 위젯. matplotlib 없으면 fallback."""
if not _MPL:
return _Fallback("차트 표시에는 matplotlib가 필요합니다.\npip install matplotlib")
widget = QWidget(parent)
layout = QVBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
fig = Figure(figsize=(5, 3), dpi=100, tight_layout=True)
canvas = FigureCanvas(fig)
layout.addWidget(canvas)
widget.setLayout(layout)
widget._figure = fig
widget._canvas = canvas
return widget
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
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
dates = [r['date'][5:] for r in records] # MM-DD만
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')
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)
widget._canvas.draw()
def draw_weekday_avg(widget: QWidget, records: List[dict]) -> None:
"""요일별 평균 근무시간 막대 그래프."""
if not getattr(widget, '_figure', None):
return
fig = widget._figure
fig.clear()
from datetime import datetime as _dt
weekday_totals = [0.0] * 7
weekday_counts = [0] * 7
for r in records:
try:
d = _dt.strptime(r['date'], '%Y-%m-%d')
except (ValueError, TypeError):
continue
weekday_totals[d.weekday()] += r.get('total_hours', 0) or 0
weekday_counts[d.weekday()] += 1
avg = [(t / c) if c else 0 for t, c in zip(weekday_totals, weekday_counts)]
labels = ['월', '화', '수', '목', '금', '토', '일']
ax = fig.add_subplot(111)
colors = ['#4a90e2'] * 5 + ['#ff6b6b'] * 2 # 주말 강조
ax.bar(labels, avg, color=colors)
ax.set_ylabel('평균 시간')
ax.set_title('요일별 평균 근무시간')
ax.grid(axis='y', alpha=0.3)
widget._canvas.draw()
+141
View File
@@ -0,0 +1,141 @@
"""
출근시간 수동 입력 다이얼로그
"""
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QTimeEdit, QMessageBox)
from PyQt5.QtCore import QTime, Qt
from datetime import datetime
from core.i18n import tr
from ui.styles import apply_dark_titlebar
class ClockInDialog(QDialog):
"""출근시간 입력 다이얼로그"""
def __init__(self, parent=None, default_time=None):
super().__init__(parent)
self.selected_time = None
self.init_ui(default_time)
apply_dark_titlebar(self)
def init_ui(self, default_time):
"""UI 초기화"""
self.setWindowTitle(tr('window.clock_in_dialog'))
self.setModal(True)
self.setFixedSize(340, 200)
layout = QVBoxLayout()
layout.setSpacing(8)
layout.setContentsMargins(12, 10, 12, 10)
# 안내 문구
info_label = QLabel("오늘의 출근시간을 입력해주세요")
info_label.setObjectName("field_label")
info_label.setAlignment(Qt.AlignCenter)
layout.addWidget(info_label)
# 시간 입력
time_layout = QHBoxLayout()
time_label = QLabel("출근시간:")
time_label.setObjectName("field_label")
self.time_edit = QTimeEdit()
self.time_edit.setDisplayFormat("HH:mm:ss")
self.time_edit.setMinimumHeight(35)
# 기본값 설정
if default_time:
qtime = QTime(default_time.hour, default_time.minute, default_time.second)
else:
# 현재 시간으로 기본값 설정
now = datetime.now()
qtime = QTime(now.hour, now.minute, now.second)
self.time_edit.setTime(qtime)
time_layout.addWidget(time_label)
time_layout.addWidget(self.time_edit)
layout.addLayout(time_layout)
# 빠른 선택 버튼
quick_layout = QHBoxLayout()
quick_label = QLabel("빠른 선택:")
quick_label.setObjectName("field_label")
btn_8am = QPushButton("08:00")
btn_9am = QPushButton("09:00")
btn_10am = QPushButton("10:00")
btn_now = QPushButton("현재")
for btn in [btn_8am, btn_9am, btn_10am, btn_now]:
btn.setMinimumHeight(30)
btn_8am.clicked.connect(lambda: self.time_edit.setTime(QTime(8, 0, 0)))
btn_9am.clicked.connect(lambda: self.time_edit.setTime(QTime(9, 0, 0)))
btn_10am.clicked.connect(lambda: self.time_edit.setTime(QTime(10, 0, 0)))
btn_now.clicked.connect(lambda: self.time_edit.setTime(QTime.currentTime()))
quick_layout.addWidget(quick_label)
quick_layout.addWidget(btn_8am)
quick_layout.addWidget(btn_9am)
quick_layout.addWidget(btn_10am)
quick_layout.addWidget(btn_now)
layout.addLayout(quick_layout)
layout.addStretch()
# 버튼
button_layout = QHBoxLayout()
ok_button = QPushButton("확인")
ok_button.setObjectName("btn_primary")
ok_button.setMinimumHeight(40)
ok_button.clicked.connect(self.accept)
cancel_button = QPushButton("취소")
cancel_button.setMinimumHeight(40)
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(cancel_button)
button_layout.addWidget(ok_button)
layout.addLayout(button_layout)
self.setLayout(layout)
def accept(self):
"""확인 버튼 클릭"""
qtime = self.time_edit.time()
# QTime을 datetime으로 변환
today = datetime.now().date()
self.selected_time = datetime.combine(
today,
datetime.min.time().replace(
hour=qtime.hour(),
minute=qtime.minute(),
second=qtime.second()
)
)
super().accept()
def get_time(self):
"""선택된 시간 반환"""
return self.selected_time
# 테스트 코드
if __name__ == "__main__":
from PyQt5.QtWidgets import QApplication
import sys
app = QApplication(sys.argv)
dialog = ClockInDialog()
if dialog.exec_() == QDialog.Accepted:
selected_time = dialog.get_time()
print(f"선택된 시간: {selected_time.strftime('%H:%M:%S')}")
else:
print("취소됨")
sys.exit()
View File
+55
View File
@@ -0,0 +1,55 @@
"""
자동 점심시간 적용 컨트롤러.
조건: auto_lunch=true, 출근 후 4시간 이상, 점심 미적용, 오늘 미적용.
주말/공휴일은 스킵. 1Hz hot path에서 호출되므로 캐시 사용.
"""
from __future__ import annotations
from datetime import datetime
from core.settings_keys import AUTO_LUNCH
class AutoLunchManager:
"""update_display() 1Hz tick에서 호출."""
def __init__(self, window):
self.window = window
self.db = window.db
self._enabled_cache = None # None=미로딩, True/False=캐시값
self._non_working_cache = None
self._non_working_date = None
def invalidate(self) -> None:
"""설정 변경 시 캐시 무효화 (reload_settings에서 호출)."""
self._enabled_cache = None
def maybe_apply(self, now: datetime) -> None:
w = self.window
if w.auto_lunch_applied_today or w.lunch_break_enabled:
return
if self._enabled_cache is None:
self._enabled_cache = (
self.db.get_setting(AUTO_LUNCH, 'false').lower() == 'true'
)
if not self._enabled_cache:
return
today = now.date()
if self._non_working_date != today:
self._non_working_cache = w.time_calc.is_non_working_day(now, self.db)
self._non_working_date = today
if self._non_working_cache:
return
elapsed = now - w.clock_in_time
if elapsed.total_seconds() < 4 * 3600:
return
w.auto_lunch_applied_today = True
w.lunch_break_enabled = True
w.lunch_button.setChecked(True)
w.update_lunch_status()
if w.is_clocked_in:
self.db.update_lunch_break(today.isoformat(), True)
+69
View File
@@ -0,0 +1,69 @@
"""
화면 잠금 감지 컨트롤러.
- AUTO_BREAK_ON_LOCK: 출근 후 잠금→외출, 해제→복귀
- CLOCK_IN_ON_UNLOCK: 미출근 상태에서 잠금 해제 시 출근 자동 기록
"""
from __future__ import annotations
from datetime import datetime
from core.settings_keys import AUTO_BREAK_ON_LOCK, CLOCK_IN_ON_UNLOCK
class LockMonitor:
"""MainWindow에서 5초마다 호출되는 잠금 상태 감시자."""
def __init__(self, window):
self.window = window
self.db = window.db
self.last_locked: bool = False
def tick(self) -> None:
try:
from utils.lock_detector import is_screen_locked
locked = is_screen_locked()
except Exception:
return
was_locked = self.last_locked
self.last_locked = locked
# 출근 후 자동 외출/복귀
if (self.db.get_setting(AUTO_BREAK_ON_LOCK, 'false').lower() == 'true'
and self.window.is_clocked_in):
if locked and not was_locked and not self.window.is_on_break:
self.window.break_out(silent=True)
elif not locked and was_locked and self.window.is_on_break:
self.window.break_in(silent=True)
# 미출근 상태에서 잠금 해제 시 출근
if (not locked and was_locked
and not self.window.is_clocked_in
and self.db.get_setting(CLOCK_IN_ON_UNLOCK, 'false').lower() == 'true'):
now = datetime.now()
today_record = self.db.get_today_record()
if not today_record or not today_record.get('clock_in'):
self._auto_clock_in_at(now)
def _auto_clock_in_at(self, when: datetime) -> None:
w = self.window
w.clock_in_time = when
w.is_clocked_in = True
w.midnight_rollover_handled = False
w.auto_lunch_applied_today = False
today = when.date().isoformat()
clock_in_str = when.strftime("%H:%M:%S")
existing = self.db.get_today_record()
if existing:
conn = self.db.get_connection()
cursor = conn.cursor()
cursor.execute(
"UPDATE work_records SET clock_in = ?, is_manual = 0 WHERE date = ?",
(clock_in_str, today),
)
conn.commit()
conn.close()
else:
self.db.add_work_record(today, clock_in_str)
w.update_display()
@@ -0,0 +1,40 @@
"""
알림 오케스트레이션.
5분 가드로 건강/주간/누적 임계 알림을 throttle.
notifier.py의 6개 알림 메서드를 적절한 시점에 호출.
"""
from __future__ import annotations
from datetime import datetime
class NotificationOrchestrator:
"""update_display() 1Hz tick에서 호출."""
def __init__(self, window):
self.window = window
self.db = window.db
self.notifier = window.notifier
self._last_5min_bucket: int | None = None # now.minute (5의 배수일 때만)
def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float) -> None:
n = self.notifier
# 1초마다 체크: 30분 전, 점심 미등록, 연장 적립
n.check_clock_out_soon(expected_clock_out, now)
n.check_lunch_reminder(self.window.clock_in_time,
self.window.lunch_break_enabled, now)
if remaining_seconds < 0:
n.check_overtime_earning(abs(int(remaining_seconds / 60)))
# 5분 간격 throttle: 건강/주간/누적
if now.minute % 5 == 0 and self._last_5min_bucket != now.minute:
self._last_5min_bucket = now.minute
consecutive = self.db.get_consecutive_overtime_days()
if consecutive >= 3:
n.notify_health_warning(consecutive)
weekly_hours = self.db.get_weekly_stats().get('total_hours', 0)
if weekly_hours > 52:
n.notify_weekly_hours(weekly_hours)
balance_minutes = self.db.get_total_overtime_balance()
if balance_minutes >= 1200:
n.notify_overtime_threshold(balance_minutes / 60.0)
+84
View File
@@ -0,0 +1,84 @@
"""
사용 설명 가이드 창.
i18n 사전(_HELP_HTML)에서 ko/en HTML을 가져와 6개 탭으로 표시.
"""
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QWidget, QTabWidget, QTextBrowser)
from PyQt5.QtCore import Qt
from core.i18n import tr, tr_html
from ui.styles import apply_dark_titlebar
class HelpView(QDialog):
"""사용 설명 가이드 다이얼로그"""
# (사전 키, 탭 라벨 키)
_TABS = [
('help.html.intro', 'help.tab_intro'),
('help.html.work_hours', 'help.tab_work_hours'),
('help.html.overtime', 'help.tab_overtime'),
('help.html.leave', 'help.tab_leave'),
('help.html.break', 'help.tab_break'),
('help.html.faq', 'help.tab_faq'),
]
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle(tr('window.help'))
self.setModal(True)
self.setMinimumSize(680, 720)
self.init_ui()
apply_dark_titlebar(self)
def init_ui(self):
main_layout = QVBoxLayout()
main_layout.setContentsMargins(0, 0, 0, 0)
main_layout.setSpacing(0)
title = QLabel(tr('window.help'))
title.setObjectName("dialog_title")
title.setAlignment(Qt.AlignCenter)
main_layout.addWidget(title)
tabs = QTabWidget()
tabs.setDocumentMode(True)
for html_key, tab_label_key in self._TABS:
tabs.addTab(self._make_tab(tr_html(html_key)), tr(tab_label_key))
main_layout.addWidget(tabs)
button_layout = QHBoxLayout()
button_layout.setContentsMargins(20, 10, 20, 20)
button_layout.addStretch()
close_button = QPushButton(tr('btn.close'))
close_button.setObjectName("btn_primary")
close_button.setMinimumHeight(40)
close_button.setMinimumWidth(120)
close_button.clicked.connect(self.close)
button_layout.addWidget(close_button)
button_layout.addStretch()
main_layout.addLayout(button_layout)
self.setLayout(main_layout)
def _make_tab(self, html: str) -> QWidget:
container = QWidget()
layout = QVBoxLayout()
layout.setContentsMargins(16, 12, 16, 12)
browser = QTextBrowser()
browser.setOpenExternalLinks(False)
browser.setHtml(html)
layout.addWidget(browser)
container.setLayout(layout)
return container
# 단독 실행 테스트
if __name__ == "__main__":
import sys
from PyQt5.QtWidgets import QApplication
app = QApplication(sys.argv)
dialog = HelpView()
dialog.exec_()
+371
View File
@@ -0,0 +1,371 @@
"""
연차 상세 내역 뷰
"""
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QTableWidget, QTableWidgetItem,
QHeaderView, QGroupBox, QMessageBox, QInputDialog,
QLineEdit, QDateEdit, QComboBox, QDoubleSpinBox,
QMenu, QAction)
from PyQt5.QtCore import Qt, QDate
from PyQt5.QtGui import QColor
from datetime import datetime
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 LeaveView(QDialog):
"""연차 상세 내역 다이얼로그"""
def __init__(self, parent=None, db=None):
super().__init__(parent)
self.db = db if db else Database()
self.init_ui()
self.load_data()
apply_dark_titlebar(self)
def init_ui(self):
"""UI 초기화"""
self.setWindowTitle(tr('window.leave_view'))
self.setModal(True)
self.setMinimumSize(700, 450)
layout = QVBoxLayout()
layout.setSpacing(6)
layout.setContentsMargins(12, 10, 12, 10)
# 제목 + 잔액 + 설정 한 줄
header_layout = QHBoxLayout()
title = QLabel("연차 관리")
title.setObjectName("dialog_title")
header_layout.addWidget(title)
header_layout.addStretch()
self.balance_label = QLabel("잔여: 0일")
self.balance_label.setObjectName("badge_leave")
header_layout.addWidget(self.balance_label)
set_balance_button = QPushButton("잔여 설정")
set_balance_button.clicked.connect(self.set_balance)
header_layout.addWidget(set_balance_button)
layout.addLayout(header_layout)
# 사용 내역
used_group = QGroupBox("📤 사용 내역")
used_layout = QVBoxLayout()
used_layout.setSpacing(4)
used_layout.setContentsMargins(8, 20, 8, 6)
self.used_table = QTableWidget()
self.used_table.setColumnCount(4)
self.used_table.setHorizontalHeaderLabels(["날짜", "구분", "사용", "사유"])
self.used_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.used_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
self.used_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
self.used_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.Stretch)
self.used_table.setAlternatingRowColors(True)
self.used_table.setEditTriggers(QTableWidget.NoEditTriggers)
self.used_table.setSelectionBehavior(QTableWidget.SelectRows)
self.used_table.setContextMenuPolicy(Qt.CustomContextMenu)
self.used_table.customContextMenuRequested.connect(self.show_context_menu)
used_layout.addWidget(self.used_table)
used_group.setLayout(used_layout)
layout.addWidget(used_group)
# 버튼들
button_layout = QHBoxLayout()
add_leave_button = QPushButton("➕ 연차 사용 추가")
add_leave_button.clicked.connect(self.add_leave_record)
button_layout.addWidget(add_leave_button)
close_button = QPushButton("닫기")
close_button.clicked.connect(self.close)
button_layout.addWidget(close_button)
layout.addLayout(button_layout)
self.setLayout(layout)
def load_data(self):
"""데이터 로드"""
# 잔액 업데이트
balance = self.db.get_leave_balance()
hours = balance * 8
self.balance_label.setText(f"잔여: {balance}일 (총 {hours}시간)")
# 사용 내역 로드 (잔액 조정 제외)
records = self.db.get_leave_records(exclude_bulk=True)
self.used_table.setRowCount(len(records))
for i, record in enumerate(records):
date_item = QTableWidgetItem(record['date'])
date_item.setTextAlignment(Qt.AlignCenter)
date_item.setData(Qt.UserRole, record['id'])
type_item = QTableWidgetItem(record['leave_type'])
type_item.setTextAlignment(Qt.AlignCenter)
days = record['days']
hours = days * 8
if days == 1.0:
days_str = "1일"
elif days == 0.5:
days_str = "0.5일 (4시간)"
elif hours < 8:
days_str = f"{days}일 ({hours}시간)"
else:
days_str = f"{days}일"
days_item = QTableWidgetItem(days_str)
days_item.setTextAlignment(Qt.AlignCenter)
days_item.setForeground(QColor(231, 76, 60)) # 빨간색
memo_item = QTableWidgetItem(record['memo'] or "")
self.used_table.setItem(i, 0, date_item)
self.used_table.setItem(i, 1, type_item)
self.used_table.setItem(i, 2, days_item)
self.used_table.setItem(i, 3, memo_item)
def show_context_menu(self, position):
"""사용 내역 우클릭 메뉴"""
selected_rows = self.used_table.selectionModel().selectedRows()
if not selected_rows:
return
menu = QMenu(self)
delete_action = QAction("삭제", self)
delete_action.triggered.connect(self.delete_leave_record)
menu.addAction(delete_action)
menu.exec_(self.used_table.viewport().mapToGlobal(position))
def delete_leave_record(self):
"""연차 사용 기록 삭제"""
selected_rows = self.used_table.selectionModel().selectedRows()
if not selected_rows:
return
row = selected_rows[0].row()
date_item = self.used_table.item(row, 0)
type_item = self.used_table.item(row, 1)
days_item = self.used_table.item(row, 2)
leave_id = date_item.data(Qt.UserRole)
reply = QMessageBox.question(
self,
"삭제 확인",
f"다음 연차 사용 기록을 삭제하시겠습니까?\n\n"
f"날짜: {date_item.text()}\n"
f"구분: {type_item.text()}\n"
f"사용: {days_item.text()}",
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
self.db.delete_leave_record(leave_id)
self.load_data()
def set_balance(self):
"""연차 개수 설정 (시간 단위)"""
current_balance = self.db.get_leave_balance()
current_hours = current_balance * 8
hours, ok = QInputDialog.getDouble(
self,
"연차 시간 설정",
"연차 잔여 시간을 입력하세요 (0.5시간 단위):\n"
"예) 8시간 = 1일, 4시간 = 0.5일(반차), 2시간 = 0.25일, 0.5시간 = 30분",
current_hours,
0.0,
999.0,
1 # 소수점 첫째자리까지 (0.5 단위)
)
if ok:
# 0.5시간 단위로 반올림
hours = round(hours * 2) / 2
# 시간을 일수로 변환
days = hours / 8.0
self.db.set_leave_balance(days)
QMessageBox.information(
self,
"설정 완료",
f"연차 잔여 개수가 {days}일 ({hours}시간)로 설정되었습니다."
)
self.load_data()
def add_leave_record(self):
"""연차 사용 기록 추가 다이얼로그"""
dialog = AddLeaveDialog(self, self.db)
if dialog.exec_() == QDialog.Accepted:
self.load_data()
class AddLeaveDialog(QDialog):
"""연차 사용 기록 추가 다이얼로그"""
def __init__(self, parent=None, db=None):
super().__init__(parent)
self.db = db
self.init_ui()
apply_dark_titlebar(self)
def init_ui(self):
"""UI 초기화"""
self.setWindowTitle("연차 사용 기록 추가")
self.setModal(True)
self.setMinimumWidth(360)
layout = QVBoxLayout()
layout.setSpacing(8)
layout.setContentsMargins(12, 10, 12, 10)
# 제목
title = QLabel("연차 사용 기록 추가")
title.setObjectName("dialog_subtitle")
title.setAlignment(Qt.AlignCenter)
layout.addWidget(title)
# 날짜 + 구분 한 줄
row1 = QHBoxLayout()
date_label = QLabel("날짜:")
date_label.setObjectName("field_label")
date_label.setFixedWidth(40)
self.date_edit = QDateEdit()
self.date_edit.setDate(QDate.currentDate())
self.date_edit.setCalendarPopup(True)
row1.addWidget(date_label)
row1.addWidget(self.date_edit)
row1.addSpacing(8)
type_label = QLabel("구분:")
type_label.setObjectName("field_label")
type_label.setFixedWidth(40)
self.type_combo = QComboBox()
self.type_combo.addItem("연차", "annual")
self.type_combo.addItem("반차", "half")
self.type_combo.addItem("반반차", "quarter")
self.type_combo.addItem("시간", "hourly")
self.type_combo.currentIndexChanged.connect(self.on_type_changed)
row1.addWidget(type_label)
row1.addWidget(self.type_combo)
layout.addLayout(row1)
# 사용 시간 (시간 연차용)
hours_layout = QHBoxLayout()
hours_label = QLabel("시간:")
hours_label.setObjectName("field_label")
hours_label.setFixedWidth(40)
self.hours_spin = QDoubleSpinBox()
self.hours_spin.setRange(0.5, 8.0)
self.hours_spin.setSingleStep(0.5)
self.hours_spin.setValue(1.0)
self.hours_spin.setSuffix(" 시간")
self.hours_spin.setEnabled(False)
hours_layout.addWidget(hours_label)
hours_layout.addWidget(self.hours_spin)
layout.addLayout(hours_layout)
# 사유
memo_layout = QHBoxLayout()
memo_label = QLabel("사유:")
memo_label.setObjectName("field_label")
memo_label.setFixedWidth(40)
self.memo_input = QLineEdit()
self.memo_input.setPlaceholderText("예) 개인 사유, 병원 방문 등")
memo_layout.addWidget(memo_label)
memo_layout.addWidget(self.memo_input)
layout.addLayout(memo_layout)
# 안내
info_label = QLabel("※ 잔여 연차가 자동 차감됩니다.")
info_label.setObjectName("note_text")
layout.addWidget(info_label)
# 버튼
button_layout = QHBoxLayout()
save_button = QPushButton("저장")
save_button.setObjectName("btn_primary")
save_button.clicked.connect(self.save_record)
button_layout.addWidget(save_button)
cancel_button = QPushButton("취소")
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
self.setLayout(layout)
def on_type_changed(self, index):
"""연차 유형 변경 시"""
leave_type = self.type_combo.currentData()
# 시간 연차일 때만 시간 입력 활성화
self.hours_spin.setEnabled(leave_type == "hourly")
def save_record(self):
"""기록 저장"""
date = self.date_edit.date().toString("yyyy-MM-dd")
leave_type = self.type_combo.currentData()
leave_type_name = self.type_combo.currentText()
memo = self.memo_input.text().strip()
# 사용 일수 계산
if leave_type == "annual":
days = 1.0
elif leave_type == "half":
days = 0.5
elif leave_type == "quarter":
days = 0.25
elif leave_type == "hourly":
hours = self.hours_spin.value()
days = hours / 8.0
else:
days = 1.0
# 잔여 연차 확인
current_balance = self.db.get_leave_balance()
if current_balance < days:
QMessageBox.warning(
self,
"잔여 연차 부족",
f"잔여 연차가 부족합니다.\n현재 잔여: {current_balance}일\n사용 요청: {days}일"
)
return
# 확인 메시지
hours = days * 8
reply = QMessageBox.question(
self,
"연차 사용 기록 추가",
f"날짜: {date}\n"
f"구분: {leave_type_name}\n"
f"사용: {days}일 ({hours}시간)\n"
f"사유: {memo if memo else '(없음)'}\n\n"
f"이 기록을 추가하시겠습니까?",
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
try:
# 연차 사용 기록 추가 (파라미터 순서: days, date, leave_type, memo)
self.db.use_leave(days, date, leave_type_name, memo)
QMessageBox.information(
self,
"추가 완료",
f"{days}일 ({hours}시간)의 연차 사용이 기록되었습니다."
)
self.accept()
except Exception as e:
QMessageBox.critical(
self,
"오류",
f"연차 기록 추가 중 오류가 발생했습니다:\n{str(e)}"
)
# 테스트 코드
if __name__ == "__main__":
from PyQt5.QtWidgets import QApplication
app = QApplication(sys.argv)
dialog = LeaveView()
dialog.exec_()
+2049
View File
File diff suppressed because it is too large Load Diff
+101
View File
@@ -0,0 +1,101 @@
"""
미니 위젯 — Always-on-top 컴팩트 디스플레이.
남은 시간만 큰 글씨로 보여주는 작은 창. 메인 창과 동일한 1Hz 갱신을
부모 윈도우의 시그널/직접 호출로 받음.
"""
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QApplication
from PyQt5.QtCore import Qt, QPoint
from PyQt5.QtGui import QFont, QMouseEvent
from core.i18n import tr
from ui.styles import apply_dark_titlebar
class MiniWidget(QWidget):
"""Always-on-top 미니 위젯.
제공 기능:
- 남은 시간 큰 글씨
- 마우스 드래그로 이동
- 우클릭 메뉴: 메인 창 열기 / 닫기
"""
def __init__(self, parent_window=None):
super().__init__()
self.parent_window = parent_window
self._drag_pos: QPoint = None
self.setWindowTitle(tr('window.mini_widget'))
self.setWindowFlags(
Qt.WindowStaysOnTopHint
| Qt.FramelessWindowHint
| Qt.Tool
)
self.setAttribute(Qt.WA_TranslucentBackground, False)
self.setFixedSize(220, 80)
layout = QVBoxLayout()
layout.setContentsMargins(12, 8, 12, 8)
layout.setSpacing(2)
self.title_label = QLabel(tr('label.remaining'))
self.title_label.setAlignment(Qt.AlignCenter)
self.title_label.setStyleSheet("color: #888; font-size: 11px;")
self.time_label = QLabel("--:--:--")
self.time_label.setAlignment(Qt.AlignCenter)
self.time_label.setFont(QFont("Consolas", 22, QFont.Bold))
layout.addWidget(self.title_label)
layout.addWidget(self.time_label)
self.setLayout(layout)
# 기본 스타일 (테마 무관 가독성 유지)
self.setStyleSheet("""
QWidget { background-color: rgba(30, 30, 30, 230); border-radius: 8px; }
QLabel { color: #fff; }
""")
apply_dark_titlebar(self)
def update_remaining(self, remaining_str: str):
"""메인 윈도우에서 호출 — 남은 시간 동기화."""
self.time_label.setText(remaining_str)
if remaining_str.startswith('+'):
self.title_label.setText(tr('label.overtime_progress'))
self.time_label.setStyleSheet("color: #ff6b6b;")
else:
self.title_label.setText(tr('label.remaining'))
self.time_label.setStyleSheet("color: #fff;")
# 드래그 이동
def mousePressEvent(self, event: QMouseEvent):
if event.button() == Qt.LeftButton:
self._drag_pos = event.globalPos() - self.frameGeometry().topLeft()
event.accept()
def mouseMoveEvent(self, event: QMouseEvent):
if self._drag_pos and event.buttons() & Qt.LeftButton:
self.move(event.globalPos() - self._drag_pos)
event.accept()
def mouseDoubleClickEvent(self, event: QMouseEvent):
"""더블클릭 시 메인 창 열기."""
if self.parent_window:
self.parent_window.show()
self.parent_window.raise_()
self.parent_window.activateWindow()
def contextMenuEvent(self, event):
from PyQt5.QtWidgets import QMenu
menu = QMenu(self)
open_main = menu.addAction("메인 창 열기")
close_mini = menu.addAction("미니 위젯 닫기")
action = menu.exec_(event.globalPos())
if action == open_main and self.parent_window:
self.parent_window.show()
self.parent_window.raise_()
self.parent_window.activateWindow()
elif action == close_mini:
self.close()
+507
View File
@@ -0,0 +1,507 @@
"""
연장근무 상세 내역 뷰
"""
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
QPushButton, QTableWidget, QTableWidgetItem,
QHeaderView, QGroupBox, QMessageBox, QMenu, QAction,
QDateEdit, QSpinBox, QLineEdit, QComboBox)
from PyQt5.QtCore import Qt, QDate
from PyQt5.QtGui import QColor
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 get_theme, ThemeColors, apply_dark_titlebar
class OvertimeView(QDialog):
"""연장근무 상세 내역 다이얼로그"""
def __init__(self, parent=None, db=None):
super().__init__(parent)
self.db = db if db else Database()
self.init_ui()
self.load_data()
apply_dark_titlebar(self)
def init_ui(self):
"""UI 초기화"""
self.setWindowTitle(tr('window.overtime_view'))
self.setModal(True)
self.setMinimumSize(800, 500)
layout = QVBoxLayout()
layout.setSpacing(6)
layout.setContentsMargins(12, 10, 12, 10)
# 제목 + 잔액 한 줄
header_layout = QHBoxLayout()
title = QLabel("연장근무 내역")
title.setObjectName("dialog_title")
header_layout.addWidget(title)
header_layout.addStretch()
self.balance_label = QLabel("잔액: 0분")
self.balance_label.setObjectName("badge_balance")
header_layout.addWidget(self.balance_label)
layout.addLayout(header_layout)
# 적립 내역
earned_group = QGroupBox("💰 적립 내역")
earned_layout = QVBoxLayout()
earned_layout.setSpacing(4)
earned_layout.setContentsMargins(8, 20, 8, 6)
self.earned_table = QTableWidget()
self.earned_table.setColumnCount(3)
self.earned_table.setHorizontalHeaderLabels(["날짜", "적립", "메모"])
self.earned_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.earned_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
self.earned_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
self.earned_table.setAlternatingRowColors(True)
self.earned_table.setEditTriggers(QTableWidget.NoEditTriggers)
self.earned_table.setSelectionBehavior(QTableWidget.SelectRows)
earned_layout.addWidget(self.earned_table)
add_earned_button = QPushButton("➕ 수동 적립")
add_earned_button.clicked.connect(self.add_earned_record)
earned_layout.addWidget(add_earned_button)
earned_group.setLayout(earned_layout)
layout.addWidget(earned_group)
# 사용 내역
used_group = QGroupBox("📤 사용 내역")
used_layout = QVBoxLayout()
used_layout.setSpacing(4)
used_layout.setContentsMargins(8, 20, 8, 6)
self.used_table = QTableWidget()
self.used_table.setColumnCount(3)
self.used_table.setHorizontalHeaderLabels(["날짜", "사용", "사유"])
self.used_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.used_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
self.used_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
self.used_table.setAlternatingRowColors(True)
self.used_table.setEditTriggers(QTableWidget.NoEditTriggers)
self.used_table.setSelectionBehavior(QTableWidget.SelectRows)
self.used_table.setContextMenuPolicy(Qt.CustomContextMenu)
self.used_table.customContextMenuRequested.connect(self.show_used_context_menu)
used_layout.addWidget(self.used_table)
add_used_button = QPushButton("➕ 수동 사용")
add_used_button.clicked.connect(self.add_used_record)
used_layout.addWidget(add_used_button)
used_group.setLayout(used_layout)
layout.addWidget(used_group)
# 닫기 버튼
close_button = QPushButton("닫기")
close_button.clicked.connect(self.close)
layout.addWidget(close_button)
self.setLayout(layout)
def load_data(self):
"""데이터 로드"""
# 잔액 업데이트
balance = self.db.get_total_overtime_balance()
hours = balance // 60
minutes = balance % 60
self.balance_label.setText(f"현재 잔액: {hours}시간 {minutes}분 ({balance}분)")
# 적립 내역 로드
conn = self.db.get_connection()
cursor = conn.cursor()
cursor.execute('''
SELECT ob.date, ob.earned_minutes,
CASE
WHEN ob.work_record_id IS NULL THEN '수동 추가'
ELSE COALESCE(wr.memo, '')
END as memo
FROM overtime_bank ob
LEFT JOIN work_records wr ON ob.work_record_id = wr.id
ORDER BY ob.date DESC
''')
earned_records = cursor.fetchall()
self.earned_table.setRowCount(len(earned_records))
for i, record in enumerate(earned_records):
date_item = QTableWidgetItem(record[0])
date_item.setTextAlignment(Qt.AlignCenter)
minutes = record[1]
hours = minutes // 60
mins = minutes % 60
time_str = f"{hours}시간 {mins}분" if hours > 0 else f"{mins}분"
time_item = QTableWidgetItem(time_str)
time_item.setTextAlignment(Qt.AlignCenter)
time_item.setForeground(QColor(39, 174, 96)) # 초록색
memo_item = QTableWidgetItem(record[2] or "")
self.earned_table.setItem(i, 0, date_item)
self.earned_table.setItem(i, 1, time_item)
self.earned_table.setItem(i, 2, memo_item)
# 사용 내역 로드 (잔액 조정 제외)
cursor.execute('''
SELECT id, date, used_minutes, reason
FROM overtime_usage
WHERE COALESCE(reason, '') != '잔액 조정'
ORDER BY date DESC
''')
used_records = cursor.fetchall()
self.used_table.setRowCount(len(used_records))
for i, record in enumerate(used_records):
# ID를 숨겨진 데이터로 저장
date_item = QTableWidgetItem(record[1])
date_item.setTextAlignment(Qt.AlignCenter)
date_item.setData(Qt.UserRole, record[0]) # ID 저장
minutes = record[2]
hours = minutes // 60
mins = minutes % 60
time_str = f"{hours}시간 {mins}분" if hours > 0 else f"{mins}분"
time_item = QTableWidgetItem(time_str)
time_item.setTextAlignment(Qt.AlignCenter)
time_item.setForeground(QColor(231, 76, 60)) # 빨간색
reason_item = QTableWidgetItem(record[3] or "")
self.used_table.setItem(i, 0, date_item)
self.used_table.setItem(i, 1, time_item)
self.used_table.setItem(i, 2, reason_item)
conn.close()
def show_used_context_menu(self, position):
"""사용 내역 우클릭 메뉴"""
# 선택된 행이 있는지 확인
selected_rows = self.used_table.selectionModel().selectedRows()
if not selected_rows:
return
# 컨텍스트 메뉴 생성
menu = QMenu(self)
delete_action = QAction("❌ 삭제", self)
delete_action.triggered.connect(self.delete_used_record)
menu.addAction(delete_action)
# 메뉴 표시
menu.exec_(self.used_table.viewport().mapToGlobal(position))
def delete_used_record(self):
"""사용 기록 삭제"""
# 선택된 행 가져오기
selected_rows = self.used_table.selectionModel().selectedRows()
if not selected_rows:
return
row = selected_rows[0].row()
date_item = self.used_table.item(row, 0)
time_item = self.used_table.item(row, 1)
reason_item = self.used_table.item(row, 2)
# ID 가져오기
usage_id = date_item.data(Qt.UserRole)
# 확인 메시지
reply = QMessageBox.question(
self,
"삭제 확인",
f"다음 사용 기록을 삭제하시겠습니까?\n\n"
f"날짜: {date_item.text()}\n"
f"시간: {time_item.text()}\n"
f"사유: {reason_item.text()}",
QMessageBox.Yes | QMessageBox.No
)
if reply == QMessageBox.Yes:
# 데이터베이스에서 삭제
conn = self.db.get_connection()
cursor = conn.cursor()
cursor.execute('DELETE FROM overtime_usage WHERE id = ?', (usage_id,))
conn.commit()
conn.close()
# 화면 새로고침
self.load_data()
# 부모 윈도우의 잔액도 업데이트
if self.parent() and hasattr(self.parent(), 'update_overtime_balance'):
self.parent().update_overtime_balance()
def add_earned_record(self):
"""수동 적립 추가"""
dialog = AddOvertimeEarnedDialog(self, self.db)
if dialog.exec_() == QDialog.Accepted:
self.load_data()
# 부모 윈도우 업데이트
if self.parent() and hasattr(self.parent(), 'update_overtime_balance'):
self.parent().update_overtime_balance()
def add_used_record(self):
"""수동 사용 추가"""
dialog = AddOvertimeUsedDialog(self, self.db)
if dialog.exec_() == QDialog.Accepted:
self.load_data()
# 부모 윈도우 업데이트
if self.parent() and hasattr(self.parent(), 'update_overtime_balance'):
self.parent().update_overtime_balance()
class AddOvertimeEarnedDialog(QDialog):
"""추가근무 수동 적립 다이얼로그"""
def __init__(self, parent=None, db=None):
super().__init__(parent)
self.db = db
self.init_ui()
apply_dark_titlebar(self)
def init_ui(self):
"""UI 초기화"""
self.setWindowTitle("추가근무 수동 적립")
self.setModal(True)
self.setMinimumWidth(360)
layout = QVBoxLayout()
layout.setSpacing(8)
layout.setContentsMargins(12, 10, 12, 10)
# 제목
title = QLabel("추가근무 수동 적립")
title.setObjectName("dialog_subtitle")
title.setAlignment(Qt.AlignCenter)
layout.addWidget(title)
# 날짜
date_layout = QHBoxLayout()
date_label = QLabel("날짜:")
date_label.setObjectName("field_label")
date_label.setFixedWidth(60)
self.date_edit = QDateEdit()
self.date_edit.setDate(QDate.currentDate())
self.date_edit.setCalendarPopup(True)
date_layout.addWidget(date_label)
date_layout.addWidget(self.date_edit)
layout.addLayout(date_layout)
# 시간 (30분 단위)
time_layout = QHBoxLayout()
time_label = QLabel("시간:")
time_label.setObjectName("field_label")
time_label.setFixedWidth(60)
self.hour_spin = QSpinBox()
self.hour_spin.setRange(0, 23)
self.hour_spin.setSuffix("시간")
self.minute_combo = QComboBox()
self.minute_combo.addItems(["0분", "30분"])
time_layout.addWidget(time_label)
time_layout.addWidget(self.hour_spin)
time_layout.addWidget(self.minute_combo)
layout.addLayout(time_layout)
# 메모
memo_layout = QHBoxLayout()
memo_label = QLabel("메모:")
memo_label.setObjectName("field_label")
memo_label.setFixedWidth(60)
self.memo_edit = QLineEdit()
self.memo_edit.setPlaceholderText("선택사항")
memo_layout.addWidget(memo_label)
memo_layout.addWidget(self.memo_edit)
layout.addLayout(memo_layout)
# 버튼
button_layout = QHBoxLayout()
save_button = QPushButton("저장")
save_button.setObjectName("btn_primary")
save_button.clicked.connect(self.save)
cancel_button = QPushButton("취소")
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(save_button)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
self.setLayout(layout)
def save(self):
"""저장"""
# 시간 계산 (30분 단위)
hours = self.hour_spin.value()
minutes = 0 if self.minute_combo.currentText() == "0분" else 30
total_minutes = hours * 60 + minutes
if total_minutes == 0:
QMessageBox.warning(self, "입력 오류", "0분은 추가할 수 없습니다.")
return
date = self.date_edit.date().toString("yyyy-MM-dd")
memo = self.memo_edit.text().strip()
# DB에 저장 (work_record_id는 NULL)
self.db.add_overtime_earned(None, total_minutes, date)
# 메모가 있으면 work_records 업데이트 (기존 메모 보존)
if memo:
record = self.db.get_work_record(date)
if record:
existing_memo = record.get('memo', '') or ''
# 기존 메모가 있으면 줄바꿈 후 추가
if existing_memo:
new_memo = f"{existing_memo}\n[수동 적립] {memo}"
else:
new_memo = f"[수동 적립] {memo}"
conn = self.db.get_connection()
cursor = conn.cursor()
cursor.execute('''
UPDATE work_records
SET memo = ?
WHERE date = ?
''', (new_memo, date))
conn.commit()
conn.close()
QMessageBox.information(
self,
"저장 완료",
f"{hours}시간 {minutes}분이 적립되었습니다."
)
self.accept()
class AddOvertimeUsedDialog(QDialog):
"""추가근무 수동 사용 다이얼로그"""
def __init__(self, parent=None, db=None):
super().__init__(parent)
self.db = db
self.init_ui()
apply_dark_titlebar(self)
def init_ui(self):
"""UI 초기화"""
self.setWindowTitle("추가근무 수동 사용")
self.setModal(True)
self.setMinimumWidth(360)
layout = QVBoxLayout()
layout.setSpacing(8)
layout.setContentsMargins(12, 10, 12, 10)
# 제목 + 잔액 한 줄
header_layout = QHBoxLayout()
title = QLabel("추가근무 수동 사용")
title.setObjectName("dialog_subtitle")
header_layout.addWidget(title)
header_layout.addStretch()
balance = self.db.get_total_overtime_balance()
hours = balance // 60
minutes = balance % 60
balance_label = QLabel(f"잔액: {hours}시간 {minutes}분")
balance_label.setObjectName("badge_balance")
header_layout.addWidget(balance_label)
layout.addLayout(header_layout)
# 날짜
date_layout = QHBoxLayout()
date_label = QLabel("날짜:")
date_label.setObjectName("field_label")
date_label.setFixedWidth(60)
self.date_edit = QDateEdit()
self.date_edit.setDate(QDate.currentDate())
self.date_edit.setCalendarPopup(True)
date_layout.addWidget(date_label)
date_layout.addWidget(self.date_edit)
layout.addLayout(date_layout)
# 시간 (30분 단위)
time_layout = QHBoxLayout()
time_label = QLabel("시간:")
time_label.setObjectName("field_label")
time_label.setFixedWidth(60)
self.hour_spin = QSpinBox()
self.hour_spin.setRange(0, 23)
self.hour_spin.setSuffix("시간")
self.minute_combo = QComboBox()
self.minute_combo.addItems(["0분", "30분"])
time_layout.addWidget(time_label)
time_layout.addWidget(self.hour_spin)
time_layout.addWidget(self.minute_combo)
layout.addLayout(time_layout)
# 사유
reason_layout = QHBoxLayout()
reason_label = QLabel("사유:")
reason_label.setObjectName("field_label")
reason_label.setFixedWidth(60)
self.reason_edit = QLineEdit()
self.reason_edit.setPlaceholderText("예: 개인 사유")
reason_layout.addWidget(reason_label)
reason_layout.addWidget(self.reason_edit)
layout.addLayout(reason_layout)
# 버튼
button_layout = QHBoxLayout()
save_button = QPushButton("저장")
save_button.setObjectName("btn_primary")
save_button.clicked.connect(self.save)
cancel_button = QPushButton("취소")
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(save_button)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
self.setLayout(layout)
def save(self):
"""저장"""
# 시간 계산 (30분 단위)
hours = self.hour_spin.value()
minutes = 0 if self.minute_combo.currentText() == "0분" else 30
total_minutes = hours * 60 + minutes
if total_minutes == 0:
QMessageBox.warning(self, "입력 오류", "0분은 사용할 수 없습니다.")
return
# 잔액 확인
balance = self.db.get_total_overtime_balance()
if total_minutes > balance:
QMessageBox.warning(
self,
"잔액 부족",
f"사용 가능한 시간이 부족합니다.\n\n"
f"요청: {hours}시간 {minutes}분\n"
f"잔액: {balance // 60}시간 {balance % 60}분"
)
return
date = self.date_edit.date().toString("yyyy-MM-dd")
reason = self.reason_edit.text().strip() or "수동 사용"
# DB에 저장
self.db.add_overtime_usage(None, total_minutes, date, reason)
QMessageBox.information(
self,
"저장 완료",
f"{hours}시간 {minutes}분이 사용 처리되었습니다."
)
self.accept()
# 테스트 코드
if __name__ == "__main__":
from PyQt5.QtWidgets import QApplication
app = QApplication(sys.argv)
dialog = OvertimeView()
dialog.exec_()
+1149
View File
File diff suppressed because it is too large Load Diff
+283
View File
@@ -0,0 +1,283 @@
"""
통계 대시보드 - 주간/월간 통계
"""
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)
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)
# 월간 차트
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.analyze_patterns(monthly_stats.get('records', []))
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_()
+955
View File
@@ -0,0 +1,955 @@
"""
테마 시스템 - 라이트/다크 테마 지원
"""
import os
import tempfile
# ─── 화살표 아이콘 생성 ──────────────────────────────────────
_arrow_dir = os.path.join(tempfile.gettempdir(), 'clockout_arrows')
os.makedirs(_arrow_dir, exist_ok=True)
_light_arrows = {}
_dark_arrows = {}
_checkmark = ''
_icons_initialized = False
def _ensure_icons():
"""아이콘 PNG가 필요할 때 생성 (QApplication 존재 필요)"""
global _light_arrows, _dark_arrows, _checkmark, _icons_initialized
if _icons_initialized:
return
_icons_initialized = True
try:
from PyQt5.QtGui import QPixmap, QPainter, QColor as _QColor, QPolygon, QPen
from PyQt5.QtCore import QPoint
except ImportError:
return
arrows = {}
for name, color_hex, points in [
('up_light', '#4A4A68', [(4, 7), (8, 3), (12, 7)]),
('down_light', '#4A4A68', [(4, 5), (8, 9), (12, 5)]),
('up_dark', '#A0A0B8', [(4, 7), (8, 3), (12, 7)]),
('down_dark', '#A0A0B8', [(4, 5), (8, 9), (12, 5)]),
]:
path = os.path.join(_arrow_dir, f'{name}.png')
if not os.path.exists(path):
pm = QPixmap(16, 12)
pm.fill(_QColor(0, 0, 0, 0))
p = QPainter(pm)
p.setRenderHint(QPainter.Antialiasing)
p.setPen(_QColor(color_hex))
p.setBrush(_QColor(color_hex))
poly = QPolygon([QPoint(x, y) for x, y in points])
p.drawPolygon(poly)
p.end()
pm.save(path, 'PNG')
arrows[name] = path.replace('\\', '/')
# 체크마크 아이콘 생성
checkmark_path = os.path.join(_arrow_dir, 'checkmark.png')
if not os.path.exists(checkmark_path):
pm = QPixmap(14, 14)
pm.fill(_QColor(0, 0, 0, 0))
p = QPainter(pm)
p.setRenderHint(QPainter.Antialiasing)
pen = QPen(_QColor('#FFFFFF'))
pen.setWidth(2)
p.setPen(pen)
p.drawLine(QPoint(2, 7), QPoint(5, 11))
p.drawLine(QPoint(5, 11), QPoint(11, 3))
p.end()
pm.save(checkmark_path, 'PNG')
_light_arrows = {k: v for k, v in arrows.items() if 'light' in k}
_dark_arrows = {k: v for k, v in arrows.items() if 'dark' in k}
_checkmark = checkmark_path.replace('\\', '/')
# ─── 색상 정의 ───────────────────────────────────────────────
LIGHT_COLORS = {
# 배경 계층
'bg_primary': '#F5F5F7',
'bg_secondary': '#FFFFFF',
'bg_tertiary': '#EDEDF0',
# 텍스트 계층
'text_primary': '#1A1A2E',
'text_secondary': '#4A4A68',
'text_tertiary': '#8E8EA0',
'text_inverse': '#FFFFFF',
# 액센트
'accent_primary': '#3B82F6',
'accent_success': '#10B981',
'accent_warning': '#F59E0B',
'accent_danger': '#EF4444',
# 테두리
'border_subtle': '#E5E7EB',
'border_default': '#D1D5DB',
'border_focus': '#3B82F6',
# 배지 배경
'badge_overtime_bg': '#FEF3C7',
'badge_overtime_text': '#92400E',
'badge_leave_bg': '#DBEAFE',
'badge_leave_text': '#1E40AF',
'badge_total_bg': '#D1FAE5',
'badge_total_text': '#065F46',
# 프로그레스
'progress_bg': '#E5E7EB',
'progress_start': '#3B82F6',
'progress_end': '#10B981',
# 상태 색상 (동적)
'status_overtime': '#EF4444',
'status_warning': '#F59E0B',
'status_normal': '#10B981',
'status_break_active': '#EF4444',
'status_break_idle': '#8E8EA0',
# 캘린더 날짜 배경
'cal_normal': '#D1FAE5',
'cal_overtime': '#FEE2E2',
'cal_incomplete': '#FEF9C3',
# 스크롤바
'scrollbar_bg': '#F5F5F7',
'scrollbar_handle': '#C4C4CC',
'scrollbar_hover': '#A0A0B0',
}
DARK_COLORS = {
'bg_primary': '#111118',
'bg_secondary': '#1C1C2E',
'bg_tertiary': '#282842',
'text_primary': '#ECECF4',
'text_secondary': '#B0B0C8',
'text_tertiary': '#808098',
'text_inverse': '#FFFFFF',
'accent_primary': '#6B9EFF',
'accent_success': '#4ADE80',
'accent_warning': '#FCD34D',
'accent_danger': '#FB7185',
'border_subtle': '#32324E',
'border_default': '#44446A',
'border_focus': '#6B9EFF',
'badge_overtime_bg': '#3D2008',
'badge_overtime_text': '#FDE68A',
'badge_leave_bg': '#1E2D5F',
'badge_leave_text': '#A5D0FE',
'badge_total_bg': '#0A3324',
'badge_total_text': '#86EFAC',
'progress_bg': '#282842',
'progress_start': '#6B9EFF',
'progress_end': '#4ADE80',
'status_overtime': '#FB7185',
'status_warning': '#FCD34D',
'status_normal': '#4ADE80',
'status_break_active': '#FB7185',
'status_break_idle': '#808098',
'cal_normal': '#1A4D3A',
'cal_overtime': '#5C1A1A',
'cal_incomplete': '#5C3A10',
'scrollbar_bg': '#111118',
'scrollbar_handle': '#44446A',
'scrollbar_hover': '#5A5A88',
}
# ─── 현재 테마 상태 ─────────────────────────────────────────
class ThemeColors:
"""런타임에 현재 테마 색상을 참조하는 헬퍼"""
current = LIGHT_COLORS
@classmethod
def set_theme(cls, theme_name: str):
cls.current = DARK_COLORS if theme_name == 'dark' else LIGHT_COLORS
@classmethod
def get(cls, key: str) -> str:
return cls.current.get(key, '#FF00FF') # 누락 시 눈에 띄는 색
# ─── QSS 생성 ────────────────────────────────────────────────
def generate_theme(colors: dict, is_dark: bool = False) -> str:
"""색상 딕셔너리로부터 전체 QSS 문자열 생성"""
_ensure_icons()
c = colors
arrows = _dark_arrows if is_dark else _light_arrows
up_arrow = arrows.get('up_dark' if is_dark else 'up_light', '')
down_arrow = arrows.get('down_dark' if is_dark else 'down_light', '')
checkmark = _checkmark
return f"""
/* ════════════════════════════════════════
기본 위젯
════════════════════════════════════════ */
QMainWindow, QDialog {{
background: {c['bg_primary']};
}}
QWidget {{
font-family: "Segoe UI", "맑은 고딕", sans-serif;
font-size: 9.5pt;
color: {c['text_primary']};
}}
QWidget#central_widget {{
background: transparent;
}}
/* ════════════════════════════════════════
타이포그래피
════════════════════════════════════════ */
QLabel#app_title {{
font-size: 12pt;
font-weight: bold;
color: {c['text_primary']};
padding: 2px;
}}
QLabel#date_label {{
font-size: 9pt;
color: {c['text_secondary']};
padding-bottom: 4px;
}}
QLabel#section_title {{
font-size: 9.5pt;
font-weight: bold;
color: {c['text_primary']};
}}
QLabel#field_label {{
font-size: 9pt;
color: {c['text_secondary']};
}}
QLabel#time_value {{
font-family: "Consolas", "D2Coding", monospace;
font-size: 11pt;
font-weight: bold;
color: {c['text_primary']};
}}
QLabel#time_display {{
font-family: "Consolas", "D2Coding", monospace;
font-size: 22pt;
font-weight: bold;
color: {c['text_primary']};
background: {c['bg_secondary']};
border: 1px solid {c['border_subtle']};
border-radius: 10px;
padding: 10px;
}}
QLabel#expected_time {{
font-size: 10pt;
font-weight: bold;
color: {c['text_primary']};
padding: 4px;
}}
QLabel#dialog_title {{
font-size: 14pt;
font-weight: bold;
color: {c['text_primary']};
padding: 16px;
}}
QLabel#dialog_subtitle {{
font-size: 12pt;
font-weight: bold;
color: {c['text_primary']};
}}
QLabel#stat_value {{
font-size: 10pt;
font-weight: bold;
color: {c['accent_primary']};
}}
QLabel#note_text {{
font-size: 8.5pt;
color: {c['text_tertiary']};
}}
QLabel#info_text {{
font-size: 9pt;
color: {c['accent_danger']};
}}
/* ════════════════════════════════════════
배지 라벨 (배경색 있는 상태 표시)
════════════════════════════════════════ */
QLabel#badge_overtime {{
font-size: 9.5pt;
font-weight: bold;
padding: 4px 10px;
min-width: 110px;
qproperty-alignment: AlignCenter;
background: {c['badge_overtime_bg']};
color: {c['badge_overtime_text']};
border-radius: 6px;
}}
QLabel#badge_leave {{
font-size: 9.5pt;
font-weight: bold;
padding: 4px 10px;
min-width: 110px;
qproperty-alignment: AlignCenter;
background: {c['badge_leave_bg']};
color: {c['badge_leave_text']};
border-radius: 6px;
}}
QLabel#badge_total {{
font-size: 9.5pt;
font-weight: bold;
padding: 4px 10px;
min-width: 110px;
qproperty-alignment: AlignCenter;
background: {c['badge_total_bg']};
color: {c['badge_total_text']};
border-radius: 6px;
}}
QLabel#badge_balance {{
font-size: 12pt;
font-weight: bold;
padding: 10px;
background: {c['bg_tertiary']};
color: {c['text_primary']};
border-radius: 6px;
}}
QLabel#badge_success {{
font-size: 10pt;
font-weight: bold;
padding: 8px;
background: {c['badge_total_bg']};
color: {c['badge_total_text']};
border-radius: 6px;
}}
/* ════════════════════════════════════════
구분선
════════════════════════════════════════ */
QLabel#separator {{
background: {c['border_subtle']};
max-height: 1px;
min-height: 1px;
}}
/* ════════════════════════════════════════
그룹 박스 (카드)
════════════════════════════════════════ */
QGroupBox {{
background: {c['bg_secondary']};
border: 1px solid {c['border_subtle']};
border-radius: 10px;
margin-top: 10px;
padding: 14px;
padding-top: 28px;
font-size: 9.5pt;
color: {c['text_primary']};
}}
QGroupBox::title {{
subcontrol-origin: margin;
subcontrol-position: top left;
padding: 3px 10px;
margin-left: 8px;
font-weight: bold;
color: {c['text_secondary']};
background: {c['bg_secondary']};
border-radius: 4px;
}}
/* ════════════════════════════════════════
버튼
════════════════════════════════════════ */
QPushButton {{
background: {c['bg_tertiary']};
color: {c['text_primary']};
border: 1px solid {c['border_default']};
border-radius: 6px;
padding: 7px 14px;
font-size: 9pt;
}}
QPushButton:hover {{
background: {c['border_default']};
}}
QPushButton:pressed {{
background: {c['border_subtle']};
}}
QPushButton:disabled {{
background: {c['bg_tertiary']};
color: {c['text_tertiary']};
border-color: {c['border_subtle']};
}}
QPushButton:checked {{
background: {c['accent_primary']};
color: {c['text_inverse']};
border-color: {c['accent_primary']};
}}
/* 퇴근 버튼 (primary action) */
QPushButton#clock_out_button {{
background: {c['accent_success']};
color: {c['text_inverse']};
font-size: 11pt;
font-weight: bold;
padding: 8px;
border: none;
border-radius: 8px;
}}
QPushButton#clock_out_button:hover {{
background: {'#0EA572' if not is_dark else '#2BB885'};
}}
QPushButton#clock_out_button:pressed {{
background: {'#0C8F63' if not is_dark else '#28A87A'};
}}
/* 주요 액션 버튼 */
QPushButton#btn_primary {{
background: {c['accent_primary']};
color: {c['text_inverse']};
border: none;
font-weight: bold;
}}
QPushButton#btn_primary:hover {{
background: {c['accent_primary']}DD;
}}
QPushButton#btn_primary:pressed {{
background: {c['accent_primary']}BB;
}}
/* 위험 버튼 */
QPushButton#btn_danger {{
background: {c['accent_danger']};
color: {c['text_inverse']};
border: none;
}}
QPushButton#btn_danger:hover {{
background: {c['accent_danger']}DD;
}}
QPushButton#btn_danger:pressed {{
background: {c['accent_danger']}BB;
}}
/* 성공 버튼 */
QPushButton#btn_success {{
background: {c['accent_success']};
color: {c['text_inverse']};
border: none;
}}
QPushButton#btn_success:hover {{
background: {c['accent_success']}DD;
}}
QPushButton#btn_success:pressed {{
background: {c['accent_success']}BB;
}}
/* 작은 버튼 */
QPushButton#btn_small {{
font-size: 8.5pt;
padding: 5px 10px;
}}
QPushButton#btn_small:hover {{
background: {c['accent_primary']}20;
}}
QPushButton#btn_small:pressed {{
background: {c['accent_primary']}35;
}}
/* ════════════════════════════════════════
입력 필드
════════════════════════════════════════ */
QLineEdit, QTextEdit, QComboBox {{
background: {c['bg_secondary']};
border: 1px solid {c['border_default']};
border-radius: 6px;
padding: 6px 8px;
color: {c['text_primary']};
font-size: 9.5pt;
min-height: 20px;
}}
QSpinBox, QDoubleSpinBox, QDateEdit, QTimeEdit {{
background: {c['bg_secondary']};
border: 1px solid {c['border_default']};
border-radius: 6px;
padding: 6px 28px 6px 8px;
color: {c['text_primary']};
font-size: 9.5pt;
min-height: 20px;
}}
QLineEdit:focus, QTextEdit:focus, QComboBox:focus {{
border: 2px solid {c['border_focus']};
padding: 5px 7px;
}}
QSpinBox:focus, QDoubleSpinBox:focus, QDateEdit:focus, QTimeEdit:focus {{
border: 2px solid {c['border_focus']};
padding: 5px 27px 5px 7px;
}}
/* 비활성 입력 필드 */
QLineEdit:disabled, QTextEdit:disabled, QComboBox:disabled,
QSpinBox:disabled, QDoubleSpinBox:disabled, QDateEdit:disabled, QTimeEdit:disabled {{
background: {c['bg_tertiary']};
color: {c['text_tertiary']};
border-color: {c['border_subtle']};
}}
QComboBox::drop-down {{
subcontrol-origin: padding;
subcontrol-position: center right;
width: 20px;
border: none;
padding-right: 4px;
}}
QComboBox::down-arrow {{
image: url({down_arrow});
width: 10px;
height: 8px;
}}
QComboBox QAbstractItemView {{
background: {c['bg_secondary']};
border: 1px solid {c['border_default']};
color: {c['text_primary']};
selection-background-color: {c['accent_primary']};
selection-color: {c['text_inverse']};
}}
QSpinBox::up-button, QSpinBox::down-button,
QDoubleSpinBox::up-button, QDoubleSpinBox::down-button,
QDateEdit::up-button, QDateEdit::down-button,
QTimeEdit::up-button, QTimeEdit::down-button {{
background: {c['bg_tertiary']};
border: 1px solid {c['border_subtle']};
width: 22px;
subcontrol-origin: border;
}}
QSpinBox::up-button, QDoubleSpinBox::up-button,
QDateEdit::up-button, QTimeEdit::up-button {{
subcontrol-position: top right;
border-top-right-radius: 4px;
}}
QSpinBox::down-button, QDoubleSpinBox::down-button,
QDateEdit::down-button, QTimeEdit::down-button {{
subcontrol-position: bottom right;
border-bottom-right-radius: 4px;
}}
QSpinBox::up-button:hover, QSpinBox::down-button:hover,
QDoubleSpinBox::up-button:hover, QDoubleSpinBox::down-button:hover,
QDateEdit::up-button:hover, QDateEdit::down-button:hover,
QTimeEdit::up-button:hover, QTimeEdit::down-button:hover {{
background: {c['border_default']};
}}
QSpinBox::up-arrow, QDoubleSpinBox::up-arrow,
QDateEdit::up-arrow, QTimeEdit::up-arrow {{
image: url({up_arrow});
width: 10px;
height: 8px;
}}
QSpinBox::down-arrow, QDoubleSpinBox::down-arrow,
QDateEdit::down-arrow, QTimeEdit::down-arrow {{
image: url({down_arrow});
width: 10px;
height: 8px;
}}
/* ════════════════════════════════════════
체크박스
════════════════════════════════════════ */
QCheckBox {{
spacing: 8px;
color: {c['text_primary']};
font-size: 9pt;
}}
QCheckBox::indicator {{
width: 18px;
height: 18px;
border: 2px solid {c['border_default']};
border-radius: 4px;
background: {c['bg_secondary']};
}}
QCheckBox::indicator:checked {{
background: {c['accent_primary']};
border-color: {c['accent_primary']};
image: url({checkmark});
}}
QCheckBox::indicator:hover {{
border-color: {c['accent_primary']};
}}
/* ════════════════════════════════════════
프로그레스 바
════════════════════════════════════════ */
QProgressBar {{
border: none;
background: {c['progress_bg']};
border-radius: 4px;
height: 8px;
text-align: center;
color: transparent;
font-size: 0px;
}}
QProgressBar::chunk {{
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
stop:0 {c['progress_start']}, stop:1 {c['progress_end']});
border-radius: 4px;
}}
/* ════════════════════════════════════════
테이블
════════════════════════════════════════ */
QTableWidget {{
background: {c['bg_secondary']};
border: 1px solid {c['border_subtle']};
border-radius: 6px;
gridline-color: {c['border_subtle']};
color: {c['text_primary']};
font-size: 9pt;
}}
QTableWidget::item {{
padding: 6px 8px;
}}
QTableWidget::item:selected {{
background: {c['accent_primary']}30;
color: {c['text_primary']};
}}
QTableWidget::item:alternate {{
background: {c['bg_tertiary']};
}}
QHeaderView::section {{
background: {c['bg_tertiary']};
color: {c['text_secondary']};
padding: 8px;
border: none;
border-bottom: 2px solid {c['accent_primary']};
font-weight: bold;
font-size: 9pt;
}}
/* ════════════════════════════════════════
탭 위젯
════════════════════════════════════════ */
QTabWidget::pane {{
border: 1px solid {c['border_subtle']};
border-radius: 6px;
background: {c['bg_secondary']};
top: -1px;
}}
QTabBar::tab {{
background: {c['bg_tertiary']};
color: {c['text_secondary']};
padding: 8px 20px;
border: 1px solid {c['border_subtle']};
border-bottom: none;
border-top-left-radius: 6px;
border-top-right-radius: 6px;
margin-right: 2px;
font-size: 10pt;
}}
QTabBar::tab:selected {{
background: {c['bg_secondary']};
color: {c['accent_primary']};
font-weight: bold;
border-bottom: 2px solid {c['accent_primary']};
}}
QTabBar::tab:hover:!selected {{
background: {c['border_subtle']};
color: {c['text_primary']};
}}
/* ════════════════════════════════════════
스크롤바
════════════════════════════════════════ */
QScrollBar:vertical {{
background: {c['scrollbar_bg']};
width: 10px;
border: none;
border-radius: 5px;
}}
QScrollBar::handle:vertical {{
background: {c['scrollbar_handle']};
min-height: 30px;
border-radius: 5px;
}}
QScrollBar::handle:vertical:hover {{
background: {c['scrollbar_hover']};
}}
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
height: 0px;
}}
QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{
background: none;
}}
QScrollBar:horizontal {{
background: {c['scrollbar_bg']};
height: 10px;
border: none;
border-radius: 5px;
}}
QScrollBar::handle:horizontal {{
background: {c['scrollbar_handle']};
min-width: 30px;
border-radius: 5px;
}}
QScrollBar::handle:horizontal:hover {{
background: {c['scrollbar_hover']};
}}
QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal {{
width: 0px;
}}
QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal {{
background: none;
}}
QScrollArea {{
border: none;
background: transparent;
}}
QWidget#fixed_bottom {{
background: {c['bg_primary']};
border-top: 1px solid {c['border_subtle']};
}}
QScrollArea > QWidget > QWidget#scroll_content {{
background: transparent;
}}
/* ════════════════════════════════════════
캘린더
════════════════════════════════════════ */
QCalendarWidget {{
background: {c['bg_secondary']};
border: 1px solid {c['border_subtle']};
border-radius: 6px;
font-size: 10pt;
}}
QCalendarWidget QAbstractItemView {{
selection-background-color: {c['accent_primary']};
selection-color: {c['text_inverse']};
background: {c['bg_secondary']};
color: {c['text_primary']};
alternate-background-color: {c['bg_secondary']};
}}
/* 요일 헤더 행 */
QCalendarWidget QAbstractItemView:enabled {{
color: {c['text_primary']};
}}
QCalendarWidget QHeaderView::section {{
background: {c['bg_tertiary']};
color: {c['text_secondary']};
font-weight: bold;
border: none;
border-bottom: 1px solid {c['border_subtle']};
padding: 4px;
}}
QCalendarWidget QWidget#qt_calendar_navigationbar {{
background: {c['bg_tertiary']};
border-top-left-radius: 6px;
border-top-right-radius: 6px;
padding: 4px;
}}
QCalendarWidget QToolButton {{
color: {c['text_primary']};
background: transparent;
font-size: 11pt;
font-weight: bold;
padding: 6px 12px;
border-radius: 6px;
min-width: 30px;
min-height: 24px;
}}
QCalendarWidget QToolButton:hover {{
background: {c['accent_primary']}25;
color: {c['accent_primary']};
}}
QCalendarWidget QToolButton#qt_calendar_prevmonth,
QCalendarWidget QToolButton#qt_calendar_nextmonth {{
qproperty-icon: none;
min-width: 36px;
min-height: 28px;
font-size: 14pt;
font-weight: bold;
color: {c['accent_primary']};
background: {c['bg_secondary']};
border: 1px solid {c['border_subtle']};
border-radius: 6px;
padding: 2px 8px;
}}
QCalendarWidget QToolButton#qt_calendar_prevmonth {{
qproperty-text: "<";
}}
QCalendarWidget QToolButton#qt_calendar_nextmonth {{
qproperty-text: ">";
}}
QCalendarWidget QToolButton#qt_calendar_prevmonth:hover,
QCalendarWidget QToolButton#qt_calendar_nextmonth:hover {{
background: {c['accent_primary']};
color: {c['text_inverse']};
border-color: {c['accent_primary']};
}}
/* ════════════════════════════════════════
메시지 박스
════════════════════════════════════════ */
QMessageBox, QInputDialog {{
background: {c['bg_primary']};
}}
QMessageBox QLabel, QInputDialog QLabel {{
color: {c['text_primary']};
font-size: 9.5pt;
}}
QDialogButtonBox QPushButton {{
min-width: 70px;
}}
/* ════════════════════════════════════════
툴팁
════════════════════════════════════════ */
QToolTip {{
background: {c['bg_secondary']};
color: {c['text_primary']};
border: 1px solid {c['border_default']};
border-radius: 4px;
padding: 4px 8px;
font-size: 9pt;
}}
/* ════════════════════════════════════════
메뉴
════════════════════════════════════════ */
QMenu {{
background: {c['bg_secondary']};
border: 1px solid {c['border_default']};
border-radius: 6px;
padding: 4px;
color: {c['text_primary']};
}}
QMenu::item {{
padding: 6px 24px;
border-radius: 4px;
}}
QMenu::item:selected {{
background: {c['accent_primary']};
color: {c['text_inverse']};
}}
"""
# ─── 편의 함수 ───────────────────────────────────────────────
def get_light_theme() -> str:
return generate_theme(LIGHT_COLORS, is_dark=False)
def get_dark_theme() -> str:
return generate_theme(DARK_COLORS, is_dark=True)
def get_theme(theme_name: str = 'light') -> str:
ThemeColors.set_theme(theme_name)
if theme_name == 'dark':
return get_dark_theme()
return get_light_theme()
def apply_dark_titlebar(widget, dark: bool = None):
"""Windows 타이틀바 다크모드 적용 (다이얼로그용)"""
if dark is None:
dark = ThemeColors.current is DARK_COLORS
try:
import ctypes
hwnd = int(widget.winId())
DWMWA_USE_IMMERSIVE_DARK_MODE = 20
value = ctypes.c_int(1 if dark else 0)
ctypes.windll.dwmapi.DwmSetWindowAttribute(
hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE,
ctypes.byref(value), ctypes.sizeof(value)
)
except Exception:
pass