핵심 기능: - 단축근무·표준·반일 등 다양한 근무 패턴 (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:
@@ -0,0 +1 @@
|
||||
# ui 모듈
|
||||
@@ -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()
|
||||
@@ -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_()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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_()
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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()
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user