diff --git a/CHANGELOG.md b/CHANGELOG.md index e37d99b..092e4f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,35 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [2.11.0] — 2026-06-04 + +### Changed — UI 전면 다크 리디자인 +- 모던 다크 미니멀 테마(Notion/Linear 톤): 배경 `#1A1B1E` / 카드 `#25262B` / 보더 `#2C2E33`, + 단일 포인트 컬러 `#4DABF7`(주요 버튼·포커스 전용), 텍스트 `#E9ECEF`/`#909296` +- **다크가 기본 테마** (신규 설치 기준; 기존 사용자가 고른 설정은 보존) +- 번들 폰트 **NanumSquare** (`font/`, `utils/font_loader.py`) — OS 미설치 시 Malgun Gothic 폴백, + `main.spec`에 동봉 +- 통일 여백(외곽 24 / 위젯 12 / 카드 16), border-radius 8px, 버튼 그라데이션·베벨 제거(flat), + 입력 포커스 시 보더 컬러만 accent, 진행률 바 6px +- 남은시간 히어로 영역(출근/현재 한 줄 + 예상 퇴근시각 통합), 퇴근 가능 시 그린(`#51CF66`) 피드백 + +### Added +- **라인 아이콘 시스템** (`ui/icons.py`, QtSvg) — 이모지 대신 테마 틴팅 모노크롬 라인 아이콘. + 하단 네비 / 통계 카드 / 트레이·미니위젯 메뉴 등 전반 적용 (`main.spec`에 `PyQt5.QtSvg` 포함) +- **연장근무 적립 기록 삭제** — 연장근무 관리의 적립 내역 우클릭 → 삭제 + (`Database.delete_overtime_earned`) + +### Fixed +- **자동 적립(auto_overtime) OFF가 자동 퇴근 경로에서 무시되던 버그** — 근무일 경계 롤오버 / + 이전일 자동 퇴근 처리도 설정을 존중하도록 게이팅 (`_apply_auto_overtime_gate`). + (`clock_out` 대화상자 '아니오' 경로는 정상이었음) +- 다크 테마 깨짐: 테이블 세로 헤더·코너 버튼 흰색 누수, 도움말 탭 상단 흰 라인(documentMode), + 트레이/미니위젯 우클릭 메뉴 미적용(검정 글씨) 수정 +- 앱 전반 UI 크롬 이모지 제거 + 색상 팔레트 정합 (일일보고/Discord 텍스트는 유지) + +### Tests +- `tests/test_overtime_accrual_guard.py` 추가 — 적립 가드 2건(OFF=미적립 / ON=적립) + 적립 삭제 1건 + ## [2.10.2] — 2026-05-16 ### Fixed diff --git a/core/database.py b/core/database.py index 52ae024..d08e5ae 100644 --- a/core/database.py +++ b/core/database.py @@ -770,7 +770,7 @@ class Database: 'dinner_duration_minutes': '60', 'auto_lunch': 'false', 'auto_overtime': 'true', - 'theme': 'light', + 'theme': 'dark', 'notification_before_minutes': '30', 'notification_clock_out': 'true', 'notification_lunch': 'true', @@ -977,6 +977,19 @@ class Database: ''', (work_record_id, earned_minutes, date)) conn.commit() + def delete_overtime_earned(self, bank_id: int) -> bool: + """연장근무 적립(은행) 기록 1건 삭제. 삭제분만큼 잔액이 즉시 감소. + + Returns: + bool: 실제로 삭제된 행이 있으면 True. + """ + with self._conn() as conn: + cursor = conn.cursor() + cursor.execute('DELETE FROM overtime_bank WHERE id = ?', (bank_id,)) + deleted = cursor.rowcount + conn.commit() + return deleted > 0 + def add_overtime_usage(self, work_record_id: int, used_minutes: int, date: str, reason: str = None): """연장근무 사용 (음수 잔액 허용 - 선사용 후적립 가능)""" diff --git a/core/i18n.py b/core/i18n.py index 3b1f168..96d9d3a 100644 --- a/core/i18n.py +++ b/core/i18n.py @@ -21,14 +21,14 @@ _DICT = { 'menu.help': '도움말', 'menu.settings': '설정', 'btn.clock_out': '퇴근하기', - 'btn.clock_out_cancel': '🔄 퇴근 취소', + 'btn.clock_out_cancel': '퇴근 취소', 'btn.lunch_add': '점심시간 추가', 'btn.lunch_applied': '점심시간 (적용됨)', 'btn.dinner_add': '저녁시간 추가', 'btn.dinner_applied': '저녁시간 (적용됨)', - 'btn.break_out': '🚪 외출 시작', - 'btn.break_in': '↩️ 복귀', - 'btn.save': '💾 저장', + 'btn.break_out': '외출 시작', + 'btn.break_in': '복귀', + 'btn.save': '저장', 'btn.close': '닫기', 'btn.apply': '적용', 'btn.cancel': '취소', @@ -40,10 +40,10 @@ _DICT = { # === 윈도우/다이얼로그 제목 === 'window.main_title': '퇴근시간 계산기', - 'window.settings': '⚙️ 설정', - 'window.help': '📖 사용 설명서', - 'window.stats': '📊 근무 통계', - 'window.calendar': '📅 캘린더', + 'window.settings': '설정', + 'window.help': '사용 설명서', + 'window.stats': '근무 통계', + 'window.calendar': '캘린더', 'window.mini_widget': '퇴근시간', 'window.clock_in_dialog': '출근 시간', 'window.break_view': '외출 관리', @@ -125,8 +125,8 @@ _DICT = { # === 트레이 === 'tray.open': '프로그램 열기', - 'tray.mini_widget': '📌 미니 위젯', - 'tray.toggle_lunch': '🍱 점심시간 토글', + 'tray.mini_widget': '미니 위젯', + 'tray.toggle_lunch': '점심시간 토글', 'tray.quit': '종료', 'tray.tooltip_remaining': '퇴근까지: {time}', 'tray.tooltip_overtime': '추가 근무 중: {time}', @@ -166,12 +166,12 @@ _DICT = { 'cal.edit_record': '기록 편집', # === HelpView (각 탭의 큰 HTML은 별도 키) === - 'help.tab_intro': '👋 시작하기', - 'help.tab_work_hours': '🕘 근무시간', - 'help.tab_overtime': '🏦 연장근무', - 'help.tab_leave': '🌴 연차/휴가', - 'help.tab_break': '🚪 외출/저녁', - 'help.tab_faq': '❓ 자주 묻는 질문', + 'help.tab_intro': '시작하기', + 'help.tab_work_hours': '근무시간', + 'help.tab_overtime': '연장근무', + 'help.tab_leave': '연차/휴가', + 'help.tab_break': '외출/저녁', + 'help.tab_faq': '자주 묻는 질문', # === clock_in_dialog === 'dlg.clock_in.prompt': '오늘의 출근시간을 입력해주세요', @@ -204,17 +204,18 @@ _DICT = { 'view.overtime.title': '연장근무 내역', 'view.overtime.balance_zero': '잔액: 0분', 'view.overtime.balance_fmt': '현재 잔액: {h}시간 {m}분 ({total}분)', - 'view.overtime.earned_group': '💰 적립 내역', - 'view.overtime.used_group': '📤 사용 내역', + 'view.overtime.earned_group': '적립 내역', + 'view.overtime.used_group': '사용 내역', 'view.overtime.col_date': '날짜', 'view.overtime.col_earned': '적립', 'view.overtime.col_used': '사용', 'view.overtime.col_memo': '메모', 'view.overtime.col_reason': '사유', - 'view.overtime.btn_add_earned': '➕ 수동 적립', - 'view.overtime.btn_add_used': '➕ 수동 사용', - 'view.overtime.menu_delete': '❌ 삭제', + 'view.overtime.btn_add_earned': '수동 적립', + 'view.overtime.btn_add_used': '수동 사용', + 'view.overtime.menu_delete': '삭제', 'view.overtime.delete_confirm_body': '다음 사용 기록을 삭제하시겠습니까?\n\n날짜: {date}\n시간: {time}\n사유: {reason}', + 'view.overtime.delete_earned_confirm_body': '다음 적립 기록을 삭제하시겠습니까?\n\n날짜: {date}\n적립: {time}\n\n삭제 시 잔액에서 차감됩니다.', 'view.overtime.manual_earned_title': '추가근무 수동 적립', 'view.overtime.manual_used_title': '추가근무 수동 사용', 'view.overtime.field_date': '날짜:', @@ -238,13 +239,13 @@ _DICT = { 'view.leave.balance_zero': '잔여: 0일', 'view.leave.balance_fmt': '잔여: {days}일 (총 {hours}시간)', 'view.leave.btn_set_balance': '잔여 설정', - 'view.leave.used_group': '📤 사용 내역', + 'view.leave.used_group': '사용 내역', 'view.leave.col_date': '날짜', 'view.leave.col_type': '구분', 'view.leave.col_used': '사용', 'view.leave.col_reason': '사유', - 'view.leave.btn_add': '➕ 연차 사용 추가', - 'view.leave.btn_calendar': '📅 캘린더 보기', + 'view.leave.btn_add': '연차 사용 추가', + 'view.leave.btn_calendar': '캘린더 보기', 'view.leave.delete_confirm_body': '다음 연차 사용 기록을 삭제하시겠습니까?\n\n날짜: {date}\n구분: {type}\n사용: {days}', 'view.leave.set_title': '연차 시간 설정', 'view.leave.set_prompt': '연차 잔여 시간을 입력하세요 (0.5시간 단위):\n예) 8시간 = 1일, 4시간 = 0.5일(반차), 2시간 = 0.25일, 0.5시간 = 30분', @@ -278,14 +279,14 @@ _DICT = { 'menu.help': 'Help', 'menu.settings': 'Settings', 'btn.clock_out': 'Clock Out', - 'btn.clock_out_cancel': '🔄 Cancel Clock-out', + 'btn.clock_out_cancel': 'Cancel Clock-out', 'btn.lunch_add': 'Add Lunch', 'btn.lunch_applied': 'Lunch (Applied)', 'btn.dinner_add': 'Add Dinner', 'btn.dinner_applied': 'Dinner (Applied)', - 'btn.break_out': '🚪 Start Break', - 'btn.break_in': '↩️ Return', - 'btn.save': '💾 Save', + 'btn.break_out': 'Start Break', + 'btn.break_in': 'Return', + 'btn.save': 'Save', 'btn.close': 'Close', 'btn.apply': 'Apply', 'btn.cancel': 'Cancel', @@ -297,10 +298,10 @@ _DICT = { # === Windows === 'window.main_title': 'Clock-out Time Calculator', - 'window.settings': '⚙️ Settings', - 'window.help': '📖 User Guide', - 'window.stats': '📊 Statistics', - 'window.calendar': '📅 Calendar', + 'window.settings': 'Settings', + 'window.help': 'User Guide', + 'window.stats': 'Statistics', + 'window.calendar': 'Calendar', 'window.mini_widget': 'Clock-out', 'window.clock_in_dialog': 'Clock-in Time', 'window.break_view': 'Break Management', @@ -382,8 +383,8 @@ _DICT = { # === Tray === 'tray.open': 'Open Program', - 'tray.mini_widget': '📌 Mini Widget', - 'tray.toggle_lunch': '🍱 Toggle Lunch', + 'tray.mini_widget': 'Mini Widget', + 'tray.toggle_lunch': 'Toggle Lunch', 'tray.quit': 'Quit', 'tray.tooltip_remaining': 'Until clock-out: {time}', 'tray.tooltip_overtime': 'Overtime: {time}', @@ -423,12 +424,12 @@ _DICT = { 'cal.edit_record': 'Edit record', # === HelpView === - 'help.tab_intro': '👋 Getting Started', - 'help.tab_work_hours': '🕘 Work Hours', - 'help.tab_overtime': '🏦 Overtime', - 'help.tab_leave': '🌴 Leave', - 'help.tab_break': '🚪 Break/Dinner', - 'help.tab_faq': '❓ FAQ', + 'help.tab_intro': 'Getting Started', + 'help.tab_work_hours': 'Work Hours', + 'help.tab_overtime': 'Overtime', + 'help.tab_leave': 'Leave', + 'help.tab_break': 'Break/Dinner', + 'help.tab_faq': 'FAQ', # === clock_in_dialog === 'dlg.clock_in.prompt': "Enter today's clock-in time", @@ -461,17 +462,18 @@ _DICT = { 'view.overtime.title': 'Overtime History', 'view.overtime.balance_zero': 'Balance: 0 min', 'view.overtime.balance_fmt': 'Current balance: {h}h {m}m ({total} min)', - 'view.overtime.earned_group': '💰 Earned', - 'view.overtime.used_group': '📤 Used', + 'view.overtime.earned_group': 'Earned', + 'view.overtime.used_group': 'Used', 'view.overtime.col_date': 'Date', 'view.overtime.col_earned': 'Earned', 'view.overtime.col_used': 'Used', 'view.overtime.col_memo': 'Memo', 'view.overtime.col_reason': 'Reason', - 'view.overtime.btn_add_earned': '➕ Manual Earn', - 'view.overtime.btn_add_used': '➕ Manual Use', - 'view.overtime.menu_delete': '❌ Delete', + 'view.overtime.btn_add_earned': 'Manual Earn', + 'view.overtime.btn_add_used': 'Manual Use', + 'view.overtime.menu_delete': 'Delete', 'view.overtime.delete_confirm_body': 'Delete this usage record?\n\nDate: {date}\nTime: {time}\nReason: {reason}', + 'view.overtime.delete_earned_confirm_body': 'Delete this accrual record?\n\nDate: {date}\nEarned: {time}\n\nThe balance will be reduced accordingly.', 'view.overtime.manual_earned_title': 'Manual Overtime Earn', 'view.overtime.manual_used_title': 'Manual Overtime Use', 'view.overtime.field_date': 'Date:', @@ -495,13 +497,13 @@ _DICT = { 'view.leave.balance_zero': 'Balance: 0 days', 'view.leave.balance_fmt': 'Balance: {days} days ({hours}h total)', 'view.leave.btn_set_balance': 'Set Balance', - 'view.leave.used_group': '📤 Used', + 'view.leave.used_group': 'Used', 'view.leave.col_date': 'Date', 'view.leave.col_type': 'Type', 'view.leave.col_used': 'Used', 'view.leave.col_reason': 'Reason', - 'view.leave.btn_add': '➕ Add Leave Usage', - 'view.leave.btn_calendar': '📅 Calendar', + 'view.leave.btn_add': 'Add Leave Usage', + 'view.leave.btn_calendar': 'Calendar', 'view.leave.delete_confirm_body': 'Delete this leave record?\n\nDate: {date}\nType: {type}\nUsed: {days}', 'view.leave.set_title': 'Set Leave Hours', 'view.leave.set_prompt': 'Enter leave hours remaining (0.5h step):\ne.g. 8h = 1d, 4h = 0.5d (half), 2h = 0.25d, 0.5h = 30min', diff --git a/core/version.py b/core/version.py index 62289d3..7675549 100644 --- a/core/version.py +++ b/core/version.py @@ -4,4 +4,4 @@ 릴리스 시 이 값을 올린 후 git tag → push. CHANGELOG.md의 최상단 항목과 일치시킬 것. """ -__version__ = '2.10.2' +__version__ = '2.11.0' diff --git a/font/NanumSquareB.otf b/font/NanumSquareB.otf new file mode 100644 index 0000000..944742a Binary files /dev/null and b/font/NanumSquareB.otf differ diff --git a/font/NanumSquareB.ttf b/font/NanumSquareB.ttf new file mode 100644 index 0000000..7711cac Binary files /dev/null and b/font/NanumSquareB.ttf differ diff --git a/font/NanumSquareEB.otf b/font/NanumSquareEB.otf new file mode 100644 index 0000000..8fda576 Binary files /dev/null and b/font/NanumSquareEB.otf differ diff --git a/font/NanumSquareEB.ttf b/font/NanumSquareEB.ttf new file mode 100644 index 0000000..cb107b6 Binary files /dev/null and b/font/NanumSquareEB.ttf differ diff --git a/font/NanumSquareL.otf b/font/NanumSquareL.otf new file mode 100644 index 0000000..aa0f117 Binary files /dev/null and b/font/NanumSquareL.otf differ diff --git a/font/NanumSquareL.ttf b/font/NanumSquareL.ttf new file mode 100644 index 0000000..285681b Binary files /dev/null and b/font/NanumSquareL.ttf differ diff --git a/font/NanumSquareOTF_acB.otf b/font/NanumSquareOTF_acB.otf new file mode 100644 index 0000000..563a700 Binary files /dev/null and b/font/NanumSquareOTF_acB.otf differ diff --git a/font/NanumSquareOTF_acEB.otf b/font/NanumSquareOTF_acEB.otf new file mode 100644 index 0000000..04fe861 Binary files /dev/null and b/font/NanumSquareOTF_acEB.otf differ diff --git a/font/NanumSquareOTF_acL.otf b/font/NanumSquareOTF_acL.otf new file mode 100644 index 0000000..e8dec12 Binary files /dev/null and b/font/NanumSquareOTF_acL.otf differ diff --git a/font/NanumSquareOTF_acR.otf b/font/NanumSquareOTF_acR.otf new file mode 100644 index 0000000..499967d Binary files /dev/null and b/font/NanumSquareOTF_acR.otf differ diff --git a/font/NanumSquareR.otf b/font/NanumSquareR.otf new file mode 100644 index 0000000..305829d Binary files /dev/null and b/font/NanumSquareR.otf differ diff --git a/font/NanumSquareR.ttf b/font/NanumSquareR.ttf new file mode 100644 index 0000000..9564297 Binary files /dev/null and b/font/NanumSquareR.ttf differ diff --git a/font/NanumSquare_acB.ttf b/font/NanumSquare_acB.ttf new file mode 100644 index 0000000..1510860 Binary files /dev/null and b/font/NanumSquare_acB.ttf differ diff --git a/font/NanumSquare_acEB.ttf b/font/NanumSquare_acEB.ttf new file mode 100644 index 0000000..6a22e06 Binary files /dev/null and b/font/NanumSquare_acEB.ttf differ diff --git a/font/NanumSquare_acL.ttf b/font/NanumSquare_acL.ttf new file mode 100644 index 0000000..5929b95 Binary files /dev/null and b/font/NanumSquare_acL.ttf differ diff --git a/font/NanumSquare_acR.ttf b/font/NanumSquare_acR.ttf new file mode 100644 index 0000000..3b43e02 Binary files /dev/null and b/font/NanumSquare_acR.ttf differ diff --git a/main.py b/main.py index 5441e4a..4d9585e 100644 --- a/main.py +++ b/main.py @@ -96,8 +96,9 @@ def main(): ) return 1 - # 폰트 설정 - app.setFont(QFont("Segoe UI", 9)) + # 폰트 설정 — 번들 NanumSquare 등록 + 전역 적용 (미설치 시 Malgun Gothic 폴백) + from utils.font_loader import apply_app_font + apply_app_font(app, 9) # 필수 패키지 확인 if not check_requirements(): diff --git a/main.spec b/main.spec index 056291a..45fadd9 100644 --- a/main.spec +++ b/main.spec @@ -14,15 +14,26 @@ if os.path.exists(_staged): elif os.path.exists(_fallback): _extra_datas.append((_fallback, '.')) +# 번들 폰트 (NanumSquare) — utils/font_loader.py 가 _MEIPASS/font/ 에서 로드 +_font_files = [ + 'NanumSquareL.ttf', 'NanumSquareR.ttf', 'NanumSquareB.ttf', 'NanumSquareEB.ttf', + 'NanumSquare_acR.ttf', 'NanumSquare_acB.ttf', +] +_font_datas = [ + (os.path.join('font', f), 'font') + for f in _font_files if os.path.exists(os.path.join('font', f)) +] + a = Analysis( ['main.py'], pathex=[], binaries=[], - datas=[('3d-alarm.png', '.')] + _extra_datas, + datas=[('3d-alarm.png', '.')] + _extra_datas + _font_datas, hiddenimports=[ 'holidays', 'holidays.countries.south_korea', 'win32evtlog', 'win32evtlogutil', 'matplotlib.backends.backend_qt5agg', + 'PyQt5.QtSvg', ], hookspath=[], hooksconfig={}, diff --git a/tests/test_i18n_runtime.py b/tests/test_i18n_runtime.py index ccee855..937238a 100644 --- a/tests/test_i18n_runtime.py +++ b/tests/test_i18n_runtime.py @@ -35,7 +35,7 @@ def test_register_applies_initial_text(qapp, i18n): set_language('ko') label = QLabel() i18n.register(label, 'btn.save') - assert label.text() == '💾 저장' + assert label.text() == '저장' def test_retranslate_after_language_change(qapp, i18n): @@ -59,10 +59,10 @@ def test_setter_kwarg_for_window_title(qapp, i18n): set_language('ko') dlg = QDialog() i18n.register(dlg, 'window.settings', setter='setWindowTitle') - assert dlg.windowTitle() == '⚙️ 설정' + assert dlg.windowTitle() == '설정' i18n.set_language_and_retranslate('en') - assert dlg.windowTitle() == '⚙️ Settings' + assert dlg.windowTitle() == 'Settings' def test_post_callback_applied(qapp, i18n): @@ -71,10 +71,10 @@ def test_post_callback_applied(qapp, i18n): set_language('ko') label = QLabel() i18n.register(label, 'btn.save', post=lambda t: f"[{t}]") - assert label.text() == '[💾 저장]' + assert label.text() == '[저장]' i18n.set_language_and_retranslate('en') - assert label.text() == '[💾 Save]' + assert label.text() == '[Save]' def test_dead_widget_pruned(qapp, i18n): diff --git a/tests/test_overtime_accrual_guard.py b/tests/test_overtime_accrual_guard.py new file mode 100644 index 0000000..0ac57d5 --- /dev/null +++ b/tests/test_overtime_accrual_guard.py @@ -0,0 +1,72 @@ +"""연장근무 자동 적립 가드 테스트. + +auto_overtime(자동 적립)가 OFF면, 자동 퇴근 경로(근무일 경계 롤오버 등)에서도 +은행 적립을 하지 않아야 한다 — clock_out() 대화상자에서 '아니오'를 고른 것과 동일한 의미. + +handle_workday_rollover는 위젯 의존이 tail(load_today_data/update_overtime_balance)뿐이라, +__new__로 만든 인스턴스에 필요한 속성만 채워 단위 테스트한다 (QApplication 불필요). +""" +import os +import sys +from datetime import datetime, timedelta, time as dtime + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from core.database import Database +from core.time_calculator import TimeCalculator +from ui.main_window import MainWindow + + +def _rollover_balance(db, monkeypatch): + """어제 미퇴근 상태에서 근무일 경계 롤오버를 실행하고 적립 잔액을 반환.""" + from PyQt5.QtWidgets import QMessageBox + monkeypatch.setattr(QMessageBox, 'information', + staticmethod(lambda *a, **k: QMessageBox.Ok)) + + today = datetime.now().date() + y = today - timedelta(days=1) + db.add_work_record(y.isoformat(), '09:00:00', is_manual=True) # 어제: 미퇴근 + + w = MainWindow.__new__(MainWindow) # __init__ 우회 (위젯/타이머 없음) + w.db = db + w.time_calc = TimeCalculator(work_minutes=480) + w.clock_in_time = datetime.combine(y, dtime(9, 0, 0)) + w.is_clocked_in = True + w.midnight_rollover_handled = False + w.is_on_break = False + w.lunch_break_enabled = False + w.dinner_break_enabled = False + w.load_today_data = lambda: None # tail UI refresh stub + w.update_overtime_balance = lambda: None # tail UI refresh stub + + w.handle_workday_rollover(datetime.combine(today, dtime(7, 0, 0))) + return db.get_total_overtime_balance() + + +def test_rollover_does_not_accrue_when_auto_overtime_off(tmp_path, monkeypatch): + db = Database(str(tmp_path / 'off.db')) + db.set_setting('auto_overtime', 'false') + assert _rollover_balance(db, monkeypatch) == 0 + + +def test_rollover_accrues_when_auto_overtime_on(tmp_path, monkeypatch): + db = Database(str(tmp_path / 'on.db')) + db.set_setting('auto_overtime', 'true') + assert _rollover_balance(db, monkeypatch) > 0 + + +def test_delete_overtime_earned_reduces_balance(tmp_path): + """적립(은행) 기록 삭제 시 잔액이 그만큼 감소한다.""" + from datetime import date + db = Database(str(tmp_path / 'del.db')) + today = date.today().isoformat() + db.add_overtime_earned(None, 90, today) + assert db.get_total_overtime_balance() == 90 + + bank_id = db.get_connection().execute( + 'SELECT id FROM overtime_bank').fetchone()[0] + assert db.delete_overtime_earned(bank_id) is True + assert db.get_total_overtime_balance() == 0 + + # 없는 id 삭제는 False + assert db.delete_overtime_earned(999999) is False diff --git a/ui/achievements_view.py b/ui/achievements_view.py index 9662317..6ce9b12 100644 --- a/ui/achievements_view.py +++ b/ui/achievements_view.py @@ -85,11 +85,11 @@ class AchievementsView(QDialog): def __init__(self, db, parent=None): super().__init__(parent) self.db = db - self.setWindowTitle("🏆 도전과제") + self.setWindowTitle("도전과제") self.setMinimumSize(960, 720) self.resize(1100, 800) self._increment_view_count() - self.setStyleSheet("QDialog { background: #0e0e14; }") + self.setStyleSheet("QDialog { background: #1A1B1E; }") self.init_ui() apply_dark_titlebar(self, dark=True) @@ -157,7 +157,7 @@ class AchievementsView(QDialog): QFrame { background: qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #1a1a30, stop:1 #2a1a3a); - border: 1px solid #3a3a5a; + border: 1px solid #2C2E33; border-radius: 12px; } QLabel { background: transparent; border: none; color: #e8e8f4; } @@ -173,21 +173,21 @@ class AchievementsView(QDialog): num_row.setSpacing(24) big = QLabel(f"{stats['earned']}" - f" / {stats['total']}") + f" / {stats['total']}") big.setTextFormat(Qt.RichText) num_row.addWidget(big) spacer = QFrame() spacer.setFrameShape(QFrame.VLine) - spacer.setStyleSheet("color: #3a3a5a;") + spacer.setStyleSheet("color: #2C2E33;") num_row.addWidget(spacer) secret_lbl = QLabel( f"
" - f"🌑 시크릿
" + f"🌑 시크릿
" f"" f"{stats['secret_earned']}" - f" / {stats['secret_total']}" + f" / {stats['secret_total']}" f"
" ) secret_lbl.setTextFormat(Qt.RichText) @@ -197,7 +197,7 @@ class AchievementsView(QDialog): pct_lbl = QLabel( f"
" - f"달성률
" + f"달성률
" f"" f"{pct:.1f}%
" ) @@ -256,7 +256,7 @@ class AchievementsView(QDialog): empty = QLabel("(아직 없음)") empty.setAlignment(Qt.AlignCenter) empty.setStyleSheet( - "color: #666; padding: 60px; font-size: 12pt; background: transparent;" + "color: #6C6E73; padding: 60px; font-size: 12pt; background: transparent;" ) grid.addWidget(empty, 0, 0) else: @@ -283,7 +283,7 @@ class AchievementsView(QDialog): if is_locked_secret: bg_top, bg_bot = '#1a1a26', '#0e0e16' border = '#3a3a4a' - text_color = '#666' + text_color = '#6C6E73' else: bg_top = theme['bg_top'] bg_bot = theme['bg_bot'] @@ -455,7 +455,7 @@ class AchievementsView(QDialog): def _tabs_qss(self) -> str: return """ QTabWidget::pane { - background: #14141c; + background: #25262B; border: 1px solid #2a2a3a; border-radius: 10px; top: -1px; @@ -472,7 +472,7 @@ class AchievementsView(QDialog): font-size: 10pt; } QTabBar::tab:selected { - background: #14141c; + background: #25262B; color: #ffd24a; font-weight: bold; border-bottom: 2px solid #ffd24a; diff --git a/ui/calendar_view.py b/ui/calendar_view.py index e1f6e39..7b4d514 100644 --- a/ui/calendar_view.py +++ b/ui/calendar_view.py @@ -55,10 +55,11 @@ class CalendarView(QDialog): # 범례 legend_layout = QHBoxLayout() legend_layout.setSpacing(12) - legend_layout.addWidget(QLabel("🟢 정상")) - legend_layout.addWidget(QLabel("🔴 연장")) - legend_layout.addWidget(QLabel("🟡 휴가")) - legend_layout.addWidget(QLabel("⚪ 없음")) + for _color, _txt in [('#51CF66', '정상'), ('#FA5252', '연장'), + ('#FAB005', '휴가'), ('#6C6E73', '없음')]: + _item = QLabel(f" {_txt}") + _item.setTextFormat(Qt.RichText) + legend_layout.addWidget(_item) legend_layout.addStretch() layout.addLayout(legend_layout) @@ -77,13 +78,13 @@ class CalendarView(QDialog): button_layout = QHBoxLayout() button_layout.setSpacing(6) - self.edit_time_button = QPushButton("✏️ 시간 수정") + 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 = QPushButton("기록 삭제") self.delete_record_button.setObjectName("btn_danger") self.delete_record_button.setEnabled(False) self.delete_record_button.clicked.connect(self.delete_selected_record) @@ -104,7 +105,7 @@ class CalendarView(QDialog): self.memo_edit.setPlaceholderText("추가근무 사유, 특이사항 등...") memo_layout.addWidget(self.memo_edit) - self.save_memo_button = QPushButton("💾 메모 저장") + 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) @@ -163,21 +164,23 @@ class CalendarView(QDialog): existing = self.db.get_work_record(date_str) menu = QMenu(self) + edit_action = delete_action = add_action = None if existing: - edit_action = menu.addAction(f"✏️ {date_str} 편집") - delete_action = menu.addAction(f"🗑️ {date_str} 삭제") + edit_action = menu.addAction(f"{date_str} 편집") + delete_action = menu.addAction(f"{date_str} 삭제") else: - add_action = menu.addAction(f"➕ {date_str} 기록 추가") + add_action = menu.addAction(f"{date_str} 기록 추가") action = menu.exec_(self.calendar.mapToGlobal(pos)) if action is None: return - if existing and action.text().startswith("✏️"): + # 텍스트 prefix 대신 액션 동일성으로 분기 (이모지 의존 제거) + if action == edit_action: self._open_edit_dialog(date_str) - elif existing and action.text().startswith("🗑️"): + elif action == delete_action: self._delete_record(date_str) - elif not existing and action.text().startswith("➕"): + elif action == add_action: self._add_past_record(date_str) def _add_past_record(self, date_str: str): @@ -267,7 +270,7 @@ class CalendarView(QDialog): if record: # 상세 정보 표시 - detail = f"📅 {selected_date.strftime('%Y년 %m월 %d일')}\n\n" + detail = f"{selected_date.strftime('%Y년 %m월 %d일')}\n\n" detail += f"출근: {record['clock_in']}\n" if record.get('clock_out'): @@ -303,7 +306,7 @@ class CalendarView(QDialog): 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.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('') @@ -406,7 +409,7 @@ class EditWorkTimeDialog(QDialog): layout.setContentsMargins(12, 10, 12, 10) # 제목 - title = QLabel(f"📅 {self.date_str} 출퇴근 시간 수정") + title = QLabel(f"{self.date_str} 출퇴근 시간 수정") title.setObjectName("dialog_subtitle") layout.addWidget(title) diff --git a/ui/chart_widget.py b/ui/chart_widget.py index 1ca3a09..484a287 100644 --- a/ui/chart_widget.py +++ b/ui/chart_widget.py @@ -20,14 +20,14 @@ except ImportError: _MPL = False -# 다크 테마 색상 (dark_components 톤과 일치) -_CHART_BG = '#14141c' -_CHART_GRID = '#2a2a3a' -_CHART_TEXT = '#c0c0d0' -_CHART_BAR_NORMAL = '#6b9eff' # blue -_CHART_BAR_OVERTIME = '#ff90b8' # pink -_CHART_BAR_WEEKEND = '#fcd34d' # gold -_CHART_AVG_LINE = '#4ade80' # green +# 다크 테마 색상 (dark_components / styles.py 톤과 일치) +_CHART_BG = '#25262B' +_CHART_GRID = '#2C2E33' +_CHART_TEXT = '#909296' +_CHART_BAR_NORMAL = '#4DABF7' # accent blue +_CHART_BAR_OVERTIME = '#ff90b8' # pink (데이터 구분용) +_CHART_BAR_WEEKEND = '#fcd34d' # gold (데이터 구분용) +_CHART_AVG_LINE = '#51CF66' # green def _apply_dark_axes(ax) -> None: @@ -55,7 +55,7 @@ class _Fallback(QWidget): label = QLabel(message) label.setAlignment(Qt.AlignCenter) label.setWordWrap(True) - label.setStyleSheet("color: #888; padding: 20px;") + label.setStyleSheet("color: #909296; padding: 20px;") layout.addWidget(label) self.setLayout(layout) diff --git a/ui/dark_components.py b/ui/dark_components.py index a28483c..22a36ca 100644 --- a/ui/dark_components.py +++ b/ui/dark_components.py @@ -19,22 +19,24 @@ from PyQt5.QtCore import Qt # ── 색상 팔레트 ──────────────────────────────────────────────── -DARK_BG = '#0e0e14' -DARK_PANEL = '#14141c' -DARK_PANEL_2 = '#1c1c28' -DARK_BORDER = '#2a2a3a' -DARK_BORDER_STRONG = '#44446a' -DARK_TEXT = '#e8e8f4' -DARK_TEXT_DIM = '#a0a0b8' -DARK_TEXT_FAINT = '#666680' +# 메인 앱(styles.py DARK_COLORS)과 정합되는 모던 다크 미니멀 톤. +DARK_BG = '#1A1B1E' +DARK_PANEL = '#25262B' +DARK_PANEL_2 = '#2C2E33' +DARK_BORDER = '#2C2E33' +DARK_BORDER_STRONG = '#373A40' +DARK_TEXT = '#E9ECEF' +DARK_TEXT_DIM = '#909296' +DARK_TEXT_FAINT = '#6C6E73' +# 단일 포인트 컬러는 ACCENT_BLUE(#4DABF7). 나머지 색은 도전과제 등급 표시 전용. ACCENT_GOLD = '#ffd24a' -ACCENT_BLUE = '#6b9eff' +ACCENT_BLUE = '#4DABF7' ACCENT_CYAN = '#4adef0' ACCENT_PINK = '#ff90b8' -ACCENT_GREEN = '#4ade80' +ACCENT_GREEN = '#51CF66' ACCENT_ORANGE = '#fcd34d' -ACCENT_RED = '#fb7185' +ACCENT_RED = '#FA5252' # 카드 테마 (등급/상태별) CARD_THEMES = { @@ -83,7 +85,7 @@ def dialog_qss() -> str: return f"QDialog {{ background: {DARK_BG}; }}" -def tabs_qss(accent: str = ACCENT_GOLD) -> str: +def tabs_qss(accent: str = ACCENT_BLUE) -> str: return f""" QTabWidget::pane {{ background: {DARK_PANEL}; @@ -142,36 +144,36 @@ def button_qss(variant: str = 'default') -> str: return f""" QPushButton {{ background: {ACCENT_BLUE}; color: white; - border: none; border-radius: 6px; + border: none; border-radius: 8px; padding: 8px 18px; font-weight: bold; font-size: 10pt; }} - QPushButton:hover {{ background: #82b0ff; }} - QPushButton:pressed {{ background: #5a8eee; }} - QPushButton:disabled {{ background: #2a2a3a; color: {DARK_TEXT_FAINT}; }} + QPushButton:hover {{ background: #69B6F8; }} + QPushButton:pressed {{ background: #3D97E0; }} + QPushButton:disabled {{ background: {DARK_PANEL_2}; color: {DARK_TEXT_FAINT}; }} """ if variant == 'success': return f""" QPushButton {{ background: {ACCENT_GREEN}; color: #0e2a1a; - border: none; border-radius: 6px; + border: none; border-radius: 8px; padding: 8px 18px; font-weight: bold; font-size: 10pt; }} - QPushButton:hover {{ background: #6ae899; }} + QPushButton:hover {{ background: #69DB7C; }} """ if variant == 'danger': return f""" QPushButton {{ background: {ACCENT_RED}; color: white; - border: none; border-radius: 6px; + border: none; border-radius: 8px; padding: 8px 18px; font-weight: bold; font-size: 10pt; }} - QPushButton:hover {{ background: #fc8896; }} + QPushButton:hover {{ background: #FF6B6B; }} """ if variant == 'ghost': return f""" QPushButton {{ background: transparent; color: {DARK_TEXT_DIM}; - border: 1px solid {DARK_BORDER_STRONG}; border-radius: 6px; + border: 1px solid {DARK_BORDER_STRONG}; border-radius: 8px; padding: 6px 14px; font-size: 9.5pt; }} QPushButton:hover {{ background: {DARK_PANEL_2}; color: {DARK_TEXT}; @@ -181,10 +183,10 @@ def button_qss(variant: str = 'default') -> str: return f""" QPushButton {{ background: {DARK_PANEL_2}; color: {DARK_TEXT}; - border: 1px solid {DARK_BORDER_STRONG}; border-radius: 6px; + border: 1px solid {DARK_BORDER_STRONG}; border-radius: 8px; padding: 8px 18px; font-size: 10pt; }} - QPushButton:hover {{ background: #2a2a36; border-color: {ACCENT_BLUE}; }} + QPushButton:hover {{ background: {DARK_BORDER_STRONG}; border-color: {ACCENT_BLUE}; }} """ @@ -205,10 +207,9 @@ def build_gradient_header(title: str, big_value: str, subtitle: str = '', container = QFrame() container.setStyleSheet(f""" QFrame {{ - background: qlineargradient(x1:0, y1:0, x2:1, y2:0, - stop:0 #1a1a30, stop:1 #2a1a3a); - border: 1px solid #3a3a5a; - border-radius: 12px; + background: {DARK_PANEL}; + border: 1px solid {DARK_BORDER}; + border-radius: 8px; }} QLabel {{ background: transparent; border: none; color: {DARK_TEXT}; }} """) @@ -228,7 +229,7 @@ def build_gradient_header(title: str, big_value: str, subtitle: str = '', left.addWidget(t) big = QLabel( f"" - f"{big_value}" + (f"" + f"{big_value}" + (f"" f" {subtitle}" if subtitle else '') ) big.setTextFormat(Qt.RichText) @@ -268,13 +269,20 @@ def build_stat_card(title: str, value: str, subtitle: str = '', outer.setSpacing(12) if icon: - icon_lbl = QLabel(icon) - icon_lbl.setStyleSheet( - f"font-size: 28pt; background: transparent; border: none; " - f"color: {t['border_strong']};" - ) + icon_lbl = QLabel() icon_lbl.setMinimumWidth(48) icon_lbl.setAlignment(Qt.AlignCenter) + from ui.icons import get_icon, _PATHS + if icon in _PATHS: + # 라인 아이콘(이름) → 등급 색으로 틴팅한 픽스맵 + icon_lbl.setPixmap(get_icon(icon, t['border_strong'], 30).pixmap(30, 30)) + else: + # 이모지/텍스트 폴백 (구버전 호환) + icon_lbl.setText(icon) + icon_lbl.setStyleSheet( + f"font-size: 28pt; background: transparent; border: none; " + f"color: {t['border_strong']};" + ) outer.addWidget(icon_lbl) text_box = QVBoxLayout() @@ -330,11 +338,16 @@ def build_section_card(title: str, content: QWidget, head = QHBoxLayout() if icon: - i = QLabel(icon) - i.setStyleSheet( - f"font-size: 16pt; color: {t['border_strong']}; " - f"background: transparent; border: none;" - ) + i = QLabel() + from ui.icons import get_icon, _PATHS + if icon in _PATHS: + i.setPixmap(get_icon(icon, t['border_strong'], 18).pixmap(18, 18)) + else: + i.setText(icon) + i.setStyleSheet( + f"font-size: 16pt; color: {t['border_strong']}; " + f"background: transparent; border: none;" + ) head.addWidget(i) title_lbl = QLabel(title) title_lbl.setStyleSheet( diff --git a/ui/goal_widget.py b/ui/goal_widget.py index e2f39af..8e120d0 100644 --- a/ui/goal_widget.py +++ b/ui/goal_widget.py @@ -20,7 +20,7 @@ class GoalWidget(QWidget): layout.setContentsMargins(8, 6, 8, 6) layout.setSpacing(4) - title = QLabel("🎯 이번 달 목표") + title = QLabel("이번 달 목표") title.setStyleSheet("font-weight: bold;") layout.addWidget(title) @@ -78,7 +78,7 @@ class GoalWidget(QWidget): ot_h, ot_m = ot_total // 60, ot_total % 60 tg_h, tg_m = ot_target // 60, ot_target % 60 self.ot_bar.setFormat(f"{ot_h}h {ot_m}m / {tg_h}h {tg_m}m") - color = '#4caf50' if ratio < 0.6 else ('#ff9800' if ratio < 1.0 else '#f44336') + color = '#51CF66' if ratio < 0.6 else ('#FAB005' if ratio < 1.0 else '#FA5252') self.ot_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}") else: self.ot_label.setVisible(False) @@ -93,7 +93,7 @@ class GoalWidget(QWidget): self.avg_bar.setValue(int(min(avg, avg_target) * 100)) self.avg_bar.setFormat(f"{avg:.1f}h / {avg_target:.1f}h") ratio = avg / avg_target if avg_target else 0 - color = '#4caf50' if ratio < 0.9 else ('#ff9800' if ratio < 1.1 else '#f44336') + color = '#51CF66' if ratio < 0.9 else ('#FAB005' if ratio < 1.1 else '#FA5252') self.avg_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}") else: self.avg_label.setVisible(False) diff --git a/ui/help_view.py b/ui/help_view.py index f9080fb..6986716 100644 --- a/ui/help_view.py +++ b/ui/help_view.py @@ -45,7 +45,7 @@ class HelpView(QDialog): main_layout.setSpacing(10) # 다크 타이틀 - title = QLabel(f"📖 {tr('window.help')}") + title = QLabel(tr('window.help')) title.setStyleSheet( f"font-size: 18pt; font-weight: bold; color: {DARK_TEXT}; " f"background: transparent; border: none; padding: 4px 0;" @@ -53,7 +53,6 @@ class HelpView(QDialog): main_layout.addWidget(title) tabs = QTabWidget() - tabs.setDocumentMode(True) tabs.setStyleSheet(tabs_qss()) for html_key, tab_label_key in self._TABS: tabs.addTab(self._make_tab(tr_html(html_key)), tr(tab_label_key)) @@ -63,7 +62,7 @@ class HelpView(QDialog): button_layout.setContentsMargins(0, 6, 0, 0) # 온보딩 다시 보기 (왼쪽, ghost 스타일) - onboarding_button = QPushButton("🚀 온보딩 다시 보기") + onboarding_button = QPushButton("온보딩 다시 보기") onboarding_button.setMinimumHeight(36) onboarding_button.setStyleSheet(button_qss('ghost')) onboarding_button.clicked.connect(self._reopen_onboarding) diff --git a/ui/icons.py b/ui/icons.py new file mode 100644 index 0000000..d846630 --- /dev/null +++ b/ui/icons.py @@ -0,0 +1,82 @@ +"""모노크롬 라인 아이콘 (Lucide 스타일) — 테마 색으로 틴팅한 QIcon 생성. + +이모지를 대체하는 세련된 벡터 아이콘. QtSvg로 24x24 stroke path를 렌더링하고 +(name, color, size)별로 캐시. 색은 호출 시점의 테마 색을 받으므로 테마 전환 시 +재호출하면 자동으로 재틴팅된다. + +사용: + from ui.icons import get_icon + btn.setIcon(get_icon('settings')) # 기본: text_secondary 색 + btn.setIcon(get_icon('logout', '#FFFFFF')) # 색 지정 +""" +from __future__ import annotations + +from PyQt5.QtCore import QByteArray, QRectF, Qt +from PyQt5.QtGui import QIcon, QPixmap, QPainter +from PyQt5.QtSvg import QSvgRenderer + +from ui.styles import ThemeColors + +# 24x24 viewBox 기준 내부 path 마크업 (Lucide). stroke 기반, fill 없음. +_PATHS = { + 'chart': '', + 'calendar': '', + 'report': '', + 'award': '', + 'help': '', + 'settings': '', + 'logout': '', + 'rotate-ccw': '', + 'edit': '', + 'clock': '', + 'trash': '', + 'flame': '', + 'trending-up': '', + 'search': '', + 'external-link': '', + 'coffee': '', + 'repeat': '', + 'home': '', +} + +_SVG_TMPL = ( + '{paths}' +) + +_cache: dict = {} + + +def get_icon(name: str, color: str = None, size: int = 18) -> QIcon: + """이름·색·크기로 틴팅된 QIcon 반환 (캐시됨). 미정의 이름은 빈 QIcon.""" + if color is None: + color = ThemeColors.get('text_secondary') + key = (name, color, size) + cached = _cache.get(key) + if cached is not None: + return cached + + paths = _PATHS.get(name) + if paths is None: + return QIcon() + + svg = _SVG_TMPL.format(color=color, paths=paths).encode('utf-8') + renderer = QSvgRenderer(QByteArray(svg)) + + dpr = 2 # 2x 렌더 후 devicePixelRatio 지정 → HiDPI에서도 선명 + pm = QPixmap(size * dpr, size * dpr) + pm.fill(Qt.transparent) + painter = QPainter(pm) + renderer.render(painter, QRectF(0, 0, size * dpr, size * dpr)) + painter.end() + pm.setDevicePixelRatio(dpr) + + icon = QIcon(pm) + _cache[key] = icon + return icon + + +def clear_cache() -> None: + """테마 전환 등으로 캐시를 비울 때 사용 (보통은 키가 색을 포함하므로 불필요).""" + _cache.clear() diff --git a/ui/leave_calendar_view.py b/ui/leave_calendar_view.py index 4da2125..262f99a 100644 --- a/ui/leave_calendar_view.py +++ b/ui/leave_calendar_view.py @@ -22,7 +22,7 @@ class LeaveCalendarView(QDialog): def __init__(self, parent=None, db=None): super().__init__(parent) self.db = db - self.setWindowTitle("📅 연차 캘린더") + self.setWindowTitle("연차 캘린더") self.setModal(True) self.setMinimumSize(540, 480) self._build_ui() @@ -37,7 +37,7 @@ class LeaveCalendarView(QDialog): balance = float(self.db.get_setting('leave_balance', '0') or 0) total = float(self.db.get_setting('annual_leave_total', '15') or 15) used = total - balance - title = QLabel(f"🌴 잔여 {balance:.2f}일 / 총 {total:.0f}일 (사용 {used:.2f}일)") + title = QLabel(f"잔여 {balance:.2f}일 / 총 {total:.0f}일 (사용 {used:.2f}일)") title.setStyleSheet("font-weight: bold; font-size: 13px;") header.addWidget(title) header.addStretch() @@ -45,10 +45,11 @@ class LeaveCalendarView(QDialog): # 범례 (사용 완료 + 예정 분리) legend = QHBoxLayout() - for label in ["🟩 종일(1.0)", "🟨 반차(0.5)", "🟪 반반차(0.25)", - "🔵 예정", "🔘 종일+예정"]: - l = QLabel(label) - l.setStyleSheet(f"padding: 2px 6px;") + for _color, _txt in [('#51CF66', '종일(1.0)'), ('#FAB005', '반차(0.5)'), + ('#B197FC', '반반차(0.25)'), ('#4DABF7', '예정'), + ('#748FFC', '종일+예정')]: + l = QLabel(f" {_txt}") + l.setStyleSheet("padding: 2px 6px;") legend.addWidget(l) legend.addStretch() layout.addLayout(legend) @@ -61,7 +62,7 @@ class LeaveCalendarView(QDialog): # 선택 일자 정보 self.detail_label = QLabel("") - self.detail_label.setStyleSheet("padding: 6px; color: #888;") + self.detail_label.setStyleSheet("padding: 6px; color: #909296;") layout.addWidget(self.detail_label) # 닫기 버튼 @@ -116,4 +117,4 @@ class LeaveCalendarView(QDialog): d = float(r.get('days') or 0) memo = r.get('memo') or '' parts.append(f"{t} {d}일" + (f" ({memo})" if memo else "")) - self.detail_label.setText(f"📅 {date_str}: " + ", ".join(parts)) + self.detail_label.setText(f"{date_str}: " + ", ".join(parts)) diff --git a/ui/leave_view.py b/ui/leave_view.py index 0fa671a..1b336af 100644 --- a/ui/leave_view.py +++ b/ui/leave_view.py @@ -90,7 +90,7 @@ class LeaveView(QDialog): cal_button.clicked.connect(self._show_calendar) button_layout.addWidget(cal_button) - schedule_button = QPushButton("🗓️ 스케줄") + schedule_button = QPushButton("스케줄") schedule_button.setToolTip("휴일 + 연차 + 반복 패턴 통합 보기") schedule_button.clicked.connect(self._show_schedule) button_layout.addWidget(schedule_button) @@ -146,7 +146,7 @@ class LeaveView(QDialog): days_str = f"{days}일" days_item = QTableWidgetItem(days_str) days_item.setTextAlignment(Qt.AlignCenter) - days_item.setForeground(QColor(231, 76, 60)) # 빨간색 + days_item.setForeground(QColor(250, 82, 82)) # 사용 = 레드 (#FA5252) memo_item = QTableWidgetItem(record['memo'] or "") diff --git a/ui/main_window.py b/ui/main_window.py index c095a16..bd3a747 100644 --- a/ui/main_window.py +++ b/ui/main_window.py @@ -8,7 +8,7 @@ from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton, QProgressBar, QMessageBox, QGroupBox, QGridLayout, QSystemTrayIcon, QShortcut, QDialog) -from PyQt5.QtCore import QTimer, Qt, pyqtSignal, QLockFile, QDir +from PyQt5.QtCore import QTimer, Qt, pyqtSignal, QLockFile, QDir, QSize from PyQt5.QtGui import QFont, QPalette, QColor, QKeySequence from core.settings_keys import ( @@ -50,7 +50,7 @@ class MainWindow(QMainWindow): super().__init__() # 테마 적용 - self.current_theme = 'light' # 설정에서 로드 후 덮어씀 + self.current_theme = 'dark' # 설정에서 로드 후 덮어씀 # 데이터베이스 — main.py가 전달하면 재사용, 아니면 자체 부트스트랩 if db is not None: @@ -82,7 +82,7 @@ class MainWindow(QMainWindow): self.cached_time_format = str(settings.get(TIME_FORMAT, '24')) # 테마 설정 - self.current_theme = str(settings.get(THEME, 'light')) + self.current_theme = str(settings.get(THEME, 'dark')) self.apply_theme(self.current_theme) self.time_calc = self._build_time_calc(settings) @@ -234,11 +234,11 @@ class MainWindow(QMainWindow): from core.version import __version__ from ui.i18n_runtime import register self._app_version = __version__ - self.setWindowTitle(f"⏰ {tr('window.main_title')} v{__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) + post=lambda t: f"{t} v{__version__}") + self.setGeometry(100, 100, 540, 720) + self.setMinimumSize(500, 600) # 외부 컨테이너 (스크롤 + 고정 하단) from PyQt5.QtWidgets import QScrollArea @@ -261,10 +261,10 @@ class MainWindow(QMainWindow): outer_widget.setLayout(outer_layout) self.setCentralWidget(outer_widget) - # 메인 레이아웃 + # 메인 레이아웃 — 외곽 24px, 위젯 간 12px (통일된 여백 시스템) main_layout = QVBoxLayout() - main_layout.setSpacing(8) - main_layout.setContentsMargins(12, 10, 12, 10) + main_layout.setSpacing(12) + main_layout.setContentsMargins(24, 20, 24, 16) # 1. 헤더 - 앱 타이틀 title_label = QLabel("퇴근시간 계산기") @@ -287,16 +287,10 @@ class MainWindow(QMainWindow): clock_in_group = self.create_clock_in_group() main_layout.addWidget(clock_in_group) - # 3. 남은 시간 표시 그룹 + # 3. 남은 시간 표시 그룹 (히어로 — 남은시간 + 진행률 + 예상 퇴근시각 통합) remaining_group = self.create_remaining_time_group() main_layout.addWidget(remaining_group) - # 4. 예상 퇴근시간 - self.expected_time_label = QLabel() - self.expected_time_label.setObjectName("expected_time") - self.expected_time_label.setAlignment(Qt.AlignCenter) - main_layout.addWidget(self.expected_time_label) - # 5. 점심/저녁 토글 (가로 배치) meal_button_layout = QHBoxLayout() meal_button_layout.setSpacing(8) @@ -359,8 +353,8 @@ class MainWindow(QMainWindow): fixed_bottom = QWidget() fixed_bottom.setObjectName("fixed_bottom") fixed_bottom_layout = QVBoxLayout() - fixed_bottom_layout.setSpacing(8) - fixed_bottom_layout.setContentsMargins(12, 8, 12, 10) + fixed_bottom_layout.setSpacing(10) + fixed_bottom_layout.setContentsMargins(24, 12, 24, 16) self.clock_out_button = QPushButton(tr('btn.clock_out')) self.clock_out_button.setObjectName("clock_out_button") @@ -375,7 +369,7 @@ class MainWindow(QMainWindow): stats_button = QPushButton(tr('menu.stats')) calendar_button = QPushButton(tr('menu.calendar')) report_button = QPushButton(tr('menu.daily_report')) - achievements_button = QPushButton("🏆 도전과제") + achievements_button = QPushButton("도전과제") help_button = QPushButton(tr('menu.help')) settings_button = QPushButton(tr('menu.settings')) @@ -387,8 +381,17 @@ class MainWindow(QMainWindow): (settings_button, 'menu.settings')]: register(btn, key) - for btn in [stats_button, calendar_button, report_button, - achievements_button, help_button, settings_button]: + # 하단 네비게이션 — 라인 아이콘 + 라벨 (이모지 대체) + self._nav_icon_specs = [ + (stats_button, 'chart'), + (calendar_button, 'calendar'), + (report_button, 'report'), + (achievements_button, 'award'), + (help_button, 'help'), + (settings_button, 'settings'), + ] + for btn, _name in self._nav_icon_specs: + btn.setObjectName("nav_btn") bottom_layout.addWidget(btn) # 버튼 연결 @@ -406,9 +409,27 @@ class MainWindow(QMainWindow): # 초기 날짜 업데이트 self.update_date_label() + # 라인 아이콘 적용 (테마 색 틴팅) + self._apply_button_icons() + # 앱 내 단축키 self._setup_shortcuts() + def _apply_button_icons(self): + """버튼 아이콘을 현재 테마 색으로 (재)적용. 테마 전환 시에도 호출돼 재틴팅.""" + from ui.icons import get_icon + sec = ThemeColors.get('text_secondary') + inv = ThemeColors.get('text_inverse') + for btn, name in getattr(self, '_nav_icon_specs', []): + btn.setIcon(get_icon(name, sec, 16)) + btn.setIconSize(QSize(16, 16)) + if getattr(self, 'edit_clock_in_button', None) is not None: + self.edit_clock_in_button.setIcon(get_icon('edit', sec, 15)) + self.edit_clock_in_button.setIconSize(QSize(15, 15)) + if getattr(self, 'clock_out_button', None) is not None: + self.clock_out_button.setIcon(get_icon('logout', inv, 18)) + self.clock_out_button.setIconSize(QSize(18, 18)) + def _setup_shortcuts(self): """앱 내 단축키 — 메인 창 포커스 시만 동작""" bindings = [ @@ -425,74 +446,93 @@ class MainWindow(QMainWindow): sc = QShortcut(QKeySequence(keyseq), self) sc.activated.connect(handler) + def _build_time_column(self, label_text: str, value_widget: QLabel) -> QVBoxLayout: + """라벨(작게) 위 + 시각(크게) 아래 형태의 세로 컬럼. 한 줄 나란히 배치용.""" + col = QVBoxLayout() + col.setSpacing(2) + lbl = QLabel(label_text) + lbl.setObjectName("field_label") + col.addWidget(lbl) + col.addWidget(value_widget) + return col + def create_clock_in_group(self) -> QGroupBox: - """출근 정보 그룹 생성""" + """출근 정보 그룹 생성 — 출근/현재 시각을 한 줄에 나란히""" group = QGroupBox("오늘의 근무") layout = QVBoxLayout() - layout.setSpacing(4) - layout.setContentsMargins(12, 20, 12, 8) + layout.setSpacing(8) + layout.setContentsMargins(16, 24, 16, 16) - # 출근 시간 레이아웃 - clock_in_layout = QHBoxLayout() - clock_in_label = QLabel("출근:") - clock_in_label.setObjectName("field_label") - clock_in_label.setFixedWidth(50) + # 출근 / 현재 시각을 한 줄에 나란히 (2-컬럼) + row = QHBoxLayout() + row.setSpacing(12) + + # 출근 컬럼 (라벨 + 편집 버튼 헤더 / 값) self.clock_in_value = QLabel("--:--:--") self.clock_in_value.setObjectName("time_value") - self.clock_in_value.setMinimumWidth(90) - # 라벨 자체도 클릭 가능 (인라인 편집 — 출퇴근 시간 빠른 수정) + # 라벨 자체도 클릭 가능 (인라인 편집 — 출근 시간 빠른 수정) self.clock_in_value.setCursor(Qt.PointingHandCursor) self.clock_in_value.setToolTip("클릭하여 출근 시간 수정") self.clock_in_value.mousePressEvent = lambda e: self.manual_clock_in() - self.edit_clock_in_button = QPushButton("✏️ 수정") + + clock_in_col = QVBoxLayout() + clock_in_col.setSpacing(2) + clock_in_label = QLabel("출근") + clock_in_label.setObjectName("field_label") + clock_in_col.addWidget(clock_in_label) + + # 시각 + 편집 버튼을 한 줄에 (편집 아이콘이 출근 시각 바로 옆에 붙도록) + clock_in_value_row = QHBoxLayout() + clock_in_value_row.setSpacing(6) + self.edit_clock_in_button = QPushButton("") self.edit_clock_in_button.setObjectName("btn_small") - self.edit_clock_in_button.setFixedWidth(70) + self.edit_clock_in_button.setFixedWidth(30) + self.edit_clock_in_button.setToolTip("출근 시간 수정") self.edit_clock_in_button.clicked.connect(self.manual_clock_in) + clock_in_value_row.addWidget(self.clock_in_value) + clock_in_value_row.addWidget(self.edit_clock_in_button) + clock_in_value_row.addStretch() + clock_in_col.addLayout(clock_in_value_row) - clock_in_layout.addWidget(clock_in_label) - clock_in_layout.addWidget(self.clock_in_value) - clock_in_layout.addStretch() - clock_in_layout.addWidget(self.edit_clock_in_button) - - # 현재 시간 레이아웃 - current_layout = QHBoxLayout() - current_label = QLabel("현재:") - current_label.setObjectName("field_label") - current_label.setFixedWidth(50) + # 현재 컬럼 self.current_time_value = QLabel("--:--:--") self.current_time_value.setObjectName("time_value") - self.current_time_value.setMinimumWidth(90) + current_col = self._build_time_column("현재", self.current_time_value) - current_layout.addWidget(current_label) - current_layout.addWidget(self.current_time_value) - current_layout.addStretch() - - layout.addLayout(clock_in_layout) - layout.addLayout(current_layout) + row.addLayout(clock_in_col, 1) + row.addLayout(current_col, 1) + layout.addLayout(row) group.setLayout(layout) return group def create_remaining_time_group(self) -> QGroupBox: - """남은 시간 표시 그룹 생성""" + """남은 시간 히어로 그룹 — 남은시간(가장 큼) + 진행률 + 예상 퇴근시각""" self.remaining_time_group = QGroupBox("남은 시간") layout = QVBoxLayout() - layout.setSpacing(6) - layout.setContentsMargins(12, 20, 12, 8) + layout.setSpacing(12) + layout.setContentsMargins(16, 24, 16, 16) - # 남은 시간 라벨 + # 남은 시간 라벨 (히어로 — 화면에서 가장 큰 결과) self.remaining_time_label = QLabel("--:--:--") self.remaining_time_label.setObjectName("time_display") self.remaining_time_label.setAlignment(Qt.AlignCenter) - # 프로그레스 바 + # 프로그레스 바 (얇게 6px) self.progress_bar = QProgressBar() self.progress_bar.setTextVisible(False) + self.progress_bar.setFixedHeight(6) + + # 예상 퇴근시각 (히어로 카드 내부에 통합) + self.expected_time_label = QLabel() + self.expected_time_label.setObjectName("expected_time") + self.expected_time_label.setAlignment(Qt.AlignCenter) layout.addWidget(self.remaining_time_label) layout.addWidget(self.progress_bar) + layout.addWidget(self.expected_time_label) self.remaining_time_group.setLayout(layout) return self.remaining_time_group @@ -502,8 +542,8 @@ class MainWindow(QMainWindow): group = QGroupBox("연장근무 및 연차 현황") layout = QVBoxLayout() - layout.setSpacing(6) - layout.setContentsMargins(12, 20, 12, 8) + layout.setSpacing(10) + layout.setContentsMargins(16, 24, 16, 16) # 연장근무 섹션 overtime_header = QHBoxLayout() @@ -621,14 +661,14 @@ class MainWindow(QMainWindow): if today_record.get('clock_out'): self.is_clocked_in = False self.clock_out_button.setEnabled(True) - self.clock_out_button.setText("🔄 퇴근 취소") + self.clock_out_button.setText("퇴근 취소") # 퇴근 완료 상태에서도 출퇴근 시간은 표시 self.clock_in_value.setText(self.format_time(self.clock_in_time, include_seconds=True)) else: # 출근 중이면 퇴근하기 버튼 self.clock_out_button.setEnabled(True) - self.clock_out_button.setText("✅ 퇴근하기") + self.clock_out_button.setText("퇴근하기") else: # 출근 기록 없음 — 종일 연차일이면 자동 감지·수동 입력 모두 스킵 @@ -799,17 +839,14 @@ class MainWindow(QMainWindow): minutes = (total_seconds % 3600) // 60 seconds = total_seconds % 60 remaining_str = f"+{hours:02d}:{minutes:02d}:{seconds:02d}" - self.remaining_time_label.setStyleSheet(f"color: {ThemeColors.get('status_overtime')};") + # 퇴근 가능(연장근무 진입) → 그린 피드백 + self.remaining_time_label.setStyleSheet(f"color: {ThemeColors.get('status_normal')};") else: - # 정상 근무 중 + # 정상 근무 중 — 아직 퇴근 전이므로 기본 텍스트 색 self.remaining_time_group.setTitle("남은 시간") remaining_str = self.time_calc.format_time_delta(remaining) - - if remaining.total_seconds() < 1800: # 30분 이내 - self.remaining_time_label.setStyleSheet(f"color: {ThemeColors.get('status_warning')};") - else: - self.remaining_time_label.setStyleSheet(f"color: {ThemeColors.get('status_normal')};") + self.remaining_time_label.setStyleSheet(f"color: {ThemeColors.get('text_primary')};") self._set_text_if_changed(self.remaining_time_label, remaining_str) @@ -885,7 +922,7 @@ class MainWindow(QMainWindow): label = '연차' memo = '' - self.remaining_time_group.setTitle("🌴 오늘은 휴가") + self.remaining_time_group.setTitle("오늘은 휴가") self.remaining_time_label.setText("연차 사용 중") self.remaining_time_label.setStyleSheet( f"color: {ThemeColors.get('status_normal')}; font-size: 18px;" @@ -893,10 +930,10 @@ class MainWindow(QMainWindow): self.progress_bar.setValue(100) if memo: self._set_text_if_changed(self.expected_time_label, - f"🌴 {label} — {memo}") + f"{label} — {memo}") else: self._set_text_if_changed(self.expected_time_label, - f"🌴 {label}") + f"{label}") # 트레이/미니 위젯 self.tray_icon.update_time_display("🌴 휴가") if getattr(self, '_mini_widget', None) is not None and self._mini_widget.isVisible(): @@ -1301,7 +1338,7 @@ class MainWindow(QMainWindow): self.is_clocked_in = False self.midnight_rollover_handled = False # 다음날을 위해 플래그 리셋 self.clock_out_button.setEnabled(True) - self.clock_out_button.setText("🔄 퇴근 취소") + self.clock_out_button.setText("퇴근 취소") # 결과 메시지 msg = f"퇴근 처리되었습니다!\n\n" @@ -1354,7 +1391,7 @@ class MainWindow(QMainWindow): # 상태 복원 self.is_clocked_in = True self.clock_out_button.setEnabled(True) - self.clock_out_button.setText("✅ 퇴근하기") + self.clock_out_button.setText("퇴근하기") # 잔액 업데이트 self.update_overtime_balance() @@ -1378,6 +1415,18 @@ class MainWindow(QMainWindow): f"퇴근 취소 중 오류가 발생했습니다:\n{str(e)}" ) + def _apply_auto_overtime_gate(self, overtime_earned: int) -> int: + """자동 적립(auto_overtime) 설정을 존중해 적립분을 게이팅. + + OFF면 0을 반환해 은행 적립(add_overtime_earned)을 건너뛰게 한다. + clock_out()은 대화상자로 직접 확인하지만, 자동 퇴근 경로(롤오버 / 이전일 + 자동 처리)는 사용자 상호작용 시점이 없으므로 설정만으로 동일하게 게이팅한다. + 실제 연장(work_records.overtime_minutes)은 그대로 기록되고 적립만 스킵된다. + """ + if overtime_earned > 0 and not self.db.get_setting_bool('auto_overtime', True): + return 0 + return overtime_earned + def handle_workday_rollover(self, now: datetime): """근무일 경계 처리: 경계시간 직전 퇴근, 경계시간에 출근 @@ -1451,6 +1500,9 @@ class MainWindow(QMainWindow): unit_minutes=unit_minutes, ) + # 자동 적립 OFF면 적립 스킵 (clock_out '아니오'와 동일 의미) + overtime_earned = self._apply_auto_overtime_gate(overtime_earned) + # DB 업데이트 (출근일 날짜에 퇴근 시간 기록) self.db.update_clock_out( workday_str, before_boundary_str, total_hours, @@ -1537,7 +1589,7 @@ class MainWindow(QMainWindow): self.clock_in_time = None self.is_clocked_in = False self.clock_out_button.setEnabled(False) - self.clock_out_button.setText("✅ 퇴근하기") + self.clock_out_button.setText("퇴근하기") def auto_clock_out_previous_days(self): """이전 퇴근 기록들(퇴근 안 한)에 대해 자동으로 종료 시간 등록""" @@ -1609,6 +1661,9 @@ class MainWindow(QMainWindow): unit_minutes=unit_minutes, ) + # 자동 적립 OFF면 적립 스킵 (clock_out '아니오'와 동일 의미) + overtime_earned = self._apply_auto_overtime_gate(overtime_earned) + # DB 업데이트 (total_hours는 원본 시간 그대로 저장) clock_out_str = shutdown_time.strftime("%H:%M:%S") self.db.update_clock_out( @@ -1678,6 +1733,9 @@ class MainWindow(QMainWindow): unit_minutes=unit_minutes, ) + # 자동 적립 OFF면 적립 스킵 (clock_out '아니오'와 동일 의미) + overtime_earned = self._apply_auto_overtime_gate(overtime_earned) + # DB 업데이트 self.db.update_clock_out( check_date, "23:59:59", total_hours, @@ -1749,7 +1807,7 @@ class MainWindow(QMainWindow): # UI 업데이트 self.clock_in_value.setText(clock_in_str) self.clock_out_button.setEnabled(True) - self.clock_out_button.setText("✅ 퇴근하기") + self.clock_out_button.setText("퇴근하기") QMessageBox.information( self, @@ -1794,8 +1852,15 @@ class MainWindow(QMainWindow): def apply_theme(self, theme_name: str): """테마 적용""" self.current_theme = theme_name + ThemeColors.set_theme(theme_name) self.setStyleSheet(get_theme(theme_name)) apply_dark_titlebar(self, theme_name == 'dark') + # 버튼 아이콘을 새 테마 색으로 재틴팅 (init_ui 이후에만) + if hasattr(self, '_nav_icon_specs'): + self._apply_button_icons() + # 트레이 메뉴도 새 테마 QSS/아이콘으로 갱신 + if getattr(self, 'tray_icon', None) is not None: + self.tray_icon.refresh_theme() # 타이틀바 갱신을 위해 크기 미세 조정 size = self.size() self.resize(size.width() + 1, size.height()) @@ -1806,7 +1871,7 @@ class MainWindow(QMainWindow): dialog = SettingsView(self, self.db) dialog.exec_() # 설정 변경 후 테마 재적용 - new_theme = str(self.db.get_setting(THEME, 'light')) + new_theme = str(self.db.get_setting(THEME, 'dark')) if new_theme != self.current_theme: self.apply_theme(new_theme) @@ -1834,7 +1899,7 @@ class MainWindow(QMainWindow): from ui.meal_time_dialog import MealTimeDialog menu = QMenu(self) title = "점심" if meal_type == 'lunch' else "저녁" - edit_action = menu.addAction(f"⏱ {title} 실제 시간 입력...") + edit_action = menu.addAction(f"{title} 실제 시간 입력...") global_pos = button.mapToGlobal(pos) action = menu.exec_(global_pos) if action != edit_action: diff --git a/ui/meal_time_dialog.py b/ui/meal_time_dialog.py index 0d93605..3cdbe94 100644 --- a/ui/meal_time_dialog.py +++ b/ui/meal_time_dialog.py @@ -50,7 +50,7 @@ class MealTimeDialog(QDialog): info_text += f"\n출근 {clock_in_time.strftime('%H:%M')} 이후만 입력 가능." info = QLabel(info_text) info.setWordWrap(True) - info.setStyleSheet("color: #888; padding-bottom: 6px;") + info.setStyleSheet("color: #909296; padding-bottom: 6px;") layout.addWidget(info) # 합리적 기본값: 출근 이후로 보정 @@ -83,7 +83,7 @@ class MealTimeDialog(QDialog): # 미리보기 라벨 self.preview = QLabel("") - self.preview.setStyleSheet("color: #4caf50; font-weight: bold; padding-top: 6px;") + self.preview.setStyleSheet("color: #51CF66; font-weight: bold; padding-top: 6px;") layout.addWidget(self.preview) self._update_preview() self.start_edit.timeChanged.connect(self._update_preview) @@ -137,11 +137,11 @@ class MealTimeDialog(QDialog): start_dt, end_dt, minutes = self._resolve_meal_window() ok, reason = self._validate_window(start_dt, end_dt, minutes) if not ok: - self.preview.setText(f"⚠️ {reason}") - self.preview.setStyleSheet("color: #f44336;") + self.preview.setText(f"{reason}") + self.preview.setStyleSheet("color: #FA5252;") else: self.preview.setText(f"총 {minutes}분") - self.preview.setStyleSheet("color: #4caf50; font-weight: bold;") + self.preview.setStyleSheet("color: #51CF66; font-weight: bold;") def _validate_window(self, start_dt: datetime, end_dt: datetime, minutes: int) -> tuple[bool, str]: diff --git a/ui/mini_widget.py b/ui/mini_widget.py index 189a0c8..aa579af 100644 --- a/ui/mini_widget.py +++ b/ui/mini_widget.py @@ -41,7 +41,7 @@ class MiniWidget(QWidget): self.title_label = QLabel(tr('label.remaining')) self.title_label.setAlignment(Qt.AlignCenter) - self.title_label.setStyleSheet("color: #888; font-size: 11px;") + self.title_label.setStyleSheet("color: #909296; font-size: 11px;") self.time_label = QLabel("--:--:--") self.time_label.setAlignment(Qt.AlignCenter) @@ -51,10 +51,10 @@ class MiniWidget(QWidget): layout.addWidget(self.time_label) self.setLayout(layout) - # 기본 스타일 (테마 무관 가독성 유지) + # 기본 스타일 (테마 무관 가독성 유지 — 메인 다크 팔레트와 정합) self.setStyleSheet(""" - QWidget { background-color: rgba(30, 30, 30, 230); border-radius: 8px; } - QLabel { color: #fff; } + QWidget { background-color: rgba(26, 27, 30, 235); border-radius: 8px; } + QLabel { color: #E9ECEF; background: transparent; } """) apply_dark_titlebar(self) @@ -63,11 +63,12 @@ class MiniWidget(QWidget): """메인 윈도우에서 호출 — 남은 시간 동기화.""" self.time_label.setText(remaining_str) if remaining_str.startswith('+'): + # 연장근무 진입 = 퇴근 가능 → 그린 (메인 히어로와 동일 피드백) self.title_label.setText(tr('label.overtime_progress')) - self.time_label.setStyleSheet("color: #ff6b6b;") + self.time_label.setStyleSheet("color: #51CF66;") else: self.title_label.setText(tr('label.remaining')) - self.time_label.setStyleSheet("color: #fff;") + self.time_label.setStyleSheet("color: #E9ECEF;") # 드래그 이동 def mousePressEvent(self, event: QMouseEvent): @@ -90,6 +91,17 @@ class MiniWidget(QWidget): def contextMenuEvent(self, event): from PyQt5.QtWidgets import QMenu menu = QMenu(self) + # 미니 위젯 자체 QSS에는 QMenu 텍스트색이 없어 기본 검정으로 보인다. + # 앱 다크 테마 QSS를 명시 적용해 가독성 확보 (트레이 메뉴와 동일 처리). + qss = self.parent_window.styleSheet() if self.parent_window else '' + if not qss: + try: + from ui.styles import get_theme + qss = get_theme('dark') + except Exception: + qss = '' + if qss: + menu.setStyleSheet(qss) open_main = menu.addAction("메인 창 열기") close_mini = menu.addAction("미니 위젯 닫기") action = menu.exec_(event.globalPos()) diff --git a/ui/onboarding_view.py b/ui/onboarding_view.py index 5a1f367..a919cb5 100644 --- a/ui/onboarding_view.py +++ b/ui/onboarding_view.py @@ -32,7 +32,7 @@ WORK_PRESETS = [ class WelcomePage(QWizardPage): def __init__(self): super().__init__() - self.setTitle("👋 환영합니다!") + self.setTitle("환영합니다!") self.setSubTitle("Clock-out Time Calculator를 처음 사용하시는군요. 5단계로 빠르게 설정하겠습니다.") layout = QVBoxLayout() intro = QLabel( @@ -51,7 +51,7 @@ class WelcomePage(QWizardPage): class WorkPatternPage(QWizardPage): def __init__(self): super().__init__() - self.setTitle("🕘 근무 패턴") + self.setTitle("근무 패턴") self.setSubTitle("본인의 하루 근무 시간을 선택하세요. 나중에 설정에서 바꿀 수 있습니다.") layout = QVBoxLayout() @@ -127,7 +127,7 @@ class WorkPatternPage(QWizardPage): class ClockInDetectionPage(QWizardPage): def __init__(self): super().__init__() - self.setTitle("⏰ 출근 시간 감지 방식") + self.setTitle("출근 시간 감지 방식") self.setSubTitle("앱이 출근 시간을 자동으로 어떻게 감지할지 선택하세요.") layout = QVBoxLayout() @@ -139,10 +139,10 @@ class ClockInDetectionPage(QWizardPage): layout.addWidget(opt) info = QLabel( - "\n💡 PC를 항상 켜둔 채 출근하시는 분은 두 번째 옵션을 권장합니다." + "\nPC를 항상 켜둔 채 출근하시는 분은 두 번째 옵션을 권장합니다." ) info.setWordWrap(True) - info.setStyleSheet("color: #888; padding: 8px;") + info.setStyleSheet("color: #909296; padding: 8px;") layout.addWidget(info) layout.addStretch() @@ -159,7 +159,7 @@ class ClockInDetectionPage(QWizardPage): class LeaveSalaryPage(QWizardPage): def __init__(self): super().__init__() - self.setTitle("🌴 연차 + 💰 급여 (옵션)") + self.setTitle("연차 + 급여 (옵션)") self.setSubTitle("연차 일수와 급여(선택)를 입력하세요.") layout = QVBoxLayout() @@ -218,7 +218,7 @@ class LeaveSalaryPage(QWizardPage): class DiscordPage(QWizardPage): def __init__(self): super().__init__() - self.setTitle("💬 Discord 알림 (선택)") + self.setTitle("Discord 알림 (선택)") self.setSubTitle("출퇴근 시각·휴식 권고를 Discord로 받으려면 웹훅 URL을 입력하세요. (모바일에서 푸시 알림)") layout = QVBoxLayout() @@ -236,7 +236,7 @@ class DiscordPage(QWizardPage): "2. 새 웹훅 만들기 → URL 복사\n" "3. 위 입력란에 붙여넣기" ) - guide.setStyleSheet("color: #888; padding: 6px;") + guide.setStyleSheet("color: #909296; padding: 6px;") guide.setWordWrap(True) layout.addWidget(guide) @@ -277,14 +277,14 @@ class DiscordPage(QWizardPage): class FinishPage(QWizardPage): def __init__(self): super().__init__() - self.setTitle("🎉 준비 완료!") + self.setTitle("준비 완료!") self.setSubTitle("이제 출근부터 자동 추적됩니다.") layout = QVBoxLayout() msg = QLabel( "설정한 내용은 [설정] 메뉴에서 언제든 바꿀 수 있습니다.\n" "온보딩을 다시 보고 싶으면 [도움말 → 온보딩 다시 보기]를 누르세요.\n\n" - "🕐 단축키:\n" + "단축키:\n" " • Ctrl+O — 출퇴근 토글\n" " • F1 — 도움말\n" " • F5 — 업데이트 확인\n" diff --git a/ui/overtime_view.py b/ui/overtime_view.py index aea2bb4..27dadec 100644 --- a/ui/overtime_view.py +++ b/ui/overtime_view.py @@ -66,6 +66,8 @@ class OvertimeView(QDialog): self.earned_table.setAlternatingRowColors(True) self.earned_table.setEditTriggers(QTableWidget.NoEditTriggers) self.earned_table.setSelectionBehavior(QTableWidget.SelectRows) + self.earned_table.setContextMenuPolicy(Qt.CustomContextMenu) + self.earned_table.customContextMenuRequested.connect(self.show_earned_context_menu) earned_layout.addWidget(self.earned_table) add_earned_button = QPushButton(tr('view.overtime.btn_add_earned')) @@ -126,7 +128,7 @@ class OvertimeView(QDialog): cursor = conn.cursor() cursor.execute(''' - SELECT ob.date, ob.earned_minutes, ob.work_record_id, wr.memo + SELECT ob.date, ob.earned_minutes, ob.work_record_id, wr.memo, ob.id FROM overtime_bank ob LEFT JOIN work_records wr ON ob.work_record_id = wr.id ORDER BY ob.date DESC @@ -138,6 +140,7 @@ class OvertimeView(QDialog): for i, record in enumerate(earned_records): date_item = QTableWidgetItem(record[0]) date_item.setTextAlignment(Qt.AlignCenter) + date_item.setData(Qt.UserRole, record[4]) # overtime_bank.id 저장 (삭제용) minutes = record[1] hours = minutes // 60 @@ -148,7 +151,7 @@ class OvertimeView(QDialog): 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)) # 초록색 + time_item.setForeground(QColor(81, 207, 102)) # 적립 = 그린 (#51CF66) # work_record_id NULL이면 "수동 추가", 아니면 wr.memo memo_text = manual_label if record[2] is None else (record[3] or "") @@ -183,7 +186,7 @@ class OvertimeView(QDialog): 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)) # 빨간색 + time_item.setForeground(QColor(250, 82, 82)) # 사용 = 레드 (#FA5252) reason_item = QTableWidgetItem(record[3] or "") @@ -249,6 +252,46 @@ class OvertimeView(QDialog): if self.parent() and hasattr(self.parent(), 'update_overtime_balance'): self.parent().update_overtime_balance() + def show_earned_context_menu(self, position): + """적립 내역 우클릭 메뉴 (삭제).""" + selected_rows = self.earned_table.selectionModel().selectedRows() + if not selected_rows: + return + menu = QMenu(self) + delete_action = QAction(tr('view.overtime.menu_delete'), self) + delete_action.triggered.connect(self.delete_earned_record) + menu.addAction(delete_action) + menu.exec_(self.earned_table.viewport().mapToGlobal(position)) + + def delete_earned_record(self): + """적립 기록 삭제 (overtime_bank에서 제거 → 잔액 즉시 감소).""" + selected_rows = self.earned_table.selectionModel().selectedRows() + if not selected_rows: + return + row = selected_rows[0].row() + date_item = self.earned_table.item(row, 0) + time_item = self.earned_table.item(row, 1) + + # 행에 저장된 overtime_bank.id + bank_id = date_item.data(Qt.UserRole) + if bank_id is None: + return + + reply = QMessageBox.question( + self, + tr('msg.confirm_delete.title'), + tr('view.overtime.delete_earned_confirm_body', + date=date_item.text(), time=time_item.text()), + QMessageBox.Yes | QMessageBox.No + ) + + if reply == QMessageBox.Yes: + self.db.delete_overtime_earned(bank_id) + 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) diff --git a/ui/past_record_dialog.py b/ui/past_record_dialog.py index a0263bb..ce6a215 100644 --- a/ui/past_record_dialog.py +++ b/ui/past_record_dialog.py @@ -26,7 +26,7 @@ class PastRecordDialog(QDialog): layout.setSpacing(8) layout.setContentsMargins(20, 16, 20, 16) - info = QLabel(f"📅 {date_str} 근무 기록을 입력하세요.") + info = QLabel(f"{date_str} 근무 기록을 입력하세요.") info.setStyleSheet("font-weight: bold; padding-bottom: 6px;") layout.addWidget(info) @@ -56,9 +56,9 @@ class PastRecordDialog(QDialog): # 점심/저녁 meal_row = QHBoxLayout() - self.lunch_check = QCheckBox("🍱 점심시간 포함") + self.lunch_check = QCheckBox("점심시간 포함") self.lunch_check.setChecked(True) - self.dinner_check = QCheckBox("🍽 저녁시간 포함") + self.dinner_check = QCheckBox("저녁시간 포함") meal_row.addWidget(self.lunch_check) meal_row.addWidget(self.dinner_check) meal_row.addStretch() diff --git a/ui/recurring_leave_dialog.py b/ui/recurring_leave_dialog.py index ee9147f..90e8f10 100644 --- a/ui/recurring_leave_dialog.py +++ b/ui/recurring_leave_dialog.py @@ -27,7 +27,7 @@ class RecurringLeaveDialog(QDialog): def __init__(self, parent=None, db=None): super().__init__(parent) self.db = db - self.setWindowTitle("🔁 반복 연차 관리") + self.setWindowTitle("반복 연차 관리") self.setMinimumSize(540, 480) self._build_ui() self._reload_list() @@ -129,7 +129,7 @@ class RecurringLeaveDialog(QDialog): ag.addLayout(memo_row) # 추가 버튼 - add_btn = QPushButton("➕ 추가") + add_btn = QPushButton("추가") add_btn.setObjectName("btn_primary") add_btn.clicked.connect(self._save) ag.addWidget(add_btn) diff --git a/ui/schedule_view.py b/ui/schedule_view.py index f755069..863ea70 100644 --- a/ui/schedule_view.py +++ b/ui/schedule_view.py @@ -38,7 +38,7 @@ class ScheduleView(QDialog): def __init__(self, parent=None, db=None): super().__init__(parent) self.db = db - self.setWindowTitle("🗓️ 스케줄") + self.setWindowTitle("스케줄") self.setMinimumSize(820, 560) self._build_ui() self._reload() @@ -54,11 +54,11 @@ class ScheduleView(QDialog): bar.addWidget(title) bar.addStretch() - rec_btn = QPushButton("🔁 반복 패턴 관리") + rec_btn = QPushButton("반복 패턴 관리") rec_btn.clicked.connect(self._open_recurring_dialog) bar.addWidget(rec_btn) - add_btn = QPushButton("➕ 연차 등록") + add_btn = QPushButton("연차 등록") add_btn.clicked.connect(self._open_add_leave_dialog) bar.addWidget(add_btn) @@ -196,11 +196,11 @@ class ScheduleView(QDialog): # 휴일 holiday = self.db.get_holiday(date_str) if hasattr(self.db, 'get_holiday') else None if holiday: - item = QListWidgetItem(f"🎌 공휴일: {holiday.get('name', '공휴일')}") + item = QListWidgetItem(f"공휴일: {holiday.get('name', '공휴일')}") item.setForeground(QBrush(QColor("#e53935"))) self.detail_list.addItem(item) elif d.weekday() in (5, 6): - item = QListWidgetItem(f"🏖️ 주말 ({weekday_kr[d.weekday()]}요일)") + item = QListWidgetItem(f"주말 ({weekday_kr[d.weekday()]}요일)") self.detail_list.addItem(item) # 연차 (구체) @@ -208,7 +208,7 @@ class ScheduleView(QDialog): days = float(r.get('days') or 0) t = r.get('leave_type', '연차') memo = r.get('memo') or '' - label = f"📌 {t} {days}일" + label = f"{t} {days}일" if memo: label += f" — {memo}" label += f" [id={r['id']}]" @@ -221,7 +221,7 @@ class ScheduleView(QDialog): from core.recurring_leaves import expand_for_date for occ in expand_for_date(recurring, d): item = QListWidgetItem( - f"🔁 {describe_pattern(occ.pattern)} · {occ.days}일 ({occ.leave_type})" + f"{describe_pattern(occ.pattern)} · {occ.days}일 ({occ.leave_type})" ) item.setData(Qt.UserRole, ('recurring', occ.recurring_id)) self.detail_list.addItem(item) diff --git a/ui/settings_view.py b/ui/settings_view.py index 1eaa310..d353a8a 100644 --- a/ui/settings_view.py +++ b/ui/settings_view.py @@ -516,7 +516,7 @@ class SettingsView(QDialog): def create_goal_group(self) -> QGroupBox: """월간 목표 설정 그룹 (0=비활성).""" - group = QGroupBox("🎯 월간 목표 (0=비활성)") + group = QGroupBox("월간 목표 (0=비활성)") layout = QVBoxLayout() layout.setSpacing(6) @@ -865,7 +865,7 @@ class SettingsView(QDialog): # CSV 가져오기 import_layout = QHBoxLayout() - import_btn = QPushButton("📥 CSV 가져오기") + import_btn = QPushButton("CSV 가져오기") import_btn.setObjectName("btn_small") import_btn.setToolTip("date,clock_in,clock_out,lunch_minutes,memo 헤더 포맷") import_btn.clicked.connect(self._import_csv) @@ -1068,7 +1068,7 @@ class SettingsView(QDialog): self.time_format_combo.setCurrentIndex(index) # 테마 - self.theme_combo.setCurrentIndex(0 if settings.get(THEME, 'light') == 'light' else 1) + self.theme_combo.setCurrentIndex(0 if settings.get(THEME, 'dark') == 'light' else 1) # 언어 선택 적용 if hasattr(self, 'language_combo'): diff --git a/ui/stats_view.py b/ui/stats_view.py index 35173bc..310137e 100644 --- a/ui/stats_view.py +++ b/ui/stats_view.py @@ -42,7 +42,7 @@ class StatsView(QDialog): layout.setContentsMargins(20, 16, 20, 14) # 다크 톤 타이틀 - title = QLabel(f"📊 {tr('stats.title')}") + title = QLabel(f"{tr('stats.title')}") title.setStyleSheet( f"font-size: 18pt; font-weight: bold; color: {DARK_TEXT}; " f"background: transparent; border: none; padding: 4px 0;" @@ -94,13 +94,13 @@ class StatsView(QDialog): cards_row = QHBoxLayout() cards_row.setSpacing(10) self.weekly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 주", - theme='blue', icon='⏱️') + theme='blue', icon='clock') self.weekly_days_card = build_stat_card("근무 일수", "0일", "이번 주", - theme='cyan', icon='📅') + theme='cyan', icon='calendar') self.weekly_avg_card = build_stat_card("일평균", "0시간", "이번 주", - theme='green', icon='📊') + theme='green', icon='chart') self.weekly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 주", - theme='gold', icon='🔥') + theme='gold', icon='flame') for c in (self.weekly_total_card, self.weekly_days_card, self.weekly_avg_card, self.weekly_ot_card): cards_row.addWidget(c, 1) @@ -110,7 +110,7 @@ class StatsView(QDialog): from ui.chart_widget import make_chart_widget self.weekly_chart = make_chart_widget(widget) chart_card = build_section_card("일별 근무 시간", self.weekly_chart, - theme='gray', icon='📈') + theme='gray', icon='trending-up') layout.addWidget(chart_card, 1) widget.setLayout(layout) @@ -128,13 +128,13 @@ class StatsView(QDialog): cards_row = QHBoxLayout() cards_row.setSpacing(10) self.monthly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 달", - theme='blue', icon='⏱️') + theme='blue', icon='clock') self.monthly_days_card = build_stat_card("근무 일수", "0일", "이번 달", - theme='cyan', icon='📅') + theme='cyan', icon='calendar') self.monthly_avg_card = build_stat_card("일평균", "0시간", "이번 달", - theme='green', icon='📊') + theme='green', icon='chart') self.monthly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 달", - theme='gold', icon='🔥') + theme='gold', icon='flame') for c in (self.monthly_total_card, self.monthly_days_card, self.monthly_avg_card, self.monthly_ot_card): cards_row.addWidget(c, 1) @@ -160,7 +160,7 @@ class StatsView(QDialog): from ui.chart_widget import make_chart_widget self.monthly_chart = make_chart_widget(widget) chart_card = build_section_card("요일별 평균", self.monthly_chart, - theme='gray', icon='📊') + theme='gray', icon='chart') layout.addWidget(chart_card, 1) widget.setLayout(layout) @@ -183,13 +183,13 @@ class StatsView(QDialog): f"background: transparent; border: none; padding: 4px 0;" ) layout.addWidget(build_section_card("패턴 인사이트", self.pattern_text, - theme='cyan', icon='🔍')) + theme='cyan', icon='search')) # 출근 시각 분포 차트 from ui.chart_widget import make_chart_widget self.clock_in_chart = make_chart_widget(widget) layout.addWidget(build_section_card("출근 시각 분포", self.clock_in_chart, - theme='gray', icon='⏰'), 1) + theme='gray', icon='clock'), 1) widget.setLayout(layout) return widget diff --git a/ui/styles.py b/ui/styles.py index ff6aac3..6b3d8a8 100644 --- a/ui/styles.py +++ b/ui/styles.py @@ -33,8 +33,8 @@ def _ensure_icons(): 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)]), + ('up_dark', '#909296', [(4, 7), (8, 3), (12, 7)]), + ('down_dark', '#909296', [(4, 5), (8, 9), (12, 5)]), ]: path = os.path.join(_arrow_dir, f'{name}.png') if not os.path.exists(path): @@ -78,6 +78,9 @@ LIGHT_COLORS = { 'bg_primary': '#F5F5F7', 'bg_secondary': '#FFFFFF', 'bg_tertiary': '#EDEDF0', + # 인터랙션 표면 + 'surface_hover': '#E2E3E7', + 'surface_pressed': '#D5D6DB', # 텍스트 계층 'text_primary': '#1A1A2E', 'text_secondary': '#4A4A68', @@ -85,9 +88,15 @@ LIGHT_COLORS = { 'text_inverse': '#FFFFFF', # 액센트 'accent_primary': '#3B82F6', + 'accent_primary_hover': '#2F74EE', + 'accent_primary_pressed': '#2563EB', 'accent_success': '#10B981', + 'accent_success_hover': '#0EA372', + 'accent_success_pressed': '#0C8F63', 'accent_warning': '#F59E0B', 'accent_danger': '#EF4444', + 'accent_danger_hover': '#DC2626', + 'accent_danger_pressed': '#B91C1C', # 테두리 'border_subtle': '#E5E7EB', 'border_default': '#D1D5DB', @@ -120,40 +129,58 @@ LIGHT_COLORS = { } DARK_COLORS = { - 'bg_primary': '#111118', - 'bg_secondary': '#1C1C2E', - 'bg_tertiary': '#282842', - 'text_primary': '#ECECF4', - 'text_secondary': '#B0B0C8', - 'text_tertiary': '#808098', + # 배경 계층 — 모던 다크 (Notion/Linear 톤) + 'bg_primary': '#1A1B1E', # 앱 배경 + 'bg_secondary': '#25262B', # 카드 / 패널 + 'bg_tertiary': '#2C2E33', # 기본 버튼 / 미묘한 채움 + # 인터랙션 표면 + 'surface_hover': '#34363D', + 'surface_pressed': '#3A3D44', + # 텍스트 계층 + 'text_primary': '#E9ECEF', + 'text_secondary': '#909296', + 'text_tertiary': '#6C6E73', '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', + # 액센트 — 단일 포인트 컬러 (주요 버튼 + 포커스 전용) + 'accent_primary': '#4DABF7', + 'accent_primary_hover': '#69B6F8', + 'accent_primary_pressed': '#3D97E0', + 'accent_success': '#51CF66', + 'accent_success_hover': '#69DB7C', + 'accent_success_pressed': '#43B85A', + 'accent_warning': '#FAB005', + 'accent_danger': '#FA5252', + 'accent_danger_hover': '#FF6B6B', + 'accent_danger_pressed': '#E64545', + # 테두리 + 'border_subtle': '#2C2E33', + 'border_default': '#373A40', + 'border_focus': '#4DABF7', + # 배지 — 플랫 (미묘한 배경 + 색조 텍스트로 미니멀 유지) + 'badge_overtime_bg': '#2C2E33', + 'badge_overtime_text': '#FAB005', + 'badge_leave_bg': '#2C2E33', + 'badge_leave_text': '#4DABF7', + 'badge_total_bg': '#2C2E33', + 'badge_total_text': '#51CF66', + # 프로그레스 — 단일 accent 솔리드 + 'progress_bg': '#2C2E33', + 'progress_start': '#4DABF7', + 'progress_end': '#4DABF7', + # 상태 색상 (동적 텍스트 피드백) + 'status_overtime': '#51CF66', # 퇴근 가능(연장근무 진입) = 그린 + 'status_warning': '#FAB005', + 'status_normal': '#51CF66', + 'status_break_active': '#FA5252', + 'status_break_idle': '#6C6E73', + # 캘린더 날짜 배경 — 미묘한 다크 틴트 + 'cal_normal': '#1E3A2A', + 'cal_overtime': '#3A2122', + 'cal_incomplete': '#3A331E', + # 스크롤바 + 'scrollbar_bg': '#1A1B1E', + 'scrollbar_handle': '#373A40', + 'scrollbar_hover': '#4DABF7', } @@ -192,7 +219,7 @@ QMainWindow, QDialog {{ }} QWidget {{ - font-family: "Segoe UI", "맑은 고딕", sans-serif; + font-family: "NanumSquare", "NanumSquareOTF", "Malgun Gothic", "맑은 고딕", sans-serif; font-size: 9.5pt; color: {c['text_primary']}; }} @@ -206,14 +233,14 @@ QWidget#central_widget {{ ════════════════════════════════════════ */ QLabel#app_title {{ - font-size: 12pt; + font-size: 13pt; font-weight: bold; color: {c['text_primary']}; padding: 2px; }} QLabel#date_label {{ - font-size: 9pt; + font-size: 9.5pt; color: {c['text_secondary']}; padding-bottom: 4px; }} @@ -221,7 +248,7 @@ QLabel#date_label {{ QLabel#section_title {{ font-size: 9.5pt; font-weight: bold; - color: {c['text_primary']}; + color: {c['text_secondary']}; }} QLabel#field_label {{ @@ -229,29 +256,30 @@ QLabel#field_label {{ color: {c['text_secondary']}; }} +/* 출근/현재 시각 — 한 줄 나란히 표시되는 중간 크기 모노스페이스 */ QLabel#time_value {{ font-family: "Consolas", "D2Coding", monospace; - font-size: 11pt; + font-size: 15pt; font-weight: bold; color: {c['text_primary']}; }} +/* 히어로 — 남은 시간 (화면에서 가장 큰 결과 표시). 카드 안에 투명 배치 */ QLabel#time_display {{ font-family: "Consolas", "D2Coding", monospace; - font-size: 22pt; + font-size: 30pt; font-weight: bold; color: {c['text_primary']}; - background: {c['bg_secondary']}; - border: 1px solid {c['border_subtle']}; - border-radius: 10px; - padding: 10px; + background: transparent; + border: none; + padding: 4px 0; }} QLabel#expected_time {{ - font-size: 10pt; + font-size: 11.5pt; font-weight: bold; - color: {c['text_primary']}; - padding: 4px; + color: {c['text_secondary']}; + padding: 2px; }} QLabel#dialog_title {{ @@ -295,7 +323,7 @@ QLabel#badge_overtime {{ qproperty-alignment: AlignCenter; background: {c['badge_overtime_bg']}; color: {c['badge_overtime_text']}; - border-radius: 6px; + border-radius: 8px; }} QLabel#badge_leave {{ @@ -306,7 +334,7 @@ QLabel#badge_leave {{ qproperty-alignment: AlignCenter; background: {c['badge_leave_bg']}; color: {c['badge_leave_text']}; - border-radius: 6px; + border-radius: 8px; }} QLabel#badge_total {{ @@ -317,7 +345,7 @@ QLabel#badge_total {{ qproperty-alignment: AlignCenter; background: {c['badge_total_bg']}; color: {c['badge_total_text']}; - border-radius: 6px; + border-radius: 8px; }} QLabel#badge_balance {{ @@ -326,7 +354,7 @@ QLabel#badge_balance {{ padding: 10px; background: {c['bg_tertiary']}; color: {c['text_primary']}; - border-radius: 6px; + border-radius: 8px; }} QLabel#badge_success {{ @@ -335,7 +363,7 @@ QLabel#badge_success {{ padding: 8px; background: {c['badge_total_bg']}; color: {c['badge_total_text']}; - border-radius: 6px; + border-radius: 8px; }} /* ════════════════════════════════════════ @@ -355,9 +383,9 @@ QLabel#separator {{ QGroupBox {{ background: {c['bg_secondary']}; border: 1px solid {c['border_subtle']}; - border-radius: 10px; + border-radius: 8px; margin-top: 10px; - padding: 14px; + padding: 16px; padding-top: 28px; font-size: 9.5pt; color: {c['text_primary']}; @@ -378,52 +406,55 @@ QGroupBox::title {{ 버튼 ════════════════════════════════════════ */ +/* 기본 버튼 — 그라데이션/베벨 없는 플랫 (border:none 기반) */ QPushButton {{ background: {c['bg_tertiary']}; color: {c['text_primary']}; - border: 1px solid {c['border_default']}; - border-radius: 6px; - padding: 7px 14px; + border: none; + border-radius: 8px; + padding: 8px 14px; font-size: 9pt; }} QPushButton:hover {{ - background: {c['border_default']}; + background: {c['surface_hover']}; }} QPushButton:pressed {{ - background: {c['border_subtle']}; + background: {c['surface_pressed']}; }} QPushButton:disabled {{ - background: {c['bg_tertiary']}; + background: {c['bg_secondary']}; 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:focus {{ + outline: none; +}} + +/* 퇴근 버튼 — 주요 액션 (단일 포인트 컬러) */ QPushButton#clock_out_button {{ - background: {c['accent_success']}; + background: {c['accent_primary']}; color: {c['text_inverse']}; font-size: 11pt; font-weight: bold; - padding: 8px; + padding: 11px; border: none; border-radius: 8px; }} QPushButton#clock_out_button:hover {{ - background: {'#0EA572' if not is_dark else '#2BB885'}; + background: {c['accent_primary_hover']}; }} QPushButton#clock_out_button:pressed {{ - background: {'#0C8F63' if not is_dark else '#28A87A'}; + background: {c['accent_primary_pressed']}; }} /* 주요 액션 버튼 */ @@ -435,11 +466,11 @@ QPushButton#btn_primary {{ }} QPushButton#btn_primary:hover {{ - background: {c['accent_primary']}DD; + background: {c['accent_primary_hover']}; }} QPushButton#btn_primary:pressed {{ - background: {c['accent_primary']}BB; + background: {c['accent_primary_pressed']}; }} /* 위험 버튼 */ @@ -450,11 +481,11 @@ QPushButton#btn_danger {{ }} QPushButton#btn_danger:hover {{ - background: {c['accent_danger']}DD; + background: {c['accent_danger_hover']}; }} QPushButton#btn_danger:pressed {{ - background: {c['accent_danger']}BB; + background: {c['accent_danger_pressed']}; }} /* 성공 버튼 */ @@ -465,25 +496,44 @@ QPushButton#btn_success {{ }} QPushButton#btn_success:hover {{ - background: {c['accent_success']}DD; + background: {c['accent_success_hover']}; }} QPushButton#btn_success:pressed {{ - background: {c['accent_success']}BB; + background: {c['accent_success_pressed']}; }} -/* 작은 버튼 */ +/* 작은 버튼 — 미묘한 표면 */ QPushButton#btn_small {{ font-size: 8.5pt; - padding: 5px 10px; + padding: 6px 10px; }} QPushButton#btn_small:hover {{ - background: {c['accent_primary']}20; + background: {c['surface_hover']}; }} QPushButton#btn_small:pressed {{ - background: {c['accent_primary']}35; + background: {c['surface_pressed']}; +}} + +/* 하단 네비게이션 — 라인 아이콘 + 라벨, 투명 배경 (Linear/Notion 풋터 톤) */ +QPushButton#nav_btn {{ + background: transparent; + border: none; + border-radius: 8px; + padding: 8px 4px; + font-size: 8.5pt; + color: {c['text_secondary']}; +}} + +QPushButton#nav_btn:hover {{ + background: {c['surface_hover']}; + color: {c['text_primary']}; +}} + +QPushButton#nav_btn:pressed {{ + background: {c['surface_pressed']}; }} /* ════════════════════════════════════════ @@ -493,7 +543,7 @@ QPushButton#btn_small:pressed {{ QLineEdit, QTextEdit, QComboBox {{ background: {c['bg_secondary']}; border: 1px solid {c['border_default']}; - border-radius: 6px; + border-radius: 8px; padding: 6px 8px; color: {c['text_primary']}; font-size: 9.5pt; @@ -503,21 +553,17 @@ QLineEdit, QTextEdit, QComboBox {{ QSpinBox, QDoubleSpinBox, QDateEdit, QTimeEdit {{ background: {c['bg_secondary']}; border: 1px solid {c['border_default']}; - border-radius: 6px; + border-radius: 8px; 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; -}} - +/* 포커스 시 보더 컬러만 포인트 컬러로 (두께 유지 → 레이아웃 흔들림 없음) */ +QLineEdit:focus, QTextEdit:focus, QComboBox:focus, QSpinBox:focus, QDoubleSpinBox:focus, QDateEdit:focus, QTimeEdit:focus {{ - border: 2px solid {c['border_focus']}; - padding: 5px 27px 5px 7px; + border: 1px solid {c['border_focus']}; }} /* 비활성 입력 필드 */ @@ -563,13 +609,13 @@ QTimeEdit::up-button, QTimeEdit::down-button {{ QSpinBox::up-button, QDoubleSpinBox::up-button, QDateEdit::up-button, QTimeEdit::up-button {{ subcontrol-position: top right; - border-top-right-radius: 4px; + border-top-right-radius: 7px; }} QSpinBox::down-button, QDoubleSpinBox::down-button, QDateEdit::down-button, QTimeEdit::down-button {{ subcontrol-position: bottom right; - border-bottom-right-radius: 4px; + border-bottom-right-radius: 7px; }} QSpinBox::up-button:hover, QSpinBox::down-button:hover, @@ -628,17 +674,17 @@ QCheckBox::indicator:hover {{ QProgressBar {{ border: none; background: {c['progress_bg']}; - border-radius: 4px; - height: 8px; + border-radius: 3px; + min-height: 6px; + max-height: 6px; 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; + background: {c['progress_start']}; + border-radius: 3px; }} /* ════════════════════════════════════════ @@ -648,7 +694,7 @@ QProgressBar::chunk {{ QTableWidget {{ background: {c['bg_secondary']}; border: 1px solid {c['border_subtle']}; - border-radius: 6px; + border-radius: 8px; gridline-color: {c['border_subtle']}; color: {c['text_primary']}; font-size: 9pt; @@ -667,23 +713,47 @@ QTableWidget::item:alternate {{ background: {c['bg_tertiary']}; }} +/* 헤더 위젯 배경 (세로헤더 빈 영역의 흰색 누수 방지) */ +QHeaderView {{ + background: {c['bg_secondary']}; + border: none; +}} + 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; }} +QHeaderView::section:horizontal {{ + border-bottom: 2px solid {c['accent_primary']}; +}} + +/* 세로헤더(행번호) — accent 밑줄 없이 미묘하게 */ +QHeaderView::section:vertical {{ + border-right: 1px solid {c['border_subtle']}; + color: {c['text_tertiary']}; + font-weight: normal; + padding: 4px 8px; +}} + +/* 테이블 좌상단 코너 버튼 (흰색 누수 방지) */ +QTableView QTableCornerButton::section {{ + background: {c['bg_tertiary']}; + border: none; + border-bottom: 2px solid {c['accent_primary']}; +}} + /* ════════════════════════════════════════ 탭 위젯 ════════════════════════════════════════ */ QTabWidget::pane {{ border: 1px solid {c['border_subtle']}; - border-radius: 6px; + border-radius: 8px; background: {c['bg_secondary']}; top: -1px; }} @@ -694,8 +764,8 @@ QTabBar::tab {{ padding: 8px 20px; border: 1px solid {c['border_subtle']}; border-bottom: none; - border-top-left-radius: 6px; - border-top-right-radius: 6px; + border-top-left-radius: 8px; + border-top-right-radius: 8px; margin-right: 2px; font-size: 10pt; }} @@ -787,7 +857,7 @@ QScrollArea > QWidget > QWidget#scroll_content {{ QCalendarWidget {{ background: {c['bg_secondary']}; border: 1px solid {c['border_subtle']}; - border-radius: 6px; + border-radius: 8px; font-size: 10pt; }} @@ -902,7 +972,7 @@ QToolTip {{ QMenu {{ background: {c['bg_secondary']}; border: 1px solid {c['border_default']}; - border-radius: 6px; + border-radius: 8px; padding: 4px; color: {c['text_primary']}; }} @@ -916,6 +986,16 @@ QMenu::item:selected {{ background: {c['accent_primary']}; color: {c['text_inverse']}; }} + +QMenu::separator {{ + height: 1px; + background: {c['border_subtle']}; + margin: 4px 8px; +}} + +QMenu::icon {{ + padding-left: 8px; +}} """ diff --git a/ui/today_summary.py b/ui/today_summary.py index a6af2c7..3707dbf 100644 --- a/ui/today_summary.py +++ b/ui/today_summary.py @@ -16,12 +16,12 @@ class TodaySummaryCard(QFrame): self.setObjectName("today_summary_card") self.setStyleSheet(""" QFrame#today_summary_card { - background-color: rgba(76, 175, 80, 0.08); - border: 1px solid rgba(76, 175, 80, 0.4); + background-color: rgba(81, 207, 102, 0.08); + border: 1px solid rgba(81, 207, 102, 0.40); border-radius: 8px; padding: 6px; } - QLabel { padding: 1px; } + QLabel { padding: 1px; background: transparent; border: none; } """) self.setVisible(False) @@ -30,7 +30,7 @@ class TodaySummaryCard(QFrame): layout.setSpacing(2) header = QHBoxLayout() - title = QLabel("📋 오늘의 요약") + title = QLabel("오늘의 요약") title.setStyleSheet("font-weight: bold; font-size: 13px;") header.addWidget(title) header.addStretch() @@ -43,9 +43,9 @@ class TodaySummaryCard(QFrame): self.total_label = QLabel("") self.detail_label = QLabel("") - self.detail_label.setStyleSheet("color: #888; font-size: 11px;") + self.detail_label.setStyleSheet("color: #909296; font-size: 11px;") self.salary_label = QLabel("") - self.salary_label.setStyleSheet("color: #4caf50; font-weight: bold;") + self.salary_label.setStyleSheet("color: #51CF66; font-weight: bold;") layout.addWidget(self.total_label) layout.addWidget(self.detail_label) @@ -70,7 +70,7 @@ class TodaySummaryCard(QFrame): """ h = int(total_hours) m = int((total_hours - h) * 60) - self.total_label.setText(f"⏱ 총 근무: {h}시간 {m}분") + self.total_label.setText(f"총 근무: {h}시간 {m}분") details = [] if lunch_minutes > 0: @@ -85,7 +85,7 @@ class TodaySummaryCard(QFrame): self.detail_label.setVisible(bool(details)) if salary_text: - self.salary_label.setText(f"💰 {salary_text}") + self.salary_label.setText(f"{salary_text}") self.salary_label.setVisible(True) else: self.salary_label.setVisible(False) diff --git a/utils/font_loader.py b/utils/font_loader.py new file mode 100644 index 0000000..bc17feb --- /dev/null +++ b/utils/font_loader.py @@ -0,0 +1,84 @@ +"""번들 폰트(NanumSquare) 로딩. + +`font/` 디렉토리의 TTF를 QFontDatabase에 등록해 OS 설치 없이도 사용. +PyInstaller frozen(_MEIPASS) / 개발 실행(프로젝트 루트) 양쪽 경로를 지원하며, +등록 실패 시 QSS 폰트 체인이 "Malgun Gothic"으로 자연 폴백한다. +""" +from __future__ import annotations +import os +import sys + +from PyQt5.QtGui import QFontDatabase, QFont + +# 로드할 폰트 파일 — TTF 우선(Windows Qt에서 OTF보다 렌더 안정적). +# L/R/B/EB 4단계 굵기 + _ac(라틴·숫자 보정) 변형을 함께 등록. +_FONT_FILES = [ + 'NanumSquareL.ttf', + 'NanumSquareR.ttf', + 'NanumSquareB.ttf', + 'NanumSquareEB.ttf', + 'NanumSquare_acR.ttf', + 'NanumSquare_acB.ttf', +] + + +def _font_dir() -> str: + """번들 font/ 디렉토리 절대 경로.""" + if getattr(sys, 'frozen', False): + base = getattr(sys, '_MEIPASS', None) or os.path.dirname(sys.executable) + else: + base = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + return os.path.join(base, 'font') + + +def load_bundled_fonts() -> list: + """번들 폰트를 등록하고, 등록된 family 이름 목록을 반환.""" + families: list = [] + fdir = _font_dir() + if not os.path.isdir(fdir): + return families + for name in _FONT_FILES: + path = os.path.join(fdir, name) + if not os.path.exists(path): + continue + fid = QFontDatabase.addApplicationFont(path) + if fid == -1: + continue + for fam in QFontDatabase.applicationFontFamilies(fid): + if fam not in families: + families.append(fam) + return families + + +def _pick_primary(families: list) -> str: + """등록된 family 중 기본 본문용(Regular 굵기) family 선택.""" + if 'NanumSquare' in families: + return 'NanumSquare' + for fam in families: + low = fam.lower() + if 'nanumsquare' in low and 'light' not in low and 'extra' not in low: + return fam + return 'Malgun Gothic' + + +def apply_app_font(app, point_size: int = 9) -> str: + """앱 전역 기본 폰트를 NanumSquare로 설정. + + Returns: + 실제 적용된 primary family 이름 (폴백 시 'Malgun Gothic'). + """ + families = load_bundled_fonts() + primary = _pick_primary(families) + font = QFont(primary, point_size) + font.setStyleStrategy(QFont.PreferAntialias) + app.setFont(font) + return primary + + +if __name__ == '__main__': + from PyQt5.QtWidgets import QApplication + _app = QApplication(sys.argv) + fams = load_bundled_fonts() + print('font dir:', _font_dir()) + print('registered families:', fams) + print('picked primary:', _pick_primary(fams)) diff --git a/utils/system_tray.py b/utils/system_tray.py index 64362d6..af67cb1 100644 --- a/utils/system_tray.py +++ b/utils/system_tray.py @@ -57,62 +57,75 @@ class SystemTrayIcon(QSystemTrayIcon): return QIcon(pixmap) def setup_menu(self): - """트레이 메뉴 설정""" + """트레이 메뉴 설정 — 라인 아이콘 + 앱 다크 톤.""" menu = QMenu() - show_action = QAction(tr('tray.open'), self) - show_action.triggered.connect(self.show_window) - menu.addAction(show_action) + # (action, 라인 아이콘 이름) — 테마 전환 시 재틴팅용으로 보관 + self._icon_actions = [] - mini_action = QAction(tr('tray.mini_widget'), self) - mini_action.triggered.connect(self._open_mini_widget) - menu.addAction(mini_action) + def add(text, slot, icon_name=None): + action = QAction(text, self) + action.triggered.connect(slot) + menu.addAction(action) + if icon_name: + self._icon_actions.append((action, icon_name)) + return action + + add(tr('tray.open'), self.show_window, 'home') + add(tr('tray.mini_widget'), self._open_mini_widget, 'external-link') menu.addSeparator() - lunch_action = QAction(tr('tray.toggle_lunch'), self) - lunch_action.triggered.connect(self._toggle_lunch) - menu.addAction(lunch_action) - - break_out_action = QAction(tr('btn.break_out'), self) - break_out_action.triggered.connect(self._break_out) - menu.addAction(break_out_action) - - break_in_action = QAction(tr('btn.break_in'), self) - break_in_action.triggered.connect(self._break_in) - menu.addAction(break_in_action) + add(tr('tray.toggle_lunch'), self._toggle_lunch, 'coffee') + add(tr('btn.break_out'), self._break_out) + add(tr('btn.break_in'), self._break_in) menu.addSeparator() - clock_out_action = QAction("✅ " + tr('btn.clock_out'), self) - clock_out_action.triggered.connect(self.quick_clock_out) - menu.addAction(clock_out_action) + add(tr('btn.clock_out'), self.quick_clock_out, 'logout') menu.addSeparator() - stats_action = QAction("📊 " + tr('menu.stats'), self) - stats_action.triggered.connect(lambda: self._call_parent('show_stats')) - menu.addAction(stats_action) - - cal_action = QAction("📅 " + tr('menu.calendar'), self) - cal_action.triggered.connect(lambda: self._call_parent('show_calendar')) - menu.addAction(cal_action) - - schedule_action = QAction("🗓️ 스케줄", self) - schedule_action.triggered.connect(lambda: self._call_parent('show_schedule')) - menu.addAction(schedule_action) - - help_action = QAction("📖 " + tr('menu.help'), self) - help_action.triggered.connect(lambda: self._call_parent('show_help')) - menu.addAction(help_action) + add(tr('menu.stats'), lambda: self._call_parent('show_stats'), 'chart') + add(tr('menu.calendar'), lambda: self._call_parent('show_calendar'), 'calendar') + add('스케줄', lambda: self._call_parent('show_schedule'), 'repeat') + add(tr('menu.help'), lambda: self._call_parent('show_help'), 'help') menu.addSeparator() - quit_action = QAction(tr('tray.quit'), self) - quit_action.triggered.connect(self.quit_app) - menu.addAction(quit_action) + add(tr('tray.quit'), self.quit_app) self.setContextMenu(menu) + self.refresh_theme() + + def refresh_theme(self): + """트레이 메뉴에 현재 앱 테마 QSS + 라인 아이콘 색을 (재)적용. + + QMenu()는 부모가 없어 메인 윈도우 스타일시트를 자동 상속하지 않으므로 + 명시적으로 적용한다. 테마 변경 시 main_window.apply_theme에서 호출. + """ + menu = self.contextMenu() + if menu is None: + return + # 다크 QSS 적용 (메인 윈도우 스타일 우선, 없으면 dark 폴백) + qss = self.parent_window.styleSheet() if self.parent_window else '' + if not qss: + try: + from ui.styles import get_theme + qss = get_theme('dark') + except Exception: + qss = '' + if qss: + menu.setStyleSheet(qss) + # 라인 아이콘 틴팅 (메뉴 텍스트 색과 동일하게) + try: + from ui.icons import get_icon + from ui.styles import ThemeColors + color = ThemeColors.get('text_primary') + for action, name in getattr(self, '_icon_actions', []): + action.setIcon(get_icon(name, color, 16)) + except Exception: + pass def _call_parent(self, method_name: str): if self.parent_window and hasattr(self.parent_window, method_name):