Compare commits
2 Commits
da5f91984b
...
130c61ea62
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
130c61ea62 | ||
|
|
5fb8655a47 |
29
CHANGELOG.md
29
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/).
|
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
|
## [2.10.2] — 2026-05-16
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@ -15,6 +15,11 @@ from pathlib import Path
|
|||||||
|
|
||||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
# 테스트 중에는 공휴일 자동 동기화(백그라운드 네트워크 스레드)를 비활성화.
|
||||||
|
# 이 스레드가 SQLite 연결을 잡고 있으면 임시 DB의 os.remove가 WinError 32(파일 사용 중)로
|
||||||
|
# 실패함 (S2/S31 등). DB 인스턴스 생성 전에 설정해야 효과 있음.
|
||||||
|
os.environ.setdefault('CLOCKOUT_DISABLE_HOLIDAY_SYNC', '1')
|
||||||
|
|
||||||
PASS = []
|
PASS = []
|
||||||
FAIL = []
|
FAIL = []
|
||||||
WARN = []
|
WARN = []
|
||||||
|
|||||||
@ -770,7 +770,7 @@ class Database:
|
|||||||
'dinner_duration_minutes': '60',
|
'dinner_duration_minutes': '60',
|
||||||
'auto_lunch': 'false',
|
'auto_lunch': 'false',
|
||||||
'auto_overtime': 'true',
|
'auto_overtime': 'true',
|
||||||
'theme': 'light',
|
'theme': 'dark',
|
||||||
'notification_before_minutes': '30',
|
'notification_before_minutes': '30',
|
||||||
'notification_clock_out': 'true',
|
'notification_clock_out': 'true',
|
||||||
'notification_lunch': 'true',
|
'notification_lunch': 'true',
|
||||||
@ -977,6 +977,19 @@ class Database:
|
|||||||
''', (work_record_id, earned_minutes, date))
|
''', (work_record_id, earned_minutes, date))
|
||||||
conn.commit()
|
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,
|
def add_overtime_usage(self, work_record_id: int, used_minutes: int,
|
||||||
date: str, reason: str = None):
|
date: str, reason: str = None):
|
||||||
"""연장근무 사용 (음수 잔액 허용 - 선사용 후적립 가능)"""
|
"""연장근무 사용 (음수 잔액 허용 - 선사용 후적립 가능)"""
|
||||||
|
|||||||
98
core/i18n.py
98
core/i18n.py
@ -21,14 +21,14 @@ _DICT = {
|
|||||||
'menu.help': '도움말',
|
'menu.help': '도움말',
|
||||||
'menu.settings': '설정',
|
'menu.settings': '설정',
|
||||||
'btn.clock_out': '퇴근하기',
|
'btn.clock_out': '퇴근하기',
|
||||||
'btn.clock_out_cancel': '🔄 퇴근 취소',
|
'btn.clock_out_cancel': '퇴근 취소',
|
||||||
'btn.lunch_add': '점심시간 추가',
|
'btn.lunch_add': '점심시간 추가',
|
||||||
'btn.lunch_applied': '점심시간 (적용됨)',
|
'btn.lunch_applied': '점심시간 (적용됨)',
|
||||||
'btn.dinner_add': '저녁시간 추가',
|
'btn.dinner_add': '저녁시간 추가',
|
||||||
'btn.dinner_applied': '저녁시간 (적용됨)',
|
'btn.dinner_applied': '저녁시간 (적용됨)',
|
||||||
'btn.break_out': '🚪 외출 시작',
|
'btn.break_out': '외출 시작',
|
||||||
'btn.break_in': '↩️ 복귀',
|
'btn.break_in': '복귀',
|
||||||
'btn.save': '💾 저장',
|
'btn.save': '저장',
|
||||||
'btn.close': '닫기',
|
'btn.close': '닫기',
|
||||||
'btn.apply': '적용',
|
'btn.apply': '적용',
|
||||||
'btn.cancel': '취소',
|
'btn.cancel': '취소',
|
||||||
@ -40,10 +40,10 @@ _DICT = {
|
|||||||
|
|
||||||
# === 윈도우/다이얼로그 제목 ===
|
# === 윈도우/다이얼로그 제목 ===
|
||||||
'window.main_title': '퇴근시간 계산기',
|
'window.main_title': '퇴근시간 계산기',
|
||||||
'window.settings': '⚙️ 설정',
|
'window.settings': '설정',
|
||||||
'window.help': '📖 사용 설명서',
|
'window.help': '사용 설명서',
|
||||||
'window.stats': '📊 근무 통계',
|
'window.stats': '근무 통계',
|
||||||
'window.calendar': '📅 캘린더',
|
'window.calendar': '캘린더',
|
||||||
'window.mini_widget': '퇴근시간',
|
'window.mini_widget': '퇴근시간',
|
||||||
'window.clock_in_dialog': '출근 시간',
|
'window.clock_in_dialog': '출근 시간',
|
||||||
'window.break_view': '외출 관리',
|
'window.break_view': '외출 관리',
|
||||||
@ -125,8 +125,8 @@ _DICT = {
|
|||||||
|
|
||||||
# === 트레이 ===
|
# === 트레이 ===
|
||||||
'tray.open': '프로그램 열기',
|
'tray.open': '프로그램 열기',
|
||||||
'tray.mini_widget': '📌 미니 위젯',
|
'tray.mini_widget': '미니 위젯',
|
||||||
'tray.toggle_lunch': '🍱 점심시간 토글',
|
'tray.toggle_lunch': '점심시간 토글',
|
||||||
'tray.quit': '종료',
|
'tray.quit': '종료',
|
||||||
'tray.tooltip_remaining': '퇴근까지: {time}',
|
'tray.tooltip_remaining': '퇴근까지: {time}',
|
||||||
'tray.tooltip_overtime': '추가 근무 중: {time}',
|
'tray.tooltip_overtime': '추가 근무 중: {time}',
|
||||||
@ -166,12 +166,12 @@ _DICT = {
|
|||||||
'cal.edit_record': '기록 편집',
|
'cal.edit_record': '기록 편집',
|
||||||
|
|
||||||
# === HelpView (각 탭의 큰 HTML은 별도 키) ===
|
# === HelpView (각 탭의 큰 HTML은 별도 키) ===
|
||||||
'help.tab_intro': '👋 시작하기',
|
'help.tab_intro': '시작하기',
|
||||||
'help.tab_work_hours': '🕘 근무시간',
|
'help.tab_work_hours': '근무시간',
|
||||||
'help.tab_overtime': '🏦 연장근무',
|
'help.tab_overtime': '연장근무',
|
||||||
'help.tab_leave': '🌴 연차/휴가',
|
'help.tab_leave': '연차/휴가',
|
||||||
'help.tab_break': '🚪 외출/저녁',
|
'help.tab_break': '외출/저녁',
|
||||||
'help.tab_faq': '❓ 자주 묻는 질문',
|
'help.tab_faq': '자주 묻는 질문',
|
||||||
|
|
||||||
# === clock_in_dialog ===
|
# === clock_in_dialog ===
|
||||||
'dlg.clock_in.prompt': '오늘의 출근시간을 입력해주세요',
|
'dlg.clock_in.prompt': '오늘의 출근시간을 입력해주세요',
|
||||||
@ -204,17 +204,18 @@ _DICT = {
|
|||||||
'view.overtime.title': '연장근무 내역',
|
'view.overtime.title': '연장근무 내역',
|
||||||
'view.overtime.balance_zero': '잔액: 0분',
|
'view.overtime.balance_zero': '잔액: 0분',
|
||||||
'view.overtime.balance_fmt': '현재 잔액: {h}시간 {m}분 ({total}분)',
|
'view.overtime.balance_fmt': '현재 잔액: {h}시간 {m}분 ({total}분)',
|
||||||
'view.overtime.earned_group': '💰 적립 내역',
|
'view.overtime.earned_group': '적립 내역',
|
||||||
'view.overtime.used_group': '📤 사용 내역',
|
'view.overtime.used_group': '사용 내역',
|
||||||
'view.overtime.col_date': '날짜',
|
'view.overtime.col_date': '날짜',
|
||||||
'view.overtime.col_earned': '적립',
|
'view.overtime.col_earned': '적립',
|
||||||
'view.overtime.col_used': '사용',
|
'view.overtime.col_used': '사용',
|
||||||
'view.overtime.col_memo': '메모',
|
'view.overtime.col_memo': '메모',
|
||||||
'view.overtime.col_reason': '사유',
|
'view.overtime.col_reason': '사유',
|
||||||
'view.overtime.btn_add_earned': '➕ 수동 적립',
|
'view.overtime.btn_add_earned': '수동 적립',
|
||||||
'view.overtime.btn_add_used': '➕ 수동 사용',
|
'view.overtime.btn_add_used': '수동 사용',
|
||||||
'view.overtime.menu_delete': '❌ 삭제',
|
'view.overtime.menu_delete': '삭제',
|
||||||
'view.overtime.delete_confirm_body': '다음 사용 기록을 삭제하시겠습니까?\n\n날짜: {date}\n시간: {time}\n사유: {reason}',
|
'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_earned_title': '추가근무 수동 적립',
|
||||||
'view.overtime.manual_used_title': '추가근무 수동 사용',
|
'view.overtime.manual_used_title': '추가근무 수동 사용',
|
||||||
'view.overtime.field_date': '날짜:',
|
'view.overtime.field_date': '날짜:',
|
||||||
@ -238,13 +239,13 @@ _DICT = {
|
|||||||
'view.leave.balance_zero': '잔여: 0일',
|
'view.leave.balance_zero': '잔여: 0일',
|
||||||
'view.leave.balance_fmt': '잔여: {days}일 (총 {hours}시간)',
|
'view.leave.balance_fmt': '잔여: {days}일 (총 {hours}시간)',
|
||||||
'view.leave.btn_set_balance': '잔여 설정',
|
'view.leave.btn_set_balance': '잔여 설정',
|
||||||
'view.leave.used_group': '📤 사용 내역',
|
'view.leave.used_group': '사용 내역',
|
||||||
'view.leave.col_date': '날짜',
|
'view.leave.col_date': '날짜',
|
||||||
'view.leave.col_type': '구분',
|
'view.leave.col_type': '구분',
|
||||||
'view.leave.col_used': '사용',
|
'view.leave.col_used': '사용',
|
||||||
'view.leave.col_reason': '사유',
|
'view.leave.col_reason': '사유',
|
||||||
'view.leave.btn_add': '➕ 연차 사용 추가',
|
'view.leave.btn_add': '연차 사용 추가',
|
||||||
'view.leave.btn_calendar': '📅 캘린더 보기',
|
'view.leave.btn_calendar': '캘린더 보기',
|
||||||
'view.leave.delete_confirm_body': '다음 연차 사용 기록을 삭제하시겠습니까?\n\n날짜: {date}\n구분: {type}\n사용: {days}',
|
'view.leave.delete_confirm_body': '다음 연차 사용 기록을 삭제하시겠습니까?\n\n날짜: {date}\n구분: {type}\n사용: {days}',
|
||||||
'view.leave.set_title': '연차 시간 설정',
|
'view.leave.set_title': '연차 시간 설정',
|
||||||
'view.leave.set_prompt': '연차 잔여 시간을 입력하세요 (0.5시간 단위):\n예) 8시간 = 1일, 4시간 = 0.5일(반차), 2시간 = 0.25일, 0.5시간 = 30분',
|
'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.help': 'Help',
|
||||||
'menu.settings': 'Settings',
|
'menu.settings': 'Settings',
|
||||||
'btn.clock_out': 'Clock Out',
|
'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_add': 'Add Lunch',
|
||||||
'btn.lunch_applied': 'Lunch (Applied)',
|
'btn.lunch_applied': 'Lunch (Applied)',
|
||||||
'btn.dinner_add': 'Add Dinner',
|
'btn.dinner_add': 'Add Dinner',
|
||||||
'btn.dinner_applied': 'Dinner (Applied)',
|
'btn.dinner_applied': 'Dinner (Applied)',
|
||||||
'btn.break_out': '🚪 Start Break',
|
'btn.break_out': 'Start Break',
|
||||||
'btn.break_in': '↩️ Return',
|
'btn.break_in': 'Return',
|
||||||
'btn.save': '💾 Save',
|
'btn.save': 'Save',
|
||||||
'btn.close': 'Close',
|
'btn.close': 'Close',
|
||||||
'btn.apply': 'Apply',
|
'btn.apply': 'Apply',
|
||||||
'btn.cancel': 'Cancel',
|
'btn.cancel': 'Cancel',
|
||||||
@ -297,10 +298,10 @@ _DICT = {
|
|||||||
|
|
||||||
# === Windows ===
|
# === Windows ===
|
||||||
'window.main_title': 'Clock-out Time Calculator',
|
'window.main_title': 'Clock-out Time Calculator',
|
||||||
'window.settings': '⚙️ Settings',
|
'window.settings': 'Settings',
|
||||||
'window.help': '📖 User Guide',
|
'window.help': 'User Guide',
|
||||||
'window.stats': '📊 Statistics',
|
'window.stats': 'Statistics',
|
||||||
'window.calendar': '📅 Calendar',
|
'window.calendar': 'Calendar',
|
||||||
'window.mini_widget': 'Clock-out',
|
'window.mini_widget': 'Clock-out',
|
||||||
'window.clock_in_dialog': 'Clock-in Time',
|
'window.clock_in_dialog': 'Clock-in Time',
|
||||||
'window.break_view': 'Break Management',
|
'window.break_view': 'Break Management',
|
||||||
@ -382,8 +383,8 @@ _DICT = {
|
|||||||
|
|
||||||
# === Tray ===
|
# === Tray ===
|
||||||
'tray.open': 'Open Program',
|
'tray.open': 'Open Program',
|
||||||
'tray.mini_widget': '📌 Mini Widget',
|
'tray.mini_widget': 'Mini Widget',
|
||||||
'tray.toggle_lunch': '🍱 Toggle Lunch',
|
'tray.toggle_lunch': 'Toggle Lunch',
|
||||||
'tray.quit': 'Quit',
|
'tray.quit': 'Quit',
|
||||||
'tray.tooltip_remaining': 'Until clock-out: {time}',
|
'tray.tooltip_remaining': 'Until clock-out: {time}',
|
||||||
'tray.tooltip_overtime': 'Overtime: {time}',
|
'tray.tooltip_overtime': 'Overtime: {time}',
|
||||||
@ -423,12 +424,12 @@ _DICT = {
|
|||||||
'cal.edit_record': 'Edit record',
|
'cal.edit_record': 'Edit record',
|
||||||
|
|
||||||
# === HelpView ===
|
# === HelpView ===
|
||||||
'help.tab_intro': '👋 Getting Started',
|
'help.tab_intro': 'Getting Started',
|
||||||
'help.tab_work_hours': '🕘 Work Hours',
|
'help.tab_work_hours': 'Work Hours',
|
||||||
'help.tab_overtime': '🏦 Overtime',
|
'help.tab_overtime': 'Overtime',
|
||||||
'help.tab_leave': '🌴 Leave',
|
'help.tab_leave': 'Leave',
|
||||||
'help.tab_break': '🚪 Break/Dinner',
|
'help.tab_break': 'Break/Dinner',
|
||||||
'help.tab_faq': '❓ FAQ',
|
'help.tab_faq': 'FAQ',
|
||||||
|
|
||||||
# === clock_in_dialog ===
|
# === clock_in_dialog ===
|
||||||
'dlg.clock_in.prompt': "Enter today's clock-in time",
|
'dlg.clock_in.prompt': "Enter today's clock-in time",
|
||||||
@ -461,17 +462,18 @@ _DICT = {
|
|||||||
'view.overtime.title': 'Overtime History',
|
'view.overtime.title': 'Overtime History',
|
||||||
'view.overtime.balance_zero': 'Balance: 0 min',
|
'view.overtime.balance_zero': 'Balance: 0 min',
|
||||||
'view.overtime.balance_fmt': 'Current balance: {h}h {m}m ({total} min)',
|
'view.overtime.balance_fmt': 'Current balance: {h}h {m}m ({total} min)',
|
||||||
'view.overtime.earned_group': '💰 Earned',
|
'view.overtime.earned_group': 'Earned',
|
||||||
'view.overtime.used_group': '📤 Used',
|
'view.overtime.used_group': 'Used',
|
||||||
'view.overtime.col_date': 'Date',
|
'view.overtime.col_date': 'Date',
|
||||||
'view.overtime.col_earned': 'Earned',
|
'view.overtime.col_earned': 'Earned',
|
||||||
'view.overtime.col_used': 'Used',
|
'view.overtime.col_used': 'Used',
|
||||||
'view.overtime.col_memo': 'Memo',
|
'view.overtime.col_memo': 'Memo',
|
||||||
'view.overtime.col_reason': 'Reason',
|
'view.overtime.col_reason': 'Reason',
|
||||||
'view.overtime.btn_add_earned': '➕ Manual Earn',
|
'view.overtime.btn_add_earned': 'Manual Earn',
|
||||||
'view.overtime.btn_add_used': '➕ Manual Use',
|
'view.overtime.btn_add_used': 'Manual Use',
|
||||||
'view.overtime.menu_delete': '❌ Delete',
|
'view.overtime.menu_delete': 'Delete',
|
||||||
'view.overtime.delete_confirm_body': 'Delete this usage record?\n\nDate: {date}\nTime: {time}\nReason: {reason}',
|
'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_earned_title': 'Manual Overtime Earn',
|
||||||
'view.overtime.manual_used_title': 'Manual Overtime Use',
|
'view.overtime.manual_used_title': 'Manual Overtime Use',
|
||||||
'view.overtime.field_date': 'Date:',
|
'view.overtime.field_date': 'Date:',
|
||||||
@ -495,13 +497,13 @@ _DICT = {
|
|||||||
'view.leave.balance_zero': 'Balance: 0 days',
|
'view.leave.balance_zero': 'Balance: 0 days',
|
||||||
'view.leave.balance_fmt': 'Balance: {days} days ({hours}h total)',
|
'view.leave.balance_fmt': 'Balance: {days} days ({hours}h total)',
|
||||||
'view.leave.btn_set_balance': 'Set Balance',
|
'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_date': 'Date',
|
||||||
'view.leave.col_type': 'Type',
|
'view.leave.col_type': 'Type',
|
||||||
'view.leave.col_used': 'Used',
|
'view.leave.col_used': 'Used',
|
||||||
'view.leave.col_reason': 'Reason',
|
'view.leave.col_reason': 'Reason',
|
||||||
'view.leave.btn_add': '➕ Add Leave Usage',
|
'view.leave.btn_add': 'Add Leave Usage',
|
||||||
'view.leave.btn_calendar': '📅 Calendar',
|
'view.leave.btn_calendar': 'Calendar',
|
||||||
'view.leave.delete_confirm_body': 'Delete this leave record?\n\nDate: {date}\nType: {type}\nUsed: {days}',
|
'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_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',
|
'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',
|
||||||
|
|||||||
@ -4,4 +4,4 @@
|
|||||||
릴리스 시 이 값을 올린 후 git tag → push.
|
릴리스 시 이 값을 올린 후 git tag → push.
|
||||||
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
||||||
"""
|
"""
|
||||||
__version__ = '2.10.2'
|
__version__ = '2.11.0'
|
||||||
|
|||||||
BIN
font/NanumSquareB.otf
Normal file
BIN
font/NanumSquareB.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareB.ttf
Normal file
BIN
font/NanumSquareB.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquareEB.otf
Normal file
BIN
font/NanumSquareEB.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareEB.ttf
Normal file
BIN
font/NanumSquareEB.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquareL.otf
Normal file
BIN
font/NanumSquareL.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareL.ttf
Normal file
BIN
font/NanumSquareL.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquareOTF_acB.otf
Normal file
BIN
font/NanumSquareOTF_acB.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareOTF_acEB.otf
Normal file
BIN
font/NanumSquareOTF_acEB.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareOTF_acL.otf
Normal file
BIN
font/NanumSquareOTF_acL.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareOTF_acR.otf
Normal file
BIN
font/NanumSquareOTF_acR.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareR.otf
Normal file
BIN
font/NanumSquareR.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareR.ttf
Normal file
BIN
font/NanumSquareR.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquare_acB.ttf
Normal file
BIN
font/NanumSquare_acB.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquare_acEB.ttf
Normal file
BIN
font/NanumSquare_acEB.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquare_acL.ttf
Normal file
BIN
font/NanumSquare_acL.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquare_acR.ttf
Normal file
BIN
font/NanumSquare_acR.ttf
Normal file
Binary file not shown.
5
main.py
5
main.py
@ -96,8 +96,9 @@ def main():
|
|||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
|
|
||||||
# 폰트 설정
|
# 폰트 설정 — 번들 NanumSquare 등록 + 전역 적용 (미설치 시 Malgun Gothic 폴백)
|
||||||
app.setFont(QFont("Segoe UI", 9))
|
from utils.font_loader import apply_app_font
|
||||||
|
apply_app_font(app, 9)
|
||||||
|
|
||||||
# 필수 패키지 확인
|
# 필수 패키지 확인
|
||||||
if not check_requirements():
|
if not check_requirements():
|
||||||
|
|||||||
13
main.spec
13
main.spec
@ -14,15 +14,26 @@ if os.path.exists(_staged):
|
|||||||
elif os.path.exists(_fallback):
|
elif os.path.exists(_fallback):
|
||||||
_extra_datas.append((_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(
|
a = Analysis(
|
||||||
['main.py'],
|
['main.py'],
|
||||||
pathex=[],
|
pathex=[],
|
||||||
binaries=[],
|
binaries=[],
|
||||||
datas=[('3d-alarm.png', '.')] + _extra_datas,
|
datas=[('3d-alarm.png', '.')] + _extra_datas + _font_datas,
|
||||||
hiddenimports=[
|
hiddenimports=[
|
||||||
'holidays', 'holidays.countries.south_korea',
|
'holidays', 'holidays.countries.south_korea',
|
||||||
'win32evtlog', 'win32evtlogutil',
|
'win32evtlog', 'win32evtlogutil',
|
||||||
'matplotlib.backends.backend_qt5agg',
|
'matplotlib.backends.backend_qt5agg',
|
||||||
|
'PyQt5.QtSvg',
|
||||||
],
|
],
|
||||||
hookspath=[],
|
hookspath=[],
|
||||||
hooksconfig={},
|
hooksconfig={},
|
||||||
|
|||||||
@ -35,7 +35,7 @@ def test_register_applies_initial_text(qapp, i18n):
|
|||||||
set_language('ko')
|
set_language('ko')
|
||||||
label = QLabel()
|
label = QLabel()
|
||||||
i18n.register(label, 'btn.save')
|
i18n.register(label, 'btn.save')
|
||||||
assert label.text() == '💾 저장'
|
assert label.text() == '저장'
|
||||||
|
|
||||||
|
|
||||||
def test_retranslate_after_language_change(qapp, i18n):
|
def test_retranslate_after_language_change(qapp, i18n):
|
||||||
@ -59,10 +59,10 @@ def test_setter_kwarg_for_window_title(qapp, i18n):
|
|||||||
set_language('ko')
|
set_language('ko')
|
||||||
dlg = QDialog()
|
dlg = QDialog()
|
||||||
i18n.register(dlg, 'window.settings', setter='setWindowTitle')
|
i18n.register(dlg, 'window.settings', setter='setWindowTitle')
|
||||||
assert dlg.windowTitle() == '⚙️ 설정'
|
assert dlg.windowTitle() == '설정'
|
||||||
|
|
||||||
i18n.set_language_and_retranslate('en')
|
i18n.set_language_and_retranslate('en')
|
||||||
assert dlg.windowTitle() == '⚙️ Settings'
|
assert dlg.windowTitle() == 'Settings'
|
||||||
|
|
||||||
|
|
||||||
def test_post_callback_applied(qapp, i18n):
|
def test_post_callback_applied(qapp, i18n):
|
||||||
@ -71,10 +71,10 @@ def test_post_callback_applied(qapp, i18n):
|
|||||||
set_language('ko')
|
set_language('ko')
|
||||||
label = QLabel()
|
label = QLabel()
|
||||||
i18n.register(label, 'btn.save', post=lambda t: f"[{t}]")
|
i18n.register(label, 'btn.save', post=lambda t: f"[{t}]")
|
||||||
assert label.text() == '[💾 저장]'
|
assert label.text() == '[저장]'
|
||||||
|
|
||||||
i18n.set_language_and_retranslate('en')
|
i18n.set_language_and_retranslate('en')
|
||||||
assert label.text() == '[💾 Save]'
|
assert label.text() == '[Save]'
|
||||||
|
|
||||||
|
|
||||||
def test_dead_widget_pruned(qapp, i18n):
|
def test_dead_widget_pruned(qapp, i18n):
|
||||||
|
|||||||
72
tests/test_overtime_accrual_guard.py
Normal file
72
tests/test_overtime_accrual_guard.py
Normal file
@ -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
|
||||||
@ -85,11 +85,11 @@ class AchievementsView(QDialog):
|
|||||||
def __init__(self, db, parent=None):
|
def __init__(self, db, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.db = db
|
self.db = db
|
||||||
self.setWindowTitle("🏆 도전과제")
|
self.setWindowTitle("도전과제")
|
||||||
self.setMinimumSize(960, 720)
|
self.setMinimumSize(960, 720)
|
||||||
self.resize(1100, 800)
|
self.resize(1100, 800)
|
||||||
self._increment_view_count()
|
self._increment_view_count()
|
||||||
self.setStyleSheet("QDialog { background: #0e0e14; }")
|
self.setStyleSheet("QDialog { background: #1A1B1E; }")
|
||||||
self.init_ui()
|
self.init_ui()
|
||||||
apply_dark_titlebar(self, dark=True)
|
apply_dark_titlebar(self, dark=True)
|
||||||
|
|
||||||
@ -157,7 +157,7 @@ class AchievementsView(QDialog):
|
|||||||
QFrame {
|
QFrame {
|
||||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||||
stop:0 #1a1a30, stop:1 #2a1a3a);
|
stop:0 #1a1a30, stop:1 #2a1a3a);
|
||||||
border: 1px solid #3a3a5a;
|
border: 1px solid #2C2E33;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
QLabel { background: transparent; border: none; color: #e8e8f4; }
|
QLabel { background: transparent; border: none; color: #e8e8f4; }
|
||||||
@ -173,21 +173,21 @@ class AchievementsView(QDialog):
|
|||||||
num_row.setSpacing(24)
|
num_row.setSpacing(24)
|
||||||
|
|
||||||
big = QLabel(f"<span style='font-size: 32pt; font-weight: bold; color: #ffd24a;'>{stats['earned']}</span>"
|
big = QLabel(f"<span style='font-size: 32pt; font-weight: bold; color: #ffd24a;'>{stats['earned']}</span>"
|
||||||
f"<span style='font-size: 18pt; color: #888;'> / {stats['total']}</span>")
|
f"<span style='font-size: 18pt; color: #909296;'> / {stats['total']}</span>")
|
||||||
big.setTextFormat(Qt.RichText)
|
big.setTextFormat(Qt.RichText)
|
||||||
num_row.addWidget(big)
|
num_row.addWidget(big)
|
||||||
|
|
||||||
spacer = QFrame()
|
spacer = QFrame()
|
||||||
spacer.setFrameShape(QFrame.VLine)
|
spacer.setFrameShape(QFrame.VLine)
|
||||||
spacer.setStyleSheet("color: #3a3a5a;")
|
spacer.setStyleSheet("color: #2C2E33;")
|
||||||
num_row.addWidget(spacer)
|
num_row.addWidget(spacer)
|
||||||
|
|
||||||
secret_lbl = QLabel(
|
secret_lbl = QLabel(
|
||||||
f"<div style='line-height: 1.3;'>"
|
f"<div style='line-height: 1.3;'>"
|
||||||
f"<span style='font-size: 9pt; color: #888;'>🌑 시크릿</span><br>"
|
f"<span style='font-size: 9pt; color: #909296;'>🌑 시크릿</span><br>"
|
||||||
f"<span style='font-size: 18pt; font-weight: bold; color: #ff90b8;'>"
|
f"<span style='font-size: 18pt; font-weight: bold; color: #ff90b8;'>"
|
||||||
f"{stats['secret_earned']}</span>"
|
f"{stats['secret_earned']}</span>"
|
||||||
f"<span style='font-size: 12pt; color: #888;'> / {stats['secret_total']}</span>"
|
f"<span style='font-size: 12pt; color: #909296;'> / {stats['secret_total']}</span>"
|
||||||
f"</div>"
|
f"</div>"
|
||||||
)
|
)
|
||||||
secret_lbl.setTextFormat(Qt.RichText)
|
secret_lbl.setTextFormat(Qt.RichText)
|
||||||
@ -197,7 +197,7 @@ class AchievementsView(QDialog):
|
|||||||
|
|
||||||
pct_lbl = QLabel(
|
pct_lbl = QLabel(
|
||||||
f"<div style='text-align: right; line-height: 1.3;'>"
|
f"<div style='text-align: right; line-height: 1.3;'>"
|
||||||
f"<span style='font-size: 9pt; color: #888;'>달성률</span><br>"
|
f"<span style='font-size: 9pt; color: #909296;'>달성률</span><br>"
|
||||||
f"<span style='font-size: 24pt; font-weight: bold; color: #4adef0;'>"
|
f"<span style='font-size: 24pt; font-weight: bold; color: #4adef0;'>"
|
||||||
f"{pct:.1f}%</span></div>"
|
f"{pct:.1f}%</span></div>"
|
||||||
)
|
)
|
||||||
@ -256,7 +256,7 @@ class AchievementsView(QDialog):
|
|||||||
empty = QLabel("(아직 없음)")
|
empty = QLabel("(아직 없음)")
|
||||||
empty.setAlignment(Qt.AlignCenter)
|
empty.setAlignment(Qt.AlignCenter)
|
||||||
empty.setStyleSheet(
|
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)
|
grid.addWidget(empty, 0, 0)
|
||||||
else:
|
else:
|
||||||
@ -283,7 +283,7 @@ class AchievementsView(QDialog):
|
|||||||
if is_locked_secret:
|
if is_locked_secret:
|
||||||
bg_top, bg_bot = '#1a1a26', '#0e0e16'
|
bg_top, bg_bot = '#1a1a26', '#0e0e16'
|
||||||
border = '#3a3a4a'
|
border = '#3a3a4a'
|
||||||
text_color = '#666'
|
text_color = '#6C6E73'
|
||||||
else:
|
else:
|
||||||
bg_top = theme['bg_top']
|
bg_top = theme['bg_top']
|
||||||
bg_bot = theme['bg_bot']
|
bg_bot = theme['bg_bot']
|
||||||
@ -455,7 +455,7 @@ class AchievementsView(QDialog):
|
|||||||
def _tabs_qss(self) -> str:
|
def _tabs_qss(self) -> str:
|
||||||
return """
|
return """
|
||||||
QTabWidget::pane {
|
QTabWidget::pane {
|
||||||
background: #14141c;
|
background: #25262B;
|
||||||
border: 1px solid #2a2a3a;
|
border: 1px solid #2a2a3a;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
top: -1px;
|
top: -1px;
|
||||||
@ -472,7 +472,7 @@ class AchievementsView(QDialog):
|
|||||||
font-size: 10pt;
|
font-size: 10pt;
|
||||||
}
|
}
|
||||||
QTabBar::tab:selected {
|
QTabBar::tab:selected {
|
||||||
background: #14141c;
|
background: #25262B;
|
||||||
color: #ffd24a;
|
color: #ffd24a;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
border-bottom: 2px solid #ffd24a;
|
border-bottom: 2px solid #ffd24a;
|
||||||
|
|||||||
@ -55,10 +55,11 @@ class CalendarView(QDialog):
|
|||||||
# 범례
|
# 범례
|
||||||
legend_layout = QHBoxLayout()
|
legend_layout = QHBoxLayout()
|
||||||
legend_layout.setSpacing(12)
|
legend_layout.setSpacing(12)
|
||||||
legend_layout.addWidget(QLabel("🟢 정상"))
|
for _color, _txt in [('#51CF66', '정상'), ('#FA5252', '연장'),
|
||||||
legend_layout.addWidget(QLabel("🔴 연장"))
|
('#FAB005', '휴가'), ('#6C6E73', '없음')]:
|
||||||
legend_layout.addWidget(QLabel("🟡 휴가"))
|
_item = QLabel(f"<span style='color:{_color};'>●</span> {_txt}")
|
||||||
legend_layout.addWidget(QLabel("⚪ 없음"))
|
_item.setTextFormat(Qt.RichText)
|
||||||
|
legend_layout.addWidget(_item)
|
||||||
legend_layout.addStretch()
|
legend_layout.addStretch()
|
||||||
layout.addLayout(legend_layout)
|
layout.addLayout(legend_layout)
|
||||||
|
|
||||||
@ -77,13 +78,13 @@ class CalendarView(QDialog):
|
|||||||
button_layout = QHBoxLayout()
|
button_layout = QHBoxLayout()
|
||||||
button_layout.setSpacing(6)
|
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.setObjectName("btn_primary")
|
||||||
self.edit_time_button.setEnabled(False)
|
self.edit_time_button.setEnabled(False)
|
||||||
self.edit_time_button.clicked.connect(self.edit_work_time)
|
self.edit_time_button.clicked.connect(self.edit_work_time)
|
||||||
button_layout.addWidget(self.edit_time_button)
|
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.setObjectName("btn_danger")
|
||||||
self.delete_record_button.setEnabled(False)
|
self.delete_record_button.setEnabled(False)
|
||||||
self.delete_record_button.clicked.connect(self.delete_selected_record)
|
self.delete_record_button.clicked.connect(self.delete_selected_record)
|
||||||
@ -104,7 +105,7 @@ class CalendarView(QDialog):
|
|||||||
self.memo_edit.setPlaceholderText("추가근무 사유, 특이사항 등...")
|
self.memo_edit.setPlaceholderText("추가근무 사유, 특이사항 등...")
|
||||||
memo_layout.addWidget(self.memo_edit)
|
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.setObjectName("btn_primary")
|
||||||
self.save_memo_button.setEnabled(False)
|
self.save_memo_button.setEnabled(False)
|
||||||
self.save_memo_button.clicked.connect(self.save_memo)
|
self.save_memo_button.clicked.connect(self.save_memo)
|
||||||
@ -163,21 +164,23 @@ class CalendarView(QDialog):
|
|||||||
existing = self.db.get_work_record(date_str)
|
existing = self.db.get_work_record(date_str)
|
||||||
|
|
||||||
menu = QMenu(self)
|
menu = QMenu(self)
|
||||||
|
edit_action = delete_action = add_action = None
|
||||||
if existing:
|
if existing:
|
||||||
edit_action = menu.addAction(f"✏️ {date_str} 편집")
|
edit_action = menu.addAction(f"{date_str} 편집")
|
||||||
delete_action = menu.addAction(f"🗑️ {date_str} 삭제")
|
delete_action = menu.addAction(f"{date_str} 삭제")
|
||||||
else:
|
else:
|
||||||
add_action = menu.addAction(f"➕ {date_str} 기록 추가")
|
add_action = menu.addAction(f"{date_str} 기록 추가")
|
||||||
|
|
||||||
action = menu.exec_(self.calendar.mapToGlobal(pos))
|
action = menu.exec_(self.calendar.mapToGlobal(pos))
|
||||||
if action is None:
|
if action is None:
|
||||||
return
|
return
|
||||||
|
|
||||||
if existing and action.text().startswith("✏️"):
|
# 텍스트 prefix 대신 액션 동일성으로 분기 (이모지 의존 제거)
|
||||||
|
if action == edit_action:
|
||||||
self._open_edit_dialog(date_str)
|
self._open_edit_dialog(date_str)
|
||||||
elif existing and action.text().startswith("🗑️"):
|
elif action == delete_action:
|
||||||
self._delete_record(date_str)
|
self._delete_record(date_str)
|
||||||
elif not existing and action.text().startswith("➕"):
|
elif action == add_action:
|
||||||
self._add_past_record(date_str)
|
self._add_past_record(date_str)
|
||||||
|
|
||||||
def _add_past_record(self, date_str: str):
|
def _add_past_record(self, date_str: str):
|
||||||
@ -267,7 +270,7 @@ class CalendarView(QDialog):
|
|||||||
|
|
||||||
if record:
|
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"
|
detail += f"출근: {record['clock_in']}\n"
|
||||||
|
|
||||||
if record.get('clock_out'):
|
if record.get('clock_out'):
|
||||||
@ -303,7 +306,7 @@ class CalendarView(QDialog):
|
|||||||
self.memo_edit.setPlainText(record.get('memo', ''))
|
self.memo_edit.setPlainText(record.get('memo', ''))
|
||||||
self.save_memo_button.setEnabled(True)
|
self.save_memo_button.setEnabled(True)
|
||||||
else:
|
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.edit_time_button.setEnabled(False)
|
||||||
self.delete_record_button.setEnabled(False)
|
self.delete_record_button.setEnabled(False)
|
||||||
self.memo_edit.setPlainText('')
|
self.memo_edit.setPlainText('')
|
||||||
@ -406,7 +409,7 @@ class EditWorkTimeDialog(QDialog):
|
|||||||
layout.setContentsMargins(12, 10, 12, 10)
|
layout.setContentsMargins(12, 10, 12, 10)
|
||||||
|
|
||||||
# 제목
|
# 제목
|
||||||
title = QLabel(f"📅 {self.date_str} 출퇴근 시간 수정")
|
title = QLabel(f"{self.date_str} 출퇴근 시간 수정")
|
||||||
title.setObjectName("dialog_subtitle")
|
title.setObjectName("dialog_subtitle")
|
||||||
layout.addWidget(title)
|
layout.addWidget(title)
|
||||||
|
|
||||||
|
|||||||
@ -20,14 +20,14 @@ except ImportError:
|
|||||||
_MPL = False
|
_MPL = False
|
||||||
|
|
||||||
|
|
||||||
# 다크 테마 색상 (dark_components 톤과 일치)
|
# 다크 테마 색상 (dark_components / styles.py 톤과 일치)
|
||||||
_CHART_BG = '#14141c'
|
_CHART_BG = '#25262B'
|
||||||
_CHART_GRID = '#2a2a3a'
|
_CHART_GRID = '#2C2E33'
|
||||||
_CHART_TEXT = '#c0c0d0'
|
_CHART_TEXT = '#909296'
|
||||||
_CHART_BAR_NORMAL = '#6b9eff' # blue
|
_CHART_BAR_NORMAL = '#4DABF7' # accent blue
|
||||||
_CHART_BAR_OVERTIME = '#ff90b8' # pink
|
_CHART_BAR_OVERTIME = '#ff90b8' # pink (데이터 구분용)
|
||||||
_CHART_BAR_WEEKEND = '#fcd34d' # gold
|
_CHART_BAR_WEEKEND = '#fcd34d' # gold (데이터 구분용)
|
||||||
_CHART_AVG_LINE = '#4ade80' # green
|
_CHART_AVG_LINE = '#51CF66' # green
|
||||||
|
|
||||||
|
|
||||||
def _apply_dark_axes(ax) -> None:
|
def _apply_dark_axes(ax) -> None:
|
||||||
@ -55,7 +55,7 @@ class _Fallback(QWidget):
|
|||||||
label = QLabel(message)
|
label = QLabel(message)
|
||||||
label.setAlignment(Qt.AlignCenter)
|
label.setAlignment(Qt.AlignCenter)
|
||||||
label.setWordWrap(True)
|
label.setWordWrap(True)
|
||||||
label.setStyleSheet("color: #888; padding: 20px;")
|
label.setStyleSheet("color: #909296; padding: 20px;")
|
||||||
layout.addWidget(label)
|
layout.addWidget(label)
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
|
||||||
|
|||||||
@ -19,22 +19,24 @@ from PyQt5.QtCore import Qt
|
|||||||
|
|
||||||
|
|
||||||
# ── 색상 팔레트 ────────────────────────────────────────────────
|
# ── 색상 팔레트 ────────────────────────────────────────────────
|
||||||
DARK_BG = '#0e0e14'
|
# 메인 앱(styles.py DARK_COLORS)과 정합되는 모던 다크 미니멀 톤.
|
||||||
DARK_PANEL = '#14141c'
|
DARK_BG = '#1A1B1E'
|
||||||
DARK_PANEL_2 = '#1c1c28'
|
DARK_PANEL = '#25262B'
|
||||||
DARK_BORDER = '#2a2a3a'
|
DARK_PANEL_2 = '#2C2E33'
|
||||||
DARK_BORDER_STRONG = '#44446a'
|
DARK_BORDER = '#2C2E33'
|
||||||
DARK_TEXT = '#e8e8f4'
|
DARK_BORDER_STRONG = '#373A40'
|
||||||
DARK_TEXT_DIM = '#a0a0b8'
|
DARK_TEXT = '#E9ECEF'
|
||||||
DARK_TEXT_FAINT = '#666680'
|
DARK_TEXT_DIM = '#909296'
|
||||||
|
DARK_TEXT_FAINT = '#6C6E73'
|
||||||
|
|
||||||
|
# 단일 포인트 컬러는 ACCENT_BLUE(#4DABF7). 나머지 색은 도전과제 등급 표시 전용.
|
||||||
ACCENT_GOLD = '#ffd24a'
|
ACCENT_GOLD = '#ffd24a'
|
||||||
ACCENT_BLUE = '#6b9eff'
|
ACCENT_BLUE = '#4DABF7'
|
||||||
ACCENT_CYAN = '#4adef0'
|
ACCENT_CYAN = '#4adef0'
|
||||||
ACCENT_PINK = '#ff90b8'
|
ACCENT_PINK = '#ff90b8'
|
||||||
ACCENT_GREEN = '#4ade80'
|
ACCENT_GREEN = '#51CF66'
|
||||||
ACCENT_ORANGE = '#fcd34d'
|
ACCENT_ORANGE = '#fcd34d'
|
||||||
ACCENT_RED = '#fb7185'
|
ACCENT_RED = '#FA5252'
|
||||||
|
|
||||||
# 카드 테마 (등급/상태별)
|
# 카드 테마 (등급/상태별)
|
||||||
CARD_THEMES = {
|
CARD_THEMES = {
|
||||||
@ -83,7 +85,7 @@ def dialog_qss() -> str:
|
|||||||
return f"QDialog {{ background: {DARK_BG}; }}"
|
return f"QDialog {{ background: {DARK_BG}; }}"
|
||||||
|
|
||||||
|
|
||||||
def tabs_qss(accent: str = ACCENT_GOLD) -> str:
|
def tabs_qss(accent: str = ACCENT_BLUE) -> str:
|
||||||
return f"""
|
return f"""
|
||||||
QTabWidget::pane {{
|
QTabWidget::pane {{
|
||||||
background: {DARK_PANEL};
|
background: {DARK_PANEL};
|
||||||
@ -142,36 +144,36 @@ def button_qss(variant: str = 'default') -> str:
|
|||||||
return f"""
|
return f"""
|
||||||
QPushButton {{
|
QPushButton {{
|
||||||
background: {ACCENT_BLUE}; color: white;
|
background: {ACCENT_BLUE}; color: white;
|
||||||
border: none; border-radius: 6px;
|
border: none; border-radius: 8px;
|
||||||
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||||
}}
|
}}
|
||||||
QPushButton:hover {{ background: #82b0ff; }}
|
QPushButton:hover {{ background: #69B6F8; }}
|
||||||
QPushButton:pressed {{ background: #5a8eee; }}
|
QPushButton:pressed {{ background: #3D97E0; }}
|
||||||
QPushButton:disabled {{ background: #2a2a3a; color: {DARK_TEXT_FAINT}; }}
|
QPushButton:disabled {{ background: {DARK_PANEL_2}; color: {DARK_TEXT_FAINT}; }}
|
||||||
"""
|
"""
|
||||||
if variant == 'success':
|
if variant == 'success':
|
||||||
return f"""
|
return f"""
|
||||||
QPushButton {{
|
QPushButton {{
|
||||||
background: {ACCENT_GREEN}; color: #0e2a1a;
|
background: {ACCENT_GREEN}; color: #0e2a1a;
|
||||||
border: none; border-radius: 6px;
|
border: none; border-radius: 8px;
|
||||||
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||||
}}
|
}}
|
||||||
QPushButton:hover {{ background: #6ae899; }}
|
QPushButton:hover {{ background: #69DB7C; }}
|
||||||
"""
|
"""
|
||||||
if variant == 'danger':
|
if variant == 'danger':
|
||||||
return f"""
|
return f"""
|
||||||
QPushButton {{
|
QPushButton {{
|
||||||
background: {ACCENT_RED}; color: white;
|
background: {ACCENT_RED}; color: white;
|
||||||
border: none; border-radius: 6px;
|
border: none; border-radius: 8px;
|
||||||
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||||
}}
|
}}
|
||||||
QPushButton:hover {{ background: #fc8896; }}
|
QPushButton:hover {{ background: #FF6B6B; }}
|
||||||
"""
|
"""
|
||||||
if variant == 'ghost':
|
if variant == 'ghost':
|
||||||
return f"""
|
return f"""
|
||||||
QPushButton {{
|
QPushButton {{
|
||||||
background: transparent; color: {DARK_TEXT_DIM};
|
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;
|
padding: 6px 14px; font-size: 9.5pt;
|
||||||
}}
|
}}
|
||||||
QPushButton:hover {{ background: {DARK_PANEL_2}; color: {DARK_TEXT};
|
QPushButton:hover {{ background: {DARK_PANEL_2}; color: {DARK_TEXT};
|
||||||
@ -181,10 +183,10 @@ def button_qss(variant: str = 'default') -> str:
|
|||||||
return f"""
|
return f"""
|
||||||
QPushButton {{
|
QPushButton {{
|
||||||
background: {DARK_PANEL_2}; color: {DARK_TEXT};
|
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;
|
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 = QFrame()
|
||||||
container.setStyleSheet(f"""
|
container.setStyleSheet(f"""
|
||||||
QFrame {{
|
QFrame {{
|
||||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
background: {DARK_PANEL};
|
||||||
stop:0 #1a1a30, stop:1 #2a1a3a);
|
border: 1px solid {DARK_BORDER};
|
||||||
border: 1px solid #3a3a5a;
|
border-radius: 8px;
|
||||||
border-radius: 12px;
|
|
||||||
}}
|
}}
|
||||||
QLabel {{ background: transparent; border: none; color: {DARK_TEXT}; }}
|
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)
|
left.addWidget(t)
|
||||||
big = QLabel(
|
big = QLabel(
|
||||||
f"<span style='font-size: 28pt; font-weight: bold; color: {big_color};'>"
|
f"<span style='font-size: 28pt; font-weight: bold; color: {big_color};'>"
|
||||||
f"{big_value}</span>" + (f"<span style='font-size: 14pt; color: #888;'>"
|
f"{big_value}</span>" + (f"<span style='font-size: 14pt; color: {DARK_TEXT_DIM};'>"
|
||||||
f" {subtitle}</span>" if subtitle else '')
|
f" {subtitle}</span>" if subtitle else '')
|
||||||
)
|
)
|
||||||
big.setTextFormat(Qt.RichText)
|
big.setTextFormat(Qt.RichText)
|
||||||
@ -268,13 +269,20 @@ def build_stat_card(title: str, value: str, subtitle: str = '',
|
|||||||
outer.setSpacing(12)
|
outer.setSpacing(12)
|
||||||
|
|
||||||
if icon:
|
if icon:
|
||||||
icon_lbl = QLabel(icon)
|
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(
|
icon_lbl.setStyleSheet(
|
||||||
f"font-size: 28pt; background: transparent; border: none; "
|
f"font-size: 28pt; background: transparent; border: none; "
|
||||||
f"color: {t['border_strong']};"
|
f"color: {t['border_strong']};"
|
||||||
)
|
)
|
||||||
icon_lbl.setMinimumWidth(48)
|
|
||||||
icon_lbl.setAlignment(Qt.AlignCenter)
|
|
||||||
outer.addWidget(icon_lbl)
|
outer.addWidget(icon_lbl)
|
||||||
|
|
||||||
text_box = QVBoxLayout()
|
text_box = QVBoxLayout()
|
||||||
@ -330,7 +338,12 @@ def build_section_card(title: str, content: QWidget,
|
|||||||
|
|
||||||
head = QHBoxLayout()
|
head = QHBoxLayout()
|
||||||
if icon:
|
if icon:
|
||||||
i = QLabel(icon)
|
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(
|
i.setStyleSheet(
|
||||||
f"font-size: 16pt; color: {t['border_strong']}; "
|
f"font-size: 16pt; color: {t['border_strong']}; "
|
||||||
f"background: transparent; border: none;"
|
f"background: transparent; border: none;"
|
||||||
|
|||||||
@ -20,7 +20,7 @@ class GoalWidget(QWidget):
|
|||||||
layout.setContentsMargins(8, 6, 8, 6)
|
layout.setContentsMargins(8, 6, 8, 6)
|
||||||
layout.setSpacing(4)
|
layout.setSpacing(4)
|
||||||
|
|
||||||
title = QLabel("🎯 이번 달 목표")
|
title = QLabel("이번 달 목표")
|
||||||
title.setStyleSheet("font-weight: bold;")
|
title.setStyleSheet("font-weight: bold;")
|
||||||
layout.addWidget(title)
|
layout.addWidget(title)
|
||||||
|
|
||||||
@ -78,7 +78,7 @@ class GoalWidget(QWidget):
|
|||||||
ot_h, ot_m = ot_total // 60, ot_total % 60
|
ot_h, ot_m = ot_total // 60, ot_total % 60
|
||||||
tg_h, tg_m = ot_target // 60, ot_target % 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")
|
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}; }}")
|
self.ot_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
|
||||||
else:
|
else:
|
||||||
self.ot_label.setVisible(False)
|
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.setValue(int(min(avg, avg_target) * 100))
|
||||||
self.avg_bar.setFormat(f"{avg:.1f}h / {avg_target:.1f}h")
|
self.avg_bar.setFormat(f"{avg:.1f}h / {avg_target:.1f}h")
|
||||||
ratio = avg / avg_target if avg_target else 0
|
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}; }}")
|
self.avg_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
|
||||||
else:
|
else:
|
||||||
self.avg_label.setVisible(False)
|
self.avg_label.setVisible(False)
|
||||||
|
|||||||
@ -45,7 +45,7 @@ class HelpView(QDialog):
|
|||||||
main_layout.setSpacing(10)
|
main_layout.setSpacing(10)
|
||||||
|
|
||||||
# 다크 타이틀
|
# 다크 타이틀
|
||||||
title = QLabel(f"📖 {tr('window.help')}")
|
title = QLabel(tr('window.help'))
|
||||||
title.setStyleSheet(
|
title.setStyleSheet(
|
||||||
f"font-size: 18pt; font-weight: bold; color: {DARK_TEXT}; "
|
f"font-size: 18pt; font-weight: bold; color: {DARK_TEXT}; "
|
||||||
f"background: transparent; border: none; padding: 4px 0;"
|
f"background: transparent; border: none; padding: 4px 0;"
|
||||||
@ -53,7 +53,6 @@ class HelpView(QDialog):
|
|||||||
main_layout.addWidget(title)
|
main_layout.addWidget(title)
|
||||||
|
|
||||||
tabs = QTabWidget()
|
tabs = QTabWidget()
|
||||||
tabs.setDocumentMode(True)
|
|
||||||
tabs.setStyleSheet(tabs_qss())
|
tabs.setStyleSheet(tabs_qss())
|
||||||
for html_key, tab_label_key in self._TABS:
|
for html_key, tab_label_key in self._TABS:
|
||||||
tabs.addTab(self._make_tab(tr_html(html_key)), tr(tab_label_key))
|
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)
|
button_layout.setContentsMargins(0, 6, 0, 0)
|
||||||
|
|
||||||
# 온보딩 다시 보기 (왼쪽, ghost 스타일)
|
# 온보딩 다시 보기 (왼쪽, ghost 스타일)
|
||||||
onboarding_button = QPushButton("🚀 온보딩 다시 보기")
|
onboarding_button = QPushButton("온보딩 다시 보기")
|
||||||
onboarding_button.setMinimumHeight(36)
|
onboarding_button.setMinimumHeight(36)
|
||||||
onboarding_button.setStyleSheet(button_qss('ghost'))
|
onboarding_button.setStyleSheet(button_qss('ghost'))
|
||||||
onboarding_button.clicked.connect(self._reopen_onboarding)
|
onboarding_button.clicked.connect(self._reopen_onboarding)
|
||||||
|
|||||||
82
ui/icons.py
Normal file
82
ui/icons.py
Normal file
@ -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': '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
|
||||||
|
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
|
||||||
|
'report': '<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/>',
|
||||||
|
'award': '<circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/>',
|
||||||
|
'help': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||||
|
'settings': '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
||||||
|
'logout': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
|
||||||
|
'rotate-ccw': '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>',
|
||||||
|
'edit': '<path d="M17 3a2.85 2.85 0 0 1 4 4L7.5 20.5 2 22l1.5-5.5z"/><path d="m15 5 4 4"/>',
|
||||||
|
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
||||||
|
'trash': '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/>',
|
||||||
|
'flame': '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
|
||||||
|
'trending-up': '<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/>',
|
||||||
|
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
|
||||||
|
'external-link': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
|
||||||
|
'coffee': '<path d="M17 8h1a4 4 0 1 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/>',
|
||||||
|
'repeat': '<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>',
|
||||||
|
'home': '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
|
||||||
|
}
|
||||||
|
|
||||||
|
_SVG_TMPL = (
|
||||||
|
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" '
|
||||||
|
'fill="none" stroke="{color}" stroke-width="2" '
|
||||||
|
'stroke-linecap="round" stroke-linejoin="round">{paths}</svg>'
|
||||||
|
)
|
||||||
|
|
||||||
|
_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()
|
||||||
@ -22,7 +22,7 @@ class LeaveCalendarView(QDialog):
|
|||||||
def __init__(self, parent=None, db=None):
|
def __init__(self, parent=None, db=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.db = db
|
self.db = db
|
||||||
self.setWindowTitle("📅 연차 캘린더")
|
self.setWindowTitle("연차 캘린더")
|
||||||
self.setModal(True)
|
self.setModal(True)
|
||||||
self.setMinimumSize(540, 480)
|
self.setMinimumSize(540, 480)
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
@ -37,7 +37,7 @@ class LeaveCalendarView(QDialog):
|
|||||||
balance = float(self.db.get_setting('leave_balance', '0') or 0)
|
balance = float(self.db.get_setting('leave_balance', '0') or 0)
|
||||||
total = float(self.db.get_setting('annual_leave_total', '15') or 15)
|
total = float(self.db.get_setting('annual_leave_total', '15') or 15)
|
||||||
used = total - balance
|
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;")
|
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||||
header.addWidget(title)
|
header.addWidget(title)
|
||||||
header.addStretch()
|
header.addStretch()
|
||||||
@ -45,10 +45,11 @@ class LeaveCalendarView(QDialog):
|
|||||||
|
|
||||||
# 범례 (사용 완료 + 예정 분리)
|
# 범례 (사용 완료 + 예정 분리)
|
||||||
legend = QHBoxLayout()
|
legend = QHBoxLayout()
|
||||||
for label in ["🟩 종일(1.0)", "🟨 반차(0.5)", "🟪 반반차(0.25)",
|
for _color, _txt in [('#51CF66', '종일(1.0)'), ('#FAB005', '반차(0.5)'),
|
||||||
"🔵 예정", "🔘 종일+예정"]:
|
('#B197FC', '반반차(0.25)'), ('#4DABF7', '예정'),
|
||||||
l = QLabel(label)
|
('#748FFC', '종일+예정')]:
|
||||||
l.setStyleSheet(f"padding: 2px 6px;")
|
l = QLabel(f"<span style='color:{_color};'>●</span> {_txt}")
|
||||||
|
l.setStyleSheet("padding: 2px 6px;")
|
||||||
legend.addWidget(l)
|
legend.addWidget(l)
|
||||||
legend.addStretch()
|
legend.addStretch()
|
||||||
layout.addLayout(legend)
|
layout.addLayout(legend)
|
||||||
@ -61,7 +62,7 @@ class LeaveCalendarView(QDialog):
|
|||||||
|
|
||||||
# 선택 일자 정보
|
# 선택 일자 정보
|
||||||
self.detail_label = QLabel("")
|
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)
|
layout.addWidget(self.detail_label)
|
||||||
|
|
||||||
# 닫기 버튼
|
# 닫기 버튼
|
||||||
@ -116,4 +117,4 @@ class LeaveCalendarView(QDialog):
|
|||||||
d = float(r.get('days') or 0)
|
d = float(r.get('days') or 0)
|
||||||
memo = r.get('memo') or ''
|
memo = r.get('memo') or ''
|
||||||
parts.append(f"{t} {d}일" + (f" ({memo})" if memo else ""))
|
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))
|
||||||
|
|||||||
@ -90,7 +90,7 @@ class LeaveView(QDialog):
|
|||||||
cal_button.clicked.connect(self._show_calendar)
|
cal_button.clicked.connect(self._show_calendar)
|
||||||
button_layout.addWidget(cal_button)
|
button_layout.addWidget(cal_button)
|
||||||
|
|
||||||
schedule_button = QPushButton("🗓️ 스케줄")
|
schedule_button = QPushButton("스케줄")
|
||||||
schedule_button.setToolTip("휴일 + 연차 + 반복 패턴 통합 보기")
|
schedule_button.setToolTip("휴일 + 연차 + 반복 패턴 통합 보기")
|
||||||
schedule_button.clicked.connect(self._show_schedule)
|
schedule_button.clicked.connect(self._show_schedule)
|
||||||
button_layout.addWidget(schedule_button)
|
button_layout.addWidget(schedule_button)
|
||||||
@ -146,7 +146,7 @@ class LeaveView(QDialog):
|
|||||||
days_str = f"{days}일"
|
days_str = f"{days}일"
|
||||||
days_item = QTableWidgetItem(days_str)
|
days_item = QTableWidgetItem(days_str)
|
||||||
days_item.setTextAlignment(Qt.AlignCenter)
|
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 "")
|
memo_item = QTableWidgetItem(record['memo'] or "")
|
||||||
|
|
||||||
|
|||||||
@ -8,7 +8,7 @@ from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
|
|||||||
QHBoxLayout, QLabel, QPushButton, QProgressBar,
|
QHBoxLayout, QLabel, QPushButton, QProgressBar,
|
||||||
QMessageBox, QGroupBox, QGridLayout, QSystemTrayIcon,
|
QMessageBox, QGroupBox, QGridLayout, QSystemTrayIcon,
|
||||||
QShortcut, QDialog)
|
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 PyQt5.QtGui import QFont, QPalette, QColor, QKeySequence
|
||||||
|
|
||||||
from core.settings_keys import (
|
from core.settings_keys import (
|
||||||
@ -50,7 +50,7 @@ class MainWindow(QMainWindow):
|
|||||||
super().__init__()
|
super().__init__()
|
||||||
|
|
||||||
# 테마 적용
|
# 테마 적용
|
||||||
self.current_theme = 'light' # 설정에서 로드 후 덮어씀
|
self.current_theme = 'dark' # 설정에서 로드 후 덮어씀
|
||||||
|
|
||||||
# 데이터베이스 — main.py가 전달하면 재사용, 아니면 자체 부트스트랩
|
# 데이터베이스 — main.py가 전달하면 재사용, 아니면 자체 부트스트랩
|
||||||
if db is not None:
|
if db is not None:
|
||||||
@ -82,7 +82,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.cached_time_format = str(settings.get(TIME_FORMAT, '24'))
|
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.apply_theme(self.current_theme)
|
||||||
self.time_calc = self._build_time_calc(settings)
|
self.time_calc = self._build_time_calc(settings)
|
||||||
|
|
||||||
@ -234,11 +234,11 @@ class MainWindow(QMainWindow):
|
|||||||
from core.version import __version__
|
from core.version import __version__
|
||||||
from ui.i18n_runtime import register
|
from ui.i18n_runtime import register
|
||||||
self._app_version = __version__
|
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',
|
register(self, 'window.main_title', setter='setWindowTitle',
|
||||||
post=lambda t: f"⏰ {t} v{__version__}")
|
post=lambda t: f"{t} v{__version__}")
|
||||||
self.setGeometry(100, 100, 500, 620)
|
self.setGeometry(100, 100, 540, 720)
|
||||||
self.setMinimumSize(480, 520)
|
self.setMinimumSize(500, 600)
|
||||||
|
|
||||||
# 외부 컨테이너 (스크롤 + 고정 하단)
|
# 외부 컨테이너 (스크롤 + 고정 하단)
|
||||||
from PyQt5.QtWidgets import QScrollArea
|
from PyQt5.QtWidgets import QScrollArea
|
||||||
@ -261,10 +261,10 @@ class MainWindow(QMainWindow):
|
|||||||
outer_widget.setLayout(outer_layout)
|
outer_widget.setLayout(outer_layout)
|
||||||
self.setCentralWidget(outer_widget)
|
self.setCentralWidget(outer_widget)
|
||||||
|
|
||||||
# 메인 레이아웃
|
# 메인 레이아웃 — 외곽 24px, 위젯 간 12px (통일된 여백 시스템)
|
||||||
main_layout = QVBoxLayout()
|
main_layout = QVBoxLayout()
|
||||||
main_layout.setSpacing(8)
|
main_layout.setSpacing(12)
|
||||||
main_layout.setContentsMargins(12, 10, 12, 10)
|
main_layout.setContentsMargins(24, 20, 24, 16)
|
||||||
|
|
||||||
# 1. 헤더 - 앱 타이틀
|
# 1. 헤더 - 앱 타이틀
|
||||||
title_label = QLabel("퇴근시간 계산기")
|
title_label = QLabel("퇴근시간 계산기")
|
||||||
@ -287,16 +287,10 @@ class MainWindow(QMainWindow):
|
|||||||
clock_in_group = self.create_clock_in_group()
|
clock_in_group = self.create_clock_in_group()
|
||||||
main_layout.addWidget(clock_in_group)
|
main_layout.addWidget(clock_in_group)
|
||||||
|
|
||||||
# 3. 남은 시간 표시 그룹
|
# 3. 남은 시간 표시 그룹 (히어로 — 남은시간 + 진행률 + 예상 퇴근시각 통합)
|
||||||
remaining_group = self.create_remaining_time_group()
|
remaining_group = self.create_remaining_time_group()
|
||||||
main_layout.addWidget(remaining_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. 점심/저녁 토글 (가로 배치)
|
# 5. 점심/저녁 토글 (가로 배치)
|
||||||
meal_button_layout = QHBoxLayout()
|
meal_button_layout = QHBoxLayout()
|
||||||
meal_button_layout.setSpacing(8)
|
meal_button_layout.setSpacing(8)
|
||||||
@ -359,8 +353,8 @@ class MainWindow(QMainWindow):
|
|||||||
fixed_bottom = QWidget()
|
fixed_bottom = QWidget()
|
||||||
fixed_bottom.setObjectName("fixed_bottom")
|
fixed_bottom.setObjectName("fixed_bottom")
|
||||||
fixed_bottom_layout = QVBoxLayout()
|
fixed_bottom_layout = QVBoxLayout()
|
||||||
fixed_bottom_layout.setSpacing(8)
|
fixed_bottom_layout.setSpacing(10)
|
||||||
fixed_bottom_layout.setContentsMargins(12, 8, 12, 10)
|
fixed_bottom_layout.setContentsMargins(24, 12, 24, 16)
|
||||||
|
|
||||||
self.clock_out_button = QPushButton(tr('btn.clock_out'))
|
self.clock_out_button = QPushButton(tr('btn.clock_out'))
|
||||||
self.clock_out_button.setObjectName("clock_out_button")
|
self.clock_out_button.setObjectName("clock_out_button")
|
||||||
@ -375,7 +369,7 @@ class MainWindow(QMainWindow):
|
|||||||
stats_button = QPushButton(tr('menu.stats'))
|
stats_button = QPushButton(tr('menu.stats'))
|
||||||
calendar_button = QPushButton(tr('menu.calendar'))
|
calendar_button = QPushButton(tr('menu.calendar'))
|
||||||
report_button = QPushButton(tr('menu.daily_report'))
|
report_button = QPushButton(tr('menu.daily_report'))
|
||||||
achievements_button = QPushButton("🏆 도전과제")
|
achievements_button = QPushButton("도전과제")
|
||||||
help_button = QPushButton(tr('menu.help'))
|
help_button = QPushButton(tr('menu.help'))
|
||||||
settings_button = QPushButton(tr('menu.settings'))
|
settings_button = QPushButton(tr('menu.settings'))
|
||||||
|
|
||||||
@ -387,8 +381,17 @@ class MainWindow(QMainWindow):
|
|||||||
(settings_button, 'menu.settings')]:
|
(settings_button, 'menu.settings')]:
|
||||||
register(btn, key)
|
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)
|
bottom_layout.addWidget(btn)
|
||||||
|
|
||||||
# 버튼 연결
|
# 버튼 연결
|
||||||
@ -406,9 +409,27 @@ class MainWindow(QMainWindow):
|
|||||||
# 초기 날짜 업데이트
|
# 초기 날짜 업데이트
|
||||||
self.update_date_label()
|
self.update_date_label()
|
||||||
|
|
||||||
|
# 라인 아이콘 적용 (테마 색 틴팅)
|
||||||
|
self._apply_button_icons()
|
||||||
|
|
||||||
# 앱 내 단축키
|
# 앱 내 단축키
|
||||||
self._setup_shortcuts()
|
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):
|
def _setup_shortcuts(self):
|
||||||
"""앱 내 단축키 — 메인 창 포커스 시만 동작"""
|
"""앱 내 단축키 — 메인 창 포커스 시만 동작"""
|
||||||
bindings = [
|
bindings = [
|
||||||
@ -425,74 +446,93 @@ class MainWindow(QMainWindow):
|
|||||||
sc = QShortcut(QKeySequence(keyseq), self)
|
sc = QShortcut(QKeySequence(keyseq), self)
|
||||||
sc.activated.connect(handler)
|
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:
|
def create_clock_in_group(self) -> QGroupBox:
|
||||||
"""출근 정보 그룹 생성"""
|
"""출근 정보 그룹 생성 — 출근/현재 시각을 한 줄에 나란히"""
|
||||||
group = QGroupBox("오늘의 근무")
|
group = QGroupBox("오늘의 근무")
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
layout.setSpacing(4)
|
layout.setSpacing(8)
|
||||||
layout.setContentsMargins(12, 20, 12, 8)
|
layout.setContentsMargins(16, 24, 16, 16)
|
||||||
|
|
||||||
# 출근 시간 레이아웃
|
# 출근 / 현재 시각을 한 줄에 나란히 (2-컬럼)
|
||||||
clock_in_layout = QHBoxLayout()
|
row = QHBoxLayout()
|
||||||
clock_in_label = QLabel("출근:")
|
row.setSpacing(12)
|
||||||
clock_in_label.setObjectName("field_label")
|
|
||||||
clock_in_label.setFixedWidth(50)
|
# 출근 컬럼 (라벨 + 편집 버튼 헤더 / 값)
|
||||||
self.clock_in_value = QLabel("--:--:--")
|
self.clock_in_value = QLabel("--:--:--")
|
||||||
self.clock_in_value.setObjectName("time_value")
|
self.clock_in_value.setObjectName("time_value")
|
||||||
self.clock_in_value.setMinimumWidth(90)
|
# 라벨 자체도 클릭 가능 (인라인 편집 — 출근 시간 빠른 수정)
|
||||||
# 라벨 자체도 클릭 가능 (인라인 편집 — 출퇴근 시간 빠른 수정)
|
|
||||||
self.clock_in_value.setCursor(Qt.PointingHandCursor)
|
self.clock_in_value.setCursor(Qt.PointingHandCursor)
|
||||||
self.clock_in_value.setToolTip("클릭하여 출근 시간 수정")
|
self.clock_in_value.setToolTip("클릭하여 출근 시간 수정")
|
||||||
self.clock_in_value.mousePressEvent = lambda e: self.manual_clock_in()
|
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.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)
|
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 = QLabel("--:--:--")
|
||||||
self.current_time_value.setObjectName("time_value")
|
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)
|
row.addLayout(clock_in_col, 1)
|
||||||
current_layout.addWidget(self.current_time_value)
|
row.addLayout(current_col, 1)
|
||||||
current_layout.addStretch()
|
layout.addLayout(row)
|
||||||
|
|
||||||
layout.addLayout(clock_in_layout)
|
|
||||||
layout.addLayout(current_layout)
|
|
||||||
|
|
||||||
group.setLayout(layout)
|
group.setLayout(layout)
|
||||||
return group
|
return group
|
||||||
|
|
||||||
def create_remaining_time_group(self) -> QGroupBox:
|
def create_remaining_time_group(self) -> QGroupBox:
|
||||||
"""남은 시간 표시 그룹 생성"""
|
"""남은 시간 히어로 그룹 — 남은시간(가장 큼) + 진행률 + 예상 퇴근시각"""
|
||||||
self.remaining_time_group = QGroupBox("남은 시간")
|
self.remaining_time_group = QGroupBox("남은 시간")
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
layout.setSpacing(6)
|
layout.setSpacing(12)
|
||||||
layout.setContentsMargins(12, 20, 12, 8)
|
layout.setContentsMargins(16, 24, 16, 16)
|
||||||
|
|
||||||
# 남은 시간 라벨
|
# 남은 시간 라벨 (히어로 — 화면에서 가장 큰 결과)
|
||||||
self.remaining_time_label = QLabel("--:--:--")
|
self.remaining_time_label = QLabel("--:--:--")
|
||||||
self.remaining_time_label.setObjectName("time_display")
|
self.remaining_time_label.setObjectName("time_display")
|
||||||
self.remaining_time_label.setAlignment(Qt.AlignCenter)
|
self.remaining_time_label.setAlignment(Qt.AlignCenter)
|
||||||
|
|
||||||
# 프로그레스 바
|
# 프로그레스 바 (얇게 6px)
|
||||||
self.progress_bar = QProgressBar()
|
self.progress_bar = QProgressBar()
|
||||||
self.progress_bar.setTextVisible(False)
|
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.remaining_time_label)
|
||||||
layout.addWidget(self.progress_bar)
|
layout.addWidget(self.progress_bar)
|
||||||
|
layout.addWidget(self.expected_time_label)
|
||||||
|
|
||||||
self.remaining_time_group.setLayout(layout)
|
self.remaining_time_group.setLayout(layout)
|
||||||
return self.remaining_time_group
|
return self.remaining_time_group
|
||||||
@ -502,8 +542,8 @@ class MainWindow(QMainWindow):
|
|||||||
group = QGroupBox("연장근무 및 연차 현황")
|
group = QGroupBox("연장근무 및 연차 현황")
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
layout.setSpacing(6)
|
layout.setSpacing(10)
|
||||||
layout.setContentsMargins(12, 20, 12, 8)
|
layout.setContentsMargins(16, 24, 16, 16)
|
||||||
|
|
||||||
# 연장근무 섹션
|
# 연장근무 섹션
|
||||||
overtime_header = QHBoxLayout()
|
overtime_header = QHBoxLayout()
|
||||||
@ -621,14 +661,14 @@ class MainWindow(QMainWindow):
|
|||||||
if today_record.get('clock_out'):
|
if today_record.get('clock_out'):
|
||||||
self.is_clocked_in = False
|
self.is_clocked_in = False
|
||||||
self.clock_out_button.setEnabled(True)
|
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))
|
self.clock_in_value.setText(self.format_time(self.clock_in_time, include_seconds=True))
|
||||||
else:
|
else:
|
||||||
# 출근 중이면 퇴근하기 버튼
|
# 출근 중이면 퇴근하기 버튼
|
||||||
self.clock_out_button.setEnabled(True)
|
self.clock_out_button.setEnabled(True)
|
||||||
self.clock_out_button.setText("✅ 퇴근하기")
|
self.clock_out_button.setText("퇴근하기")
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# 출근 기록 없음 — 종일 연차일이면 자동 감지·수동 입력 모두 스킵
|
# 출근 기록 없음 — 종일 연차일이면 자동 감지·수동 입력 모두 스킵
|
||||||
@ -799,17 +839,14 @@ class MainWindow(QMainWindow):
|
|||||||
minutes = (total_seconds % 3600) // 60
|
minutes = (total_seconds % 3600) // 60
|
||||||
seconds = total_seconds % 60
|
seconds = total_seconds % 60
|
||||||
remaining_str = f"+{hours:02d}:{minutes:02d}:{seconds:02d}"
|
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:
|
else:
|
||||||
# 정상 근무 중
|
# 정상 근무 중 — 아직 퇴근 전이므로 기본 텍스트 색
|
||||||
self.remaining_time_group.setTitle("남은 시간")
|
self.remaining_time_group.setTitle("남은 시간")
|
||||||
remaining_str = self.time_calc.format_time_delta(remaining)
|
remaining_str = self.time_calc.format_time_delta(remaining)
|
||||||
|
self.remaining_time_label.setStyleSheet(f"color: {ThemeColors.get('text_primary')};")
|
||||||
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._set_text_if_changed(self.remaining_time_label, remaining_str)
|
self._set_text_if_changed(self.remaining_time_label, remaining_str)
|
||||||
@ -885,7 +922,7 @@ class MainWindow(QMainWindow):
|
|||||||
label = '연차'
|
label = '연차'
|
||||||
memo = ''
|
memo = ''
|
||||||
|
|
||||||
self.remaining_time_group.setTitle("🌴 오늘은 휴가")
|
self.remaining_time_group.setTitle("오늘은 휴가")
|
||||||
self.remaining_time_label.setText("연차 사용 중")
|
self.remaining_time_label.setText("연차 사용 중")
|
||||||
self.remaining_time_label.setStyleSheet(
|
self.remaining_time_label.setStyleSheet(
|
||||||
f"color: {ThemeColors.get('status_normal')}; font-size: 18px;"
|
f"color: {ThemeColors.get('status_normal')}; font-size: 18px;"
|
||||||
@ -893,10 +930,10 @@ class MainWindow(QMainWindow):
|
|||||||
self.progress_bar.setValue(100)
|
self.progress_bar.setValue(100)
|
||||||
if memo:
|
if memo:
|
||||||
self._set_text_if_changed(self.expected_time_label,
|
self._set_text_if_changed(self.expected_time_label,
|
||||||
f"🌴 {label} — {memo}")
|
f"{label} — {memo}")
|
||||||
else:
|
else:
|
||||||
self._set_text_if_changed(self.expected_time_label,
|
self._set_text_if_changed(self.expected_time_label,
|
||||||
f"🌴 {label}")
|
f"{label}")
|
||||||
# 트레이/미니 위젯
|
# 트레이/미니 위젯
|
||||||
self.tray_icon.update_time_display("🌴 휴가")
|
self.tray_icon.update_time_display("🌴 휴가")
|
||||||
if getattr(self, '_mini_widget', None) is not None and self._mini_widget.isVisible():
|
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.is_clocked_in = False
|
||||||
self.midnight_rollover_handled = False # 다음날을 위해 플래그 리셋
|
self.midnight_rollover_handled = False # 다음날을 위해 플래그 리셋
|
||||||
self.clock_out_button.setEnabled(True)
|
self.clock_out_button.setEnabled(True)
|
||||||
self.clock_out_button.setText("🔄 퇴근 취소")
|
self.clock_out_button.setText("퇴근 취소")
|
||||||
|
|
||||||
# 결과 메시지
|
# 결과 메시지
|
||||||
msg = f"퇴근 처리되었습니다!\n\n"
|
msg = f"퇴근 처리되었습니다!\n\n"
|
||||||
@ -1354,7 +1391,7 @@ class MainWindow(QMainWindow):
|
|||||||
# 상태 복원
|
# 상태 복원
|
||||||
self.is_clocked_in = True
|
self.is_clocked_in = True
|
||||||
self.clock_out_button.setEnabled(True)
|
self.clock_out_button.setEnabled(True)
|
||||||
self.clock_out_button.setText("✅ 퇴근하기")
|
self.clock_out_button.setText("퇴근하기")
|
||||||
|
|
||||||
# 잔액 업데이트
|
# 잔액 업데이트
|
||||||
self.update_overtime_balance()
|
self.update_overtime_balance()
|
||||||
@ -1378,6 +1415,18 @@ class MainWindow(QMainWindow):
|
|||||||
f"퇴근 취소 중 오류가 발생했습니다:\n{str(e)}"
|
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):
|
def handle_workday_rollover(self, now: datetime):
|
||||||
"""근무일 경계 처리: 경계시간 직전 퇴근, 경계시간에 출근
|
"""근무일 경계 처리: 경계시간 직전 퇴근, 경계시간에 출근
|
||||||
|
|
||||||
@ -1451,6 +1500,9 @@ class MainWindow(QMainWindow):
|
|||||||
unit_minutes=unit_minutes,
|
unit_minutes=unit_minutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 자동 적립 OFF면 적립 스킵 (clock_out '아니오'와 동일 의미)
|
||||||
|
overtime_earned = self._apply_auto_overtime_gate(overtime_earned)
|
||||||
|
|
||||||
# DB 업데이트 (출근일 날짜에 퇴근 시간 기록)
|
# DB 업데이트 (출근일 날짜에 퇴근 시간 기록)
|
||||||
self.db.update_clock_out(
|
self.db.update_clock_out(
|
||||||
workday_str, before_boundary_str, total_hours,
|
workday_str, before_boundary_str, total_hours,
|
||||||
@ -1537,7 +1589,7 @@ class MainWindow(QMainWindow):
|
|||||||
self.clock_in_time = None
|
self.clock_in_time = None
|
||||||
self.is_clocked_in = False
|
self.is_clocked_in = False
|
||||||
self.clock_out_button.setEnabled(False)
|
self.clock_out_button.setEnabled(False)
|
||||||
self.clock_out_button.setText("✅ 퇴근하기")
|
self.clock_out_button.setText("퇴근하기")
|
||||||
|
|
||||||
def auto_clock_out_previous_days(self):
|
def auto_clock_out_previous_days(self):
|
||||||
"""이전 퇴근 기록들(퇴근 안 한)에 대해 자동으로 종료 시간 등록"""
|
"""이전 퇴근 기록들(퇴근 안 한)에 대해 자동으로 종료 시간 등록"""
|
||||||
@ -1609,6 +1661,9 @@ class MainWindow(QMainWindow):
|
|||||||
unit_minutes=unit_minutes,
|
unit_minutes=unit_minutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 자동 적립 OFF면 적립 스킵 (clock_out '아니오'와 동일 의미)
|
||||||
|
overtime_earned = self._apply_auto_overtime_gate(overtime_earned)
|
||||||
|
|
||||||
# DB 업데이트 (total_hours는 원본 시간 그대로 저장)
|
# DB 업데이트 (total_hours는 원본 시간 그대로 저장)
|
||||||
clock_out_str = shutdown_time.strftime("%H:%M:%S")
|
clock_out_str = shutdown_time.strftime("%H:%M:%S")
|
||||||
self.db.update_clock_out(
|
self.db.update_clock_out(
|
||||||
@ -1678,6 +1733,9 @@ class MainWindow(QMainWindow):
|
|||||||
unit_minutes=unit_minutes,
|
unit_minutes=unit_minutes,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# 자동 적립 OFF면 적립 스킵 (clock_out '아니오'와 동일 의미)
|
||||||
|
overtime_earned = self._apply_auto_overtime_gate(overtime_earned)
|
||||||
|
|
||||||
# DB 업데이트
|
# DB 업데이트
|
||||||
self.db.update_clock_out(
|
self.db.update_clock_out(
|
||||||
check_date, "23:59:59", total_hours,
|
check_date, "23:59:59", total_hours,
|
||||||
@ -1749,7 +1807,7 @@ class MainWindow(QMainWindow):
|
|||||||
# UI 업데이트
|
# UI 업데이트
|
||||||
self.clock_in_value.setText(clock_in_str)
|
self.clock_in_value.setText(clock_in_str)
|
||||||
self.clock_out_button.setEnabled(True)
|
self.clock_out_button.setEnabled(True)
|
||||||
self.clock_out_button.setText("✅ 퇴근하기")
|
self.clock_out_button.setText("퇴근하기")
|
||||||
|
|
||||||
QMessageBox.information(
|
QMessageBox.information(
|
||||||
self,
|
self,
|
||||||
@ -1794,8 +1852,15 @@ class MainWindow(QMainWindow):
|
|||||||
def apply_theme(self, theme_name: str):
|
def apply_theme(self, theme_name: str):
|
||||||
"""테마 적용"""
|
"""테마 적용"""
|
||||||
self.current_theme = theme_name
|
self.current_theme = theme_name
|
||||||
|
ThemeColors.set_theme(theme_name)
|
||||||
self.setStyleSheet(get_theme(theme_name))
|
self.setStyleSheet(get_theme(theme_name))
|
||||||
apply_dark_titlebar(self, theme_name == 'dark')
|
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()
|
size = self.size()
|
||||||
self.resize(size.width() + 1, size.height())
|
self.resize(size.width() + 1, size.height())
|
||||||
@ -1806,7 +1871,7 @@ class MainWindow(QMainWindow):
|
|||||||
dialog = SettingsView(self, self.db)
|
dialog = SettingsView(self, self.db)
|
||||||
dialog.exec_()
|
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:
|
if new_theme != self.current_theme:
|
||||||
self.apply_theme(new_theme)
|
self.apply_theme(new_theme)
|
||||||
|
|
||||||
@ -1834,7 +1899,7 @@ class MainWindow(QMainWindow):
|
|||||||
from ui.meal_time_dialog import MealTimeDialog
|
from ui.meal_time_dialog import MealTimeDialog
|
||||||
menu = QMenu(self)
|
menu = QMenu(self)
|
||||||
title = "점심" if meal_type == 'lunch' else "저녁"
|
title = "점심" if meal_type == 'lunch' else "저녁"
|
||||||
edit_action = menu.addAction(f"⏱ {title} 실제 시간 입력...")
|
edit_action = menu.addAction(f"{title} 실제 시간 입력...")
|
||||||
global_pos = button.mapToGlobal(pos)
|
global_pos = button.mapToGlobal(pos)
|
||||||
action = menu.exec_(global_pos)
|
action = menu.exec_(global_pos)
|
||||||
if action != edit_action:
|
if action != edit_action:
|
||||||
|
|||||||
@ -50,7 +50,7 @@ class MealTimeDialog(QDialog):
|
|||||||
info_text += f"\n출근 {clock_in_time.strftime('%H:%M')} 이후만 입력 가능."
|
info_text += f"\n출근 {clock_in_time.strftime('%H:%M')} 이후만 입력 가능."
|
||||||
info = QLabel(info_text)
|
info = QLabel(info_text)
|
||||||
info.setWordWrap(True)
|
info.setWordWrap(True)
|
||||||
info.setStyleSheet("color: #888; padding-bottom: 6px;")
|
info.setStyleSheet("color: #909296; padding-bottom: 6px;")
|
||||||
layout.addWidget(info)
|
layout.addWidget(info)
|
||||||
|
|
||||||
# 합리적 기본값: 출근 이후로 보정
|
# 합리적 기본값: 출근 이후로 보정
|
||||||
@ -83,7 +83,7 @@ class MealTimeDialog(QDialog):
|
|||||||
|
|
||||||
# 미리보기 라벨
|
# 미리보기 라벨
|
||||||
self.preview = QLabel("")
|
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)
|
layout.addWidget(self.preview)
|
||||||
self._update_preview()
|
self._update_preview()
|
||||||
self.start_edit.timeChanged.connect(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()
|
start_dt, end_dt, minutes = self._resolve_meal_window()
|
||||||
ok, reason = self._validate_window(start_dt, end_dt, minutes)
|
ok, reason = self._validate_window(start_dt, end_dt, minutes)
|
||||||
if not ok:
|
if not ok:
|
||||||
self.preview.setText(f"⚠️ {reason}")
|
self.preview.setText(f"{reason}")
|
||||||
self.preview.setStyleSheet("color: #f44336;")
|
self.preview.setStyleSheet("color: #FA5252;")
|
||||||
else:
|
else:
|
||||||
self.preview.setText(f"총 {minutes}분")
|
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,
|
def _validate_window(self, start_dt: datetime, end_dt: datetime,
|
||||||
minutes: int) -> tuple[bool, str]:
|
minutes: int) -> tuple[bool, str]:
|
||||||
|
|||||||
@ -41,7 +41,7 @@ class MiniWidget(QWidget):
|
|||||||
|
|
||||||
self.title_label = QLabel(tr('label.remaining'))
|
self.title_label = QLabel(tr('label.remaining'))
|
||||||
self.title_label.setAlignment(Qt.AlignCenter)
|
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 = QLabel("--:--:--")
|
||||||
self.time_label.setAlignment(Qt.AlignCenter)
|
self.time_label.setAlignment(Qt.AlignCenter)
|
||||||
@ -51,10 +51,10 @@ class MiniWidget(QWidget):
|
|||||||
layout.addWidget(self.time_label)
|
layout.addWidget(self.time_label)
|
||||||
self.setLayout(layout)
|
self.setLayout(layout)
|
||||||
|
|
||||||
# 기본 스타일 (테마 무관 가독성 유지)
|
# 기본 스타일 (테마 무관 가독성 유지 — 메인 다크 팔레트와 정합)
|
||||||
self.setStyleSheet("""
|
self.setStyleSheet("""
|
||||||
QWidget { background-color: rgba(30, 30, 30, 230); border-radius: 8px; }
|
QWidget { background-color: rgba(26, 27, 30, 235); border-radius: 8px; }
|
||||||
QLabel { color: #fff; }
|
QLabel { color: #E9ECEF; background: transparent; }
|
||||||
""")
|
""")
|
||||||
|
|
||||||
apply_dark_titlebar(self)
|
apply_dark_titlebar(self)
|
||||||
@ -63,11 +63,12 @@ class MiniWidget(QWidget):
|
|||||||
"""메인 윈도우에서 호출 — 남은 시간 동기화."""
|
"""메인 윈도우에서 호출 — 남은 시간 동기화."""
|
||||||
self.time_label.setText(remaining_str)
|
self.time_label.setText(remaining_str)
|
||||||
if remaining_str.startswith('+'):
|
if remaining_str.startswith('+'):
|
||||||
|
# 연장근무 진입 = 퇴근 가능 → 그린 (메인 히어로와 동일 피드백)
|
||||||
self.title_label.setText(tr('label.overtime_progress'))
|
self.title_label.setText(tr('label.overtime_progress'))
|
||||||
self.time_label.setStyleSheet("color: #ff6b6b;")
|
self.time_label.setStyleSheet("color: #51CF66;")
|
||||||
else:
|
else:
|
||||||
self.title_label.setText(tr('label.remaining'))
|
self.title_label.setText(tr('label.remaining'))
|
||||||
self.time_label.setStyleSheet("color: #fff;")
|
self.time_label.setStyleSheet("color: #E9ECEF;")
|
||||||
|
|
||||||
# 드래그 이동
|
# 드래그 이동
|
||||||
def mousePressEvent(self, event: QMouseEvent):
|
def mousePressEvent(self, event: QMouseEvent):
|
||||||
@ -90,6 +91,17 @@ class MiniWidget(QWidget):
|
|||||||
def contextMenuEvent(self, event):
|
def contextMenuEvent(self, event):
|
||||||
from PyQt5.QtWidgets import QMenu
|
from PyQt5.QtWidgets import QMenu
|
||||||
menu = QMenu(self)
|
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("메인 창 열기")
|
open_main = menu.addAction("메인 창 열기")
|
||||||
close_mini = menu.addAction("미니 위젯 닫기")
|
close_mini = menu.addAction("미니 위젯 닫기")
|
||||||
action = menu.exec_(event.globalPos())
|
action = menu.exec_(event.globalPos())
|
||||||
|
|||||||
@ -32,7 +32,7 @@ WORK_PRESETS = [
|
|||||||
class WelcomePage(QWizardPage):
|
class WelcomePage(QWizardPage):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setTitle("👋 환영합니다!")
|
self.setTitle("환영합니다!")
|
||||||
self.setSubTitle("Clock-out Time Calculator를 처음 사용하시는군요. 5단계로 빠르게 설정하겠습니다.")
|
self.setSubTitle("Clock-out Time Calculator를 처음 사용하시는군요. 5단계로 빠르게 설정하겠습니다.")
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
intro = QLabel(
|
intro = QLabel(
|
||||||
@ -51,7 +51,7 @@ class WelcomePage(QWizardPage):
|
|||||||
class WorkPatternPage(QWizardPage):
|
class WorkPatternPage(QWizardPage):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setTitle("🕘 근무 패턴")
|
self.setTitle("근무 패턴")
|
||||||
self.setSubTitle("본인의 하루 근무 시간을 선택하세요. 나중에 설정에서 바꿀 수 있습니다.")
|
self.setSubTitle("본인의 하루 근무 시간을 선택하세요. 나중에 설정에서 바꿀 수 있습니다.")
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
@ -127,7 +127,7 @@ class WorkPatternPage(QWizardPage):
|
|||||||
class ClockInDetectionPage(QWizardPage):
|
class ClockInDetectionPage(QWizardPage):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setTitle("⏰ 출근 시간 감지 방식")
|
self.setTitle("출근 시간 감지 방식")
|
||||||
self.setSubTitle("앱이 출근 시간을 자동으로 어떻게 감지할지 선택하세요.")
|
self.setSubTitle("앱이 출근 시간을 자동으로 어떻게 감지할지 선택하세요.")
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
@ -139,10 +139,10 @@ class ClockInDetectionPage(QWizardPage):
|
|||||||
layout.addWidget(opt)
|
layout.addWidget(opt)
|
||||||
|
|
||||||
info = QLabel(
|
info = QLabel(
|
||||||
"\n💡 PC를 항상 켜둔 채 출근하시는 분은 두 번째 옵션을 권장합니다."
|
"\nPC를 항상 켜둔 채 출근하시는 분은 두 번째 옵션을 권장합니다."
|
||||||
)
|
)
|
||||||
info.setWordWrap(True)
|
info.setWordWrap(True)
|
||||||
info.setStyleSheet("color: #888; padding: 8px;")
|
info.setStyleSheet("color: #909296; padding: 8px;")
|
||||||
layout.addWidget(info)
|
layout.addWidget(info)
|
||||||
|
|
||||||
layout.addStretch()
|
layout.addStretch()
|
||||||
@ -159,7 +159,7 @@ class ClockInDetectionPage(QWizardPage):
|
|||||||
class LeaveSalaryPage(QWizardPage):
|
class LeaveSalaryPage(QWizardPage):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setTitle("🌴 연차 + 💰 급여 (옵션)")
|
self.setTitle("연차 + 급여 (옵션)")
|
||||||
self.setSubTitle("연차 일수와 급여(선택)를 입력하세요.")
|
self.setSubTitle("연차 일수와 급여(선택)를 입력하세요.")
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
@ -218,7 +218,7 @@ class LeaveSalaryPage(QWizardPage):
|
|||||||
class DiscordPage(QWizardPage):
|
class DiscordPage(QWizardPage):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setTitle("💬 Discord 알림 (선택)")
|
self.setTitle("Discord 알림 (선택)")
|
||||||
self.setSubTitle("출퇴근 시각·휴식 권고를 Discord로 받으려면 웹훅 URL을 입력하세요. (모바일에서 푸시 알림)")
|
self.setSubTitle("출퇴근 시각·휴식 권고를 Discord로 받으려면 웹훅 URL을 입력하세요. (모바일에서 푸시 알림)")
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
@ -236,7 +236,7 @@ class DiscordPage(QWizardPage):
|
|||||||
"2. 새 웹훅 만들기 → URL 복사\n"
|
"2. 새 웹훅 만들기 → URL 복사\n"
|
||||||
"3. 위 입력란에 붙여넣기"
|
"3. 위 입력란에 붙여넣기"
|
||||||
)
|
)
|
||||||
guide.setStyleSheet("color: #888; padding: 6px;")
|
guide.setStyleSheet("color: #909296; padding: 6px;")
|
||||||
guide.setWordWrap(True)
|
guide.setWordWrap(True)
|
||||||
layout.addWidget(guide)
|
layout.addWidget(guide)
|
||||||
|
|
||||||
@ -277,14 +277,14 @@ class DiscordPage(QWizardPage):
|
|||||||
class FinishPage(QWizardPage):
|
class FinishPage(QWizardPage):
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.setTitle("🎉 준비 완료!")
|
self.setTitle("준비 완료!")
|
||||||
self.setSubTitle("이제 출근부터 자동 추적됩니다.")
|
self.setSubTitle("이제 출근부터 자동 추적됩니다.")
|
||||||
|
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
msg = QLabel(
|
msg = QLabel(
|
||||||
"설정한 내용은 [설정] 메뉴에서 언제든 바꿀 수 있습니다.\n"
|
"설정한 내용은 [설정] 메뉴에서 언제든 바꿀 수 있습니다.\n"
|
||||||
"온보딩을 다시 보고 싶으면 [도움말 → 온보딩 다시 보기]를 누르세요.\n\n"
|
"온보딩을 다시 보고 싶으면 [도움말 → 온보딩 다시 보기]를 누르세요.\n\n"
|
||||||
"🕐 단축키:\n"
|
"단축키:\n"
|
||||||
" • Ctrl+O — 출퇴근 토글\n"
|
" • Ctrl+O — 출퇴근 토글\n"
|
||||||
" • F1 — 도움말\n"
|
" • F1 — 도움말\n"
|
||||||
" • F5 — 업데이트 확인\n"
|
" • F5 — 업데이트 확인\n"
|
||||||
|
|||||||
@ -66,6 +66,8 @@ class OvertimeView(QDialog):
|
|||||||
self.earned_table.setAlternatingRowColors(True)
|
self.earned_table.setAlternatingRowColors(True)
|
||||||
self.earned_table.setEditTriggers(QTableWidget.NoEditTriggers)
|
self.earned_table.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||||
self.earned_table.setSelectionBehavior(QTableWidget.SelectRows)
|
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)
|
earned_layout.addWidget(self.earned_table)
|
||||||
|
|
||||||
add_earned_button = QPushButton(tr('view.overtime.btn_add_earned'))
|
add_earned_button = QPushButton(tr('view.overtime.btn_add_earned'))
|
||||||
@ -126,7 +128,7 @@ class OvertimeView(QDialog):
|
|||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
cursor.execute('''
|
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
|
FROM overtime_bank ob
|
||||||
LEFT JOIN work_records wr ON ob.work_record_id = wr.id
|
LEFT JOIN work_records wr ON ob.work_record_id = wr.id
|
||||||
ORDER BY ob.date DESC
|
ORDER BY ob.date DESC
|
||||||
@ -138,6 +140,7 @@ class OvertimeView(QDialog):
|
|||||||
for i, record in enumerate(earned_records):
|
for i, record in enumerate(earned_records):
|
||||||
date_item = QTableWidgetItem(record[0])
|
date_item = QTableWidgetItem(record[0])
|
||||||
date_item.setTextAlignment(Qt.AlignCenter)
|
date_item.setTextAlignment(Qt.AlignCenter)
|
||||||
|
date_item.setData(Qt.UserRole, record[4]) # overtime_bank.id 저장 (삭제용)
|
||||||
|
|
||||||
minutes = record[1]
|
minutes = record[1]
|
||||||
hours = minutes // 60
|
hours = minutes // 60
|
||||||
@ -148,7 +151,7 @@ class OvertimeView(QDialog):
|
|||||||
time_str = tr('view.break.duration_min_only', m=mins)
|
time_str = tr('view.break.duration_min_only', m=mins)
|
||||||
time_item = QTableWidgetItem(time_str)
|
time_item = QTableWidgetItem(time_str)
|
||||||
time_item.setTextAlignment(Qt.AlignCenter)
|
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
|
# work_record_id NULL이면 "수동 추가", 아니면 wr.memo
|
||||||
memo_text = manual_label if record[2] is None else (record[3] or "")
|
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_str = tr('view.break.duration_min_only', m=mins)
|
||||||
time_item = QTableWidgetItem(time_str)
|
time_item = QTableWidgetItem(time_str)
|
||||||
time_item.setTextAlignment(Qt.AlignCenter)
|
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 "")
|
reason_item = QTableWidgetItem(record[3] or "")
|
||||||
|
|
||||||
@ -249,6 +252,46 @@ class OvertimeView(QDialog):
|
|||||||
if self.parent() and hasattr(self.parent(), 'update_overtime_balance'):
|
if self.parent() and hasattr(self.parent(), 'update_overtime_balance'):
|
||||||
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):
|
def add_earned_record(self):
|
||||||
"""수동 적립 추가"""
|
"""수동 적립 추가"""
|
||||||
dialog = AddOvertimeEarnedDialog(self, self.db)
|
dialog = AddOvertimeEarnedDialog(self, self.db)
|
||||||
|
|||||||
@ -26,7 +26,7 @@ class PastRecordDialog(QDialog):
|
|||||||
layout.setSpacing(8)
|
layout.setSpacing(8)
|
||||||
layout.setContentsMargins(20, 16, 20, 16)
|
layout.setContentsMargins(20, 16, 20, 16)
|
||||||
|
|
||||||
info = QLabel(f"📅 {date_str} 근무 기록을 입력하세요.")
|
info = QLabel(f"{date_str} 근무 기록을 입력하세요.")
|
||||||
info.setStyleSheet("font-weight: bold; padding-bottom: 6px;")
|
info.setStyleSheet("font-weight: bold; padding-bottom: 6px;")
|
||||||
layout.addWidget(info)
|
layout.addWidget(info)
|
||||||
|
|
||||||
@ -56,9 +56,9 @@ class PastRecordDialog(QDialog):
|
|||||||
|
|
||||||
# 점심/저녁
|
# 점심/저녁
|
||||||
meal_row = QHBoxLayout()
|
meal_row = QHBoxLayout()
|
||||||
self.lunch_check = QCheckBox("🍱 점심시간 포함")
|
self.lunch_check = QCheckBox("점심시간 포함")
|
||||||
self.lunch_check.setChecked(True)
|
self.lunch_check.setChecked(True)
|
||||||
self.dinner_check = QCheckBox("🍽 저녁시간 포함")
|
self.dinner_check = QCheckBox("저녁시간 포함")
|
||||||
meal_row.addWidget(self.lunch_check)
|
meal_row.addWidget(self.lunch_check)
|
||||||
meal_row.addWidget(self.dinner_check)
|
meal_row.addWidget(self.dinner_check)
|
||||||
meal_row.addStretch()
|
meal_row.addStretch()
|
||||||
|
|||||||
@ -27,7 +27,7 @@ class RecurringLeaveDialog(QDialog):
|
|||||||
def __init__(self, parent=None, db=None):
|
def __init__(self, parent=None, db=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.db = db
|
self.db = db
|
||||||
self.setWindowTitle("🔁 반복 연차 관리")
|
self.setWindowTitle("반복 연차 관리")
|
||||||
self.setMinimumSize(540, 480)
|
self.setMinimumSize(540, 480)
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._reload_list()
|
self._reload_list()
|
||||||
@ -129,7 +129,7 @@ class RecurringLeaveDialog(QDialog):
|
|||||||
ag.addLayout(memo_row)
|
ag.addLayout(memo_row)
|
||||||
|
|
||||||
# 추가 버튼
|
# 추가 버튼
|
||||||
add_btn = QPushButton("➕ 추가")
|
add_btn = QPushButton("추가")
|
||||||
add_btn.setObjectName("btn_primary")
|
add_btn.setObjectName("btn_primary")
|
||||||
add_btn.clicked.connect(self._save)
|
add_btn.clicked.connect(self._save)
|
||||||
ag.addWidget(add_btn)
|
ag.addWidget(add_btn)
|
||||||
|
|||||||
@ -38,7 +38,7 @@ class ScheduleView(QDialog):
|
|||||||
def __init__(self, parent=None, db=None):
|
def __init__(self, parent=None, db=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self.db = db
|
self.db = db
|
||||||
self.setWindowTitle("🗓️ 스케줄")
|
self.setWindowTitle("스케줄")
|
||||||
self.setMinimumSize(820, 560)
|
self.setMinimumSize(820, 560)
|
||||||
self._build_ui()
|
self._build_ui()
|
||||||
self._reload()
|
self._reload()
|
||||||
@ -54,11 +54,11 @@ class ScheduleView(QDialog):
|
|||||||
bar.addWidget(title)
|
bar.addWidget(title)
|
||||||
bar.addStretch()
|
bar.addStretch()
|
||||||
|
|
||||||
rec_btn = QPushButton("🔁 반복 패턴 관리")
|
rec_btn = QPushButton("반복 패턴 관리")
|
||||||
rec_btn.clicked.connect(self._open_recurring_dialog)
|
rec_btn.clicked.connect(self._open_recurring_dialog)
|
||||||
bar.addWidget(rec_btn)
|
bar.addWidget(rec_btn)
|
||||||
|
|
||||||
add_btn = QPushButton("➕ 연차 등록")
|
add_btn = QPushButton("연차 등록")
|
||||||
add_btn.clicked.connect(self._open_add_leave_dialog)
|
add_btn.clicked.connect(self._open_add_leave_dialog)
|
||||||
bar.addWidget(add_btn)
|
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
|
holiday = self.db.get_holiday(date_str) if hasattr(self.db, 'get_holiday') else None
|
||||||
if holiday:
|
if holiday:
|
||||||
item = QListWidgetItem(f"🎌 공휴일: {holiday.get('name', '공휴일')}")
|
item = QListWidgetItem(f"공휴일: {holiday.get('name', '공휴일')}")
|
||||||
item.setForeground(QBrush(QColor("#e53935")))
|
item.setForeground(QBrush(QColor("#e53935")))
|
||||||
self.detail_list.addItem(item)
|
self.detail_list.addItem(item)
|
||||||
elif d.weekday() in (5, 6):
|
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)
|
self.detail_list.addItem(item)
|
||||||
|
|
||||||
# 연차 (구체)
|
# 연차 (구체)
|
||||||
@ -208,7 +208,7 @@ class ScheduleView(QDialog):
|
|||||||
days = float(r.get('days') or 0)
|
days = float(r.get('days') or 0)
|
||||||
t = r.get('leave_type', '연차')
|
t = r.get('leave_type', '연차')
|
||||||
memo = r.get('memo') or ''
|
memo = r.get('memo') or ''
|
||||||
label = f"📌 {t} {days}일"
|
label = f"{t} {days}일"
|
||||||
if memo:
|
if memo:
|
||||||
label += f" — {memo}"
|
label += f" — {memo}"
|
||||||
label += f" [id={r['id']}]"
|
label += f" [id={r['id']}]"
|
||||||
@ -221,7 +221,7 @@ class ScheduleView(QDialog):
|
|||||||
from core.recurring_leaves import expand_for_date
|
from core.recurring_leaves import expand_for_date
|
||||||
for occ in expand_for_date(recurring, d):
|
for occ in expand_for_date(recurring, d):
|
||||||
item = QListWidgetItem(
|
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))
|
item.setData(Qt.UserRole, ('recurring', occ.recurring_id))
|
||||||
self.detail_list.addItem(item)
|
self.detail_list.addItem(item)
|
||||||
|
|||||||
@ -516,7 +516,7 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
def create_goal_group(self) -> QGroupBox:
|
def create_goal_group(self) -> QGroupBox:
|
||||||
"""월간 목표 설정 그룹 (0=비활성)."""
|
"""월간 목표 설정 그룹 (0=비활성)."""
|
||||||
group = QGroupBox("🎯 월간 목표 (0=비활성)")
|
group = QGroupBox("월간 목표 (0=비활성)")
|
||||||
layout = QVBoxLayout()
|
layout = QVBoxLayout()
|
||||||
layout.setSpacing(6)
|
layout.setSpacing(6)
|
||||||
|
|
||||||
@ -865,7 +865,7 @@ class SettingsView(QDialog):
|
|||||||
|
|
||||||
# CSV 가져오기
|
# CSV 가져오기
|
||||||
import_layout = QHBoxLayout()
|
import_layout = QHBoxLayout()
|
||||||
import_btn = QPushButton("📥 CSV 가져오기")
|
import_btn = QPushButton("CSV 가져오기")
|
||||||
import_btn.setObjectName("btn_small")
|
import_btn.setObjectName("btn_small")
|
||||||
import_btn.setToolTip("date,clock_in,clock_out,lunch_minutes,memo 헤더 포맷")
|
import_btn.setToolTip("date,clock_in,clock_out,lunch_minutes,memo 헤더 포맷")
|
||||||
import_btn.clicked.connect(self._import_csv)
|
import_btn.clicked.connect(self._import_csv)
|
||||||
@ -1068,7 +1068,7 @@ class SettingsView(QDialog):
|
|||||||
self.time_format_combo.setCurrentIndex(index)
|
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'):
|
if hasattr(self, 'language_combo'):
|
||||||
|
|||||||
@ -42,7 +42,7 @@ class StatsView(QDialog):
|
|||||||
layout.setContentsMargins(20, 16, 20, 14)
|
layout.setContentsMargins(20, 16, 20, 14)
|
||||||
|
|
||||||
# 다크 톤 타이틀
|
# 다크 톤 타이틀
|
||||||
title = QLabel(f"📊 {tr('stats.title')}")
|
title = QLabel(f"{tr('stats.title')}")
|
||||||
title.setStyleSheet(
|
title.setStyleSheet(
|
||||||
f"font-size: 18pt; font-weight: bold; color: {DARK_TEXT}; "
|
f"font-size: 18pt; font-weight: bold; color: {DARK_TEXT}; "
|
||||||
f"background: transparent; border: none; padding: 4px 0;"
|
f"background: transparent; border: none; padding: 4px 0;"
|
||||||
@ -94,13 +94,13 @@ class StatsView(QDialog):
|
|||||||
cards_row = QHBoxLayout()
|
cards_row = QHBoxLayout()
|
||||||
cards_row.setSpacing(10)
|
cards_row.setSpacing(10)
|
||||||
self.weekly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 주",
|
self.weekly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 주",
|
||||||
theme='blue', icon='⏱️')
|
theme='blue', icon='clock')
|
||||||
self.weekly_days_card = build_stat_card("근무 일수", "0일", "이번 주",
|
self.weekly_days_card = build_stat_card("근무 일수", "0일", "이번 주",
|
||||||
theme='cyan', icon='📅')
|
theme='cyan', icon='calendar')
|
||||||
self.weekly_avg_card = build_stat_card("일평균", "0시간", "이번 주",
|
self.weekly_avg_card = build_stat_card("일평균", "0시간", "이번 주",
|
||||||
theme='green', icon='📊')
|
theme='green', icon='chart')
|
||||||
self.weekly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 주",
|
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,
|
for c in (self.weekly_total_card, self.weekly_days_card,
|
||||||
self.weekly_avg_card, self.weekly_ot_card):
|
self.weekly_avg_card, self.weekly_ot_card):
|
||||||
cards_row.addWidget(c, 1)
|
cards_row.addWidget(c, 1)
|
||||||
@ -110,7 +110,7 @@ class StatsView(QDialog):
|
|||||||
from ui.chart_widget import make_chart_widget
|
from ui.chart_widget import make_chart_widget
|
||||||
self.weekly_chart = make_chart_widget(widget)
|
self.weekly_chart = make_chart_widget(widget)
|
||||||
chart_card = build_section_card("일별 근무 시간", self.weekly_chart,
|
chart_card = build_section_card("일별 근무 시간", self.weekly_chart,
|
||||||
theme='gray', icon='📈')
|
theme='gray', icon='trending-up')
|
||||||
layout.addWidget(chart_card, 1)
|
layout.addWidget(chart_card, 1)
|
||||||
|
|
||||||
widget.setLayout(layout)
|
widget.setLayout(layout)
|
||||||
@ -128,13 +128,13 @@ class StatsView(QDialog):
|
|||||||
cards_row = QHBoxLayout()
|
cards_row = QHBoxLayout()
|
||||||
cards_row.setSpacing(10)
|
cards_row.setSpacing(10)
|
||||||
self.monthly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 달",
|
self.monthly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 달",
|
||||||
theme='blue', icon='⏱️')
|
theme='blue', icon='clock')
|
||||||
self.monthly_days_card = build_stat_card("근무 일수", "0일", "이번 달",
|
self.monthly_days_card = build_stat_card("근무 일수", "0일", "이번 달",
|
||||||
theme='cyan', icon='📅')
|
theme='cyan', icon='calendar')
|
||||||
self.monthly_avg_card = build_stat_card("일평균", "0시간", "이번 달",
|
self.monthly_avg_card = build_stat_card("일평균", "0시간", "이번 달",
|
||||||
theme='green', icon='📊')
|
theme='green', icon='chart')
|
||||||
self.monthly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 달",
|
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,
|
for c in (self.monthly_total_card, self.monthly_days_card,
|
||||||
self.monthly_avg_card, self.monthly_ot_card):
|
self.monthly_avg_card, self.monthly_ot_card):
|
||||||
cards_row.addWidget(c, 1)
|
cards_row.addWidget(c, 1)
|
||||||
@ -160,7 +160,7 @@ class StatsView(QDialog):
|
|||||||
from ui.chart_widget import make_chart_widget
|
from ui.chart_widget import make_chart_widget
|
||||||
self.monthly_chart = make_chart_widget(widget)
|
self.monthly_chart = make_chart_widget(widget)
|
||||||
chart_card = build_section_card("요일별 평균", self.monthly_chart,
|
chart_card = build_section_card("요일별 평균", self.monthly_chart,
|
||||||
theme='gray', icon='📊')
|
theme='gray', icon='chart')
|
||||||
layout.addWidget(chart_card, 1)
|
layout.addWidget(chart_card, 1)
|
||||||
|
|
||||||
widget.setLayout(layout)
|
widget.setLayout(layout)
|
||||||
@ -183,13 +183,13 @@ class StatsView(QDialog):
|
|||||||
f"background: transparent; border: none; padding: 4px 0;"
|
f"background: transparent; border: none; padding: 4px 0;"
|
||||||
)
|
)
|
||||||
layout.addWidget(build_section_card("패턴 인사이트", self.pattern_text,
|
layout.addWidget(build_section_card("패턴 인사이트", self.pattern_text,
|
||||||
theme='cyan', icon='🔍'))
|
theme='cyan', icon='search'))
|
||||||
|
|
||||||
# 출근 시각 분포 차트
|
# 출근 시각 분포 차트
|
||||||
from ui.chart_widget import make_chart_widget
|
from ui.chart_widget import make_chart_widget
|
||||||
self.clock_in_chart = make_chart_widget(widget)
|
self.clock_in_chart = make_chart_widget(widget)
|
||||||
layout.addWidget(build_section_card("출근 시각 분포", self.clock_in_chart,
|
layout.addWidget(build_section_card("출근 시각 분포", self.clock_in_chart,
|
||||||
theme='gray', icon='⏰'), 1)
|
theme='gray', icon='clock'), 1)
|
||||||
|
|
||||||
widget.setLayout(layout)
|
widget.setLayout(layout)
|
||||||
return widget
|
return widget
|
||||||
|
|||||||
282
ui/styles.py
282
ui/styles.py
@ -33,8 +33,8 @@ def _ensure_icons():
|
|||||||
for name, color_hex, points in [
|
for name, color_hex, points in [
|
||||||
('up_light', '#4A4A68', [(4, 7), (8, 3), (12, 7)]),
|
('up_light', '#4A4A68', [(4, 7), (8, 3), (12, 7)]),
|
||||||
('down_light', '#4A4A68', [(4, 5), (8, 9), (12, 5)]),
|
('down_light', '#4A4A68', [(4, 5), (8, 9), (12, 5)]),
|
||||||
('up_dark', '#A0A0B8', [(4, 7), (8, 3), (12, 7)]),
|
('up_dark', '#909296', [(4, 7), (8, 3), (12, 7)]),
|
||||||
('down_dark', '#A0A0B8', [(4, 5), (8, 9), (12, 5)]),
|
('down_dark', '#909296', [(4, 5), (8, 9), (12, 5)]),
|
||||||
]:
|
]:
|
||||||
path = os.path.join(_arrow_dir, f'{name}.png')
|
path = os.path.join(_arrow_dir, f'{name}.png')
|
||||||
if not os.path.exists(path):
|
if not os.path.exists(path):
|
||||||
@ -78,6 +78,9 @@ LIGHT_COLORS = {
|
|||||||
'bg_primary': '#F5F5F7',
|
'bg_primary': '#F5F5F7',
|
||||||
'bg_secondary': '#FFFFFF',
|
'bg_secondary': '#FFFFFF',
|
||||||
'bg_tertiary': '#EDEDF0',
|
'bg_tertiary': '#EDEDF0',
|
||||||
|
# 인터랙션 표면
|
||||||
|
'surface_hover': '#E2E3E7',
|
||||||
|
'surface_pressed': '#D5D6DB',
|
||||||
# 텍스트 계층
|
# 텍스트 계층
|
||||||
'text_primary': '#1A1A2E',
|
'text_primary': '#1A1A2E',
|
||||||
'text_secondary': '#4A4A68',
|
'text_secondary': '#4A4A68',
|
||||||
@ -85,9 +88,15 @@ LIGHT_COLORS = {
|
|||||||
'text_inverse': '#FFFFFF',
|
'text_inverse': '#FFFFFF',
|
||||||
# 액센트
|
# 액센트
|
||||||
'accent_primary': '#3B82F6',
|
'accent_primary': '#3B82F6',
|
||||||
|
'accent_primary_hover': '#2F74EE',
|
||||||
|
'accent_primary_pressed': '#2563EB',
|
||||||
'accent_success': '#10B981',
|
'accent_success': '#10B981',
|
||||||
|
'accent_success_hover': '#0EA372',
|
||||||
|
'accent_success_pressed': '#0C8F63',
|
||||||
'accent_warning': '#F59E0B',
|
'accent_warning': '#F59E0B',
|
||||||
'accent_danger': '#EF4444',
|
'accent_danger': '#EF4444',
|
||||||
|
'accent_danger_hover': '#DC2626',
|
||||||
|
'accent_danger_pressed': '#B91C1C',
|
||||||
# 테두리
|
# 테두리
|
||||||
'border_subtle': '#E5E7EB',
|
'border_subtle': '#E5E7EB',
|
||||||
'border_default': '#D1D5DB',
|
'border_default': '#D1D5DB',
|
||||||
@ -120,40 +129,58 @@ LIGHT_COLORS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
DARK_COLORS = {
|
DARK_COLORS = {
|
||||||
'bg_primary': '#111118',
|
# 배경 계층 — 모던 다크 (Notion/Linear 톤)
|
||||||
'bg_secondary': '#1C1C2E',
|
'bg_primary': '#1A1B1E', # 앱 배경
|
||||||
'bg_tertiary': '#282842',
|
'bg_secondary': '#25262B', # 카드 / 패널
|
||||||
'text_primary': '#ECECF4',
|
'bg_tertiary': '#2C2E33', # 기본 버튼 / 미묘한 채움
|
||||||
'text_secondary': '#B0B0C8',
|
# 인터랙션 표면
|
||||||
'text_tertiary': '#808098',
|
'surface_hover': '#34363D',
|
||||||
|
'surface_pressed': '#3A3D44',
|
||||||
|
# 텍스트 계층
|
||||||
|
'text_primary': '#E9ECEF',
|
||||||
|
'text_secondary': '#909296',
|
||||||
|
'text_tertiary': '#6C6E73',
|
||||||
'text_inverse': '#FFFFFF',
|
'text_inverse': '#FFFFFF',
|
||||||
'accent_primary': '#6B9EFF',
|
# 액센트 — 단일 포인트 컬러 (주요 버튼 + 포커스 전용)
|
||||||
'accent_success': '#4ADE80',
|
'accent_primary': '#4DABF7',
|
||||||
'accent_warning': '#FCD34D',
|
'accent_primary_hover': '#69B6F8',
|
||||||
'accent_danger': '#FB7185',
|
'accent_primary_pressed': '#3D97E0',
|
||||||
'border_subtle': '#32324E',
|
'accent_success': '#51CF66',
|
||||||
'border_default': '#44446A',
|
'accent_success_hover': '#69DB7C',
|
||||||
'border_focus': '#6B9EFF',
|
'accent_success_pressed': '#43B85A',
|
||||||
'badge_overtime_bg': '#3D2008',
|
'accent_warning': '#FAB005',
|
||||||
'badge_overtime_text': '#FDE68A',
|
'accent_danger': '#FA5252',
|
||||||
'badge_leave_bg': '#1E2D5F',
|
'accent_danger_hover': '#FF6B6B',
|
||||||
'badge_leave_text': '#A5D0FE',
|
'accent_danger_pressed': '#E64545',
|
||||||
'badge_total_bg': '#0A3324',
|
# 테두리
|
||||||
'badge_total_text': '#86EFAC',
|
'border_subtle': '#2C2E33',
|
||||||
'progress_bg': '#282842',
|
'border_default': '#373A40',
|
||||||
'progress_start': '#6B9EFF',
|
'border_focus': '#4DABF7',
|
||||||
'progress_end': '#4ADE80',
|
# 배지 — 플랫 (미묘한 배경 + 색조 텍스트로 미니멀 유지)
|
||||||
'status_overtime': '#FB7185',
|
'badge_overtime_bg': '#2C2E33',
|
||||||
'status_warning': '#FCD34D',
|
'badge_overtime_text': '#FAB005',
|
||||||
'status_normal': '#4ADE80',
|
'badge_leave_bg': '#2C2E33',
|
||||||
'status_break_active': '#FB7185',
|
'badge_leave_text': '#4DABF7',
|
||||||
'status_break_idle': '#808098',
|
'badge_total_bg': '#2C2E33',
|
||||||
'cal_normal': '#1A4D3A',
|
'badge_total_text': '#51CF66',
|
||||||
'cal_overtime': '#5C1A1A',
|
# 프로그레스 — 단일 accent 솔리드
|
||||||
'cal_incomplete': '#5C3A10',
|
'progress_bg': '#2C2E33',
|
||||||
'scrollbar_bg': '#111118',
|
'progress_start': '#4DABF7',
|
||||||
'scrollbar_handle': '#44446A',
|
'progress_end': '#4DABF7',
|
||||||
'scrollbar_hover': '#5A5A88',
|
# 상태 색상 (동적 텍스트 피드백)
|
||||||
|
'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 {{
|
QWidget {{
|
||||||
font-family: "Segoe UI", "맑은 고딕", sans-serif;
|
font-family: "NanumSquare", "NanumSquareOTF", "Malgun Gothic", "맑은 고딕", sans-serif;
|
||||||
font-size: 9.5pt;
|
font-size: 9.5pt;
|
||||||
color: {c['text_primary']};
|
color: {c['text_primary']};
|
||||||
}}
|
}}
|
||||||
@ -206,14 +233,14 @@ QWidget#central_widget {{
|
|||||||
════════════════════════════════════════ */
|
════════════════════════════════════════ */
|
||||||
|
|
||||||
QLabel#app_title {{
|
QLabel#app_title {{
|
||||||
font-size: 12pt;
|
font-size: 13pt;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: {c['text_primary']};
|
color: {c['text_primary']};
|
||||||
padding: 2px;
|
padding: 2px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QLabel#date_label {{
|
QLabel#date_label {{
|
||||||
font-size: 9pt;
|
font-size: 9.5pt;
|
||||||
color: {c['text_secondary']};
|
color: {c['text_secondary']};
|
||||||
padding-bottom: 4px;
|
padding-bottom: 4px;
|
||||||
}}
|
}}
|
||||||
@ -221,7 +248,7 @@ QLabel#date_label {{
|
|||||||
QLabel#section_title {{
|
QLabel#section_title {{
|
||||||
font-size: 9.5pt;
|
font-size: 9.5pt;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: {c['text_primary']};
|
color: {c['text_secondary']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QLabel#field_label {{
|
QLabel#field_label {{
|
||||||
@ -229,29 +256,30 @@ QLabel#field_label {{
|
|||||||
color: {c['text_secondary']};
|
color: {c['text_secondary']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
/* 출근/현재 시각 — 한 줄 나란히 표시되는 중간 크기 모노스페이스 */
|
||||||
QLabel#time_value {{
|
QLabel#time_value {{
|
||||||
font-family: "Consolas", "D2Coding", monospace;
|
font-family: "Consolas", "D2Coding", monospace;
|
||||||
font-size: 11pt;
|
font-size: 15pt;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: {c['text_primary']};
|
color: {c['text_primary']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
/* 히어로 — 남은 시간 (화면에서 가장 큰 결과 표시). 카드 안에 투명 배치 */
|
||||||
QLabel#time_display {{
|
QLabel#time_display {{
|
||||||
font-family: "Consolas", "D2Coding", monospace;
|
font-family: "Consolas", "D2Coding", monospace;
|
||||||
font-size: 22pt;
|
font-size: 30pt;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: {c['text_primary']};
|
color: {c['text_primary']};
|
||||||
background: {c['bg_secondary']};
|
background: transparent;
|
||||||
border: 1px solid {c['border_subtle']};
|
border: none;
|
||||||
border-radius: 10px;
|
padding: 4px 0;
|
||||||
padding: 10px;
|
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QLabel#expected_time {{
|
QLabel#expected_time {{
|
||||||
font-size: 10pt;
|
font-size: 11.5pt;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: {c['text_primary']};
|
color: {c['text_secondary']};
|
||||||
padding: 4px;
|
padding: 2px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QLabel#dialog_title {{
|
QLabel#dialog_title {{
|
||||||
@ -295,7 +323,7 @@ QLabel#badge_overtime {{
|
|||||||
qproperty-alignment: AlignCenter;
|
qproperty-alignment: AlignCenter;
|
||||||
background: {c['badge_overtime_bg']};
|
background: {c['badge_overtime_bg']};
|
||||||
color: {c['badge_overtime_text']};
|
color: {c['badge_overtime_text']};
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QLabel#badge_leave {{
|
QLabel#badge_leave {{
|
||||||
@ -306,7 +334,7 @@ QLabel#badge_leave {{
|
|||||||
qproperty-alignment: AlignCenter;
|
qproperty-alignment: AlignCenter;
|
||||||
background: {c['badge_leave_bg']};
|
background: {c['badge_leave_bg']};
|
||||||
color: {c['badge_leave_text']};
|
color: {c['badge_leave_text']};
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QLabel#badge_total {{
|
QLabel#badge_total {{
|
||||||
@ -317,7 +345,7 @@ QLabel#badge_total {{
|
|||||||
qproperty-alignment: AlignCenter;
|
qproperty-alignment: AlignCenter;
|
||||||
background: {c['badge_total_bg']};
|
background: {c['badge_total_bg']};
|
||||||
color: {c['badge_total_text']};
|
color: {c['badge_total_text']};
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QLabel#badge_balance {{
|
QLabel#badge_balance {{
|
||||||
@ -326,7 +354,7 @@ QLabel#badge_balance {{
|
|||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: {c['bg_tertiary']};
|
background: {c['bg_tertiary']};
|
||||||
color: {c['text_primary']};
|
color: {c['text_primary']};
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QLabel#badge_success {{
|
QLabel#badge_success {{
|
||||||
@ -335,7 +363,7 @@ QLabel#badge_success {{
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
background: {c['badge_total_bg']};
|
background: {c['badge_total_bg']};
|
||||||
color: {c['badge_total_text']};
|
color: {c['badge_total_text']};
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
/* ════════════════════════════════════════
|
/* ════════════════════════════════════════
|
||||||
@ -355,9 +383,9 @@ QLabel#separator {{
|
|||||||
QGroupBox {{
|
QGroupBox {{
|
||||||
background: {c['bg_secondary']};
|
background: {c['bg_secondary']};
|
||||||
border: 1px solid {c['border_subtle']};
|
border: 1px solid {c['border_subtle']};
|
||||||
border-radius: 10px;
|
border-radius: 8px;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
padding: 14px;
|
padding: 16px;
|
||||||
padding-top: 28px;
|
padding-top: 28px;
|
||||||
font-size: 9.5pt;
|
font-size: 9.5pt;
|
||||||
color: {c['text_primary']};
|
color: {c['text_primary']};
|
||||||
@ -378,52 +406,55 @@ QGroupBox::title {{
|
|||||||
버튼
|
버튼
|
||||||
════════════════════════════════════════ */
|
════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* 기본 버튼 — 그라데이션/베벨 없는 플랫 (border:none 기반) */
|
||||||
QPushButton {{
|
QPushButton {{
|
||||||
background: {c['bg_tertiary']};
|
background: {c['bg_tertiary']};
|
||||||
color: {c['text_primary']};
|
color: {c['text_primary']};
|
||||||
border: 1px solid {c['border_default']};
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
padding: 7px 14px;
|
padding: 8px 14px;
|
||||||
font-size: 9pt;
|
font-size: 9pt;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QPushButton:hover {{
|
QPushButton:hover {{
|
||||||
background: {c['border_default']};
|
background: {c['surface_hover']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QPushButton:pressed {{
|
QPushButton:pressed {{
|
||||||
background: {c['border_subtle']};
|
background: {c['surface_pressed']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QPushButton:disabled {{
|
QPushButton:disabled {{
|
||||||
background: {c['bg_tertiary']};
|
background: {c['bg_secondary']};
|
||||||
color: {c['text_tertiary']};
|
color: {c['text_tertiary']};
|
||||||
border-color: {c['border_subtle']};
|
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QPushButton:checked {{
|
QPushButton:checked {{
|
||||||
background: {c['accent_primary']};
|
background: {c['accent_primary']};
|
||||||
color: {c['text_inverse']};
|
color: {c['text_inverse']};
|
||||||
border-color: {c['accent_primary']};
|
|
||||||
}}
|
}}
|
||||||
|
|
||||||
/* 퇴근 버튼 (primary action) */
|
QPushButton:focus {{
|
||||||
|
outline: none;
|
||||||
|
}}
|
||||||
|
|
||||||
|
/* 퇴근 버튼 — 주요 액션 (단일 포인트 컬러) */
|
||||||
QPushButton#clock_out_button {{
|
QPushButton#clock_out_button {{
|
||||||
background: {c['accent_success']};
|
background: {c['accent_primary']};
|
||||||
color: {c['text_inverse']};
|
color: {c['text_inverse']};
|
||||||
font-size: 11pt;
|
font-size: 11pt;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
padding: 8px;
|
padding: 11px;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QPushButton#clock_out_button:hover {{
|
QPushButton#clock_out_button:hover {{
|
||||||
background: {'#0EA572' if not is_dark else '#2BB885'};
|
background: {c['accent_primary_hover']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QPushButton#clock_out_button:pressed {{
|
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 {{
|
QPushButton#btn_primary:hover {{
|
||||||
background: {c['accent_primary']}DD;
|
background: {c['accent_primary_hover']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QPushButton#btn_primary:pressed {{
|
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 {{
|
QPushButton#btn_danger:hover {{
|
||||||
background: {c['accent_danger']}DD;
|
background: {c['accent_danger_hover']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QPushButton#btn_danger:pressed {{
|
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 {{
|
QPushButton#btn_success:hover {{
|
||||||
background: {c['accent_success']}DD;
|
background: {c['accent_success_hover']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QPushButton#btn_success:pressed {{
|
QPushButton#btn_success:pressed {{
|
||||||
background: {c['accent_success']}BB;
|
background: {c['accent_success_pressed']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
/* 작은 버튼 */
|
/* 작은 버튼 — 미묘한 표면 */
|
||||||
QPushButton#btn_small {{
|
QPushButton#btn_small {{
|
||||||
font-size: 8.5pt;
|
font-size: 8.5pt;
|
||||||
padding: 5px 10px;
|
padding: 6px 10px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QPushButton#btn_small:hover {{
|
QPushButton#btn_small:hover {{
|
||||||
background: {c['accent_primary']}20;
|
background: {c['surface_hover']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QPushButton#btn_small:pressed {{
|
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 {{
|
QLineEdit, QTextEdit, QComboBox {{
|
||||||
background: {c['bg_secondary']};
|
background: {c['bg_secondary']};
|
||||||
border: 1px solid {c['border_default']};
|
border: 1px solid {c['border_default']};
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
padding: 6px 8px;
|
padding: 6px 8px;
|
||||||
color: {c['text_primary']};
|
color: {c['text_primary']};
|
||||||
font-size: 9.5pt;
|
font-size: 9.5pt;
|
||||||
@ -503,21 +553,17 @@ QLineEdit, QTextEdit, QComboBox {{
|
|||||||
QSpinBox, QDoubleSpinBox, QDateEdit, QTimeEdit {{
|
QSpinBox, QDoubleSpinBox, QDateEdit, QTimeEdit {{
|
||||||
background: {c['bg_secondary']};
|
background: {c['bg_secondary']};
|
||||||
border: 1px solid {c['border_default']};
|
border: 1px solid {c['border_default']};
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
padding: 6px 28px 6px 8px;
|
padding: 6px 28px 6px 8px;
|
||||||
color: {c['text_primary']};
|
color: {c['text_primary']};
|
||||||
font-size: 9.5pt;
|
font-size: 9.5pt;
|
||||||
min-height: 20px;
|
min-height: 20px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QLineEdit:focus, QTextEdit:focus, QComboBox:focus {{
|
/* 포커스 시 보더 컬러만 포인트 컬러로 (두께 유지 → 레이아웃 흔들림 없음) */
|
||||||
border: 2px solid {c['border_focus']};
|
QLineEdit:focus, QTextEdit:focus, QComboBox:focus,
|
||||||
padding: 5px 7px;
|
|
||||||
}}
|
|
||||||
|
|
||||||
QSpinBox:focus, QDoubleSpinBox:focus, QDateEdit:focus, QTimeEdit:focus {{
|
QSpinBox:focus, QDoubleSpinBox:focus, QDateEdit:focus, QTimeEdit:focus {{
|
||||||
border: 2px solid {c['border_focus']};
|
border: 1px solid {c['border_focus']};
|
||||||
padding: 5px 27px 5px 7px;
|
|
||||||
}}
|
}}
|
||||||
|
|
||||||
/* 비활성 입력 필드 */
|
/* 비활성 입력 필드 */
|
||||||
@ -563,13 +609,13 @@ QTimeEdit::up-button, QTimeEdit::down-button {{
|
|||||||
QSpinBox::up-button, QDoubleSpinBox::up-button,
|
QSpinBox::up-button, QDoubleSpinBox::up-button,
|
||||||
QDateEdit::up-button, QTimeEdit::up-button {{
|
QDateEdit::up-button, QTimeEdit::up-button {{
|
||||||
subcontrol-position: top right;
|
subcontrol-position: top right;
|
||||||
border-top-right-radius: 4px;
|
border-top-right-radius: 7px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QSpinBox::down-button, QDoubleSpinBox::down-button,
|
QSpinBox::down-button, QDoubleSpinBox::down-button,
|
||||||
QDateEdit::down-button, QTimeEdit::down-button {{
|
QDateEdit::down-button, QTimeEdit::down-button {{
|
||||||
subcontrol-position: bottom right;
|
subcontrol-position: bottom right;
|
||||||
border-bottom-right-radius: 4px;
|
border-bottom-right-radius: 7px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QSpinBox::up-button:hover, QSpinBox::down-button:hover,
|
QSpinBox::up-button:hover, QSpinBox::down-button:hover,
|
||||||
@ -628,17 +674,17 @@ QCheckBox::indicator:hover {{
|
|||||||
QProgressBar {{
|
QProgressBar {{
|
||||||
border: none;
|
border: none;
|
||||||
background: {c['progress_bg']};
|
background: {c['progress_bg']};
|
||||||
border-radius: 4px;
|
border-radius: 3px;
|
||||||
height: 8px;
|
min-height: 6px;
|
||||||
|
max-height: 6px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: transparent;
|
color: transparent;
|
||||||
font-size: 0px;
|
font-size: 0px;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
QProgressBar::chunk {{
|
QProgressBar::chunk {{
|
||||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
background: {c['progress_start']};
|
||||||
stop:0 {c['progress_start']}, stop:1 {c['progress_end']});
|
border-radius: 3px;
|
||||||
border-radius: 4px;
|
|
||||||
}}
|
}}
|
||||||
|
|
||||||
/* ════════════════════════════════════════
|
/* ════════════════════════════════════════
|
||||||
@ -648,7 +694,7 @@ QProgressBar::chunk {{
|
|||||||
QTableWidget {{
|
QTableWidget {{
|
||||||
background: {c['bg_secondary']};
|
background: {c['bg_secondary']};
|
||||||
border: 1px solid {c['border_subtle']};
|
border: 1px solid {c['border_subtle']};
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
gridline-color: {c['border_subtle']};
|
gridline-color: {c['border_subtle']};
|
||||||
color: {c['text_primary']};
|
color: {c['text_primary']};
|
||||||
font-size: 9pt;
|
font-size: 9pt;
|
||||||
@ -667,23 +713,47 @@ QTableWidget::item:alternate {{
|
|||||||
background: {c['bg_tertiary']};
|
background: {c['bg_tertiary']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
/* 헤더 위젯 배경 (세로헤더 빈 영역의 흰색 누수 방지) */
|
||||||
|
QHeaderView {{
|
||||||
|
background: {c['bg_secondary']};
|
||||||
|
border: none;
|
||||||
|
}}
|
||||||
|
|
||||||
QHeaderView::section {{
|
QHeaderView::section {{
|
||||||
background: {c['bg_tertiary']};
|
background: {c['bg_tertiary']};
|
||||||
color: {c['text_secondary']};
|
color: {c['text_secondary']};
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border: none;
|
border: none;
|
||||||
border-bottom: 2px solid {c['accent_primary']};
|
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 9pt;
|
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 {{
|
QTabWidget::pane {{
|
||||||
border: 1px solid {c['border_subtle']};
|
border: 1px solid {c['border_subtle']};
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
background: {c['bg_secondary']};
|
background: {c['bg_secondary']};
|
||||||
top: -1px;
|
top: -1px;
|
||||||
}}
|
}}
|
||||||
@ -694,8 +764,8 @@ QTabBar::tab {{
|
|||||||
padding: 8px 20px;
|
padding: 8px 20px;
|
||||||
border: 1px solid {c['border_subtle']};
|
border: 1px solid {c['border_subtle']};
|
||||||
border-bottom: none;
|
border-bottom: none;
|
||||||
border-top-left-radius: 6px;
|
border-top-left-radius: 8px;
|
||||||
border-top-right-radius: 6px;
|
border-top-right-radius: 8px;
|
||||||
margin-right: 2px;
|
margin-right: 2px;
|
||||||
font-size: 10pt;
|
font-size: 10pt;
|
||||||
}}
|
}}
|
||||||
@ -787,7 +857,7 @@ QScrollArea > QWidget > QWidget#scroll_content {{
|
|||||||
QCalendarWidget {{
|
QCalendarWidget {{
|
||||||
background: {c['bg_secondary']};
|
background: {c['bg_secondary']};
|
||||||
border: 1px solid {c['border_subtle']};
|
border: 1px solid {c['border_subtle']};
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
font-size: 10pt;
|
font-size: 10pt;
|
||||||
}}
|
}}
|
||||||
|
|
||||||
@ -902,7 +972,7 @@ QToolTip {{
|
|||||||
QMenu {{
|
QMenu {{
|
||||||
background: {c['bg_secondary']};
|
background: {c['bg_secondary']};
|
||||||
border: 1px solid {c['border_default']};
|
border: 1px solid {c['border_default']};
|
||||||
border-radius: 6px;
|
border-radius: 8px;
|
||||||
padding: 4px;
|
padding: 4px;
|
||||||
color: {c['text_primary']};
|
color: {c['text_primary']};
|
||||||
}}
|
}}
|
||||||
@ -916,6 +986,16 @@ QMenu::item:selected {{
|
|||||||
background: {c['accent_primary']};
|
background: {c['accent_primary']};
|
||||||
color: {c['text_inverse']};
|
color: {c['text_inverse']};
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
QMenu::separator {{
|
||||||
|
height: 1px;
|
||||||
|
background: {c['border_subtle']};
|
||||||
|
margin: 4px 8px;
|
||||||
|
}}
|
||||||
|
|
||||||
|
QMenu::icon {{
|
||||||
|
padding-left: 8px;
|
||||||
|
}}
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -16,12 +16,12 @@ class TodaySummaryCard(QFrame):
|
|||||||
self.setObjectName("today_summary_card")
|
self.setObjectName("today_summary_card")
|
||||||
self.setStyleSheet("""
|
self.setStyleSheet("""
|
||||||
QFrame#today_summary_card {
|
QFrame#today_summary_card {
|
||||||
background-color: rgba(76, 175, 80, 0.08);
|
background-color: rgba(81, 207, 102, 0.08);
|
||||||
border: 1px solid rgba(76, 175, 80, 0.4);
|
border: 1px solid rgba(81, 207, 102, 0.40);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
}
|
}
|
||||||
QLabel { padding: 1px; }
|
QLabel { padding: 1px; background: transparent; border: none; }
|
||||||
""")
|
""")
|
||||||
self.setVisible(False)
|
self.setVisible(False)
|
||||||
|
|
||||||
@ -30,7 +30,7 @@ class TodaySummaryCard(QFrame):
|
|||||||
layout.setSpacing(2)
|
layout.setSpacing(2)
|
||||||
|
|
||||||
header = QHBoxLayout()
|
header = QHBoxLayout()
|
||||||
title = QLabel("📋 오늘의 요약")
|
title = QLabel("오늘의 요약")
|
||||||
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||||
header.addWidget(title)
|
header.addWidget(title)
|
||||||
header.addStretch()
|
header.addStretch()
|
||||||
@ -43,9 +43,9 @@ class TodaySummaryCard(QFrame):
|
|||||||
|
|
||||||
self.total_label = QLabel("")
|
self.total_label = QLabel("")
|
||||||
self.detail_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 = 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.total_label)
|
||||||
layout.addWidget(self.detail_label)
|
layout.addWidget(self.detail_label)
|
||||||
@ -70,7 +70,7 @@ class TodaySummaryCard(QFrame):
|
|||||||
"""
|
"""
|
||||||
h = int(total_hours)
|
h = int(total_hours)
|
||||||
m = int((total_hours - h) * 60)
|
m = int((total_hours - h) * 60)
|
||||||
self.total_label.setText(f"⏱ 총 근무: {h}시간 {m}분")
|
self.total_label.setText(f"총 근무: {h}시간 {m}분")
|
||||||
|
|
||||||
details = []
|
details = []
|
||||||
if lunch_minutes > 0:
|
if lunch_minutes > 0:
|
||||||
@ -85,7 +85,7 @@ class TodaySummaryCard(QFrame):
|
|||||||
self.detail_label.setVisible(bool(details))
|
self.detail_label.setVisible(bool(details))
|
||||||
|
|
||||||
if salary_text:
|
if salary_text:
|
||||||
self.salary_label.setText(f"💰 {salary_text}")
|
self.salary_label.setText(f"{salary_text}")
|
||||||
self.salary_label.setVisible(True)
|
self.salary_label.setVisible(True)
|
||||||
else:
|
else:
|
||||||
self.salary_label.setVisible(False)
|
self.salary_label.setVisible(False)
|
||||||
|
|||||||
84
utils/font_loader.py
Normal file
84
utils/font_loader.py
Normal file
@ -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))
|
||||||
@ -57,62 +57,75 @@ class SystemTrayIcon(QSystemTrayIcon):
|
|||||||
return QIcon(pixmap)
|
return QIcon(pixmap)
|
||||||
|
|
||||||
def setup_menu(self):
|
def setup_menu(self):
|
||||||
"""트레이 메뉴 설정"""
|
"""트레이 메뉴 설정 — 라인 아이콘 + 앱 다크 톤."""
|
||||||
menu = QMenu()
|
menu = QMenu()
|
||||||
|
|
||||||
show_action = QAction(tr('tray.open'), self)
|
# (action, 라인 아이콘 이름) — 테마 전환 시 재틴팅용으로 보관
|
||||||
show_action.triggered.connect(self.show_window)
|
self._icon_actions = []
|
||||||
menu.addAction(show_action)
|
|
||||||
|
|
||||||
mini_action = QAction(tr('tray.mini_widget'), self)
|
def add(text, slot, icon_name=None):
|
||||||
mini_action.triggered.connect(self._open_mini_widget)
|
action = QAction(text, self)
|
||||||
menu.addAction(mini_action)
|
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()
|
menu.addSeparator()
|
||||||
|
|
||||||
lunch_action = QAction(tr('tray.toggle_lunch'), self)
|
add(tr('tray.toggle_lunch'), self._toggle_lunch, 'coffee')
|
||||||
lunch_action.triggered.connect(self._toggle_lunch)
|
add(tr('btn.break_out'), self._break_out)
|
||||||
menu.addAction(lunch_action)
|
add(tr('btn.break_in'), self._break_in)
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
|
|
||||||
clock_out_action = QAction("✅ " + tr('btn.clock_out'), self)
|
add(tr('btn.clock_out'), self.quick_clock_out, 'logout')
|
||||||
clock_out_action.triggered.connect(self.quick_clock_out)
|
|
||||||
menu.addAction(clock_out_action)
|
|
||||||
|
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
|
|
||||||
stats_action = QAction("📊 " + tr('menu.stats'), self)
|
add(tr('menu.stats'), lambda: self._call_parent('show_stats'), 'chart')
|
||||||
stats_action.triggered.connect(lambda: self._call_parent('show_stats'))
|
add(tr('menu.calendar'), lambda: self._call_parent('show_calendar'), 'calendar')
|
||||||
menu.addAction(stats_action)
|
add('스케줄', lambda: self._call_parent('show_schedule'), 'repeat')
|
||||||
|
add(tr('menu.help'), lambda: self._call_parent('show_help'), 'help')
|
||||||
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)
|
|
||||||
|
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
|
|
||||||
quit_action = QAction(tr('tray.quit'), self)
|
add(tr('tray.quit'), self.quit_app)
|
||||||
quit_action.triggered.connect(self.quit_app)
|
|
||||||
menu.addAction(quit_action)
|
|
||||||
|
|
||||||
self.setContextMenu(menu)
|
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):
|
def _call_parent(self, method_name: str):
|
||||||
if self.parent_window and hasattr(self.parent_window, method_name):
|
if self.parent_window and hasattr(self.parent_window, method_name):
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user