v2.7.0: i18n 100% + 런타임 retranslate + 테스트 +47 + 폴리싱

- i18n 사전 100% (break/overtime/leave/clockin) — 50+ 신규 키
- 런타임 재번역 인프라 (ui/i18n_runtime.py) — 재시작 없이 메인 UI 적용
- MealController 분리 — 점심/저녁 토글을 컨트롤러로 추출
- 통합 테스트 +15 (S36-S52: 온보딩/salary/CSV/notification dedupe 등)
- pytest 신규 4종 + i18n_runtime 테스트 (총 122 케이스, 90→122)
- README/INSTALL/CLAUDE/AGENTS v2.6+ 아키텍처 반영
This commit is contained in:
KINDNICK
2026-04-30 19:30:47 +09:00
parent 6a17876af1
commit ff71886fd7
21 changed files with 1744 additions and 336 deletions
+28 -19
View File
@@ -21,7 +21,7 @@ class BreakEditDialog(QDialog):
def init_ui(self):
"""UI 초기화"""
self.setWindowTitle("외출 기록 수정")
self.setWindowTitle(tr('dlg.break.edit_title'))
self.setFixedSize(380, 180)
layout = QVBoxLayout()
@@ -30,7 +30,7 @@ class BreakEditDialog(QDialog):
# 외출 시간
out_layout = QHBoxLayout()
out_label = QLabel("외출 시간:")
out_label = QLabel(tr('dlg.break.out_label'))
out_label.setFixedWidth(80)
self.out_time_edit = QTimeEdit()
self.out_time_edit.setDisplayFormat("HH:mm:ss")
@@ -40,7 +40,7 @@ class BreakEditDialog(QDialog):
# 복귀 시간
in_layout = QHBoxLayout()
in_label = QLabel("복귀 시간:")
in_label = QLabel(tr('dlg.break.in_label'))
in_label.setFixedWidth(80)
self.in_time_edit = QTimeEdit()
self.in_time_edit.setDisplayFormat("HH:mm:ss")
@@ -50,7 +50,7 @@ class BreakEditDialog(QDialog):
# 사유
reason_layout = QHBoxLayout()
reason_label = QLabel("사유:")
reason_label = QLabel(tr('dlg.break.reason_label'))
reason_label.setFixedWidth(80)
self.reason_edit = QLineEdit()
reason_layout.addWidget(reason_label)
@@ -74,8 +74,8 @@ class BreakEditDialog(QDialog):
# 버튼
button_layout = QHBoxLayout()
save_button = QPushButton("저장")
cancel_button = QPushButton("취소")
save_button = QPushButton(tr('btn.save'))
cancel_button = QPushButton(tr('btn.cancel'))
save_button.clicked.connect(self.accept)
cancel_button.clicked.connect(self.reject)
@@ -128,7 +128,7 @@ class BreakView(QDialog):
layout.setContentsMargins(12, 10, 12, 10)
# 제목
title = QLabel("오늘의 외출 기록")
title = QLabel(tr('view.break.today_title'))
title.setObjectName("dialog_subtitle")
title.setAlignment(Qt.AlignCenter)
layout.addWidget(title)
@@ -136,7 +136,13 @@ class BreakView(QDialog):
# 외출 리스트 테이블
self.table = QTableWidget()
self.table.setColumnCount(5)
self.table.setHorizontalHeaderLabels(["외출 시간", "복귀 시간", "소요 시간", "사유", ""])
self.table.setHorizontalHeaderLabels([
tr('view.break.col_out'),
tr('view.break.col_in'),
tr('view.break.col_duration'),
tr('view.break.col_reason'),
"",
])
# 테이블 설정
header = self.table.horizontalHeader()
@@ -152,7 +158,7 @@ class BreakView(QDialog):
layout.addWidget(self.table)
# 총 외출 시간 표시
self.total_label = QLabel("총 외출 시간: 0분")
self.total_label = QLabel(tr('view.break.total_zero'))
self.total_label.setObjectName("section_title")
self.total_label.setAlignment(Qt.AlignRight)
layout.addWidget(self.total_label)
@@ -160,8 +166,8 @@ class BreakView(QDialog):
# 버튼
button_layout = QHBoxLayout()
self.refresh_button = QPushButton("새로고침")
close_button = QPushButton("닫기")
self.refresh_button = QPushButton(tr('btn.refresh'))
close_button = QPushButton(tr('btn.close'))
self.refresh_button.clicked.connect(self.load_break_records)
close_button.clicked.connect(self.accept)
@@ -190,7 +196,7 @@ class BreakView(QDialog):
if break_in:
self.table.setItem(i, 1, QTableWidgetItem(break_in))
else:
item = QTableWidgetItem("진행중")
item = QTableWidgetItem(tr('view.break.in_progress'))
item.setForeground(Qt.red)
self.table.setItem(i, 1, item)
@@ -199,7 +205,10 @@ class BreakView(QDialog):
if total_minutes:
hours = total_minutes // 60
minutes = total_minutes % 60
duration_str = f"{hours}시간 {minutes}분" if hours > 0 else f"{minutes}분"
if hours > 0:
duration_str = tr('view.break.duration_fmt', h=hours, m=minutes)
else:
duration_str = tr('view.break.duration_min_only', m=minutes)
self.table.setItem(i, 2, QTableWidgetItem(duration_str))
else:
self.table.setItem(i, 2, QTableWidgetItem("-"))
@@ -214,8 +223,8 @@ class BreakView(QDialog):
action_layout.setContentsMargins(0, 0, 0, 0)
action_layout.setSpacing(5)
edit_button = QPushButton("수정")
delete_button = QPushButton("삭제")
edit_button = QPushButton(tr('btn.edit_short'))
delete_button = QPushButton(tr('btn.delete_short'))
edit_button.setFixedSize(50, 25)
delete_button.setFixedSize(50, 25)
@@ -237,9 +246,9 @@ class BreakView(QDialog):
minutes = total_minutes % 60
if hours > 0:
self.total_label.setText(f"총 외출 시간: {hours}시간 {minutes}분")
self.total_label.setText(tr('view.break.total_fmt', h=hours, m=minutes))
else:
self.total_label.setText(f"총 외출 시간: {minutes}분")
self.total_label.setText(tr('view.break.total_min_only', m=minutes))
def edit_record(self, record_id):
"""외출 기록 수정"""
@@ -277,8 +286,8 @@ class BreakView(QDialog):
"""외출 기록 삭제"""
reply = QMessageBox.question(
self,
"삭제 확인",
"이 외출 기록을 삭제하시겠습니까?",
tr('msg.confirm_delete.title'),
tr('view.break.delete_confirm'),
QMessageBox.Yes | QMessageBox.No
)
+6 -6
View File
@@ -29,14 +29,14 @@ class ClockInDialog(QDialog):
layout.setContentsMargins(12, 10, 12, 10)
# 안내 문구
info_label = QLabel("오늘의 출근시간을 입력해주세요")
info_label = QLabel(tr('dlg.clock_in.prompt'))
info_label.setObjectName("field_label")
info_label.setAlignment(Qt.AlignCenter)
layout.addWidget(info_label)
# 시간 입력
time_layout = QHBoxLayout()
time_label = QLabel("출근시간:")
time_label = QLabel(tr('dlg.clock_in.label'))
time_label.setObjectName("field_label")
self.time_edit = QTimeEdit()
@@ -59,13 +59,13 @@ class ClockInDialog(QDialog):
# 빠른 선택 버튼
quick_layout = QHBoxLayout()
quick_label = QLabel("빠른 선택:")
quick_label = QLabel(tr('dlg.clock_in.quick'))
quick_label.setObjectName("field_label")
btn_8am = QPushButton("08:00")
btn_9am = QPushButton("09:00")
btn_10am = QPushButton("10:00")
btn_now = QPushButton("현재")
btn_now = QPushButton(tr('dlg.clock_in.btn_now'))
for btn in [btn_8am, btn_9am, btn_10am, btn_now]:
btn.setMinimumHeight(30)
@@ -87,12 +87,12 @@ class ClockInDialog(QDialog):
# 버튼
button_layout = QHBoxLayout()
ok_button = QPushButton("확인")
ok_button = QPushButton(tr('btn.confirm'))
ok_button.setObjectName("btn_primary")
ok_button.setMinimumHeight(40)
ok_button.clicked.connect(self.accept)
cancel_button = QPushButton("취소")
cancel_button = QPushButton(tr('btn.cancel'))
cancel_button.setMinimumHeight(40)
cancel_button.clicked.connect(self.reject)
+56
View File
@@ -0,0 +1,56 @@
"""
점심/저녁 토글 컨트롤러.
main_window.py에서 toggle_lunch_break / toggle_dinner_break / update_lunch_status /
update_dinner_status 가 합쳐져 있던 것을 분리. 1Hz hot path 외 사용자 액션 응답.
단위 테스트가 가능하도록 window 의존성을 명시적으로 받음.
"""
from __future__ import annotations
from datetime import datetime
from core.i18n import tr
class MealController:
"""점심/저녁 토글 + 상태 라벨 갱신."""
def __init__(self, window):
self.window = window
self.db = window.db
# -------- 토글 --------
def toggle_lunch(self) -> None:
w = self.window
w.lunch_break_enabled = w.lunch_button.isChecked()
self.refresh_lunch_label()
# 사용자가 직접 토글하면 자동 적용 플래그를 처리됨으로 간주 (중복 알림 방지)
if w.lunch_break_enabled:
w.auto_lunch_applied_today = True
if w.is_clocked_in:
today = datetime.now().date().isoformat()
self.db.update_lunch_break(today, w.lunch_break_enabled)
def toggle_dinner(self) -> None:
w = self.window
w.dinner_break_enabled = w.dinner_button.isChecked()
self.refresh_dinner_label()
if w.is_clocked_in:
today = datetime.now().date().isoformat()
self.db.update_dinner_break(today, w.dinner_break_enabled)
# -------- 라벨 --------
def refresh_lunch_label(self) -> None:
w = self.window
w.lunch_button.setText(
tr('btn.lunch_applied') if w.lunch_break_enabled else tr('btn.lunch_add')
)
def refresh_dinner_label(self) -> None:
w = self.window
w.dinner_button.setText(
tr('btn.dinner_applied') if w.dinner_break_enabled else tr('btn.dinner_add')
)
+94
View File
@@ -0,0 +1,94 @@
"""
런타임 i18n 재번역 — 재시작 없이 언어 전환.
사용법:
from ui.i18n_runtime import register, retranslate_all, set_language_and_retranslate
label = QLabel(tr('label.foo'))
register(label, 'label.foo') # 약한 참조로 등록
button = QPushButton()
register(button, 'btn.bar', kwargs={'name': 'X'})
# 그룹박스 제목 등 setter가 다른 경우
register(group, 'group.work_time', setter='setTitle')
# 윈도우 제목
register(dialog, 'window.foo', setter='setWindowTitle')
언어 변경:
set_language_and_retranslate('en')
각 widget은 weakref로 보관되므로 삭제되면 자동 정리. format placeholder가 있는
키는 kwargs를 함께 등록하면 retranslate 시 같은 인자로 재계산.
"""
from __future__ import annotations
import weakref
from typing import Any, Callable, List, Tuple, Optional
from core.i18n import tr, set_language as _set_language
# (weakref, key, setter_name, kwargs, post_format) 튜플 리스트
# weakref가 죽으면 다음 retranslate 시 정리.
_registry: List[Tuple[weakref.ReferenceType, str, str, dict, Optional[Callable]]] = []
def register(widget: Any, key: str, *, setter: str = 'setText',
kwargs: Optional[dict] = None,
post: Optional[Callable[[str], str]] = None) -> None:
"""위젯을 retranslate 대상으로 등록.
Args:
widget: PyQt 위젯 (setText/setTitle/setWindowTitle 등 지원)
key: i18n 키
setter: 호출할 메서드명 (기본 setText)
kwargs: tr()에 전달할 format 인자 (정적인 경우만)
post: 번역 후 한 번 더 가공할 콜백 — 예: 이모지 prefix
"""
# 약한 참조 — 위젯 삭제 시 자동 GC
try:
ref = weakref.ref(widget)
except TypeError:
# weakref 미지원 객체는 retranslate 불가
return
_registry.append((ref, key, setter, kwargs or {}, post))
# 초기 적용
_apply(widget, key, setter, kwargs or {}, post)
def retranslate_all() -> None:
"""모든 등록된 위젯에 현재 언어로 텍스트 재적용."""
global _registry
alive = []
for ref, key, setter, kw, post in _registry:
widget = ref()
if widget is None:
continue # 죽은 위젯은 빼버림
try:
_apply(widget, key, setter, kw, post)
alive.append((ref, key, setter, kw, post))
except RuntimeError:
# Qt C++ 객체 삭제 후 호출 — 정리만 하고 패스
continue
_registry = alive
def set_language_and_retranslate(lang: str) -> None:
"""언어 전환 + 즉시 재번역."""
_set_language(lang)
retranslate_all()
def clear() -> None:
"""레지스트리 비우기 (테스트용)."""
_registry.clear()
def _apply(widget: Any, key: str, setter: str, kw: dict,
post: Optional[Callable[[str], str]]) -> None:
text = tr(key, **kw)
if post:
text = post(text)
fn = getattr(widget, setter, None)
if callable(fn):
fn(text)
+50 -48
View File
@@ -40,27 +40,32 @@ class LeaveView(QDialog):
# 제목 + 잔액 + 설정 한 줄
header_layout = QHBoxLayout()
title = QLabel("연차 관리")
title = QLabel(tr('view.leave.title'))
title.setObjectName("dialog_title")
header_layout.addWidget(title)
header_layout.addStretch()
self.balance_label = QLabel("잔여: 0일")
self.balance_label = QLabel(tr('view.leave.balance_zero'))
self.balance_label.setObjectName("badge_leave")
header_layout.addWidget(self.balance_label)
set_balance_button = QPushButton("잔여 설정")
set_balance_button = QPushButton(tr('view.leave.btn_set_balance'))
set_balance_button.clicked.connect(self.set_balance)
header_layout.addWidget(set_balance_button)
layout.addLayout(header_layout)
# 사용 내역
used_group = QGroupBox("📤 사용 내역")
used_group = QGroupBox(tr('view.leave.used_group'))
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.setHorizontalHeaderLabels([
tr('view.leave.col_date'),
tr('view.leave.col_type'),
tr('view.leave.col_used'),
tr('view.leave.col_reason'),
])
self.used_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.used_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
self.used_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents)
@@ -77,15 +82,15 @@ class LeaveView(QDialog):
# 버튼들
button_layout = QHBoxLayout()
add_leave_button = QPushButton("➕ 연차 사용 추가")
add_leave_button = QPushButton(tr('view.leave.btn_add'))
add_leave_button.clicked.connect(self.add_leave_record)
button_layout.addWidget(add_leave_button)
cal_button = QPushButton("📅 캘린더 보기")
cal_button = QPushButton(tr('view.leave.btn_calendar'))
cal_button.clicked.connect(self._show_calendar)
button_layout.addWidget(cal_button)
close_button = QPushButton("닫기")
close_button = QPushButton(tr('btn.close'))
close_button.clicked.connect(self.close)
button_layout.addWidget(close_button)
layout.addLayout(button_layout)
@@ -102,7 +107,8 @@ class LeaveView(QDialog):
# 잔액 업데이트
balance = self.db.get_leave_balance()
hours = balance * 8
self.balance_label.setText(f"잔여: {balance}일 (총 {hours}시간)")
self.balance_label.setText(tr('view.leave.balance_fmt',
days=balance, hours=hours))
# 사용 내역 로드 (잔액 조정 제외)
records = self.db.get_leave_records(exclude_bulk=True)
@@ -144,7 +150,7 @@ class LeaveView(QDialog):
return
menu = QMenu(self)
delete_action = QAction("삭제", self)
delete_action = QAction(tr('btn.delete_short'), self)
delete_action.triggered.connect(self.delete_leave_record)
menu.addAction(delete_action)
menu.exec_(self.used_table.viewport().mapToGlobal(position))
@@ -164,11 +170,10 @@ class LeaveView(QDialog):
reply = QMessageBox.question(
self,
"삭제 확인",
f"다음 연차 사용 기록을 삭제하시겠습니까?\n\n"
f"날짜: {date_item.text()}\n"
f"구분: {type_item.text()}\n"
f"사용: {days_item.text()}",
tr('msg.confirm_delete.title'),
tr('view.leave.delete_confirm_body',
date=date_item.text(), type=type_item.text(),
days=days_item.text()),
QMessageBox.Yes | QMessageBox.No
)
@@ -183,13 +188,12 @@ class LeaveView(QDialog):
hours, ok = QInputDialog.getDouble(
self,
"연차 시간 설정",
"연차 잔여 시간을 입력하세요 (0.5시간 단위):\n"
"예) 8시간 = 1일, 4시간 = 0.5일(반차), 2시간 = 0.25일, 0.5시간 = 30분",
tr('view.leave.set_title'),
tr('view.leave.set_prompt'),
current_hours,
0.0,
999.0,
1 # 소수점 첫째자리까지 (0.5 단위)
1
)
if ok:
@@ -200,8 +204,8 @@ class LeaveView(QDialog):
self.db.set_leave_balance(days)
QMessageBox.information(
self,
"설정 완료",
f"연차 잔여 개수가 {days}일 ({hours}시간)로 설정되었습니다."
tr('view.leave.set_done_title'),
tr('view.leave.set_done_body', days=days, hours=hours)
)
self.load_data()
@@ -223,7 +227,7 @@ class AddLeaveDialog(QDialog):
def init_ui(self):
"""UI 초기화"""
self.setWindowTitle("연차 사용 기록 추가")
self.setWindowTitle(tr('view.leave.add_title'))
self.setModal(True)
self.setMinimumWidth(360)
@@ -232,14 +236,14 @@ class AddLeaveDialog(QDialog):
layout.setContentsMargins(12, 10, 12, 10)
# 제목
title = QLabel("연차 사용 기록 추가")
title = QLabel(tr('view.leave.add_title'))
title.setObjectName("dialog_subtitle")
title.setAlignment(Qt.AlignCenter)
layout.addWidget(title)
# 날짜 + 구분 한 줄
row1 = QHBoxLayout()
date_label = QLabel("날짜:")
date_label = QLabel(tr('view.leave.field_date'))
date_label.setObjectName("field_label")
date_label.setFixedWidth(40)
self.date_edit = QDateEdit()
@@ -248,14 +252,14 @@ class AddLeaveDialog(QDialog):
row1.addWidget(date_label)
row1.addWidget(self.date_edit)
row1.addSpacing(8)
type_label = QLabel("구분:")
type_label = QLabel(tr('view.leave.field_type'))
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.addItem(tr('view.leave.type_annual'), "annual")
self.type_combo.addItem(tr('view.leave.type_half'), "half")
self.type_combo.addItem(tr('view.leave.type_quarter'), "quarter")
self.type_combo.addItem(tr('view.leave.type_hourly'), "hourly")
self.type_combo.currentIndexChanged.connect(self.on_type_changed)
row1.addWidget(type_label)
row1.addWidget(self.type_combo)
@@ -263,14 +267,14 @@ class AddLeaveDialog(QDialog):
# 사용 시간 (시간 연차용)
hours_layout = QHBoxLayout()
hours_label = QLabel("시간:")
hours_label = QLabel(tr('view.leave.field_hours'))
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.setSuffix(' ' + tr('label.unit_hour'))
self.hours_spin.setEnabled(False)
hours_layout.addWidget(hours_label)
hours_layout.addWidget(self.hours_spin)
@@ -278,27 +282,27 @@ class AddLeaveDialog(QDialog):
# 사유
memo_layout = QHBoxLayout()
memo_label = QLabel("사유:")
memo_label = QLabel(tr('view.leave.field_reason'))
memo_label.setObjectName("field_label")
memo_label.setFixedWidth(40)
self.memo_input = QLineEdit()
self.memo_input.setPlaceholderText("예) 개인 사유, 병원 방문 등")
self.memo_input.setPlaceholderText(tr('view.leave.placeholder_reason'))
memo_layout.addWidget(memo_label)
memo_layout.addWidget(self.memo_input)
layout.addLayout(memo_layout)
# 안내
info_label = QLabel("※ 잔여 연차가 자동 차감됩니다.")
info_label = QLabel(tr('view.leave.note_auto_deduct'))
info_label.setObjectName("note_text")
layout.addWidget(info_label)
# 버튼
button_layout = QHBoxLayout()
save_button = QPushButton("저장")
save_button = QPushButton(tr('btn.save'))
save_button.setObjectName("btn_primary")
save_button.clicked.connect(self.save_record)
button_layout.addWidget(save_button)
cancel_button = QPushButton("취소")
cancel_button = QPushButton(tr('btn.cancel'))
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(cancel_button)
layout.addLayout(button_layout)
@@ -336,8 +340,8 @@ class AddLeaveDialog(QDialog):
if current_balance < days:
QMessageBox.warning(
self,
"잔여 연차 부족",
f"잔여 연차가 부족합니다.\n현재 잔여: {current_balance}일\n사용 요청: {days}일"
tr('view.leave.short_title'),
tr('view.leave.short_body', balance=current_balance, req=days)
)
return
@@ -345,12 +349,10 @@ class AddLeaveDialog(QDialog):
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"이 기록을 추가하시겠습니까?",
tr('view.leave.confirm_title'),
tr('view.leave.confirm_body',
date=date, type=leave_type_name, days=days, hours=hours,
reason=(memo if memo else '-')),
QMessageBox.Yes | QMessageBox.No
)
@@ -360,15 +362,15 @@ class AddLeaveDialog(QDialog):
self.db.use_leave(days, date, leave_type_name, memo)
QMessageBox.information(
self,
"추가 완료",
f"{days}일 ({hours}시간)의 연차 사용이 기록되었습니다."
tr('view.leave.added_title'),
tr('view.leave.added_body', days=days, hours=hours)
)
self.accept()
except Exception as e:
QMessageBox.critical(
self,
"오류",
f"연차 기록 추가 중 오류가 발생했습니다:\n{str(e)}"
tr('view.leave.error_title'),
tr('view.leave.error_body', err=str(e))
)
+23 -29
View File
@@ -81,13 +81,15 @@ class MainWindow(QMainWindow):
self.notifier = Notifier(self, db=self.db)
self.notifier.notification_signal.connect(self.show_notification)
# 책임 분리된 컨트롤러들 (1Hz hot path)
# 책임 분리된 컨트롤러들 (1Hz hot path + 사용자 액션)
from ui.controllers.lock_monitor import LockMonitor
from ui.controllers.auto_lunch import AutoLunchManager
from ui.controllers.notification_orchestrator import NotificationOrchestrator
from ui.controllers.meal_controller import MealController
self._lock_monitor = LockMonitor(self)
self._auto_lunch = AutoLunchManager(self)
self._notif_orch = NotificationOrchestrator(self)
self._meal = MealController(self)
# 시스템 트레이
self.tray_icon = SystemTrayIcon(self)
@@ -184,7 +186,11 @@ class MainWindow(QMainWindow):
def init_ui(self):
"""UI 초기화"""
from core.version import __version__
from ui.i18n_runtime import register
self._app_version = __version__
self.setWindowTitle(f"⏰ {tr('window.main_title')} v{__version__}")
register(self, 'window.main_title', setter='setWindowTitle',
post=lambda t: f"⏰ {t} v{__version__}")
self.setGeometry(100, 100, 500, 620)
self.setMinimumSize(480, 520)
@@ -326,6 +332,14 @@ class MainWindow(QMainWindow):
help_button = QPushButton(tr('menu.help'))
settings_button = QPushButton(tr('menu.settings'))
# 런타임 i18n 등록
for btn, key in [(stats_button, 'menu.stats'),
(calendar_button, 'menu.calendar'),
(report_button, 'menu.daily_report'),
(help_button, 'menu.help'),
(settings_button, 'menu.settings')]:
register(btn, key)
for btn in [stats_button, calendar_button, report_button, help_button, settings_button]:
bottom_layout.addWidget(btn)
@@ -750,40 +764,20 @@ class MainWindow(QMainWindow):
self.date_label.setText(date_str)
def toggle_lunch_break(self):
"""점심시간 토글"""
self.lunch_break_enabled = self.lunch_button.isChecked()
self.update_lunch_status()
# 사용자가 직접 토글하면 자동 적용 플래그를 처리됨으로 간주 (중복 알림 방지)
if self.lunch_break_enabled:
self.auto_lunch_applied_today = True
# DB 업데이트
if self.is_clocked_in:
today = datetime.now().date().isoformat()
self.db.update_lunch_break(today, self.lunch_break_enabled)
"""점심시간 토글 — MealController 위임."""
self._meal.toggle_lunch()
def toggle_dinner_break(self):
"""저녁시간 토글"""
self.dinner_break_enabled = self.dinner_button.isChecked()
self.update_dinner_status()
# DB 업데이트
if self.is_clocked_in:
today = datetime.now().date().isoformat()
self.db.update_dinner_break(today, self.dinner_break_enabled)
"""저녁시간 토글 — MealController 위임."""
self._meal.toggle_dinner()
def update_lunch_status(self):
"""점심시간 상태 업데이트"""
self.lunch_button.setText(
tr('btn.lunch_applied') if self.lunch_break_enabled else tr('btn.lunch_add')
)
"""점심시간 상태 업데이트 — MealController 위임."""
self._meal.refresh_lunch_label()
def update_dinner_status(self):
"""저녁시간 상태 업데이트"""
self.dinner_button.setText(
tr('btn.dinner_applied') if self.dinner_break_enabled else tr('btn.dinner_add')
)
"""저녁시간 상태 업데이트 — MealController 위임."""
self._meal.refresh_dinner_label()
def update_overtime_balance(self):
"""연장근무 잔액 업데이트"""
+79 -58
View File
@@ -38,24 +38,28 @@ class OvertimeView(QDialog):
# 제목 + 잔액 한 줄
header_layout = QHBoxLayout()
title = QLabel("연장근무 내역")
title = QLabel(tr('view.overtime.title'))
title.setObjectName("dialog_title")
header_layout.addWidget(title)
header_layout.addStretch()
self.balance_label = QLabel("잔액: 0분")
self.balance_label = QLabel(tr('view.overtime.balance_zero'))
self.balance_label.setObjectName("badge_balance")
header_layout.addWidget(self.balance_label)
layout.addLayout(header_layout)
# 적립 내역
earned_group = QGroupBox("💰 적립 내역")
earned_group = QGroupBox(tr('view.overtime.earned_group'))
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.setHorizontalHeaderLabels([
tr('view.overtime.col_date'),
tr('view.overtime.col_earned'),
tr('view.overtime.col_memo'),
])
self.earned_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.earned_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
self.earned_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
@@ -64,7 +68,7 @@ class OvertimeView(QDialog):
self.earned_table.setSelectionBehavior(QTableWidget.SelectRows)
earned_layout.addWidget(self.earned_table)
add_earned_button = QPushButton("➕ 수동 적립")
add_earned_button = QPushButton(tr('view.overtime.btn_add_earned'))
add_earned_button.clicked.connect(self.add_earned_record)
earned_layout.addWidget(add_earned_button)
@@ -72,14 +76,18 @@ class OvertimeView(QDialog):
layout.addWidget(earned_group)
# 사용 내역
used_group = QGroupBox("📤 사용 내역")
used_group = QGroupBox(tr('view.overtime.used_group'))
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.setHorizontalHeaderLabels([
tr('view.overtime.col_date'),
tr('view.overtime.col_used'),
tr('view.overtime.col_reason'),
])
self.used_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.ResizeToContents)
self.used_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents)
self.used_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.Stretch)
@@ -90,7 +98,7 @@ class OvertimeView(QDialog):
self.used_table.customContextMenuRequested.connect(self.show_used_context_menu)
used_layout.addWidget(self.used_table)
add_used_button = QPushButton("➕ 수동 사용")
add_used_button = QPushButton(tr('view.overtime.btn_add_used'))
add_used_button.clicked.connect(self.add_used_record)
used_layout.addWidget(add_used_button)
@@ -98,7 +106,7 @@ class OvertimeView(QDialog):
layout.addWidget(used_group)
# 닫기 버튼
close_button = QPushButton("닫기")
close_button = QPushButton(tr('btn.close'))
close_button.clicked.connect(self.close)
layout.addWidget(close_button)
@@ -110,23 +118,21 @@ class OvertimeView(QDialog):
balance = self.db.get_total_overtime_balance()
hours = balance // 60
minutes = balance % 60
self.balance_label.setText(f"현재 잔액: {hours}시간 {minutes}분 ({balance}분)")
self.balance_label.setText(tr('view.overtime.balance_fmt',
h=hours, m=minutes, total=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
SELECT ob.date, ob.earned_minutes, ob.work_record_id, wr.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()
manual_label = tr('msg.manual_added')
self.earned_table.setRowCount(len(earned_records))
for i, record in enumerate(earned_records):
@@ -136,12 +142,17 @@ class OvertimeView(QDialog):
minutes = record[1]
hours = minutes // 60
mins = minutes % 60
time_str = f"{hours}시간 {mins}분" if hours > 0 else f"{mins}분"
if hours > 0:
time_str = tr('view.break.duration_fmt', h=hours, m=mins)
else:
time_str = tr('view.break.duration_min_only', m=mins)
time_item = QTableWidgetItem(time_str)
time_item.setTextAlignment(Qt.AlignCenter)
time_item.setForeground(QColor(39, 174, 96)) # 초록색
memo_item = QTableWidgetItem(record[2] or "")
# work_record_id NULL이면 "수동 추가", 아니면 wr.memo
memo_text = manual_label if record[2] is None else (record[3] or "")
memo_item = QTableWidgetItem(memo_text)
self.earned_table.setItem(i, 0, date_item)
self.earned_table.setItem(i, 1, time_item)
@@ -166,7 +177,10 @@ class OvertimeView(QDialog):
minutes = record[2]
hours = minutes // 60
mins = minutes % 60
time_str = f"{hours}시간 {mins}분" if hours > 0 else f"{mins}분"
if hours > 0:
time_str = tr('view.break.duration_fmt', h=hours, m=mins)
else:
time_str = tr('view.break.duration_min_only', m=mins)
time_item = QTableWidgetItem(time_str)
time_item.setTextAlignment(Qt.AlignCenter)
time_item.setForeground(QColor(231, 76, 60)) # 빨간색
@@ -188,7 +202,7 @@ class OvertimeView(QDialog):
# 컨텍스트 메뉴 생성
menu = QMenu(self)
delete_action = QAction("❌ 삭제", self)
delete_action = QAction(tr('view.overtime.menu_delete'), self)
delete_action.triggered.connect(self.delete_used_record)
menu.addAction(delete_action)
@@ -213,11 +227,10 @@ class OvertimeView(QDialog):
# 확인 메시지
reply = QMessageBox.question(
self,
"삭제 확인",
f"다음 사용 기록을 삭제하시겠습니까?\n\n"
f"날짜: {date_item.text()}\n"
f"시간: {time_item.text()}\n"
f"사유: {reason_item.text()}",
tr('msg.confirm_delete.title'),
tr('view.overtime.delete_confirm_body',
date=date_item.text(), time=time_item.text(),
reason=reason_item.text()),
QMessageBox.Yes | QMessageBox.No
)
@@ -266,7 +279,7 @@ class AddOvertimeEarnedDialog(QDialog):
def init_ui(self):
"""UI 초기화"""
self.setWindowTitle("추가근무 수동 적립")
self.setWindowTitle(tr('view.overtime.manual_earned_title'))
self.setModal(True)
self.setMinimumWidth(360)
@@ -275,14 +288,14 @@ class AddOvertimeEarnedDialog(QDialog):
layout.setContentsMargins(12, 10, 12, 10)
# 제목
title = QLabel("추가근무 수동 적립")
title = QLabel(tr('view.overtime.manual_earned_title'))
title.setObjectName("dialog_subtitle")
title.setAlignment(Qt.AlignCenter)
layout.addWidget(title)
# 날짜
date_layout = QHBoxLayout()
date_label = QLabel("날짜:")
date_label = QLabel(tr('view.overtime.field_date'))
date_label.setObjectName("field_label")
date_label.setFixedWidth(60)
self.date_edit = QDateEdit()
@@ -294,14 +307,15 @@ class AddOvertimeEarnedDialog(QDialog):
# 시간 (30분 단위)
time_layout = QHBoxLayout()
time_label = QLabel("시간:")
time_label = QLabel(tr('view.overtime.field_time'))
time_label.setObjectName("field_label")
time_label.setFixedWidth(60)
self.hour_spin = QSpinBox()
self.hour_spin.setRange(0, 23)
self.hour_spin.setSuffix("시간")
self.hour_spin.setSuffix(tr('view.overtime.unit_hour_suffix'))
self.minute_combo = QComboBox()
self.minute_combo.addItems(["0분", "30분"])
self.minute_combo.addItems([tr('view.overtime.minute_0'),
tr('view.overtime.minute_30')])
time_layout.addWidget(time_label)
time_layout.addWidget(self.hour_spin)
time_layout.addWidget(self.minute_combo)
@@ -309,21 +323,21 @@ class AddOvertimeEarnedDialog(QDialog):
# 메모
memo_layout = QHBoxLayout()
memo_label = QLabel("메모:")
memo_label = QLabel(tr('view.overtime.field_memo'))
memo_label.setObjectName("field_label")
memo_label.setFixedWidth(60)
self.memo_edit = QLineEdit()
self.memo_edit.setPlaceholderText("선택사항")
self.memo_edit.setPlaceholderText(tr('view.overtime.placeholder_memo'))
memo_layout.addWidget(memo_label)
memo_layout.addWidget(self.memo_edit)
layout.addLayout(memo_layout)
# 버튼
button_layout = QHBoxLayout()
save_button = QPushButton("저장")
save_button = QPushButton(tr('btn.save'))
save_button.setObjectName("btn_primary")
save_button.clicked.connect(self.save)
cancel_button = QPushButton("취소")
cancel_button = QPushButton(tr('btn.cancel'))
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(save_button)
button_layout.addWidget(cancel_button)
@@ -335,11 +349,12 @@ class AddOvertimeEarnedDialog(QDialog):
"""저장"""
# 시간 계산 (30분 단위)
hours = self.hour_spin.value()
minutes = 0 if self.minute_combo.currentText() == "0분" else 30
minutes = 0 if self.minute_combo.currentIndex() == 0 else 30
total_minutes = hours * 60 + minutes
if total_minutes == 0:
QMessageBox.warning(self, "입력 오류", "0분은 추가할 수 없습니다.")
QMessageBox.warning(self, tr('msg.input_error.title'),
tr('view.overtime.zero_add_error'))
return
date = self.date_edit.date().toString("yyyy-MM-dd")
@@ -371,8 +386,8 @@ class AddOvertimeEarnedDialog(QDialog):
QMessageBox.information(
self,
"저장 완료",
f"{hours}시간 {minutes}분이 적립되었습니다."
tr('msg.save_success.title'),
tr('view.overtime.saved_earned', h=hours, m=minutes)
)
self.accept()
@@ -388,7 +403,7 @@ class AddOvertimeUsedDialog(QDialog):
def init_ui(self):
"""UI 초기화"""
self.setWindowTitle("추가근무 수동 사용")
self.setWindowTitle(tr('view.overtime.manual_used_title'))
self.setModal(True)
self.setMinimumWidth(360)
@@ -398,21 +413,25 @@ class AddOvertimeUsedDialog(QDialog):
# 제목 + 잔액 한 줄
header_layout = QHBoxLayout()
title = QLabel("추가근무 수동 사용")
title = QLabel(tr('view.overtime.manual_used_title'))
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}분")
if hours > 0:
balance_text = tr('view.break.duration_fmt', h=hours, m=minutes)
else:
balance_text = tr('view.break.duration_min_only', m=minutes)
balance_label = QLabel(f"{tr('view.overtime.balance_zero').split(':')[0]}: {balance_text}")
balance_label.setObjectName("badge_balance")
header_layout.addWidget(balance_label)
layout.addLayout(header_layout)
# 날짜
date_layout = QHBoxLayout()
date_label = QLabel("날짜:")
date_label = QLabel(tr('view.overtime.field_date'))
date_label.setObjectName("field_label")
date_label.setFixedWidth(60)
self.date_edit = QDateEdit()
@@ -424,14 +443,15 @@ class AddOvertimeUsedDialog(QDialog):
# 시간 (30분 단위)
time_layout = QHBoxLayout()
time_label = QLabel("시간:")
time_label = QLabel(tr('view.overtime.field_time'))
time_label.setObjectName("field_label")
time_label.setFixedWidth(60)
self.hour_spin = QSpinBox()
self.hour_spin.setRange(0, 23)
self.hour_spin.setSuffix("시간")
self.hour_spin.setSuffix(tr('view.overtime.unit_hour_suffix'))
self.minute_combo = QComboBox()
self.minute_combo.addItems(["0분", "30분"])
self.minute_combo.addItems([tr('view.overtime.minute_0'),
tr('view.overtime.minute_30')])
time_layout.addWidget(time_label)
time_layout.addWidget(self.hour_spin)
time_layout.addWidget(self.minute_combo)
@@ -439,21 +459,21 @@ class AddOvertimeUsedDialog(QDialog):
# 사유
reason_layout = QHBoxLayout()
reason_label = QLabel("사유:")
reason_label = QLabel(tr('view.overtime.field_reason'))
reason_label.setObjectName("field_label")
reason_label.setFixedWidth(60)
self.reason_edit = QLineEdit()
self.reason_edit.setPlaceholderText("예: 개인 사유")
self.reason_edit.setPlaceholderText(tr('view.overtime.placeholder_reason'))
reason_layout.addWidget(reason_label)
reason_layout.addWidget(self.reason_edit)
layout.addLayout(reason_layout)
# 버튼
button_layout = QHBoxLayout()
save_button = QPushButton("저장")
save_button = QPushButton(tr('btn.save'))
save_button.setObjectName("btn_primary")
save_button.clicked.connect(self.save)
cancel_button = QPushButton("취소")
cancel_button = QPushButton(tr('btn.cancel'))
cancel_button.clicked.connect(self.reject)
button_layout.addWidget(save_button)
button_layout.addWidget(cancel_button)
@@ -465,11 +485,12 @@ class AddOvertimeUsedDialog(QDialog):
"""저장"""
# 시간 계산 (30분 단위)
hours = self.hour_spin.value()
minutes = 0 if self.minute_combo.currentText() == "0분" else 30
minutes = 0 if self.minute_combo.currentIndex() == 0 else 30
total_minutes = hours * 60 + minutes
if total_minutes == 0:
QMessageBox.warning(self, "입력 오류", "0분은 사용할 수 없습니다.")
QMessageBox.warning(self, tr('msg.input_error.title'),
tr('view.overtime.zero_use_error'))
return
# 잔액 확인
@@ -477,23 +498,23 @@ class AddOvertimeUsedDialog(QDialog):
if total_minutes > balance:
QMessageBox.warning(
self,
"잔액 부족",
f"사용 가능한 시간이 부족합니다.\n\n"
f"요청: {hours}시간 {minutes}분\n"
f"잔액: {balance // 60}시간 {balance % 60}분"
tr('view.overtime.balance_short_title'),
tr('view.overtime.balance_short_body',
req_h=hours, req_m=minutes,
bal_h=balance // 60, bal_m=balance % 60)
)
return
date = self.date_edit.date().toString("yyyy-MM-dd")
reason = self.reason_edit.text().strip() or "수동 사용"
reason = self.reason_edit.text().strip() or tr('msg.manual_added')
# DB에 저장
self.db.add_overtime_usage(None, total_minutes, date, reason)
QMessageBox.information(
self,
"저장 완료",
f"{hours}시간 {minutes}분이 사용 처리되었습니다."
tr('msg.save_success.title'),
tr('view.overtime.saved_used', h=hours, m=minutes)
)
self.accept()
+6 -4
View File
@@ -1114,16 +1114,18 @@ class SettingsView(QDialog):
if self.parent_window and hasattr(self.parent_window, 'reload_settings'):
self.parent_window.reload_settings()
# 언어 변경 감지 → 재시작 제안
# 언어 변경 감지 → 등록된 위젯 즉시 재번역, 아직 미등록 영역은 재시작 권장
if hasattr(self, 'language_combo'):
from core.i18n import get_language
from ui.i18n_runtime import set_language_and_retranslate
new_lang = self.language_combo.currentData()
if new_lang and new_lang != get_language():
set_language_and_retranslate(new_lang)
reply = QMessageBox.question(
self,
"재시작 필요 / Restart required",
"언어 변경을 완전히 적용하려면 재시작이 필요합니다.\n지금 재시작할까요?\n\n"
"Restart now to fully apply the language change?",
"재시작 / Restart",
"주요 화면은 즉시 적용됩니다. 일부 다이얼로그는 재시작 후 완전히 반영됩니다.\n지금 재시작할까요?\n\n"
"Main UI updates immediately. Some dialogs need a restart for full effect.\nRestart now?",
QMessageBox.Yes | QMessageBox.No,
)
if reply == QMessageBox.Yes: