KINDNICK bedbb1e9ec
Some checks failed
CI / test (push) Has been cancelled
Initial release v2.2.0
핵심 기능:
- 단축근무·표준·반일 등 다양한 근무 패턴 (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>
2026-04-30 12:54:40 +09:00

294 lines
10 KiB
Python

"""
외출 관리 화면
"""
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()