Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
130c61ea62 | ||
|
|
5fb8655a47 | ||
|
|
da5f91984b | ||
|
|
3db4ed2351 | ||
|
|
97dd4e39f7 |
@@ -4,6 +4,86 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [2.11.0] — 2026-06-04
|
||||
|
||||
### Changed — UI 전면 다크 리디자인
|
||||
- 모던 다크 미니멀 테마(Notion/Linear 톤): 배경 `#1A1B1E` / 카드 `#25262B` / 보더 `#2C2E33`,
|
||||
단일 포인트 컬러 `#4DABF7`(주요 버튼·포커스 전용), 텍스트 `#E9ECEF`/`#909296`
|
||||
- **다크가 기본 테마** (신규 설치 기준; 기존 사용자가 고른 설정은 보존)
|
||||
- 번들 폰트 **NanumSquare** (`font/`, `utils/font_loader.py`) — OS 미설치 시 Malgun Gothic 폴백,
|
||||
`main.spec`에 동봉
|
||||
- 통일 여백(외곽 24 / 위젯 12 / 카드 16), border-radius 8px, 버튼 그라데이션·베벨 제거(flat),
|
||||
입력 포커스 시 보더 컬러만 accent, 진행률 바 6px
|
||||
- 남은시간 히어로 영역(출근/현재 한 줄 + 예상 퇴근시각 통합), 퇴근 가능 시 그린(`#51CF66`) 피드백
|
||||
|
||||
### Added
|
||||
- **라인 아이콘 시스템** (`ui/icons.py`, QtSvg) — 이모지 대신 테마 틴팅 모노크롬 라인 아이콘.
|
||||
하단 네비 / 통계 카드 / 트레이·미니위젯 메뉴 등 전반 적용 (`main.spec`에 `PyQt5.QtSvg` 포함)
|
||||
- **연장근무 적립 기록 삭제** — 연장근무 관리의 적립 내역 우클릭 → 삭제
|
||||
(`Database.delete_overtime_earned`)
|
||||
|
||||
### Fixed
|
||||
- **자동 적립(auto_overtime) OFF가 자동 퇴근 경로에서 무시되던 버그** — 근무일 경계 롤오버 /
|
||||
이전일 자동 퇴근 처리도 설정을 존중하도록 게이팅 (`_apply_auto_overtime_gate`).
|
||||
(`clock_out` 대화상자 '아니오' 경로는 정상이었음)
|
||||
- 다크 테마 깨짐: 테이블 세로 헤더·코너 버튼 흰색 누수, 도움말 탭 상단 흰 라인(documentMode),
|
||||
트레이/미니위젯 우클릭 메뉴 미적용(검정 글씨) 수정
|
||||
- 앱 전반 UI 크롬 이모지 제거 + 색상 팔레트 정합 (일일보고/Discord 텍스트는 유지)
|
||||
|
||||
### Tests
|
||||
- `tests/test_overtime_accrual_guard.py` 추가 — 적립 가드 2건(OFF=미적립 / ON=적립) + 적립 삭제 1건
|
||||
|
||||
## [2.10.2] — 2026-05-16
|
||||
|
||||
### Fixed
|
||||
- **휴일/주말 근무 시 카운터 초가 항상 `00`** 으로 멈춰 보이던 문제 (사용자 보고)
|
||||
- 원인: 휴일 분기에서 `calculate_holiday_overtime`의 분 절삭값(적립 단위)을
|
||||
그대로 표시에 사용 → 초 정보 소실
|
||||
- 수정: 표시용 `remaining`을 초 정밀도 timedelta로 분리 계산
|
||||
(적립 계산은 퇴근 시 분 단위 그대로 — 영향 없음)
|
||||
- 차감 항목(점심·저녁·외출·연장 사용)은 `calculate_holiday_overtime`과 동일하게 적용
|
||||
|
||||
## [2.10.1] — 2026-05-01
|
||||
|
||||
### Fixed — 업데이트 시 cmd 창 깜빡임 제거
|
||||
- **`updater.spec`**: `console=True` → `console=False` (windowed 빌드).
|
||||
자동 업데이트 적용 시 잠깐 뜨던 까만 cmd 창이 더 이상 보이지 않음.
|
||||
- **`updater.py`**: stderr 출력을 `~/.clockout_logs/updater.log` 파일 폴백으로 전환
|
||||
— windowed 모드라도 진단 로그는 보존. 모든 단계(시작/PID 대기/replace/launch)
|
||||
에 타임스탬프 + 결과 기록.
|
||||
- **`updater.py launch()`**: `subprocess.Popen` 에 `CREATE_NO_WINDOW` 플래그 추가
|
||||
(DETACHED_PROCESS와 함께) — 자식 프로세스가 콘솔을 새로 만들지 않음.
|
||||
- **`utils/updater_client.py apply_update()`**: 같은 패턴으로 `CREATE_NO_WINDOW` 추가.
|
||||
main.exe → updater.exe 호출 시점에서도 콘솔 생성 차단.
|
||||
|
||||
## [2.10.0] — 2026-05-01
|
||||
|
||||
### Added — 정부 공휴일 API 자동 동기화
|
||||
- **공공데이터포털 특일정보 API 연동** (`utils/holiday_api.py`)
|
||||
- 한국천문연구원 운영 공식 데이터 — `/getRestDeInfo` 엔드포인트
|
||||
- 임시공휴일·근로자의 날까지 정부 공인 데이터로 보강
|
||||
- 일일 한도 10,000회 / 사용자 50명 = 0.5% 사용
|
||||
- 키는 dev 본인 계정의 특일정보 API 한정 키
|
||||
- **`Database.add_korean_holidays_from_api(year)`** — 정부 API 1차 시도
|
||||
- **`add_korean_holidays_auto()` 동작 변경** — 1차 정부 API → 2차 fallback `holidays` 패키지
|
||||
- **`migrate_v290_holidays_auto_sync`** — 일 1회 자동 동기화 (백그라운드 스레드)
|
||||
- sentinel: `settings['holidays_synced_date']`
|
||||
- 매일 호출 → 정부가 임시공휴일 발표하면 다음 날 자동 반영
|
||||
- 부트스트랩 비차단 (네트워크 호출은 daemon thread)
|
||||
- 테스트 환경: `CLOCKOUT_DISABLE_HOLIDAY_SYNC=1` 로 비활성화
|
||||
|
||||
### Changed
|
||||
- 설정 → "한국 공휴일 자동 추가" 버튼 안내문 — 1차 정부 API / 2차 holidays 패키지
|
||||
|
||||
### Tests
|
||||
- `tests/test_holiday_api.py` 14개 신규 (응답 파싱 / 단일/다중 item / 401·timeout / 응답 검증)
|
||||
- `tests/conftest.py` — 모든 테스트에서 백그라운드 동기화 비활성화
|
||||
- pytest: 175 → **189**
|
||||
|
||||
### 주의
|
||||
- 키 활용기간 시작 직후엔 백엔드 propagation으로 401 가능 (1~2시간 또는 익일 활성화).
|
||||
401 시 fallback (holidays 패키지 + 근로자의 날 명시 추가) 정상 동작 — 사용자 영향 없음.
|
||||
|
||||
## [2.9.0] — 2026-05-01
|
||||
|
||||
### Fixed — 휴일 hot-path 버그 (사용자 보고)
|
||||
|
||||
@@ -15,6 +15,11 @@ from pathlib import Path
|
||||
|
||||
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 = []
|
||||
FAIL = []
|
||||
WARN = []
|
||||
|
||||
+95
-10
@@ -89,10 +89,54 @@ class Database:
|
||||
self.migrate_v271_work_records_indexes()
|
||||
self.migrate_v280_achievements_columns()
|
||||
self.migrate_v280_hire_date()
|
||||
self.migrate_v290_holidays_auto_sync()
|
||||
|
||||
# 기본 설정 초기화
|
||||
self.init_default_settings()
|
||||
|
||||
def migrate_v290_holidays_auto_sync(self) -> None:
|
||||
"""일 1회 한국 공휴일 자동 동기화 (백그라운드).
|
||||
|
||||
Sentinel: settings['holidays_synced_date'] = 'YYYY-MM-DD' (오늘 날짜).
|
||||
값이 오늘과 같으면 스킵 — 즉 같은 날 여러 번 켜도 호출 1회.
|
||||
|
||||
매일 호출하므로 정부가 임시공휴일 발표하면 다음 날 자동 반영.
|
||||
일일 한도 10000회, 사용자 50명 × 1회 = 0.5% 소비.
|
||||
|
||||
실제 동기화는 백그라운드 스레드에서 — 부트스트랩이 네트워크에 묶이지 않음.
|
||||
실패는 silent, 다음 실행 시 재시도.
|
||||
|
||||
테스트 환경에서는 CLOCKOUT_DISABLE_HOLIDAY_SYNC=1 로 비활성화.
|
||||
"""
|
||||
import os
|
||||
if os.environ.get('CLOCKOUT_DISABLE_HOLIDAY_SYNC'):
|
||||
return
|
||||
from datetime import datetime as _dt
|
||||
import threading
|
||||
try:
|
||||
today = _dt.now().date().isoformat()
|
||||
sentinel = self.get_setting('holidays_synced_date', '')
|
||||
if sentinel == today:
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
|
||||
cur_year = _dt.now().year
|
||||
|
||||
def _worker():
|
||||
try:
|
||||
# 새 연결로 작업 (sqlite3 connection은 thread-affine)
|
||||
from core.database import Database
|
||||
db = Database(self.db_path)
|
||||
added = db.add_korean_holidays_auto(cur_year, include_next_year=True)
|
||||
if added >= 0:
|
||||
db.set_setting('holidays_synced_date', today)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
t = threading.Thread(target=_worker, daemon=True, name='holiday-sync')
|
||||
t.start()
|
||||
|
||||
def _create_tables(self, conn) -> None:
|
||||
"""init_database()에서 호출되는 CREATE TABLE 묶음. conn은 호출자가 관리."""
|
||||
cursor = conn.cursor()
|
||||
@@ -726,7 +770,7 @@ class Database:
|
||||
'dinner_duration_minutes': '60',
|
||||
'auto_lunch': 'false',
|
||||
'auto_overtime': 'true',
|
||||
'theme': 'light',
|
||||
'theme': 'dark',
|
||||
'notification_before_minutes': '30',
|
||||
'notification_clock_out': 'true',
|
||||
'notification_lunch': 'true',
|
||||
@@ -933,6 +977,19 @@ class Database:
|
||||
''', (work_record_id, earned_minutes, date))
|
||||
conn.commit()
|
||||
|
||||
def delete_overtime_earned(self, bank_id: int) -> bool:
|
||||
"""연장근무 적립(은행) 기록 1건 삭제. 삭제분만큼 잔액이 즉시 감소.
|
||||
|
||||
Returns:
|
||||
bool: 실제로 삭제된 행이 있으면 True.
|
||||
"""
|
||||
with self._conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('DELETE FROM overtime_bank WHERE id = ?', (bank_id,))
|
||||
deleted = cursor.rowcount
|
||||
conn.commit()
|
||||
return deleted > 0
|
||||
|
||||
def add_overtime_usage(self, work_record_id: int, used_minutes: int,
|
||||
date: str, reason: str = None):
|
||||
"""연장근무 사용 (음수 잔액 허용 - 선사용 후적립 가능)"""
|
||||
@@ -1713,6 +1770,32 @@ class Database:
|
||||
for date, name in fixed_holidays:
|
||||
self.add_holiday(date, name, is_recurring=True)
|
||||
|
||||
def add_korean_holidays_from_api(self, year: int) -> int:
|
||||
"""공공데이터포털 특일정보 API로 한국 공휴일 등록 (정부 공인).
|
||||
|
||||
임시공휴일 + 근로자의 날 등 holidays 패키지가 놓치는 항목까지 포함.
|
||||
네트워크 실패 시 -1 반환 → 호출자 fallback.
|
||||
|
||||
Returns:
|
||||
추가된 공휴일 개수 (기존 등록과 중복은 제외). 실패 시 -1.
|
||||
"""
|
||||
try:
|
||||
from utils.holiday_api import fetch_korean_holidays
|
||||
except ImportError:
|
||||
return -1
|
||||
items = fetch_korean_holidays(year)
|
||||
if items is None:
|
||||
return -1
|
||||
added = 0
|
||||
for it in items:
|
||||
if not it.get('is_holiday'):
|
||||
continue
|
||||
date_str = it['date']
|
||||
if not self.is_holiday(date_str):
|
||||
self.add_holiday(date_str, it['name'], is_recurring=False)
|
||||
added += 1
|
||||
return added
|
||||
|
||||
def add_korean_holidays_auto(self, year: int, include_next_year: bool = False) -> int:
|
||||
"""`holidays` 패키지로 음력 명절 포함 한국 공휴일 자동 등록.
|
||||
|
||||
@@ -1738,30 +1821,32 @@ class Database:
|
||||
Returns:
|
||||
추가된 공휴일 개수. 패키지 미설치 시 -1.
|
||||
"""
|
||||
try:
|
||||
import holidays as _holidays
|
||||
except ImportError:
|
||||
return -1
|
||||
|
||||
years_to_add = [year]
|
||||
if include_next_year:
|
||||
years_to_add.append(year + 1)
|
||||
|
||||
added = 0
|
||||
for y in years_to_add:
|
||||
# 1차: 정부 API (임시공휴일 포함, 가장 정확)
|
||||
api_count = self.add_korean_holidays_from_api(y)
|
||||
if api_count >= 0:
|
||||
added += api_count
|
||||
# API가 응답했으면 근로자의 날도 포함되어 있음. 끝.
|
||||
continue
|
||||
|
||||
# 2차 fallback: holidays 패키지
|
||||
try:
|
||||
import holidays as _holidays
|
||||
kr = _holidays.country_holidays('KR', years=y)
|
||||
except Exception:
|
||||
continue # 패키지 내부 오류는 해당 연도만 스킵
|
||||
continue # 둘 다 실패면 해당 연도만 스킵
|
||||
for d, name in kr.items():
|
||||
date_str = d.isoformat()
|
||||
if not self.is_holiday(date_str):
|
||||
self.add_holiday(date_str, name, is_recurring=False)
|
||||
added += 1
|
||||
|
||||
# holidays.KR이 누락하는 한국 노동자 휴일 보강.
|
||||
# 근로자의 날(5/1)은 공식 '공휴일'은 아니지만 대부분 회사가 휴무.
|
||||
# 패키지 버전마다 포함 여부가 달라서 명시적 추가.
|
||||
# holidays.KR이 누락하는 근로자의 날 명시적 보강
|
||||
extra = [(f"{y}-05-01", "근로자의 날")]
|
||||
for date_str, name in extra:
|
||||
if not self.is_holiday(date_str):
|
||||
|
||||
+50
-48
@@ -21,14 +21,14 @@ _DICT = {
|
||||
'menu.help': '도움말',
|
||||
'menu.settings': '설정',
|
||||
'btn.clock_out': '퇴근하기',
|
||||
'btn.clock_out_cancel': '🔄 퇴근 취소',
|
||||
'btn.clock_out_cancel': '퇴근 취소',
|
||||
'btn.lunch_add': '점심시간 추가',
|
||||
'btn.lunch_applied': '점심시간 (적용됨)',
|
||||
'btn.dinner_add': '저녁시간 추가',
|
||||
'btn.dinner_applied': '저녁시간 (적용됨)',
|
||||
'btn.break_out': '🚪 외출 시작',
|
||||
'btn.break_in': '↩️ 복귀',
|
||||
'btn.save': '💾 저장',
|
||||
'btn.break_out': '외출 시작',
|
||||
'btn.break_in': '복귀',
|
||||
'btn.save': '저장',
|
||||
'btn.close': '닫기',
|
||||
'btn.apply': '적용',
|
||||
'btn.cancel': '취소',
|
||||
@@ -40,10 +40,10 @@ _DICT = {
|
||||
|
||||
# === 윈도우/다이얼로그 제목 ===
|
||||
'window.main_title': '퇴근시간 계산기',
|
||||
'window.settings': '⚙️ 설정',
|
||||
'window.help': '📖 사용 설명서',
|
||||
'window.stats': '📊 근무 통계',
|
||||
'window.calendar': '📅 캘린더',
|
||||
'window.settings': '설정',
|
||||
'window.help': '사용 설명서',
|
||||
'window.stats': '근무 통계',
|
||||
'window.calendar': '캘린더',
|
||||
'window.mini_widget': '퇴근시간',
|
||||
'window.clock_in_dialog': '출근 시간',
|
||||
'window.break_view': '외출 관리',
|
||||
@@ -125,8 +125,8 @@ _DICT = {
|
||||
|
||||
# === 트레이 ===
|
||||
'tray.open': '프로그램 열기',
|
||||
'tray.mini_widget': '📌 미니 위젯',
|
||||
'tray.toggle_lunch': '🍱 점심시간 토글',
|
||||
'tray.mini_widget': '미니 위젯',
|
||||
'tray.toggle_lunch': '점심시간 토글',
|
||||
'tray.quit': '종료',
|
||||
'tray.tooltip_remaining': '퇴근까지: {time}',
|
||||
'tray.tooltip_overtime': '추가 근무 중: {time}',
|
||||
@@ -166,12 +166,12 @@ _DICT = {
|
||||
'cal.edit_record': '기록 편집',
|
||||
|
||||
# === HelpView (각 탭의 큰 HTML은 별도 키) ===
|
||||
'help.tab_intro': '👋 시작하기',
|
||||
'help.tab_work_hours': '🕘 근무시간',
|
||||
'help.tab_overtime': '🏦 연장근무',
|
||||
'help.tab_leave': '🌴 연차/휴가',
|
||||
'help.tab_break': '🚪 외출/저녁',
|
||||
'help.tab_faq': '❓ 자주 묻는 질문',
|
||||
'help.tab_intro': '시작하기',
|
||||
'help.tab_work_hours': '근무시간',
|
||||
'help.tab_overtime': '연장근무',
|
||||
'help.tab_leave': '연차/휴가',
|
||||
'help.tab_break': '외출/저녁',
|
||||
'help.tab_faq': '자주 묻는 질문',
|
||||
|
||||
# === clock_in_dialog ===
|
||||
'dlg.clock_in.prompt': '오늘의 출근시간을 입력해주세요',
|
||||
@@ -204,17 +204,18 @@ _DICT = {
|
||||
'view.overtime.title': '연장근무 내역',
|
||||
'view.overtime.balance_zero': '잔액: 0분',
|
||||
'view.overtime.balance_fmt': '현재 잔액: {h}시간 {m}분 ({total}분)',
|
||||
'view.overtime.earned_group': '💰 적립 내역',
|
||||
'view.overtime.used_group': '📤 사용 내역',
|
||||
'view.overtime.earned_group': '적립 내역',
|
||||
'view.overtime.used_group': '사용 내역',
|
||||
'view.overtime.col_date': '날짜',
|
||||
'view.overtime.col_earned': '적립',
|
||||
'view.overtime.col_used': '사용',
|
||||
'view.overtime.col_memo': '메모',
|
||||
'view.overtime.col_reason': '사유',
|
||||
'view.overtime.btn_add_earned': '➕ 수동 적립',
|
||||
'view.overtime.btn_add_used': '➕ 수동 사용',
|
||||
'view.overtime.menu_delete': '❌ 삭제',
|
||||
'view.overtime.btn_add_earned': '수동 적립',
|
||||
'view.overtime.btn_add_used': '수동 사용',
|
||||
'view.overtime.menu_delete': '삭제',
|
||||
'view.overtime.delete_confirm_body': '다음 사용 기록을 삭제하시겠습니까?\n\n날짜: {date}\n시간: {time}\n사유: {reason}',
|
||||
'view.overtime.delete_earned_confirm_body': '다음 적립 기록을 삭제하시겠습니까?\n\n날짜: {date}\n적립: {time}\n\n삭제 시 잔액에서 차감됩니다.',
|
||||
'view.overtime.manual_earned_title': '추가근무 수동 적립',
|
||||
'view.overtime.manual_used_title': '추가근무 수동 사용',
|
||||
'view.overtime.field_date': '날짜:',
|
||||
@@ -238,13 +239,13 @@ _DICT = {
|
||||
'view.leave.balance_zero': '잔여: 0일',
|
||||
'view.leave.balance_fmt': '잔여: {days}일 (총 {hours}시간)',
|
||||
'view.leave.btn_set_balance': '잔여 설정',
|
||||
'view.leave.used_group': '📤 사용 내역',
|
||||
'view.leave.used_group': '사용 내역',
|
||||
'view.leave.col_date': '날짜',
|
||||
'view.leave.col_type': '구분',
|
||||
'view.leave.col_used': '사용',
|
||||
'view.leave.col_reason': '사유',
|
||||
'view.leave.btn_add': '➕ 연차 사용 추가',
|
||||
'view.leave.btn_calendar': '📅 캘린더 보기',
|
||||
'view.leave.btn_add': '연차 사용 추가',
|
||||
'view.leave.btn_calendar': '캘린더 보기',
|
||||
'view.leave.delete_confirm_body': '다음 연차 사용 기록을 삭제하시겠습니까?\n\n날짜: {date}\n구분: {type}\n사용: {days}',
|
||||
'view.leave.set_title': '연차 시간 설정',
|
||||
'view.leave.set_prompt': '연차 잔여 시간을 입력하세요 (0.5시간 단위):\n예) 8시간 = 1일, 4시간 = 0.5일(반차), 2시간 = 0.25일, 0.5시간 = 30분',
|
||||
@@ -278,14 +279,14 @@ _DICT = {
|
||||
'menu.help': 'Help',
|
||||
'menu.settings': 'Settings',
|
||||
'btn.clock_out': 'Clock Out',
|
||||
'btn.clock_out_cancel': '🔄 Cancel Clock-out',
|
||||
'btn.clock_out_cancel': 'Cancel Clock-out',
|
||||
'btn.lunch_add': 'Add Lunch',
|
||||
'btn.lunch_applied': 'Lunch (Applied)',
|
||||
'btn.dinner_add': 'Add Dinner',
|
||||
'btn.dinner_applied': 'Dinner (Applied)',
|
||||
'btn.break_out': '🚪 Start Break',
|
||||
'btn.break_in': '↩️ Return',
|
||||
'btn.save': '💾 Save',
|
||||
'btn.break_out': 'Start Break',
|
||||
'btn.break_in': 'Return',
|
||||
'btn.save': 'Save',
|
||||
'btn.close': 'Close',
|
||||
'btn.apply': 'Apply',
|
||||
'btn.cancel': 'Cancel',
|
||||
@@ -297,10 +298,10 @@ _DICT = {
|
||||
|
||||
# === Windows ===
|
||||
'window.main_title': 'Clock-out Time Calculator',
|
||||
'window.settings': '⚙️ Settings',
|
||||
'window.help': '📖 User Guide',
|
||||
'window.stats': '📊 Statistics',
|
||||
'window.calendar': '📅 Calendar',
|
||||
'window.settings': 'Settings',
|
||||
'window.help': 'User Guide',
|
||||
'window.stats': 'Statistics',
|
||||
'window.calendar': 'Calendar',
|
||||
'window.mini_widget': 'Clock-out',
|
||||
'window.clock_in_dialog': 'Clock-in Time',
|
||||
'window.break_view': 'Break Management',
|
||||
@@ -382,8 +383,8 @@ _DICT = {
|
||||
|
||||
# === Tray ===
|
||||
'tray.open': 'Open Program',
|
||||
'tray.mini_widget': '📌 Mini Widget',
|
||||
'tray.toggle_lunch': '🍱 Toggle Lunch',
|
||||
'tray.mini_widget': 'Mini Widget',
|
||||
'tray.toggle_lunch': 'Toggle Lunch',
|
||||
'tray.quit': 'Quit',
|
||||
'tray.tooltip_remaining': 'Until clock-out: {time}',
|
||||
'tray.tooltip_overtime': 'Overtime: {time}',
|
||||
@@ -423,12 +424,12 @@ _DICT = {
|
||||
'cal.edit_record': 'Edit record',
|
||||
|
||||
# === HelpView ===
|
||||
'help.tab_intro': '👋 Getting Started',
|
||||
'help.tab_work_hours': '🕘 Work Hours',
|
||||
'help.tab_overtime': '🏦 Overtime',
|
||||
'help.tab_leave': '🌴 Leave',
|
||||
'help.tab_break': '🚪 Break/Dinner',
|
||||
'help.tab_faq': '❓ FAQ',
|
||||
'help.tab_intro': 'Getting Started',
|
||||
'help.tab_work_hours': 'Work Hours',
|
||||
'help.tab_overtime': 'Overtime',
|
||||
'help.tab_leave': 'Leave',
|
||||
'help.tab_break': 'Break/Dinner',
|
||||
'help.tab_faq': 'FAQ',
|
||||
|
||||
# === clock_in_dialog ===
|
||||
'dlg.clock_in.prompt': "Enter today's clock-in time",
|
||||
@@ -461,17 +462,18 @@ _DICT = {
|
||||
'view.overtime.title': 'Overtime History',
|
||||
'view.overtime.balance_zero': 'Balance: 0 min',
|
||||
'view.overtime.balance_fmt': 'Current balance: {h}h {m}m ({total} min)',
|
||||
'view.overtime.earned_group': '💰 Earned',
|
||||
'view.overtime.used_group': '📤 Used',
|
||||
'view.overtime.earned_group': 'Earned',
|
||||
'view.overtime.used_group': 'Used',
|
||||
'view.overtime.col_date': 'Date',
|
||||
'view.overtime.col_earned': 'Earned',
|
||||
'view.overtime.col_used': 'Used',
|
||||
'view.overtime.col_memo': 'Memo',
|
||||
'view.overtime.col_reason': 'Reason',
|
||||
'view.overtime.btn_add_earned': '➕ Manual Earn',
|
||||
'view.overtime.btn_add_used': '➕ Manual Use',
|
||||
'view.overtime.menu_delete': '❌ Delete',
|
||||
'view.overtime.btn_add_earned': 'Manual Earn',
|
||||
'view.overtime.btn_add_used': 'Manual Use',
|
||||
'view.overtime.menu_delete': 'Delete',
|
||||
'view.overtime.delete_confirm_body': 'Delete this usage record?\n\nDate: {date}\nTime: {time}\nReason: {reason}',
|
||||
'view.overtime.delete_earned_confirm_body': 'Delete this accrual record?\n\nDate: {date}\nEarned: {time}\n\nThe balance will be reduced accordingly.',
|
||||
'view.overtime.manual_earned_title': 'Manual Overtime Earn',
|
||||
'view.overtime.manual_used_title': 'Manual Overtime Use',
|
||||
'view.overtime.field_date': 'Date:',
|
||||
@@ -495,13 +497,13 @@ _DICT = {
|
||||
'view.leave.balance_zero': 'Balance: 0 days',
|
||||
'view.leave.balance_fmt': 'Balance: {days} days ({hours}h total)',
|
||||
'view.leave.btn_set_balance': 'Set Balance',
|
||||
'view.leave.used_group': '📤 Used',
|
||||
'view.leave.used_group': 'Used',
|
||||
'view.leave.col_date': 'Date',
|
||||
'view.leave.col_type': 'Type',
|
||||
'view.leave.col_used': 'Used',
|
||||
'view.leave.col_reason': 'Reason',
|
||||
'view.leave.btn_add': '➕ Add Leave Usage',
|
||||
'view.leave.btn_calendar': '📅 Calendar',
|
||||
'view.leave.btn_add': 'Add Leave Usage',
|
||||
'view.leave.btn_calendar': 'Calendar',
|
||||
'view.leave.delete_confirm_body': 'Delete this leave record?\n\nDate: {date}\nType: {type}\nUsed: {days}',
|
||||
'view.leave.set_title': 'Set Leave Hours',
|
||||
'view.leave.set_prompt': 'Enter leave hours remaining (0.5h step):\ne.g. 8h = 1d, 4h = 0.5d (half), 2h = 0.25d, 0.5h = 30min',
|
||||
|
||||
+1
-1
@@ -4,4 +4,4 @@
|
||||
릴리스 시 이 값을 올린 후 git tag → push.
|
||||
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
||||
"""
|
||||
__version__ = '2.9.0'
|
||||
__version__ = '2.11.0'
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -96,8 +96,9 @@ def main():
|
||||
)
|
||||
return 1
|
||||
|
||||
# 폰트 설정
|
||||
app.setFont(QFont("Segoe UI", 9))
|
||||
# 폰트 설정 — 번들 NanumSquare 등록 + 전역 적용 (미설치 시 Malgun Gothic 폴백)
|
||||
from utils.font_loader import apply_app_font
|
||||
apply_app_font(app, 9)
|
||||
|
||||
# 필수 패키지 확인
|
||||
if not check_requirements():
|
||||
|
||||
@@ -14,15 +14,26 @@ if os.path.exists(_staged):
|
||||
elif os.path.exists(_fallback):
|
||||
_extra_datas.append((_fallback, '.'))
|
||||
|
||||
# 번들 폰트 (NanumSquare) — utils/font_loader.py 가 _MEIPASS/font/ 에서 로드
|
||||
_font_files = [
|
||||
'NanumSquareL.ttf', 'NanumSquareR.ttf', 'NanumSquareB.ttf', 'NanumSquareEB.ttf',
|
||||
'NanumSquare_acR.ttf', 'NanumSquare_acB.ttf',
|
||||
]
|
||||
_font_datas = [
|
||||
(os.path.join('font', f), 'font')
|
||||
for f in _font_files if os.path.exists(os.path.join('font', f))
|
||||
]
|
||||
|
||||
a = Analysis(
|
||||
['main.py'],
|
||||
pathex=[],
|
||||
binaries=[],
|
||||
datas=[('3d-alarm.png', '.')] + _extra_datas,
|
||||
datas=[('3d-alarm.png', '.')] + _extra_datas + _font_datas,
|
||||
hiddenimports=[
|
||||
'holidays', 'holidays.countries.south_korea',
|
||||
'win32evtlog', 'win32evtlogutil',
|
||||
'matplotlib.backends.backend_qt5agg',
|
||||
'PyQt5.QtSvg',
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
pytest 공통 설정.
|
||||
|
||||
모든 테스트는 백그라운드 휴일 동기화를 끔 — Database 생성 시 spawn되는
|
||||
holiday-sync 스레드가 DB 파일을 lock해서 다음 테스트의 fixture cleanup이 깨짐.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1'
|
||||
@@ -0,0 +1,166 @@
|
||||
"""
|
||||
utils.holiday_api 단위 테스트.
|
||||
|
||||
실제 정부 API는 호출하지 않음 — 모두 urlopen mock.
|
||||
"""
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from utils.holiday_api import (
|
||||
fetch_korean_holidays, _parse_response, is_configured,
|
||||
)
|
||||
|
||||
|
||||
def _ok_response(items):
|
||||
"""API 정상 응답 형식 빌드."""
|
||||
return {
|
||||
'response': {
|
||||
'header': {'resultCode': '00', 'resultMsg': 'NORMAL SERVICE.'},
|
||||
'body': {
|
||||
'items': {'item': items} if items else {'item': []},
|
||||
'numOfRows': 100,
|
||||
'pageNo': 1,
|
||||
'totalCount': len(items) if isinstance(items, list) else 1,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
class TestParseResponse:
|
||||
def test_multiple_items(self):
|
||||
items = [
|
||||
{'dateKind': '01', 'dateName': '근로자의 날', 'isHoliday': 'Y',
|
||||
'locdate': 20260501, 'seq': 1},
|
||||
{'dateKind': '01', 'dateName': '어린이날', 'isHoliday': 'Y',
|
||||
'locdate': 20260505, 'seq': 1},
|
||||
]
|
||||
out = _parse_response(_ok_response(items))
|
||||
assert len(out) == 2
|
||||
assert out[0]['date'] == '2026-05-01'
|
||||
assert out[0]['name'] == '근로자의 날'
|
||||
assert out[0]['is_holiday'] is True
|
||||
assert out[1]['date'] == '2026-05-05'
|
||||
|
||||
def test_single_item_as_dict(self):
|
||||
# API가 결과 1개일 때 list가 아닌 dict로 반환하는 케이스
|
||||
item = {'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501}
|
||||
data = {
|
||||
'response': {
|
||||
'header': {'resultCode': '00'},
|
||||
'body': {'items': {'item': item}, 'totalCount': 1},
|
||||
}
|
||||
}
|
||||
out = _parse_response(data)
|
||||
assert len(out) == 1
|
||||
assert out[0]['name'] == '근로자의 날'
|
||||
|
||||
def test_empty_year(self):
|
||||
# totalCount=0 같은 정상 빈 응답
|
||||
data = {
|
||||
'response': {
|
||||
'header': {'resultCode': '00'},
|
||||
'body': {'items': '', 'totalCount': 0},
|
||||
}
|
||||
}
|
||||
assert _parse_response(data) == []
|
||||
|
||||
def test_error_result_code(self):
|
||||
data = {
|
||||
'response': {
|
||||
'header': {'resultCode': '30', 'resultMsg': 'SERVICE_KEY_IS_NOT_REGISTERED'},
|
||||
'body': {},
|
||||
}
|
||||
}
|
||||
assert _parse_response(data) is None
|
||||
|
||||
def test_isholiday_n_filtered_at_caller_level(self):
|
||||
# 응답 자체엔 is_holiday=False도 포함됨 (예: 24절기). _parse는 그대로 반환,
|
||||
# 실제 휴일 등록은 호출자가 is_holiday=True만 필터.
|
||||
items = [{'dateName': '동지', 'isHoliday': 'N', 'locdate': 20261221}]
|
||||
out = _parse_response(_ok_response(items))
|
||||
assert len(out) == 1
|
||||
assert out[0]['is_holiday'] is False
|
||||
|
||||
def test_locdate_str_form(self):
|
||||
items = [{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': '20260501'}]
|
||||
out = _parse_response(_ok_response(items))
|
||||
assert out[0]['date'] == '2026-05-01'
|
||||
|
||||
def test_invalid_locdate_skipped(self):
|
||||
items = [
|
||||
{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501},
|
||||
{'dateName': '잘못된 날짜', 'isHoliday': 'Y', 'locdate': 'abc'},
|
||||
{'dateName': '짧은 날짜', 'isHoliday': 'Y', 'locdate': '202605'},
|
||||
]
|
||||
out = _parse_response(_ok_response(items))
|
||||
assert len(out) == 1 # 정상 1개만
|
||||
assert out[0]['name'] == '근로자의 날'
|
||||
|
||||
def test_missing_required_fields_skipped(self):
|
||||
items = [
|
||||
{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501},
|
||||
{'isHoliday': 'Y', 'locdate': 20260505}, # name 없음
|
||||
{'dateName': '신정', 'isHoliday': 'Y'}, # locdate 없음
|
||||
]
|
||||
out = _parse_response(_ok_response(items))
|
||||
assert len(out) == 1
|
||||
|
||||
def test_malformed_response_returns_none(self):
|
||||
# response 구조 자체가 깨진 경우
|
||||
assert _parse_response({'random': 'data'}) is None or _parse_response({'random': 'data'}) == []
|
||||
# 위는 implementation-dependent — 둘 다 합리적
|
||||
# 정확히는: response 키 없음 → response={}, header={}, resultCode != '00' → None
|
||||
assert _parse_response({}) is None
|
||||
|
||||
|
||||
class TestFetchNetwork:
|
||||
@patch('utils.holiday_api.urllib.request.urlopen')
|
||||
def test_success(self, mock_urlopen):
|
||||
items = [{'dateName': '근로자의 날', 'isHoliday': 'Y', 'locdate': 20260501}]
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = json.dumps(_ok_response(items)).encode('utf-8')
|
||||
resp.__enter__.return_value = resp
|
||||
resp.__exit__.return_value = False
|
||||
mock_urlopen.return_value = resp
|
||||
|
||||
out = fetch_korean_holidays(2026)
|
||||
assert out is not None
|
||||
assert len(out) == 1
|
||||
assert out[0]['date'] == '2026-05-01'
|
||||
|
||||
# 요청 URL에 serviceKey + solYear=2026 + _type=json 포함되었는지
|
||||
req = mock_urlopen.call_args[0][0]
|
||||
assert 'serviceKey=' in req.full_url
|
||||
assert 'solYear=2026' in req.full_url
|
||||
assert '_type=json' in req.full_url
|
||||
|
||||
@patch('utils.holiday_api.urllib.request.urlopen')
|
||||
def test_network_error_returns_none(self, mock_urlopen):
|
||||
import urllib.error
|
||||
mock_urlopen.side_effect = urllib.error.URLError('boom')
|
||||
assert fetch_korean_holidays(2026) is None
|
||||
|
||||
@patch('utils.holiday_api.urllib.request.urlopen')
|
||||
def test_timeout_returns_none(self, mock_urlopen):
|
||||
mock_urlopen.side_effect = TimeoutError('slow')
|
||||
assert fetch_korean_holidays(2026) is None
|
||||
|
||||
@patch('utils.holiday_api.urllib.request.urlopen')
|
||||
def test_invalid_json_returns_none(self, mock_urlopen):
|
||||
resp = MagicMock()
|
||||
resp.read.return_value = b'<html>error</html>'
|
||||
resp.__enter__.return_value = resp
|
||||
resp.__exit__.return_value = False
|
||||
mock_urlopen.return_value = resp
|
||||
assert fetch_korean_holidays(2026) is None
|
||||
|
||||
|
||||
class TestConfigured:
|
||||
def test_key_set(self):
|
||||
assert is_configured() is True
|
||||
@@ -35,7 +35,7 @@ def test_register_applies_initial_text(qapp, i18n):
|
||||
set_language('ko')
|
||||
label = QLabel()
|
||||
i18n.register(label, 'btn.save')
|
||||
assert label.text() == '💾 저장'
|
||||
assert label.text() == '저장'
|
||||
|
||||
|
||||
def test_retranslate_after_language_change(qapp, i18n):
|
||||
@@ -59,10 +59,10 @@ def test_setter_kwarg_for_window_title(qapp, i18n):
|
||||
set_language('ko')
|
||||
dlg = QDialog()
|
||||
i18n.register(dlg, 'window.settings', setter='setWindowTitle')
|
||||
assert dlg.windowTitle() == '⚙️ 설정'
|
||||
assert dlg.windowTitle() == '설정'
|
||||
|
||||
i18n.set_language_and_retranslate('en')
|
||||
assert dlg.windowTitle() == '⚙️ Settings'
|
||||
assert dlg.windowTitle() == 'Settings'
|
||||
|
||||
|
||||
def test_post_callback_applied(qapp, i18n):
|
||||
@@ -71,10 +71,10 @@ def test_post_callback_applied(qapp, i18n):
|
||||
set_language('ko')
|
||||
label = QLabel()
|
||||
i18n.register(label, 'btn.save', post=lambda t: f"[{t}]")
|
||||
assert label.text() == '[💾 저장]'
|
||||
assert label.text() == '[저장]'
|
||||
|
||||
i18n.set_language_and_retranslate('en')
|
||||
assert label.text() == '[💾 Save]'
|
||||
assert label.text() == '[Save]'
|
||||
|
||||
|
||||
def test_dead_widget_pruned(qapp, i18n):
|
||||
|
||||
@@ -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
|
||||
+12
-12
@@ -85,11 +85,11 @@ class AchievementsView(QDialog):
|
||||
def __init__(self, db, parent=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle("🏆 도전과제")
|
||||
self.setWindowTitle("도전과제")
|
||||
self.setMinimumSize(960, 720)
|
||||
self.resize(1100, 800)
|
||||
self._increment_view_count()
|
||||
self.setStyleSheet("QDialog { background: #0e0e14; }")
|
||||
self.setStyleSheet("QDialog { background: #1A1B1E; }")
|
||||
self.init_ui()
|
||||
apply_dark_titlebar(self, dark=True)
|
||||
|
||||
@@ -157,7 +157,7 @@ class AchievementsView(QDialog):
|
||||
QFrame {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #1a1a30, stop:1 #2a1a3a);
|
||||
border: 1px solid #3a3a5a;
|
||||
border: 1px solid #2C2E33;
|
||||
border-radius: 12px;
|
||||
}
|
||||
QLabel { background: transparent; border: none; color: #e8e8f4; }
|
||||
@@ -173,21 +173,21 @@ class AchievementsView(QDialog):
|
||||
num_row.setSpacing(24)
|
||||
|
||||
big = QLabel(f"<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)
|
||||
num_row.addWidget(big)
|
||||
|
||||
spacer = QFrame()
|
||||
spacer.setFrameShape(QFrame.VLine)
|
||||
spacer.setStyleSheet("color: #3a3a5a;")
|
||||
spacer.setStyleSheet("color: #2C2E33;")
|
||||
num_row.addWidget(spacer)
|
||||
|
||||
secret_lbl = QLabel(
|
||||
f"<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"{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>"
|
||||
)
|
||||
secret_lbl.setTextFormat(Qt.RichText)
|
||||
@@ -197,7 +197,7 @@ class AchievementsView(QDialog):
|
||||
|
||||
pct_lbl = QLabel(
|
||||
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"{pct:.1f}%</span></div>"
|
||||
)
|
||||
@@ -256,7 +256,7 @@ class AchievementsView(QDialog):
|
||||
empty = QLabel("(아직 없음)")
|
||||
empty.setAlignment(Qt.AlignCenter)
|
||||
empty.setStyleSheet(
|
||||
"color: #666; padding: 60px; font-size: 12pt; background: transparent;"
|
||||
"color: #6C6E73; padding: 60px; font-size: 12pt; background: transparent;"
|
||||
)
|
||||
grid.addWidget(empty, 0, 0)
|
||||
else:
|
||||
@@ -283,7 +283,7 @@ class AchievementsView(QDialog):
|
||||
if is_locked_secret:
|
||||
bg_top, bg_bot = '#1a1a26', '#0e0e16'
|
||||
border = '#3a3a4a'
|
||||
text_color = '#666'
|
||||
text_color = '#6C6E73'
|
||||
else:
|
||||
bg_top = theme['bg_top']
|
||||
bg_bot = theme['bg_bot']
|
||||
@@ -455,7 +455,7 @@ class AchievementsView(QDialog):
|
||||
def _tabs_qss(self) -> str:
|
||||
return """
|
||||
QTabWidget::pane {
|
||||
background: #14141c;
|
||||
background: #25262B;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 10px;
|
||||
top: -1px;
|
||||
@@ -472,7 +472,7 @@ class AchievementsView(QDialog):
|
||||
font-size: 10pt;
|
||||
}
|
||||
QTabBar::tab:selected {
|
||||
background: #14141c;
|
||||
background: #25262B;
|
||||
color: #ffd24a;
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid #ffd24a;
|
||||
|
||||
+19
-16
@@ -55,10 +55,11 @@ class CalendarView(QDialog):
|
||||
# 범례
|
||||
legend_layout = QHBoxLayout()
|
||||
legend_layout.setSpacing(12)
|
||||
legend_layout.addWidget(QLabel("🟢 정상"))
|
||||
legend_layout.addWidget(QLabel("🔴 연장"))
|
||||
legend_layout.addWidget(QLabel("🟡 휴가"))
|
||||
legend_layout.addWidget(QLabel("⚪ 없음"))
|
||||
for _color, _txt in [('#51CF66', '정상'), ('#FA5252', '연장'),
|
||||
('#FAB005', '휴가'), ('#6C6E73', '없음')]:
|
||||
_item = QLabel(f"<span style='color:{_color};'>●</span> {_txt}")
|
||||
_item.setTextFormat(Qt.RichText)
|
||||
legend_layout.addWidget(_item)
|
||||
legend_layout.addStretch()
|
||||
layout.addLayout(legend_layout)
|
||||
|
||||
@@ -77,13 +78,13 @@ class CalendarView(QDialog):
|
||||
button_layout = QHBoxLayout()
|
||||
button_layout.setSpacing(6)
|
||||
|
||||
self.edit_time_button = QPushButton("✏️ 시간 수정")
|
||||
self.edit_time_button = QPushButton("시간 수정")
|
||||
self.edit_time_button.setObjectName("btn_primary")
|
||||
self.edit_time_button.setEnabled(False)
|
||||
self.edit_time_button.clicked.connect(self.edit_work_time)
|
||||
button_layout.addWidget(self.edit_time_button)
|
||||
|
||||
self.delete_record_button = QPushButton("🗑️ 기록 삭제")
|
||||
self.delete_record_button = QPushButton("기록 삭제")
|
||||
self.delete_record_button.setObjectName("btn_danger")
|
||||
self.delete_record_button.setEnabled(False)
|
||||
self.delete_record_button.clicked.connect(self.delete_selected_record)
|
||||
@@ -104,7 +105,7 @@ class CalendarView(QDialog):
|
||||
self.memo_edit.setPlaceholderText("추가근무 사유, 특이사항 등...")
|
||||
memo_layout.addWidget(self.memo_edit)
|
||||
|
||||
self.save_memo_button = QPushButton("💾 메모 저장")
|
||||
self.save_memo_button = QPushButton("메모 저장")
|
||||
self.save_memo_button.setObjectName("btn_primary")
|
||||
self.save_memo_button.setEnabled(False)
|
||||
self.save_memo_button.clicked.connect(self.save_memo)
|
||||
@@ -163,21 +164,23 @@ class CalendarView(QDialog):
|
||||
existing = self.db.get_work_record(date_str)
|
||||
|
||||
menu = QMenu(self)
|
||||
edit_action = delete_action = add_action = None
|
||||
if existing:
|
||||
edit_action = menu.addAction(f"✏️ {date_str} 편집")
|
||||
delete_action = menu.addAction(f"🗑️ {date_str} 삭제")
|
||||
edit_action = menu.addAction(f"{date_str} 편집")
|
||||
delete_action = menu.addAction(f"{date_str} 삭제")
|
||||
else:
|
||||
add_action = menu.addAction(f"➕ {date_str} 기록 추가")
|
||||
add_action = menu.addAction(f"{date_str} 기록 추가")
|
||||
|
||||
action = menu.exec_(self.calendar.mapToGlobal(pos))
|
||||
if action is None:
|
||||
return
|
||||
|
||||
if existing and action.text().startswith("✏️"):
|
||||
# 텍스트 prefix 대신 액션 동일성으로 분기 (이모지 의존 제거)
|
||||
if action == edit_action:
|
||||
self._open_edit_dialog(date_str)
|
||||
elif existing and action.text().startswith("🗑️"):
|
||||
elif action == delete_action:
|
||||
self._delete_record(date_str)
|
||||
elif not existing and action.text().startswith("➕"):
|
||||
elif action == add_action:
|
||||
self._add_past_record(date_str)
|
||||
|
||||
def _add_past_record(self, date_str: str):
|
||||
@@ -267,7 +270,7 @@ class CalendarView(QDialog):
|
||||
|
||||
if record:
|
||||
# 상세 정보 표시
|
||||
detail = f"📅 {selected_date.strftime('%Y년 %m월 %d일')}\n\n"
|
||||
detail = f"{selected_date.strftime('%Y년 %m월 %d일')}\n\n"
|
||||
detail += f"출근: {record['clock_in']}\n"
|
||||
|
||||
if record.get('clock_out'):
|
||||
@@ -303,7 +306,7 @@ class CalendarView(QDialog):
|
||||
self.memo_edit.setPlainText(record.get('memo', ''))
|
||||
self.save_memo_button.setEnabled(True)
|
||||
else:
|
||||
self.detail_text.setText(f"📅 {selected_date.strftime('%Y년 %m월 %d일')}\n\n기록이 없습니다.")
|
||||
self.detail_text.setText(f"{selected_date.strftime('%Y년 %m월 %d일')}\n\n기록이 없습니다.")
|
||||
self.edit_time_button.setEnabled(False)
|
||||
self.delete_record_button.setEnabled(False)
|
||||
self.memo_edit.setPlainText('')
|
||||
@@ -406,7 +409,7 @@ class EditWorkTimeDialog(QDialog):
|
||||
layout.setContentsMargins(12, 10, 12, 10)
|
||||
|
||||
# 제목
|
||||
title = QLabel(f"📅 {self.date_str} 출퇴근 시간 수정")
|
||||
title = QLabel(f"{self.date_str} 출퇴근 시간 수정")
|
||||
title.setObjectName("dialog_subtitle")
|
||||
layout.addWidget(title)
|
||||
|
||||
|
||||
+9
-9
@@ -20,14 +20,14 @@ except ImportError:
|
||||
_MPL = False
|
||||
|
||||
|
||||
# 다크 테마 색상 (dark_components 톤과 일치)
|
||||
_CHART_BG = '#14141c'
|
||||
_CHART_GRID = '#2a2a3a'
|
||||
_CHART_TEXT = '#c0c0d0'
|
||||
_CHART_BAR_NORMAL = '#6b9eff' # blue
|
||||
_CHART_BAR_OVERTIME = '#ff90b8' # pink
|
||||
_CHART_BAR_WEEKEND = '#fcd34d' # gold
|
||||
_CHART_AVG_LINE = '#4ade80' # green
|
||||
# 다크 테마 색상 (dark_components / styles.py 톤과 일치)
|
||||
_CHART_BG = '#25262B'
|
||||
_CHART_GRID = '#2C2E33'
|
||||
_CHART_TEXT = '#909296'
|
||||
_CHART_BAR_NORMAL = '#4DABF7' # accent blue
|
||||
_CHART_BAR_OVERTIME = '#ff90b8' # pink (데이터 구분용)
|
||||
_CHART_BAR_WEEKEND = '#fcd34d' # gold (데이터 구분용)
|
||||
_CHART_AVG_LINE = '#51CF66' # green
|
||||
|
||||
|
||||
def _apply_dark_axes(ax) -> None:
|
||||
@@ -55,7 +55,7 @@ class _Fallback(QWidget):
|
||||
label = QLabel(message)
|
||||
label.setAlignment(Qt.AlignCenter)
|
||||
label.setWordWrap(True)
|
||||
label.setStyleSheet("color: #888; padding: 20px;")
|
||||
label.setStyleSheet("color: #909296; padding: 20px;")
|
||||
layout.addWidget(label)
|
||||
self.setLayout(layout)
|
||||
|
||||
|
||||
+45
-32
@@ -19,22 +19,24 @@ from PyQt5.QtCore import Qt
|
||||
|
||||
|
||||
# ── 색상 팔레트 ────────────────────────────────────────────────
|
||||
DARK_BG = '#0e0e14'
|
||||
DARK_PANEL = '#14141c'
|
||||
DARK_PANEL_2 = '#1c1c28'
|
||||
DARK_BORDER = '#2a2a3a'
|
||||
DARK_BORDER_STRONG = '#44446a'
|
||||
DARK_TEXT = '#e8e8f4'
|
||||
DARK_TEXT_DIM = '#a0a0b8'
|
||||
DARK_TEXT_FAINT = '#666680'
|
||||
# 메인 앱(styles.py DARK_COLORS)과 정합되는 모던 다크 미니멀 톤.
|
||||
DARK_BG = '#1A1B1E'
|
||||
DARK_PANEL = '#25262B'
|
||||
DARK_PANEL_2 = '#2C2E33'
|
||||
DARK_BORDER = '#2C2E33'
|
||||
DARK_BORDER_STRONG = '#373A40'
|
||||
DARK_TEXT = '#E9ECEF'
|
||||
DARK_TEXT_DIM = '#909296'
|
||||
DARK_TEXT_FAINT = '#6C6E73'
|
||||
|
||||
# 단일 포인트 컬러는 ACCENT_BLUE(#4DABF7). 나머지 색은 도전과제 등급 표시 전용.
|
||||
ACCENT_GOLD = '#ffd24a'
|
||||
ACCENT_BLUE = '#6b9eff'
|
||||
ACCENT_BLUE = '#4DABF7'
|
||||
ACCENT_CYAN = '#4adef0'
|
||||
ACCENT_PINK = '#ff90b8'
|
||||
ACCENT_GREEN = '#4ade80'
|
||||
ACCENT_GREEN = '#51CF66'
|
||||
ACCENT_ORANGE = '#fcd34d'
|
||||
ACCENT_RED = '#fb7185'
|
||||
ACCENT_RED = '#FA5252'
|
||||
|
||||
# 카드 테마 (등급/상태별)
|
||||
CARD_THEMES = {
|
||||
@@ -83,7 +85,7 @@ def dialog_qss() -> str:
|
||||
return f"QDialog {{ background: {DARK_BG}; }}"
|
||||
|
||||
|
||||
def tabs_qss(accent: str = ACCENT_GOLD) -> str:
|
||||
def tabs_qss(accent: str = ACCENT_BLUE) -> str:
|
||||
return f"""
|
||||
QTabWidget::pane {{
|
||||
background: {DARK_PANEL};
|
||||
@@ -142,36 +144,36 @@ def button_qss(variant: str = 'default') -> str:
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {ACCENT_BLUE}; color: white;
|
||||
border: none; border-radius: 6px;
|
||||
border: none; border-radius: 8px;
|
||||
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: #82b0ff; }}
|
||||
QPushButton:pressed {{ background: #5a8eee; }}
|
||||
QPushButton:disabled {{ background: #2a2a3a; color: {DARK_TEXT_FAINT}; }}
|
||||
QPushButton:hover {{ background: #69B6F8; }}
|
||||
QPushButton:pressed {{ background: #3D97E0; }}
|
||||
QPushButton:disabled {{ background: {DARK_PANEL_2}; color: {DARK_TEXT_FAINT}; }}
|
||||
"""
|
||||
if variant == 'success':
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {ACCENT_GREEN}; color: #0e2a1a;
|
||||
border: none; border-radius: 6px;
|
||||
border: none; border-radius: 8px;
|
||||
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: #6ae899; }}
|
||||
QPushButton:hover {{ background: #69DB7C; }}
|
||||
"""
|
||||
if variant == 'danger':
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {ACCENT_RED}; color: white;
|
||||
border: none; border-radius: 6px;
|
||||
border: none; border-radius: 8px;
|
||||
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: #fc8896; }}
|
||||
QPushButton:hover {{ background: #FF6B6B; }}
|
||||
"""
|
||||
if variant == 'ghost':
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: transparent; color: {DARK_TEXT_DIM};
|
||||
border: 1px solid {DARK_BORDER_STRONG}; border-radius: 6px;
|
||||
border: 1px solid {DARK_BORDER_STRONG}; border-radius: 8px;
|
||||
padding: 6px 14px; font-size: 9.5pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: {DARK_PANEL_2}; color: {DARK_TEXT};
|
||||
@@ -181,10 +183,10 @@ def button_qss(variant: str = 'default') -> str:
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {DARK_PANEL_2}; color: {DARK_TEXT};
|
||||
border: 1px solid {DARK_BORDER_STRONG}; border-radius: 6px;
|
||||
border: 1px solid {DARK_BORDER_STRONG}; border-radius: 8px;
|
||||
padding: 8px 18px; font-size: 10pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: #2a2a36; border-color: {ACCENT_BLUE}; }}
|
||||
QPushButton:hover {{ background: {DARK_BORDER_STRONG}; border-color: {ACCENT_BLUE}; }}
|
||||
"""
|
||||
|
||||
|
||||
@@ -205,10 +207,9 @@ def build_gradient_header(title: str, big_value: str, subtitle: str = '',
|
||||
container = QFrame()
|
||||
container.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #1a1a30, stop:1 #2a1a3a);
|
||||
border: 1px solid #3a3a5a;
|
||||
border-radius: 12px;
|
||||
background: {DARK_PANEL};
|
||||
border: 1px solid {DARK_BORDER};
|
||||
border-radius: 8px;
|
||||
}}
|
||||
QLabel {{ background: transparent; border: none; color: {DARK_TEXT}; }}
|
||||
""")
|
||||
@@ -228,7 +229,7 @@ def build_gradient_header(title: str, big_value: str, subtitle: str = '',
|
||||
left.addWidget(t)
|
||||
big = QLabel(
|
||||
f"<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 '')
|
||||
)
|
||||
big.setTextFormat(Qt.RichText)
|
||||
@@ -268,13 +269,20 @@ def build_stat_card(title: str, value: str, subtitle: str = '',
|
||||
outer.setSpacing(12)
|
||||
|
||||
if icon:
|
||||
icon_lbl = QLabel(icon)
|
||||
icon_lbl = QLabel()
|
||||
icon_lbl.setMinimumWidth(48)
|
||||
icon_lbl.setAlignment(Qt.AlignCenter)
|
||||
from ui.icons import get_icon, _PATHS
|
||||
if icon in _PATHS:
|
||||
# 라인 아이콘(이름) → 등급 색으로 틴팅한 픽스맵
|
||||
icon_lbl.setPixmap(get_icon(icon, t['border_strong'], 30).pixmap(30, 30))
|
||||
else:
|
||||
# 이모지/텍스트 폴백 (구버전 호환)
|
||||
icon_lbl.setText(icon)
|
||||
icon_lbl.setStyleSheet(
|
||||
f"font-size: 28pt; background: transparent; border: none; "
|
||||
f"color: {t['border_strong']};"
|
||||
)
|
||||
icon_lbl.setMinimumWidth(48)
|
||||
icon_lbl.setAlignment(Qt.AlignCenter)
|
||||
outer.addWidget(icon_lbl)
|
||||
|
||||
text_box = QVBoxLayout()
|
||||
@@ -330,7 +338,12 @@ def build_section_card(title: str, content: QWidget,
|
||||
|
||||
head = QHBoxLayout()
|
||||
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(
|
||||
f"font-size: 16pt; color: {t['border_strong']}; "
|
||||
f"background: transparent; border: none;"
|
||||
|
||||
+3
-3
@@ -20,7 +20,7 @@ class GoalWidget(QWidget):
|
||||
layout.setContentsMargins(8, 6, 8, 6)
|
||||
layout.setSpacing(4)
|
||||
|
||||
title = QLabel("🎯 이번 달 목표")
|
||||
title = QLabel("이번 달 목표")
|
||||
title.setStyleSheet("font-weight: bold;")
|
||||
layout.addWidget(title)
|
||||
|
||||
@@ -78,7 +78,7 @@ class GoalWidget(QWidget):
|
||||
ot_h, ot_m = ot_total // 60, ot_total % 60
|
||||
tg_h, tg_m = ot_target // 60, ot_target % 60
|
||||
self.ot_bar.setFormat(f"{ot_h}h {ot_m}m / {tg_h}h {tg_m}m")
|
||||
color = '#4caf50' if ratio < 0.6 else ('#ff9800' if ratio < 1.0 else '#f44336')
|
||||
color = '#51CF66' if ratio < 0.6 else ('#FAB005' if ratio < 1.0 else '#FA5252')
|
||||
self.ot_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
|
||||
else:
|
||||
self.ot_label.setVisible(False)
|
||||
@@ -93,7 +93,7 @@ class GoalWidget(QWidget):
|
||||
self.avg_bar.setValue(int(min(avg, avg_target) * 100))
|
||||
self.avg_bar.setFormat(f"{avg:.1f}h / {avg_target:.1f}h")
|
||||
ratio = avg / avg_target if avg_target else 0
|
||||
color = '#4caf50' if ratio < 0.9 else ('#ff9800' if ratio < 1.1 else '#f44336')
|
||||
color = '#51CF66' if ratio < 0.9 else ('#FAB005' if ratio < 1.1 else '#FA5252')
|
||||
self.avg_bar.setStyleSheet(f"QProgressBar::chunk {{ background-color: {color}; }}")
|
||||
else:
|
||||
self.avg_label.setVisible(False)
|
||||
|
||||
+2
-3
@@ -45,7 +45,7 @@ class HelpView(QDialog):
|
||||
main_layout.setSpacing(10)
|
||||
|
||||
# 다크 타이틀
|
||||
title = QLabel(f"📖 {tr('window.help')}")
|
||||
title = QLabel(tr('window.help'))
|
||||
title.setStyleSheet(
|
||||
f"font-size: 18pt; font-weight: bold; color: {DARK_TEXT}; "
|
||||
f"background: transparent; border: none; padding: 4px 0;"
|
||||
@@ -53,7 +53,6 @@ class HelpView(QDialog):
|
||||
main_layout.addWidget(title)
|
||||
|
||||
tabs = QTabWidget()
|
||||
tabs.setDocumentMode(True)
|
||||
tabs.setStyleSheet(tabs_qss())
|
||||
for html_key, tab_label_key in self._TABS:
|
||||
tabs.addTab(self._make_tab(tr_html(html_key)), tr(tab_label_key))
|
||||
@@ -63,7 +62,7 @@ class HelpView(QDialog):
|
||||
button_layout.setContentsMargins(0, 6, 0, 0)
|
||||
|
||||
# 온보딩 다시 보기 (왼쪽, ghost 스타일)
|
||||
onboarding_button = QPushButton("🚀 온보딩 다시 보기")
|
||||
onboarding_button = QPushButton("온보딩 다시 보기")
|
||||
onboarding_button.setMinimumHeight(36)
|
||||
onboarding_button.setStyleSheet(button_qss('ghost'))
|
||||
onboarding_button.clicked.connect(self._reopen_onboarding)
|
||||
|
||||
+82
@@ -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):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle("📅 연차 캘린더")
|
||||
self.setWindowTitle("연차 캘린더")
|
||||
self.setModal(True)
|
||||
self.setMinimumSize(540, 480)
|
||||
self._build_ui()
|
||||
@@ -37,7 +37,7 @@ class LeaveCalendarView(QDialog):
|
||||
balance = float(self.db.get_setting('leave_balance', '0') or 0)
|
||||
total = float(self.db.get_setting('annual_leave_total', '15') or 15)
|
||||
used = total - balance
|
||||
title = QLabel(f"🌴 잔여 {balance:.2f}일 / 총 {total:.0f}일 (사용 {used:.2f}일)")
|
||||
title = QLabel(f"잔여 {balance:.2f}일 / 총 {total:.0f}일 (사용 {used:.2f}일)")
|
||||
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||
header.addWidget(title)
|
||||
header.addStretch()
|
||||
@@ -45,10 +45,11 @@ class LeaveCalendarView(QDialog):
|
||||
|
||||
# 범례 (사용 완료 + 예정 분리)
|
||||
legend = QHBoxLayout()
|
||||
for label in ["🟩 종일(1.0)", "🟨 반차(0.5)", "🟪 반반차(0.25)",
|
||||
"🔵 예정", "🔘 종일+예정"]:
|
||||
l = QLabel(label)
|
||||
l.setStyleSheet(f"padding: 2px 6px;")
|
||||
for _color, _txt in [('#51CF66', '종일(1.0)'), ('#FAB005', '반차(0.5)'),
|
||||
('#B197FC', '반반차(0.25)'), ('#4DABF7', '예정'),
|
||||
('#748FFC', '종일+예정')]:
|
||||
l = QLabel(f"<span style='color:{_color};'>●</span> {_txt}")
|
||||
l.setStyleSheet("padding: 2px 6px;")
|
||||
legend.addWidget(l)
|
||||
legend.addStretch()
|
||||
layout.addLayout(legend)
|
||||
@@ -61,7 +62,7 @@ class LeaveCalendarView(QDialog):
|
||||
|
||||
# 선택 일자 정보
|
||||
self.detail_label = QLabel("")
|
||||
self.detail_label.setStyleSheet("padding: 6px; color: #888;")
|
||||
self.detail_label.setStyleSheet("padding: 6px; color: #909296;")
|
||||
layout.addWidget(self.detail_label)
|
||||
|
||||
# 닫기 버튼
|
||||
@@ -116,4 +117,4 @@ class LeaveCalendarView(QDialog):
|
||||
d = float(r.get('days') or 0)
|
||||
memo = r.get('memo') or ''
|
||||
parts.append(f"{t} {d}일" + (f" ({memo})" if memo else ""))
|
||||
self.detail_label.setText(f"📅 {date_str}: " + ", ".join(parts))
|
||||
self.detail_label.setText(f"{date_str}: " + ", ".join(parts))
|
||||
|
||||
+2
-2
@@ -90,7 +90,7 @@ class LeaveView(QDialog):
|
||||
cal_button.clicked.connect(self._show_calendar)
|
||||
button_layout.addWidget(cal_button)
|
||||
|
||||
schedule_button = QPushButton("🗓️ 스케줄")
|
||||
schedule_button = QPushButton("스케줄")
|
||||
schedule_button.setToolTip("휴일 + 연차 + 반복 패턴 통합 보기")
|
||||
schedule_button.clicked.connect(self._show_schedule)
|
||||
button_layout.addWidget(schedule_button)
|
||||
@@ -146,7 +146,7 @@ class LeaveView(QDialog):
|
||||
days_str = f"{days}일"
|
||||
days_item = QTableWidgetItem(days_str)
|
||||
days_item.setTextAlignment(Qt.AlignCenter)
|
||||
days_item.setForeground(QColor(231, 76, 60)) # 빨간색
|
||||
days_item.setForeground(QColor(250, 82, 82)) # 사용 = 레드 (#FA5252)
|
||||
|
||||
memo_item = QTableWidgetItem(record['memo'] or "")
|
||||
|
||||
|
||||
+152
-85
@@ -8,7 +8,7 @@ from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout,
|
||||
QHBoxLayout, QLabel, QPushButton, QProgressBar,
|
||||
QMessageBox, QGroupBox, QGridLayout, QSystemTrayIcon,
|
||||
QShortcut, QDialog)
|
||||
from PyQt5.QtCore import QTimer, Qt, pyqtSignal, QLockFile, QDir
|
||||
from PyQt5.QtCore import QTimer, Qt, pyqtSignal, QLockFile, QDir, QSize
|
||||
from PyQt5.QtGui import QFont, QPalette, QColor, QKeySequence
|
||||
|
||||
from core.settings_keys import (
|
||||
@@ -50,7 +50,7 @@ class MainWindow(QMainWindow):
|
||||
super().__init__()
|
||||
|
||||
# 테마 적용
|
||||
self.current_theme = 'light' # 설정에서 로드 후 덮어씀
|
||||
self.current_theme = 'dark' # 설정에서 로드 후 덮어씀
|
||||
|
||||
# 데이터베이스 — main.py가 전달하면 재사용, 아니면 자체 부트스트랩
|
||||
if db is not None:
|
||||
@@ -82,7 +82,7 @@ class MainWindow(QMainWindow):
|
||||
self.cached_time_format = str(settings.get(TIME_FORMAT, '24'))
|
||||
|
||||
# 테마 설정
|
||||
self.current_theme = str(settings.get(THEME, 'light'))
|
||||
self.current_theme = str(settings.get(THEME, 'dark'))
|
||||
self.apply_theme(self.current_theme)
|
||||
self.time_calc = self._build_time_calc(settings)
|
||||
|
||||
@@ -234,11 +234,11 @@ class MainWindow(QMainWindow):
|
||||
from core.version import __version__
|
||||
from ui.i18n_runtime import register
|
||||
self._app_version = __version__
|
||||
self.setWindowTitle(f"⏰ {tr('window.main_title')} v{__version__}")
|
||||
self.setWindowTitle(f"{tr('window.main_title')} v{__version__}")
|
||||
register(self, 'window.main_title', setter='setWindowTitle',
|
||||
post=lambda t: f"⏰ {t} v{__version__}")
|
||||
self.setGeometry(100, 100, 500, 620)
|
||||
self.setMinimumSize(480, 520)
|
||||
post=lambda t: f"{t} v{__version__}")
|
||||
self.setGeometry(100, 100, 540, 720)
|
||||
self.setMinimumSize(500, 600)
|
||||
|
||||
# 외부 컨테이너 (스크롤 + 고정 하단)
|
||||
from PyQt5.QtWidgets import QScrollArea
|
||||
@@ -261,10 +261,10 @@ class MainWindow(QMainWindow):
|
||||
outer_widget.setLayout(outer_layout)
|
||||
self.setCentralWidget(outer_widget)
|
||||
|
||||
# 메인 레이아웃
|
||||
# 메인 레이아웃 — 외곽 24px, 위젯 간 12px (통일된 여백 시스템)
|
||||
main_layout = QVBoxLayout()
|
||||
main_layout.setSpacing(8)
|
||||
main_layout.setContentsMargins(12, 10, 12, 10)
|
||||
main_layout.setSpacing(12)
|
||||
main_layout.setContentsMargins(24, 20, 24, 16)
|
||||
|
||||
# 1. 헤더 - 앱 타이틀
|
||||
title_label = QLabel("퇴근시간 계산기")
|
||||
@@ -287,16 +287,10 @@ class MainWindow(QMainWindow):
|
||||
clock_in_group = self.create_clock_in_group()
|
||||
main_layout.addWidget(clock_in_group)
|
||||
|
||||
# 3. 남은 시간 표시 그룹
|
||||
# 3. 남은 시간 표시 그룹 (히어로 — 남은시간 + 진행률 + 예상 퇴근시각 통합)
|
||||
remaining_group = self.create_remaining_time_group()
|
||||
main_layout.addWidget(remaining_group)
|
||||
|
||||
# 4. 예상 퇴근시간
|
||||
self.expected_time_label = QLabel()
|
||||
self.expected_time_label.setObjectName("expected_time")
|
||||
self.expected_time_label.setAlignment(Qt.AlignCenter)
|
||||
main_layout.addWidget(self.expected_time_label)
|
||||
|
||||
# 5. 점심/저녁 토글 (가로 배치)
|
||||
meal_button_layout = QHBoxLayout()
|
||||
meal_button_layout.setSpacing(8)
|
||||
@@ -359,8 +353,8 @@ class MainWindow(QMainWindow):
|
||||
fixed_bottom = QWidget()
|
||||
fixed_bottom.setObjectName("fixed_bottom")
|
||||
fixed_bottom_layout = QVBoxLayout()
|
||||
fixed_bottom_layout.setSpacing(8)
|
||||
fixed_bottom_layout.setContentsMargins(12, 8, 12, 10)
|
||||
fixed_bottom_layout.setSpacing(10)
|
||||
fixed_bottom_layout.setContentsMargins(24, 12, 24, 16)
|
||||
|
||||
self.clock_out_button = QPushButton(tr('btn.clock_out'))
|
||||
self.clock_out_button.setObjectName("clock_out_button")
|
||||
@@ -375,7 +369,7 @@ class MainWindow(QMainWindow):
|
||||
stats_button = QPushButton(tr('menu.stats'))
|
||||
calendar_button = QPushButton(tr('menu.calendar'))
|
||||
report_button = QPushButton(tr('menu.daily_report'))
|
||||
achievements_button = QPushButton("🏆 도전과제")
|
||||
achievements_button = QPushButton("도전과제")
|
||||
help_button = QPushButton(tr('menu.help'))
|
||||
settings_button = QPushButton(tr('menu.settings'))
|
||||
|
||||
@@ -387,8 +381,17 @@ class MainWindow(QMainWindow):
|
||||
(settings_button, 'menu.settings')]:
|
||||
register(btn, key)
|
||||
|
||||
for btn in [stats_button, calendar_button, report_button,
|
||||
achievements_button, help_button, settings_button]:
|
||||
# 하단 네비게이션 — 라인 아이콘 + 라벨 (이모지 대체)
|
||||
self._nav_icon_specs = [
|
||||
(stats_button, 'chart'),
|
||||
(calendar_button, 'calendar'),
|
||||
(report_button, 'report'),
|
||||
(achievements_button, 'award'),
|
||||
(help_button, 'help'),
|
||||
(settings_button, 'settings'),
|
||||
]
|
||||
for btn, _name in self._nav_icon_specs:
|
||||
btn.setObjectName("nav_btn")
|
||||
bottom_layout.addWidget(btn)
|
||||
|
||||
# 버튼 연결
|
||||
@@ -406,9 +409,27 @@ class MainWindow(QMainWindow):
|
||||
# 초기 날짜 업데이트
|
||||
self.update_date_label()
|
||||
|
||||
# 라인 아이콘 적용 (테마 색 틴팅)
|
||||
self._apply_button_icons()
|
||||
|
||||
# 앱 내 단축키
|
||||
self._setup_shortcuts()
|
||||
|
||||
def _apply_button_icons(self):
|
||||
"""버튼 아이콘을 현재 테마 색으로 (재)적용. 테마 전환 시에도 호출돼 재틴팅."""
|
||||
from ui.icons import get_icon
|
||||
sec = ThemeColors.get('text_secondary')
|
||||
inv = ThemeColors.get('text_inverse')
|
||||
for btn, name in getattr(self, '_nav_icon_specs', []):
|
||||
btn.setIcon(get_icon(name, sec, 16))
|
||||
btn.setIconSize(QSize(16, 16))
|
||||
if getattr(self, 'edit_clock_in_button', None) is not None:
|
||||
self.edit_clock_in_button.setIcon(get_icon('edit', sec, 15))
|
||||
self.edit_clock_in_button.setIconSize(QSize(15, 15))
|
||||
if getattr(self, 'clock_out_button', None) is not None:
|
||||
self.clock_out_button.setIcon(get_icon('logout', inv, 18))
|
||||
self.clock_out_button.setIconSize(QSize(18, 18))
|
||||
|
||||
def _setup_shortcuts(self):
|
||||
"""앱 내 단축키 — 메인 창 포커스 시만 동작"""
|
||||
bindings = [
|
||||
@@ -425,74 +446,93 @@ class MainWindow(QMainWindow):
|
||||
sc = QShortcut(QKeySequence(keyseq), self)
|
||||
sc.activated.connect(handler)
|
||||
|
||||
def _build_time_column(self, label_text: str, value_widget: QLabel) -> QVBoxLayout:
|
||||
"""라벨(작게) 위 + 시각(크게) 아래 형태의 세로 컬럼. 한 줄 나란히 배치용."""
|
||||
col = QVBoxLayout()
|
||||
col.setSpacing(2)
|
||||
lbl = QLabel(label_text)
|
||||
lbl.setObjectName("field_label")
|
||||
col.addWidget(lbl)
|
||||
col.addWidget(value_widget)
|
||||
return col
|
||||
|
||||
def create_clock_in_group(self) -> QGroupBox:
|
||||
"""출근 정보 그룹 생성"""
|
||||
"""출근 정보 그룹 생성 — 출근/현재 시각을 한 줄에 나란히"""
|
||||
group = QGroupBox("오늘의 근무")
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(4)
|
||||
layout.setContentsMargins(12, 20, 12, 8)
|
||||
layout.setSpacing(8)
|
||||
layout.setContentsMargins(16, 24, 16, 16)
|
||||
|
||||
# 출근 시간 레이아웃
|
||||
clock_in_layout = QHBoxLayout()
|
||||
clock_in_label = QLabel("출근:")
|
||||
clock_in_label.setObjectName("field_label")
|
||||
clock_in_label.setFixedWidth(50)
|
||||
# 출근 / 현재 시각을 한 줄에 나란히 (2-컬럼)
|
||||
row = QHBoxLayout()
|
||||
row.setSpacing(12)
|
||||
|
||||
# 출근 컬럼 (라벨 + 편집 버튼 헤더 / 값)
|
||||
self.clock_in_value = QLabel("--:--:--")
|
||||
self.clock_in_value.setObjectName("time_value")
|
||||
self.clock_in_value.setMinimumWidth(90)
|
||||
# 라벨 자체도 클릭 가능 (인라인 편집 — 출퇴근 시간 빠른 수정)
|
||||
# 라벨 자체도 클릭 가능 (인라인 편집 — 출근 시간 빠른 수정)
|
||||
self.clock_in_value.setCursor(Qt.PointingHandCursor)
|
||||
self.clock_in_value.setToolTip("클릭하여 출근 시간 수정")
|
||||
self.clock_in_value.mousePressEvent = lambda e: self.manual_clock_in()
|
||||
self.edit_clock_in_button = QPushButton("✏️ 수정")
|
||||
|
||||
clock_in_col = QVBoxLayout()
|
||||
clock_in_col.setSpacing(2)
|
||||
clock_in_label = QLabel("출근")
|
||||
clock_in_label.setObjectName("field_label")
|
||||
clock_in_col.addWidget(clock_in_label)
|
||||
|
||||
# 시각 + 편집 버튼을 한 줄에 (편집 아이콘이 출근 시각 바로 옆에 붙도록)
|
||||
clock_in_value_row = QHBoxLayout()
|
||||
clock_in_value_row.setSpacing(6)
|
||||
self.edit_clock_in_button = QPushButton("")
|
||||
self.edit_clock_in_button.setObjectName("btn_small")
|
||||
self.edit_clock_in_button.setFixedWidth(70)
|
||||
self.edit_clock_in_button.setFixedWidth(30)
|
||||
self.edit_clock_in_button.setToolTip("출근 시간 수정")
|
||||
self.edit_clock_in_button.clicked.connect(self.manual_clock_in)
|
||||
clock_in_value_row.addWidget(self.clock_in_value)
|
||||
clock_in_value_row.addWidget(self.edit_clock_in_button)
|
||||
clock_in_value_row.addStretch()
|
||||
clock_in_col.addLayout(clock_in_value_row)
|
||||
|
||||
clock_in_layout.addWidget(clock_in_label)
|
||||
clock_in_layout.addWidget(self.clock_in_value)
|
||||
clock_in_layout.addStretch()
|
||||
clock_in_layout.addWidget(self.edit_clock_in_button)
|
||||
|
||||
# 현재 시간 레이아웃
|
||||
current_layout = QHBoxLayout()
|
||||
current_label = QLabel("현재:")
|
||||
current_label.setObjectName("field_label")
|
||||
current_label.setFixedWidth(50)
|
||||
# 현재 컬럼
|
||||
self.current_time_value = QLabel("--:--:--")
|
||||
self.current_time_value.setObjectName("time_value")
|
||||
self.current_time_value.setMinimumWidth(90)
|
||||
current_col = self._build_time_column("현재", self.current_time_value)
|
||||
|
||||
current_layout.addWidget(current_label)
|
||||
current_layout.addWidget(self.current_time_value)
|
||||
current_layout.addStretch()
|
||||
|
||||
layout.addLayout(clock_in_layout)
|
||||
layout.addLayout(current_layout)
|
||||
row.addLayout(clock_in_col, 1)
|
||||
row.addLayout(current_col, 1)
|
||||
layout.addLayout(row)
|
||||
|
||||
group.setLayout(layout)
|
||||
return group
|
||||
|
||||
def create_remaining_time_group(self) -> QGroupBox:
|
||||
"""남은 시간 표시 그룹 생성"""
|
||||
"""남은 시간 히어로 그룹 — 남은시간(가장 큼) + 진행률 + 예상 퇴근시각"""
|
||||
self.remaining_time_group = QGroupBox("남은 시간")
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(6)
|
||||
layout.setContentsMargins(12, 20, 12, 8)
|
||||
layout.setSpacing(12)
|
||||
layout.setContentsMargins(16, 24, 16, 16)
|
||||
|
||||
# 남은 시간 라벨
|
||||
# 남은 시간 라벨 (히어로 — 화면에서 가장 큰 결과)
|
||||
self.remaining_time_label = QLabel("--:--:--")
|
||||
self.remaining_time_label.setObjectName("time_display")
|
||||
self.remaining_time_label.setAlignment(Qt.AlignCenter)
|
||||
|
||||
# 프로그레스 바
|
||||
# 프로그레스 바 (얇게 6px)
|
||||
self.progress_bar = QProgressBar()
|
||||
self.progress_bar.setTextVisible(False)
|
||||
self.progress_bar.setFixedHeight(6)
|
||||
|
||||
# 예상 퇴근시각 (히어로 카드 내부에 통합)
|
||||
self.expected_time_label = QLabel()
|
||||
self.expected_time_label.setObjectName("expected_time")
|
||||
self.expected_time_label.setAlignment(Qt.AlignCenter)
|
||||
|
||||
layout.addWidget(self.remaining_time_label)
|
||||
layout.addWidget(self.progress_bar)
|
||||
layout.addWidget(self.expected_time_label)
|
||||
|
||||
self.remaining_time_group.setLayout(layout)
|
||||
return self.remaining_time_group
|
||||
@@ -502,8 +542,8 @@ class MainWindow(QMainWindow):
|
||||
group = QGroupBox("연장근무 및 연차 현황")
|
||||
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(6)
|
||||
layout.setContentsMargins(12, 20, 12, 8)
|
||||
layout.setSpacing(10)
|
||||
layout.setContentsMargins(16, 24, 16, 16)
|
||||
|
||||
# 연장근무 섹션
|
||||
overtime_header = QHBoxLayout()
|
||||
@@ -621,14 +661,14 @@ class MainWindow(QMainWindow):
|
||||
if today_record.get('clock_out'):
|
||||
self.is_clocked_in = False
|
||||
self.clock_out_button.setEnabled(True)
|
||||
self.clock_out_button.setText("🔄 퇴근 취소")
|
||||
self.clock_out_button.setText("퇴근 취소")
|
||||
|
||||
# 퇴근 완료 상태에서도 출퇴근 시간은 표시
|
||||
self.clock_in_value.setText(self.format_time(self.clock_in_time, include_seconds=True))
|
||||
else:
|
||||
# 출근 중이면 퇴근하기 버튼
|
||||
self.clock_out_button.setEnabled(True)
|
||||
self.clock_out_button.setText("✅ 퇴근하기")
|
||||
self.clock_out_button.setText("퇴근하기")
|
||||
|
||||
else:
|
||||
# 출근 기록 없음 — 종일 연차일이면 자동 감지·수동 입력 모두 스킵
|
||||
@@ -757,15 +797,17 @@ class MainWindow(QMainWindow):
|
||||
is_holiday = is_non_working or is_full_day_leave
|
||||
|
||||
if is_holiday:
|
||||
actual_ot, _ = self.time_calc.calculate_holiday_overtime(
|
||||
self.clock_in_time, now,
|
||||
include_lunch=self.lunch_break_enabled,
|
||||
include_dinner=self.dinner_break_enabled,
|
||||
break_minutes=break_minutes,
|
||||
)
|
||||
# 사용한 추가근무 차감만 반영 (leave_used는 holiday/override 케이스에서 의미 없음)
|
||||
actual_ot = max(0, actual_ot - overtime_used_today)
|
||||
remaining = -timedelta(minutes=actual_ot)
|
||||
# 표시는 초 단위로 부드럽게 — 적립(분 절삭)은 퇴근 시 별도 계산.
|
||||
# calculate_holiday_overtime와 동일한 차감 항목을 timedelta로 적용.
|
||||
deduction_min = break_minutes + overtime_used_today
|
||||
if self.lunch_break_enabled:
|
||||
deduction_min += self.time_calc.lunch_duration_minutes
|
||||
if self.dinner_break_enabled:
|
||||
deduction_min += self.time_calc.dinner_duration_minutes
|
||||
worked = (now - self.clock_in_time) - timedelta(minutes=deduction_min)
|
||||
if worked.total_seconds() < 0:
|
||||
worked = timedelta(0)
|
||||
remaining = -worked
|
||||
else:
|
||||
# 평일: 정상 남은 시간 계산. 부분 연차(반차/시간연차)는 leave_used_today에
|
||||
# 그대로 반영되어 카운트다운이 단축됨.
|
||||
@@ -797,17 +839,14 @@ class MainWindow(QMainWindow):
|
||||
minutes = (total_seconds % 3600) // 60
|
||||
seconds = total_seconds % 60
|
||||
remaining_str = f"+{hours:02d}:{minutes:02d}:{seconds:02d}"
|
||||
self.remaining_time_label.setStyleSheet(f"color: {ThemeColors.get('status_overtime')};")
|
||||
# 퇴근 가능(연장근무 진입) → 그린 피드백
|
||||
self.remaining_time_label.setStyleSheet(f"color: {ThemeColors.get('status_normal')};")
|
||||
|
||||
else:
|
||||
# 정상 근무 중
|
||||
# 정상 근무 중 — 아직 퇴근 전이므로 기본 텍스트 색
|
||||
self.remaining_time_group.setTitle("남은 시간")
|
||||
remaining_str = self.time_calc.format_time_delta(remaining)
|
||||
|
||||
if remaining.total_seconds() < 1800: # 30분 이내
|
||||
self.remaining_time_label.setStyleSheet(f"color: {ThemeColors.get('status_warning')};")
|
||||
else:
|
||||
self.remaining_time_label.setStyleSheet(f"color: {ThemeColors.get('status_normal')};")
|
||||
self.remaining_time_label.setStyleSheet(f"color: {ThemeColors.get('text_primary')};")
|
||||
|
||||
|
||||
self._set_text_if_changed(self.remaining_time_label, remaining_str)
|
||||
@@ -883,7 +922,7 @@ class MainWindow(QMainWindow):
|
||||
label = '연차'
|
||||
memo = ''
|
||||
|
||||
self.remaining_time_group.setTitle("🌴 오늘은 휴가")
|
||||
self.remaining_time_group.setTitle("오늘은 휴가")
|
||||
self.remaining_time_label.setText("연차 사용 중")
|
||||
self.remaining_time_label.setStyleSheet(
|
||||
f"color: {ThemeColors.get('status_normal')}; font-size: 18px;"
|
||||
@@ -891,10 +930,10 @@ class MainWindow(QMainWindow):
|
||||
self.progress_bar.setValue(100)
|
||||
if memo:
|
||||
self._set_text_if_changed(self.expected_time_label,
|
||||
f"🌴 {label} — {memo}")
|
||||
f"{label} — {memo}")
|
||||
else:
|
||||
self._set_text_if_changed(self.expected_time_label,
|
||||
f"🌴 {label}")
|
||||
f"{label}")
|
||||
# 트레이/미니 위젯
|
||||
self.tray_icon.update_time_display("🌴 휴가")
|
||||
if getattr(self, '_mini_widget', None) is not None and self._mini_widget.isVisible():
|
||||
@@ -1299,7 +1338,7 @@ class MainWindow(QMainWindow):
|
||||
self.is_clocked_in = False
|
||||
self.midnight_rollover_handled = False # 다음날을 위해 플래그 리셋
|
||||
self.clock_out_button.setEnabled(True)
|
||||
self.clock_out_button.setText("🔄 퇴근 취소")
|
||||
self.clock_out_button.setText("퇴근 취소")
|
||||
|
||||
# 결과 메시지
|
||||
msg = f"퇴근 처리되었습니다!\n\n"
|
||||
@@ -1352,7 +1391,7 @@ class MainWindow(QMainWindow):
|
||||
# 상태 복원
|
||||
self.is_clocked_in = True
|
||||
self.clock_out_button.setEnabled(True)
|
||||
self.clock_out_button.setText("✅ 퇴근하기")
|
||||
self.clock_out_button.setText("퇴근하기")
|
||||
|
||||
# 잔액 업데이트
|
||||
self.update_overtime_balance()
|
||||
@@ -1376,6 +1415,18 @@ class MainWindow(QMainWindow):
|
||||
f"퇴근 취소 중 오류가 발생했습니다:\n{str(e)}"
|
||||
)
|
||||
|
||||
def _apply_auto_overtime_gate(self, overtime_earned: int) -> int:
|
||||
"""자동 적립(auto_overtime) 설정을 존중해 적립분을 게이팅.
|
||||
|
||||
OFF면 0을 반환해 은행 적립(add_overtime_earned)을 건너뛰게 한다.
|
||||
clock_out()은 대화상자로 직접 확인하지만, 자동 퇴근 경로(롤오버 / 이전일
|
||||
자동 처리)는 사용자 상호작용 시점이 없으므로 설정만으로 동일하게 게이팅한다.
|
||||
실제 연장(work_records.overtime_minutes)은 그대로 기록되고 적립만 스킵된다.
|
||||
"""
|
||||
if overtime_earned > 0 and not self.db.get_setting_bool('auto_overtime', True):
|
||||
return 0
|
||||
return overtime_earned
|
||||
|
||||
def handle_workday_rollover(self, now: datetime):
|
||||
"""근무일 경계 처리: 경계시간 직전 퇴근, 경계시간에 출근
|
||||
|
||||
@@ -1449,6 +1500,9 @@ class MainWindow(QMainWindow):
|
||||
unit_minutes=unit_minutes,
|
||||
)
|
||||
|
||||
# 자동 적립 OFF면 적립 스킵 (clock_out '아니오'와 동일 의미)
|
||||
overtime_earned = self._apply_auto_overtime_gate(overtime_earned)
|
||||
|
||||
# DB 업데이트 (출근일 날짜에 퇴근 시간 기록)
|
||||
self.db.update_clock_out(
|
||||
workday_str, before_boundary_str, total_hours,
|
||||
@@ -1535,7 +1589,7 @@ class MainWindow(QMainWindow):
|
||||
self.clock_in_time = None
|
||||
self.is_clocked_in = False
|
||||
self.clock_out_button.setEnabled(False)
|
||||
self.clock_out_button.setText("✅ 퇴근하기")
|
||||
self.clock_out_button.setText("퇴근하기")
|
||||
|
||||
def auto_clock_out_previous_days(self):
|
||||
"""이전 퇴근 기록들(퇴근 안 한)에 대해 자동으로 종료 시간 등록"""
|
||||
@@ -1607,6 +1661,9 @@ class MainWindow(QMainWindow):
|
||||
unit_minutes=unit_minutes,
|
||||
)
|
||||
|
||||
# 자동 적립 OFF면 적립 스킵 (clock_out '아니오'와 동일 의미)
|
||||
overtime_earned = self._apply_auto_overtime_gate(overtime_earned)
|
||||
|
||||
# DB 업데이트 (total_hours는 원본 시간 그대로 저장)
|
||||
clock_out_str = shutdown_time.strftime("%H:%M:%S")
|
||||
self.db.update_clock_out(
|
||||
@@ -1676,6 +1733,9 @@ class MainWindow(QMainWindow):
|
||||
unit_minutes=unit_minutes,
|
||||
)
|
||||
|
||||
# 자동 적립 OFF면 적립 스킵 (clock_out '아니오'와 동일 의미)
|
||||
overtime_earned = self._apply_auto_overtime_gate(overtime_earned)
|
||||
|
||||
# DB 업데이트
|
||||
self.db.update_clock_out(
|
||||
check_date, "23:59:59", total_hours,
|
||||
@@ -1747,7 +1807,7 @@ class MainWindow(QMainWindow):
|
||||
# UI 업데이트
|
||||
self.clock_in_value.setText(clock_in_str)
|
||||
self.clock_out_button.setEnabled(True)
|
||||
self.clock_out_button.setText("✅ 퇴근하기")
|
||||
self.clock_out_button.setText("퇴근하기")
|
||||
|
||||
QMessageBox.information(
|
||||
self,
|
||||
@@ -1792,8 +1852,15 @@ class MainWindow(QMainWindow):
|
||||
def apply_theme(self, theme_name: str):
|
||||
"""테마 적용"""
|
||||
self.current_theme = theme_name
|
||||
ThemeColors.set_theme(theme_name)
|
||||
self.setStyleSheet(get_theme(theme_name))
|
||||
apply_dark_titlebar(self, theme_name == 'dark')
|
||||
# 버튼 아이콘을 새 테마 색으로 재틴팅 (init_ui 이후에만)
|
||||
if hasattr(self, '_nav_icon_specs'):
|
||||
self._apply_button_icons()
|
||||
# 트레이 메뉴도 새 테마 QSS/아이콘으로 갱신
|
||||
if getattr(self, 'tray_icon', None) is not None:
|
||||
self.tray_icon.refresh_theme()
|
||||
# 타이틀바 갱신을 위해 크기 미세 조정
|
||||
size = self.size()
|
||||
self.resize(size.width() + 1, size.height())
|
||||
@@ -1804,7 +1871,7 @@ class MainWindow(QMainWindow):
|
||||
dialog = SettingsView(self, self.db)
|
||||
dialog.exec_()
|
||||
# 설정 변경 후 테마 재적용
|
||||
new_theme = str(self.db.get_setting(THEME, 'light'))
|
||||
new_theme = str(self.db.get_setting(THEME, 'dark'))
|
||||
if new_theme != self.current_theme:
|
||||
self.apply_theme(new_theme)
|
||||
|
||||
@@ -1832,7 +1899,7 @@ class MainWindow(QMainWindow):
|
||||
from ui.meal_time_dialog import MealTimeDialog
|
||||
menu = QMenu(self)
|
||||
title = "점심" if meal_type == 'lunch' else "저녁"
|
||||
edit_action = menu.addAction(f"⏱ {title} 실제 시간 입력...")
|
||||
edit_action = menu.addAction(f"{title} 실제 시간 입력...")
|
||||
global_pos = button.mapToGlobal(pos)
|
||||
action = menu.exec_(global_pos)
|
||||
if action != edit_action:
|
||||
|
||||
@@ -50,7 +50,7 @@ class MealTimeDialog(QDialog):
|
||||
info_text += f"\n출근 {clock_in_time.strftime('%H:%M')} 이후만 입력 가능."
|
||||
info = QLabel(info_text)
|
||||
info.setWordWrap(True)
|
||||
info.setStyleSheet("color: #888; padding-bottom: 6px;")
|
||||
info.setStyleSheet("color: #909296; padding-bottom: 6px;")
|
||||
layout.addWidget(info)
|
||||
|
||||
# 합리적 기본값: 출근 이후로 보정
|
||||
@@ -83,7 +83,7 @@ class MealTimeDialog(QDialog):
|
||||
|
||||
# 미리보기 라벨
|
||||
self.preview = QLabel("")
|
||||
self.preview.setStyleSheet("color: #4caf50; font-weight: bold; padding-top: 6px;")
|
||||
self.preview.setStyleSheet("color: #51CF66; font-weight: bold; padding-top: 6px;")
|
||||
layout.addWidget(self.preview)
|
||||
self._update_preview()
|
||||
self.start_edit.timeChanged.connect(self._update_preview)
|
||||
@@ -137,11 +137,11 @@ class MealTimeDialog(QDialog):
|
||||
start_dt, end_dt, minutes = self._resolve_meal_window()
|
||||
ok, reason = self._validate_window(start_dt, end_dt, minutes)
|
||||
if not ok:
|
||||
self.preview.setText(f"⚠️ {reason}")
|
||||
self.preview.setStyleSheet("color: #f44336;")
|
||||
self.preview.setText(f"{reason}")
|
||||
self.preview.setStyleSheet("color: #FA5252;")
|
||||
else:
|
||||
self.preview.setText(f"총 {minutes}분")
|
||||
self.preview.setStyleSheet("color: #4caf50; font-weight: bold;")
|
||||
self.preview.setStyleSheet("color: #51CF66; font-weight: bold;")
|
||||
|
||||
def _validate_window(self, start_dt: datetime, end_dt: datetime,
|
||||
minutes: int) -> tuple[bool, str]:
|
||||
|
||||
+18
-6
@@ -41,7 +41,7 @@ class MiniWidget(QWidget):
|
||||
|
||||
self.title_label = QLabel(tr('label.remaining'))
|
||||
self.title_label.setAlignment(Qt.AlignCenter)
|
||||
self.title_label.setStyleSheet("color: #888; font-size: 11px;")
|
||||
self.title_label.setStyleSheet("color: #909296; font-size: 11px;")
|
||||
|
||||
self.time_label = QLabel("--:--:--")
|
||||
self.time_label.setAlignment(Qt.AlignCenter)
|
||||
@@ -51,10 +51,10 @@ class MiniWidget(QWidget):
|
||||
layout.addWidget(self.time_label)
|
||||
self.setLayout(layout)
|
||||
|
||||
# 기본 스타일 (테마 무관 가독성 유지)
|
||||
# 기본 스타일 (테마 무관 가독성 유지 — 메인 다크 팔레트와 정합)
|
||||
self.setStyleSheet("""
|
||||
QWidget { background-color: rgba(30, 30, 30, 230); border-radius: 8px; }
|
||||
QLabel { color: #fff; }
|
||||
QWidget { background-color: rgba(26, 27, 30, 235); border-radius: 8px; }
|
||||
QLabel { color: #E9ECEF; background: transparent; }
|
||||
""")
|
||||
|
||||
apply_dark_titlebar(self)
|
||||
@@ -63,11 +63,12 @@ class MiniWidget(QWidget):
|
||||
"""메인 윈도우에서 호출 — 남은 시간 동기화."""
|
||||
self.time_label.setText(remaining_str)
|
||||
if remaining_str.startswith('+'):
|
||||
# 연장근무 진입 = 퇴근 가능 → 그린 (메인 히어로와 동일 피드백)
|
||||
self.title_label.setText(tr('label.overtime_progress'))
|
||||
self.time_label.setStyleSheet("color: #ff6b6b;")
|
||||
self.time_label.setStyleSheet("color: #51CF66;")
|
||||
else:
|
||||
self.title_label.setText(tr('label.remaining'))
|
||||
self.time_label.setStyleSheet("color: #fff;")
|
||||
self.time_label.setStyleSheet("color: #E9ECEF;")
|
||||
|
||||
# 드래그 이동
|
||||
def mousePressEvent(self, event: QMouseEvent):
|
||||
@@ -90,6 +91,17 @@ class MiniWidget(QWidget):
|
||||
def contextMenuEvent(self, event):
|
||||
from PyQt5.QtWidgets import QMenu
|
||||
menu = QMenu(self)
|
||||
# 미니 위젯 자체 QSS에는 QMenu 텍스트색이 없어 기본 검정으로 보인다.
|
||||
# 앱 다크 테마 QSS를 명시 적용해 가독성 확보 (트레이 메뉴와 동일 처리).
|
||||
qss = self.parent_window.styleSheet() if self.parent_window else ''
|
||||
if not qss:
|
||||
try:
|
||||
from ui.styles import get_theme
|
||||
qss = get_theme('dark')
|
||||
except Exception:
|
||||
qss = ''
|
||||
if qss:
|
||||
menu.setStyleSheet(qss)
|
||||
open_main = menu.addAction("메인 창 열기")
|
||||
close_mini = menu.addAction("미니 위젯 닫기")
|
||||
action = menu.exec_(event.globalPos())
|
||||
|
||||
+10
-10
@@ -32,7 +32,7 @@ WORK_PRESETS = [
|
||||
class WelcomePage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle("👋 환영합니다!")
|
||||
self.setTitle("환영합니다!")
|
||||
self.setSubTitle("Clock-out Time Calculator를 처음 사용하시는군요. 5단계로 빠르게 설정하겠습니다.")
|
||||
layout = QVBoxLayout()
|
||||
intro = QLabel(
|
||||
@@ -51,7 +51,7 @@ class WelcomePage(QWizardPage):
|
||||
class WorkPatternPage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle("🕘 근무 패턴")
|
||||
self.setTitle("근무 패턴")
|
||||
self.setSubTitle("본인의 하루 근무 시간을 선택하세요. 나중에 설정에서 바꿀 수 있습니다.")
|
||||
|
||||
layout = QVBoxLayout()
|
||||
@@ -127,7 +127,7 @@ class WorkPatternPage(QWizardPage):
|
||||
class ClockInDetectionPage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle("⏰ 출근 시간 감지 방식")
|
||||
self.setTitle("출근 시간 감지 방식")
|
||||
self.setSubTitle("앱이 출근 시간을 자동으로 어떻게 감지할지 선택하세요.")
|
||||
|
||||
layout = QVBoxLayout()
|
||||
@@ -139,10 +139,10 @@ class ClockInDetectionPage(QWizardPage):
|
||||
layout.addWidget(opt)
|
||||
|
||||
info = QLabel(
|
||||
"\n💡 PC를 항상 켜둔 채 출근하시는 분은 두 번째 옵션을 권장합니다."
|
||||
"\nPC를 항상 켜둔 채 출근하시는 분은 두 번째 옵션을 권장합니다."
|
||||
)
|
||||
info.setWordWrap(True)
|
||||
info.setStyleSheet("color: #888; padding: 8px;")
|
||||
info.setStyleSheet("color: #909296; padding: 8px;")
|
||||
layout.addWidget(info)
|
||||
|
||||
layout.addStretch()
|
||||
@@ -159,7 +159,7 @@ class ClockInDetectionPage(QWizardPage):
|
||||
class LeaveSalaryPage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle("🌴 연차 + 💰 급여 (옵션)")
|
||||
self.setTitle("연차 + 급여 (옵션)")
|
||||
self.setSubTitle("연차 일수와 급여(선택)를 입력하세요.")
|
||||
|
||||
layout = QVBoxLayout()
|
||||
@@ -218,7 +218,7 @@ class LeaveSalaryPage(QWizardPage):
|
||||
class DiscordPage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle("💬 Discord 알림 (선택)")
|
||||
self.setTitle("Discord 알림 (선택)")
|
||||
self.setSubTitle("출퇴근 시각·휴식 권고를 Discord로 받으려면 웹훅 URL을 입력하세요. (모바일에서 푸시 알림)")
|
||||
|
||||
layout = QVBoxLayout()
|
||||
@@ -236,7 +236,7 @@ class DiscordPage(QWizardPage):
|
||||
"2. 새 웹훅 만들기 → URL 복사\n"
|
||||
"3. 위 입력란에 붙여넣기"
|
||||
)
|
||||
guide.setStyleSheet("color: #888; padding: 6px;")
|
||||
guide.setStyleSheet("color: #909296; padding: 6px;")
|
||||
guide.setWordWrap(True)
|
||||
layout.addWidget(guide)
|
||||
|
||||
@@ -277,14 +277,14 @@ class DiscordPage(QWizardPage):
|
||||
class FinishPage(QWizardPage):
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self.setTitle("🎉 준비 완료!")
|
||||
self.setTitle("준비 완료!")
|
||||
self.setSubTitle("이제 출근부터 자동 추적됩니다.")
|
||||
|
||||
layout = QVBoxLayout()
|
||||
msg = QLabel(
|
||||
"설정한 내용은 [설정] 메뉴에서 언제든 바꿀 수 있습니다.\n"
|
||||
"온보딩을 다시 보고 싶으면 [도움말 → 온보딩 다시 보기]를 누르세요.\n\n"
|
||||
"🕐 단축키:\n"
|
||||
"단축키:\n"
|
||||
" • Ctrl+O — 출퇴근 토글\n"
|
||||
" • F1 — 도움말\n"
|
||||
" • F5 — 업데이트 확인\n"
|
||||
|
||||
+46
-3
@@ -66,6 +66,8 @@ class OvertimeView(QDialog):
|
||||
self.earned_table.setAlternatingRowColors(True)
|
||||
self.earned_table.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||
self.earned_table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
self.earned_table.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.earned_table.customContextMenuRequested.connect(self.show_earned_context_menu)
|
||||
earned_layout.addWidget(self.earned_table)
|
||||
|
||||
add_earned_button = QPushButton(tr('view.overtime.btn_add_earned'))
|
||||
@@ -126,7 +128,7 @@ class OvertimeView(QDialog):
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT ob.date, ob.earned_minutes, ob.work_record_id, wr.memo
|
||||
SELECT ob.date, ob.earned_minutes, ob.work_record_id, wr.memo, ob.id
|
||||
FROM overtime_bank ob
|
||||
LEFT JOIN work_records wr ON ob.work_record_id = wr.id
|
||||
ORDER BY ob.date DESC
|
||||
@@ -138,6 +140,7 @@ class OvertimeView(QDialog):
|
||||
for i, record in enumerate(earned_records):
|
||||
date_item = QTableWidgetItem(record[0])
|
||||
date_item.setTextAlignment(Qt.AlignCenter)
|
||||
date_item.setData(Qt.UserRole, record[4]) # overtime_bank.id 저장 (삭제용)
|
||||
|
||||
minutes = record[1]
|
||||
hours = minutes // 60
|
||||
@@ -148,7 +151,7 @@ class OvertimeView(QDialog):
|
||||
time_str = tr('view.break.duration_min_only', m=mins)
|
||||
time_item = QTableWidgetItem(time_str)
|
||||
time_item.setTextAlignment(Qt.AlignCenter)
|
||||
time_item.setForeground(QColor(39, 174, 96)) # 초록색
|
||||
time_item.setForeground(QColor(81, 207, 102)) # 적립 = 그린 (#51CF66)
|
||||
|
||||
# work_record_id NULL이면 "수동 추가", 아니면 wr.memo
|
||||
memo_text = manual_label if record[2] is None else (record[3] or "")
|
||||
@@ -183,7 +186,7 @@ class OvertimeView(QDialog):
|
||||
time_str = tr('view.break.duration_min_only', m=mins)
|
||||
time_item = QTableWidgetItem(time_str)
|
||||
time_item.setTextAlignment(Qt.AlignCenter)
|
||||
time_item.setForeground(QColor(231, 76, 60)) # 빨간색
|
||||
time_item.setForeground(QColor(250, 82, 82)) # 사용 = 레드 (#FA5252)
|
||||
|
||||
reason_item = QTableWidgetItem(record[3] or "")
|
||||
|
||||
@@ -249,6 +252,46 @@ class OvertimeView(QDialog):
|
||||
if self.parent() and hasattr(self.parent(), 'update_overtime_balance'):
|
||||
self.parent().update_overtime_balance()
|
||||
|
||||
def show_earned_context_menu(self, position):
|
||||
"""적립 내역 우클릭 메뉴 (삭제)."""
|
||||
selected_rows = self.earned_table.selectionModel().selectedRows()
|
||||
if not selected_rows:
|
||||
return
|
||||
menu = QMenu(self)
|
||||
delete_action = QAction(tr('view.overtime.menu_delete'), self)
|
||||
delete_action.triggered.connect(self.delete_earned_record)
|
||||
menu.addAction(delete_action)
|
||||
menu.exec_(self.earned_table.viewport().mapToGlobal(position))
|
||||
|
||||
def delete_earned_record(self):
|
||||
"""적립 기록 삭제 (overtime_bank에서 제거 → 잔액 즉시 감소)."""
|
||||
selected_rows = self.earned_table.selectionModel().selectedRows()
|
||||
if not selected_rows:
|
||||
return
|
||||
row = selected_rows[0].row()
|
||||
date_item = self.earned_table.item(row, 0)
|
||||
time_item = self.earned_table.item(row, 1)
|
||||
|
||||
# 행에 저장된 overtime_bank.id
|
||||
bank_id = date_item.data(Qt.UserRole)
|
||||
if bank_id is None:
|
||||
return
|
||||
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
tr('msg.confirm_delete.title'),
|
||||
tr('view.overtime.delete_earned_confirm_body',
|
||||
date=date_item.text(), time=time_item.text()),
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
|
||||
if reply == QMessageBox.Yes:
|
||||
self.db.delete_overtime_earned(bank_id)
|
||||
self.load_data()
|
||||
# 부모 윈도우 잔액 업데이트
|
||||
if self.parent() and hasattr(self.parent(), 'update_overtime_balance'):
|
||||
self.parent().update_overtime_balance()
|
||||
|
||||
def add_earned_record(self):
|
||||
"""수동 적립 추가"""
|
||||
dialog = AddOvertimeEarnedDialog(self, self.db)
|
||||
|
||||
@@ -26,7 +26,7 @@ class PastRecordDialog(QDialog):
|
||||
layout.setSpacing(8)
|
||||
layout.setContentsMargins(20, 16, 20, 16)
|
||||
|
||||
info = QLabel(f"📅 {date_str} 근무 기록을 입력하세요.")
|
||||
info = QLabel(f"{date_str} 근무 기록을 입력하세요.")
|
||||
info.setStyleSheet("font-weight: bold; padding-bottom: 6px;")
|
||||
layout.addWidget(info)
|
||||
|
||||
@@ -56,9 +56,9 @@ class PastRecordDialog(QDialog):
|
||||
|
||||
# 점심/저녁
|
||||
meal_row = QHBoxLayout()
|
||||
self.lunch_check = QCheckBox("🍱 점심시간 포함")
|
||||
self.lunch_check = QCheckBox("점심시간 포함")
|
||||
self.lunch_check.setChecked(True)
|
||||
self.dinner_check = QCheckBox("🍽 저녁시간 포함")
|
||||
self.dinner_check = QCheckBox("저녁시간 포함")
|
||||
meal_row.addWidget(self.lunch_check)
|
||||
meal_row.addWidget(self.dinner_check)
|
||||
meal_row.addStretch()
|
||||
|
||||
@@ -27,7 +27,7 @@ class RecurringLeaveDialog(QDialog):
|
||||
def __init__(self, parent=None, db=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle("🔁 반복 연차 관리")
|
||||
self.setWindowTitle("반복 연차 관리")
|
||||
self.setMinimumSize(540, 480)
|
||||
self._build_ui()
|
||||
self._reload_list()
|
||||
@@ -129,7 +129,7 @@ class RecurringLeaveDialog(QDialog):
|
||||
ag.addLayout(memo_row)
|
||||
|
||||
# 추가 버튼
|
||||
add_btn = QPushButton("➕ 추가")
|
||||
add_btn = QPushButton("추가")
|
||||
add_btn.setObjectName("btn_primary")
|
||||
add_btn.clicked.connect(self._save)
|
||||
ag.addWidget(add_btn)
|
||||
|
||||
+7
-7
@@ -38,7 +38,7 @@ class ScheduleView(QDialog):
|
||||
def __init__(self, parent=None, db=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle("🗓️ 스케줄")
|
||||
self.setWindowTitle("스케줄")
|
||||
self.setMinimumSize(820, 560)
|
||||
self._build_ui()
|
||||
self._reload()
|
||||
@@ -54,11 +54,11 @@ class ScheduleView(QDialog):
|
||||
bar.addWidget(title)
|
||||
bar.addStretch()
|
||||
|
||||
rec_btn = QPushButton("🔁 반복 패턴 관리")
|
||||
rec_btn = QPushButton("반복 패턴 관리")
|
||||
rec_btn.clicked.connect(self._open_recurring_dialog)
|
||||
bar.addWidget(rec_btn)
|
||||
|
||||
add_btn = QPushButton("➕ 연차 등록")
|
||||
add_btn = QPushButton("연차 등록")
|
||||
add_btn.clicked.connect(self._open_add_leave_dialog)
|
||||
bar.addWidget(add_btn)
|
||||
|
||||
@@ -196,11 +196,11 @@ class ScheduleView(QDialog):
|
||||
# 휴일
|
||||
holiday = self.db.get_holiday(date_str) if hasattr(self.db, 'get_holiday') else None
|
||||
if holiday:
|
||||
item = QListWidgetItem(f"🎌 공휴일: {holiday.get('name', '공휴일')}")
|
||||
item = QListWidgetItem(f"공휴일: {holiday.get('name', '공휴일')}")
|
||||
item.setForeground(QBrush(QColor("#e53935")))
|
||||
self.detail_list.addItem(item)
|
||||
elif d.weekday() in (5, 6):
|
||||
item = QListWidgetItem(f"🏖️ 주말 ({weekday_kr[d.weekday()]}요일)")
|
||||
item = QListWidgetItem(f"주말 ({weekday_kr[d.weekday()]}요일)")
|
||||
self.detail_list.addItem(item)
|
||||
|
||||
# 연차 (구체)
|
||||
@@ -208,7 +208,7 @@ class ScheduleView(QDialog):
|
||||
days = float(r.get('days') or 0)
|
||||
t = r.get('leave_type', '연차')
|
||||
memo = r.get('memo') or ''
|
||||
label = f"📌 {t} {days}일"
|
||||
label = f"{t} {days}일"
|
||||
if memo:
|
||||
label += f" — {memo}"
|
||||
label += f" [id={r['id']}]"
|
||||
@@ -221,7 +221,7 @@ class ScheduleView(QDialog):
|
||||
from core.recurring_leaves import expand_for_date
|
||||
for occ in expand_for_date(recurring, d):
|
||||
item = QListWidgetItem(
|
||||
f"🔁 {describe_pattern(occ.pattern)} · {occ.days}일 ({occ.leave_type})"
|
||||
f"{describe_pattern(occ.pattern)} · {occ.days}일 ({occ.leave_type})"
|
||||
)
|
||||
item.setData(Qt.UserRole, ('recurring', occ.recurring_id))
|
||||
self.detail_list.addItem(item)
|
||||
|
||||
+6
-5
@@ -516,7 +516,7 @@ class SettingsView(QDialog):
|
||||
|
||||
def create_goal_group(self) -> QGroupBox:
|
||||
"""월간 목표 설정 그룹 (0=비활성)."""
|
||||
group = QGroupBox("🎯 월간 목표 (0=비활성)")
|
||||
group = QGroupBox("월간 목표 (0=비활성)")
|
||||
layout = QVBoxLayout()
|
||||
layout.setSpacing(6)
|
||||
|
||||
@@ -688,10 +688,11 @@ class SettingsView(QDialog):
|
||||
"한국 공휴일 자동 추가",
|
||||
f"{target_label} 한국 공휴일을 자동으로 등록하시겠습니까?\n\n"
|
||||
"포함:\n"
|
||||
"• 양력 공휴일 (신정/삼일절/어린이날 등)\n"
|
||||
"• 양력 공휴일 (신정/삼일절/어린이날/근로자의 날 등)\n"
|
||||
"• 음력 명절 (설날 연휴/추석 연휴/석가탄신일)\n"
|
||||
"• 정부 지정 대체·임시공휴일\n\n"
|
||||
"※ 외부 'holidays' 패키지 사용 (requirements.txt 참조)",
|
||||
"※ 1차: 공공데이터포털 특일정보 API (정부 공인, 임시공휴일 포함)\n"
|
||||
"※ 2차 fallback: 'holidays' 패키지 (오프라인)",
|
||||
QMessageBox.Yes | QMessageBox.No
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
@@ -864,7 +865,7 @@ class SettingsView(QDialog):
|
||||
|
||||
# CSV 가져오기
|
||||
import_layout = QHBoxLayout()
|
||||
import_btn = QPushButton("📥 CSV 가져오기")
|
||||
import_btn = QPushButton("CSV 가져오기")
|
||||
import_btn.setObjectName("btn_small")
|
||||
import_btn.setToolTip("date,clock_in,clock_out,lunch_minutes,memo 헤더 포맷")
|
||||
import_btn.clicked.connect(self._import_csv)
|
||||
@@ -1067,7 +1068,7 @@ class SettingsView(QDialog):
|
||||
self.time_format_combo.setCurrentIndex(index)
|
||||
|
||||
# 테마
|
||||
self.theme_combo.setCurrentIndex(0 if settings.get(THEME, 'light') == 'light' else 1)
|
||||
self.theme_combo.setCurrentIndex(0 if settings.get(THEME, 'dark') == 'light' else 1)
|
||||
|
||||
# 언어 선택 적용
|
||||
if hasattr(self, 'language_combo'):
|
||||
|
||||
+13
-13
@@ -42,7 +42,7 @@ class StatsView(QDialog):
|
||||
layout.setContentsMargins(20, 16, 20, 14)
|
||||
|
||||
# 다크 톤 타이틀
|
||||
title = QLabel(f"📊 {tr('stats.title')}")
|
||||
title = QLabel(f"{tr('stats.title')}")
|
||||
title.setStyleSheet(
|
||||
f"font-size: 18pt; font-weight: bold; color: {DARK_TEXT}; "
|
||||
f"background: transparent; border: none; padding: 4px 0;"
|
||||
@@ -94,13 +94,13 @@ class StatsView(QDialog):
|
||||
cards_row = QHBoxLayout()
|
||||
cards_row.setSpacing(10)
|
||||
self.weekly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 주",
|
||||
theme='blue', icon='⏱️')
|
||||
theme='blue', icon='clock')
|
||||
self.weekly_days_card = build_stat_card("근무 일수", "0일", "이번 주",
|
||||
theme='cyan', icon='📅')
|
||||
theme='cyan', icon='calendar')
|
||||
self.weekly_avg_card = build_stat_card("일평균", "0시간", "이번 주",
|
||||
theme='green', icon='📊')
|
||||
theme='green', icon='chart')
|
||||
self.weekly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 주",
|
||||
theme='gold', icon='🔥')
|
||||
theme='gold', icon='flame')
|
||||
for c in (self.weekly_total_card, self.weekly_days_card,
|
||||
self.weekly_avg_card, self.weekly_ot_card):
|
||||
cards_row.addWidget(c, 1)
|
||||
@@ -110,7 +110,7 @@ class StatsView(QDialog):
|
||||
from ui.chart_widget import make_chart_widget
|
||||
self.weekly_chart = make_chart_widget(widget)
|
||||
chart_card = build_section_card("일별 근무 시간", self.weekly_chart,
|
||||
theme='gray', icon='📈')
|
||||
theme='gray', icon='trending-up')
|
||||
layout.addWidget(chart_card, 1)
|
||||
|
||||
widget.setLayout(layout)
|
||||
@@ -128,13 +128,13 @@ class StatsView(QDialog):
|
||||
cards_row = QHBoxLayout()
|
||||
cards_row.setSpacing(10)
|
||||
self.monthly_total_card = build_stat_card("총 근무 시간", "0시간", "이번 달",
|
||||
theme='blue', icon='⏱️')
|
||||
theme='blue', icon='clock')
|
||||
self.monthly_days_card = build_stat_card("근무 일수", "0일", "이번 달",
|
||||
theme='cyan', icon='📅')
|
||||
theme='cyan', icon='calendar')
|
||||
self.monthly_avg_card = build_stat_card("일평균", "0시간", "이번 달",
|
||||
theme='green', icon='📊')
|
||||
theme='green', icon='chart')
|
||||
self.monthly_ot_card = build_stat_card("연장근무", "0시간 0분", "이번 달",
|
||||
theme='gold', icon='🔥')
|
||||
theme='gold', icon='flame')
|
||||
for c in (self.monthly_total_card, self.monthly_days_card,
|
||||
self.monthly_avg_card, self.monthly_ot_card):
|
||||
cards_row.addWidget(c, 1)
|
||||
@@ -160,7 +160,7 @@ class StatsView(QDialog):
|
||||
from ui.chart_widget import make_chart_widget
|
||||
self.monthly_chart = make_chart_widget(widget)
|
||||
chart_card = build_section_card("요일별 평균", self.monthly_chart,
|
||||
theme='gray', icon='📊')
|
||||
theme='gray', icon='chart')
|
||||
layout.addWidget(chart_card, 1)
|
||||
|
||||
widget.setLayout(layout)
|
||||
@@ -183,13 +183,13 @@ class StatsView(QDialog):
|
||||
f"background: transparent; border: none; padding: 4px 0;"
|
||||
)
|
||||
layout.addWidget(build_section_card("패턴 인사이트", self.pattern_text,
|
||||
theme='cyan', icon='🔍'))
|
||||
theme='cyan', icon='search'))
|
||||
|
||||
# 출근 시각 분포 차트
|
||||
from ui.chart_widget import make_chart_widget
|
||||
self.clock_in_chart = make_chart_widget(widget)
|
||||
layout.addWidget(build_section_card("출근 시각 분포", self.clock_in_chart,
|
||||
theme='gray', icon='⏰'), 1)
|
||||
theme='gray', icon='clock'), 1)
|
||||
|
||||
widget.setLayout(layout)
|
||||
return widget
|
||||
|
||||
+181
-101
@@ -33,8 +33,8 @@ def _ensure_icons():
|
||||
for name, color_hex, points in [
|
||||
('up_light', '#4A4A68', [(4, 7), (8, 3), (12, 7)]),
|
||||
('down_light', '#4A4A68', [(4, 5), (8, 9), (12, 5)]),
|
||||
('up_dark', '#A0A0B8', [(4, 7), (8, 3), (12, 7)]),
|
||||
('down_dark', '#A0A0B8', [(4, 5), (8, 9), (12, 5)]),
|
||||
('up_dark', '#909296', [(4, 7), (8, 3), (12, 7)]),
|
||||
('down_dark', '#909296', [(4, 5), (8, 9), (12, 5)]),
|
||||
]:
|
||||
path = os.path.join(_arrow_dir, f'{name}.png')
|
||||
if not os.path.exists(path):
|
||||
@@ -78,6 +78,9 @@ LIGHT_COLORS = {
|
||||
'bg_primary': '#F5F5F7',
|
||||
'bg_secondary': '#FFFFFF',
|
||||
'bg_tertiary': '#EDEDF0',
|
||||
# 인터랙션 표면
|
||||
'surface_hover': '#E2E3E7',
|
||||
'surface_pressed': '#D5D6DB',
|
||||
# 텍스트 계층
|
||||
'text_primary': '#1A1A2E',
|
||||
'text_secondary': '#4A4A68',
|
||||
@@ -85,9 +88,15 @@ LIGHT_COLORS = {
|
||||
'text_inverse': '#FFFFFF',
|
||||
# 액센트
|
||||
'accent_primary': '#3B82F6',
|
||||
'accent_primary_hover': '#2F74EE',
|
||||
'accent_primary_pressed': '#2563EB',
|
||||
'accent_success': '#10B981',
|
||||
'accent_success_hover': '#0EA372',
|
||||
'accent_success_pressed': '#0C8F63',
|
||||
'accent_warning': '#F59E0B',
|
||||
'accent_danger': '#EF4444',
|
||||
'accent_danger_hover': '#DC2626',
|
||||
'accent_danger_pressed': '#B91C1C',
|
||||
# 테두리
|
||||
'border_subtle': '#E5E7EB',
|
||||
'border_default': '#D1D5DB',
|
||||
@@ -120,40 +129,58 @@ LIGHT_COLORS = {
|
||||
}
|
||||
|
||||
DARK_COLORS = {
|
||||
'bg_primary': '#111118',
|
||||
'bg_secondary': '#1C1C2E',
|
||||
'bg_tertiary': '#282842',
|
||||
'text_primary': '#ECECF4',
|
||||
'text_secondary': '#B0B0C8',
|
||||
'text_tertiary': '#808098',
|
||||
# 배경 계층 — 모던 다크 (Notion/Linear 톤)
|
||||
'bg_primary': '#1A1B1E', # 앱 배경
|
||||
'bg_secondary': '#25262B', # 카드 / 패널
|
||||
'bg_tertiary': '#2C2E33', # 기본 버튼 / 미묘한 채움
|
||||
# 인터랙션 표면
|
||||
'surface_hover': '#34363D',
|
||||
'surface_pressed': '#3A3D44',
|
||||
# 텍스트 계층
|
||||
'text_primary': '#E9ECEF',
|
||||
'text_secondary': '#909296',
|
||||
'text_tertiary': '#6C6E73',
|
||||
'text_inverse': '#FFFFFF',
|
||||
'accent_primary': '#6B9EFF',
|
||||
'accent_success': '#4ADE80',
|
||||
'accent_warning': '#FCD34D',
|
||||
'accent_danger': '#FB7185',
|
||||
'border_subtle': '#32324E',
|
||||
'border_default': '#44446A',
|
||||
'border_focus': '#6B9EFF',
|
||||
'badge_overtime_bg': '#3D2008',
|
||||
'badge_overtime_text': '#FDE68A',
|
||||
'badge_leave_bg': '#1E2D5F',
|
||||
'badge_leave_text': '#A5D0FE',
|
||||
'badge_total_bg': '#0A3324',
|
||||
'badge_total_text': '#86EFAC',
|
||||
'progress_bg': '#282842',
|
||||
'progress_start': '#6B9EFF',
|
||||
'progress_end': '#4ADE80',
|
||||
'status_overtime': '#FB7185',
|
||||
'status_warning': '#FCD34D',
|
||||
'status_normal': '#4ADE80',
|
||||
'status_break_active': '#FB7185',
|
||||
'status_break_idle': '#808098',
|
||||
'cal_normal': '#1A4D3A',
|
||||
'cal_overtime': '#5C1A1A',
|
||||
'cal_incomplete': '#5C3A10',
|
||||
'scrollbar_bg': '#111118',
|
||||
'scrollbar_handle': '#44446A',
|
||||
'scrollbar_hover': '#5A5A88',
|
||||
# 액센트 — 단일 포인트 컬러 (주요 버튼 + 포커스 전용)
|
||||
'accent_primary': '#4DABF7',
|
||||
'accent_primary_hover': '#69B6F8',
|
||||
'accent_primary_pressed': '#3D97E0',
|
||||
'accent_success': '#51CF66',
|
||||
'accent_success_hover': '#69DB7C',
|
||||
'accent_success_pressed': '#43B85A',
|
||||
'accent_warning': '#FAB005',
|
||||
'accent_danger': '#FA5252',
|
||||
'accent_danger_hover': '#FF6B6B',
|
||||
'accent_danger_pressed': '#E64545',
|
||||
# 테두리
|
||||
'border_subtle': '#2C2E33',
|
||||
'border_default': '#373A40',
|
||||
'border_focus': '#4DABF7',
|
||||
# 배지 — 플랫 (미묘한 배경 + 색조 텍스트로 미니멀 유지)
|
||||
'badge_overtime_bg': '#2C2E33',
|
||||
'badge_overtime_text': '#FAB005',
|
||||
'badge_leave_bg': '#2C2E33',
|
||||
'badge_leave_text': '#4DABF7',
|
||||
'badge_total_bg': '#2C2E33',
|
||||
'badge_total_text': '#51CF66',
|
||||
# 프로그레스 — 단일 accent 솔리드
|
||||
'progress_bg': '#2C2E33',
|
||||
'progress_start': '#4DABF7',
|
||||
'progress_end': '#4DABF7',
|
||||
# 상태 색상 (동적 텍스트 피드백)
|
||||
'status_overtime': '#51CF66', # 퇴근 가능(연장근무 진입) = 그린
|
||||
'status_warning': '#FAB005',
|
||||
'status_normal': '#51CF66',
|
||||
'status_break_active': '#FA5252',
|
||||
'status_break_idle': '#6C6E73',
|
||||
# 캘린더 날짜 배경 — 미묘한 다크 틴트
|
||||
'cal_normal': '#1E3A2A',
|
||||
'cal_overtime': '#3A2122',
|
||||
'cal_incomplete': '#3A331E',
|
||||
# 스크롤바
|
||||
'scrollbar_bg': '#1A1B1E',
|
||||
'scrollbar_handle': '#373A40',
|
||||
'scrollbar_hover': '#4DABF7',
|
||||
}
|
||||
|
||||
|
||||
@@ -192,7 +219,7 @@ QMainWindow, QDialog {{
|
||||
}}
|
||||
|
||||
QWidget {{
|
||||
font-family: "Segoe UI", "맑은 고딕", sans-serif;
|
||||
font-family: "NanumSquare", "NanumSquareOTF", "Malgun Gothic", "맑은 고딕", sans-serif;
|
||||
font-size: 9.5pt;
|
||||
color: {c['text_primary']};
|
||||
}}
|
||||
@@ -206,14 +233,14 @@ QWidget#central_widget {{
|
||||
════════════════════════════════════════ */
|
||||
|
||||
QLabel#app_title {{
|
||||
font-size: 12pt;
|
||||
font-size: 13pt;
|
||||
font-weight: bold;
|
||||
color: {c['text_primary']};
|
||||
padding: 2px;
|
||||
}}
|
||||
|
||||
QLabel#date_label {{
|
||||
font-size: 9pt;
|
||||
font-size: 9.5pt;
|
||||
color: {c['text_secondary']};
|
||||
padding-bottom: 4px;
|
||||
}}
|
||||
@@ -221,7 +248,7 @@ QLabel#date_label {{
|
||||
QLabel#section_title {{
|
||||
font-size: 9.5pt;
|
||||
font-weight: bold;
|
||||
color: {c['text_primary']};
|
||||
color: {c['text_secondary']};
|
||||
}}
|
||||
|
||||
QLabel#field_label {{
|
||||
@@ -229,29 +256,30 @@ QLabel#field_label {{
|
||||
color: {c['text_secondary']};
|
||||
}}
|
||||
|
||||
/* 출근/현재 시각 — 한 줄 나란히 표시되는 중간 크기 모노스페이스 */
|
||||
QLabel#time_value {{
|
||||
font-family: "Consolas", "D2Coding", monospace;
|
||||
font-size: 11pt;
|
||||
font-size: 15pt;
|
||||
font-weight: bold;
|
||||
color: {c['text_primary']};
|
||||
}}
|
||||
|
||||
/* 히어로 — 남은 시간 (화면에서 가장 큰 결과 표시). 카드 안에 투명 배치 */
|
||||
QLabel#time_display {{
|
||||
font-family: "Consolas", "D2Coding", monospace;
|
||||
font-size: 22pt;
|
||||
font-size: 30pt;
|
||||
font-weight: bold;
|
||||
color: {c['text_primary']};
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px 0;
|
||||
}}
|
||||
|
||||
QLabel#expected_time {{
|
||||
font-size: 10pt;
|
||||
font-size: 11.5pt;
|
||||
font-weight: bold;
|
||||
color: {c['text_primary']};
|
||||
padding: 4px;
|
||||
color: {c['text_secondary']};
|
||||
padding: 2px;
|
||||
}}
|
||||
|
||||
QLabel#dialog_title {{
|
||||
@@ -295,7 +323,7 @@ QLabel#badge_overtime {{
|
||||
qproperty-alignment: AlignCenter;
|
||||
background: {c['badge_overtime_bg']};
|
||||
color: {c['badge_overtime_text']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
QLabel#badge_leave {{
|
||||
@@ -306,7 +334,7 @@ QLabel#badge_leave {{
|
||||
qproperty-alignment: AlignCenter;
|
||||
background: {c['badge_leave_bg']};
|
||||
color: {c['badge_leave_text']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
QLabel#badge_total {{
|
||||
@@ -317,7 +345,7 @@ QLabel#badge_total {{
|
||||
qproperty-alignment: AlignCenter;
|
||||
background: {c['badge_total_bg']};
|
||||
color: {c['badge_total_text']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
QLabel#badge_balance {{
|
||||
@@ -326,7 +354,7 @@ QLabel#badge_balance {{
|
||||
padding: 10px;
|
||||
background: {c['bg_tertiary']};
|
||||
color: {c['text_primary']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
QLabel#badge_success {{
|
||||
@@ -335,7 +363,7 @@ QLabel#badge_success {{
|
||||
padding: 8px;
|
||||
background: {c['badge_total_bg']};
|
||||
color: {c['badge_total_text']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
@@ -355,9 +383,9 @@ QLabel#separator {{
|
||||
QGroupBox {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-radius: 10px;
|
||||
border-radius: 8px;
|
||||
margin-top: 10px;
|
||||
padding: 14px;
|
||||
padding: 16px;
|
||||
padding-top: 28px;
|
||||
font-size: 9.5pt;
|
||||
color: {c['text_primary']};
|
||||
@@ -378,52 +406,55 @@ QGroupBox::title {{
|
||||
버튼
|
||||
════════════════════════════════════════ */
|
||||
|
||||
/* 기본 버튼 — 그라데이션/베벨 없는 플랫 (border:none 기반) */
|
||||
QPushButton {{
|
||||
background: {c['bg_tertiary']};
|
||||
color: {c['text_primary']};
|
||||
border: 1px solid {c['border_default']};
|
||||
border-radius: 6px;
|
||||
padding: 7px 14px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 9pt;
|
||||
}}
|
||||
|
||||
QPushButton:hover {{
|
||||
background: {c['border_default']};
|
||||
background: {c['surface_hover']};
|
||||
}}
|
||||
|
||||
QPushButton:pressed {{
|
||||
background: {c['border_subtle']};
|
||||
background: {c['surface_pressed']};
|
||||
}}
|
||||
|
||||
QPushButton:disabled {{
|
||||
background: {c['bg_tertiary']};
|
||||
background: {c['bg_secondary']};
|
||||
color: {c['text_tertiary']};
|
||||
border-color: {c['border_subtle']};
|
||||
}}
|
||||
|
||||
QPushButton:checked {{
|
||||
background: {c['accent_primary']};
|
||||
color: {c['text_inverse']};
|
||||
border-color: {c['accent_primary']};
|
||||
}}
|
||||
|
||||
/* 퇴근 버튼 (primary action) */
|
||||
QPushButton:focus {{
|
||||
outline: none;
|
||||
}}
|
||||
|
||||
/* 퇴근 버튼 — 주요 액션 (단일 포인트 컬러) */
|
||||
QPushButton#clock_out_button {{
|
||||
background: {c['accent_success']};
|
||||
background: {c['accent_primary']};
|
||||
color: {c['text_inverse']};
|
||||
font-size: 11pt;
|
||||
font-weight: bold;
|
||||
padding: 8px;
|
||||
padding: 11px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
QPushButton#clock_out_button:hover {{
|
||||
background: {'#0EA572' if not is_dark else '#2BB885'};
|
||||
background: {c['accent_primary_hover']};
|
||||
}}
|
||||
|
||||
QPushButton#clock_out_button:pressed {{
|
||||
background: {'#0C8F63' if not is_dark else '#28A87A'};
|
||||
background: {c['accent_primary_pressed']};
|
||||
}}
|
||||
|
||||
/* 주요 액션 버튼 */
|
||||
@@ -435,11 +466,11 @@ QPushButton#btn_primary {{
|
||||
}}
|
||||
|
||||
QPushButton#btn_primary:hover {{
|
||||
background: {c['accent_primary']}DD;
|
||||
background: {c['accent_primary_hover']};
|
||||
}}
|
||||
|
||||
QPushButton#btn_primary:pressed {{
|
||||
background: {c['accent_primary']}BB;
|
||||
background: {c['accent_primary_pressed']};
|
||||
}}
|
||||
|
||||
/* 위험 버튼 */
|
||||
@@ -450,11 +481,11 @@ QPushButton#btn_danger {{
|
||||
}}
|
||||
|
||||
QPushButton#btn_danger:hover {{
|
||||
background: {c['accent_danger']}DD;
|
||||
background: {c['accent_danger_hover']};
|
||||
}}
|
||||
|
||||
QPushButton#btn_danger:pressed {{
|
||||
background: {c['accent_danger']}BB;
|
||||
background: {c['accent_danger_pressed']};
|
||||
}}
|
||||
|
||||
/* 성공 버튼 */
|
||||
@@ -465,25 +496,44 @@ QPushButton#btn_success {{
|
||||
}}
|
||||
|
||||
QPushButton#btn_success:hover {{
|
||||
background: {c['accent_success']}DD;
|
||||
background: {c['accent_success_hover']};
|
||||
}}
|
||||
|
||||
QPushButton#btn_success:pressed {{
|
||||
background: {c['accent_success']}BB;
|
||||
background: {c['accent_success_pressed']};
|
||||
}}
|
||||
|
||||
/* 작은 버튼 */
|
||||
/* 작은 버튼 — 미묘한 표면 */
|
||||
QPushButton#btn_small {{
|
||||
font-size: 8.5pt;
|
||||
padding: 5px 10px;
|
||||
padding: 6px 10px;
|
||||
}}
|
||||
|
||||
QPushButton#btn_small:hover {{
|
||||
background: {c['accent_primary']}20;
|
||||
background: {c['surface_hover']};
|
||||
}}
|
||||
|
||||
QPushButton#btn_small:pressed {{
|
||||
background: {c['accent_primary']}35;
|
||||
background: {c['surface_pressed']};
|
||||
}}
|
||||
|
||||
/* 하단 네비게이션 — 라인 아이콘 + 라벨, 투명 배경 (Linear/Notion 풋터 톤) */
|
||||
QPushButton#nav_btn {{
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 4px;
|
||||
font-size: 8.5pt;
|
||||
color: {c['text_secondary']};
|
||||
}}
|
||||
|
||||
QPushButton#nav_btn:hover {{
|
||||
background: {c['surface_hover']};
|
||||
color: {c['text_primary']};
|
||||
}}
|
||||
|
||||
QPushButton#nav_btn:pressed {{
|
||||
background: {c['surface_pressed']};
|
||||
}}
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
@@ -493,7 +543,7 @@ QPushButton#btn_small:pressed {{
|
||||
QLineEdit, QTextEdit, QComboBox {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_default']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
color: {c['text_primary']};
|
||||
font-size: 9.5pt;
|
||||
@@ -503,21 +553,17 @@ QLineEdit, QTextEdit, QComboBox {{
|
||||
QSpinBox, QDoubleSpinBox, QDateEdit, QTimeEdit {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_default']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
padding: 6px 28px 6px 8px;
|
||||
color: {c['text_primary']};
|
||||
font-size: 9.5pt;
|
||||
min-height: 20px;
|
||||
}}
|
||||
|
||||
QLineEdit:focus, QTextEdit:focus, QComboBox:focus {{
|
||||
border: 2px solid {c['border_focus']};
|
||||
padding: 5px 7px;
|
||||
}}
|
||||
|
||||
/* 포커스 시 보더 컬러만 포인트 컬러로 (두께 유지 → 레이아웃 흔들림 없음) */
|
||||
QLineEdit:focus, QTextEdit:focus, QComboBox:focus,
|
||||
QSpinBox:focus, QDoubleSpinBox:focus, QDateEdit:focus, QTimeEdit:focus {{
|
||||
border: 2px solid {c['border_focus']};
|
||||
padding: 5px 27px 5px 7px;
|
||||
border: 1px solid {c['border_focus']};
|
||||
}}
|
||||
|
||||
/* 비활성 입력 필드 */
|
||||
@@ -563,13 +609,13 @@ QTimeEdit::up-button, QTimeEdit::down-button {{
|
||||
QSpinBox::up-button, QDoubleSpinBox::up-button,
|
||||
QDateEdit::up-button, QTimeEdit::up-button {{
|
||||
subcontrol-position: top right;
|
||||
border-top-right-radius: 4px;
|
||||
border-top-right-radius: 7px;
|
||||
}}
|
||||
|
||||
QSpinBox::down-button, QDoubleSpinBox::down-button,
|
||||
QDateEdit::down-button, QTimeEdit::down-button {{
|
||||
subcontrol-position: bottom right;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom-right-radius: 7px;
|
||||
}}
|
||||
|
||||
QSpinBox::up-button:hover, QSpinBox::down-button:hover,
|
||||
@@ -628,17 +674,17 @@ QCheckBox::indicator:hover {{
|
||||
QProgressBar {{
|
||||
border: none;
|
||||
background: {c['progress_bg']};
|
||||
border-radius: 4px;
|
||||
height: 8px;
|
||||
border-radius: 3px;
|
||||
min-height: 6px;
|
||||
max-height: 6px;
|
||||
text-align: center;
|
||||
color: transparent;
|
||||
font-size: 0px;
|
||||
}}
|
||||
|
||||
QProgressBar::chunk {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 {c['progress_start']}, stop:1 {c['progress_end']});
|
||||
border-radius: 4px;
|
||||
background: {c['progress_start']};
|
||||
border-radius: 3px;
|
||||
}}
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
@@ -648,7 +694,7 @@ QProgressBar::chunk {{
|
||||
QTableWidget {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
gridline-color: {c['border_subtle']};
|
||||
color: {c['text_primary']};
|
||||
font-size: 9pt;
|
||||
@@ -667,23 +713,47 @@ QTableWidget::item:alternate {{
|
||||
background: {c['bg_tertiary']};
|
||||
}}
|
||||
|
||||
/* 헤더 위젯 배경 (세로헤더 빈 영역의 흰색 누수 방지) */
|
||||
QHeaderView {{
|
||||
background: {c['bg_secondary']};
|
||||
border: none;
|
||||
}}
|
||||
|
||||
QHeaderView::section {{
|
||||
background: {c['bg_tertiary']};
|
||||
color: {c['text_secondary']};
|
||||
padding: 8px;
|
||||
border: none;
|
||||
border-bottom: 2px solid {c['accent_primary']};
|
||||
font-weight: bold;
|
||||
font-size: 9pt;
|
||||
}}
|
||||
|
||||
QHeaderView::section:horizontal {{
|
||||
border-bottom: 2px solid {c['accent_primary']};
|
||||
}}
|
||||
|
||||
/* 세로헤더(행번호) — accent 밑줄 없이 미묘하게 */
|
||||
QHeaderView::section:vertical {{
|
||||
border-right: 1px solid {c['border_subtle']};
|
||||
color: {c['text_tertiary']};
|
||||
font-weight: normal;
|
||||
padding: 4px 8px;
|
||||
}}
|
||||
|
||||
/* 테이블 좌상단 코너 버튼 (흰색 누수 방지) */
|
||||
QTableView QTableCornerButton::section {{
|
||||
background: {c['bg_tertiary']};
|
||||
border: none;
|
||||
border-bottom: 2px solid {c['accent_primary']};
|
||||
}}
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
탭 위젯
|
||||
════════════════════════════════════════ */
|
||||
|
||||
QTabWidget::pane {{
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
background: {c['bg_secondary']};
|
||||
top: -1px;
|
||||
}}
|
||||
@@ -694,8 +764,8 @@ QTabBar::tab {{
|
||||
padding: 8px 20px;
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-bottom: none;
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
margin-right: 2px;
|
||||
font-size: 10pt;
|
||||
}}
|
||||
@@ -787,7 +857,7 @@ QScrollArea > QWidget > QWidget#scroll_content {{
|
||||
QCalendarWidget {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 10pt;
|
||||
}}
|
||||
|
||||
@@ -902,7 +972,7 @@ QToolTip {{
|
||||
QMenu {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_default']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
color: {c['text_primary']};
|
||||
}}
|
||||
@@ -916,6 +986,16 @@ QMenu::item:selected {{
|
||||
background: {c['accent_primary']};
|
||||
color: {c['text_inverse']};
|
||||
}}
|
||||
|
||||
QMenu::separator {{
|
||||
height: 1px;
|
||||
background: {c['border_subtle']};
|
||||
margin: 4px 8px;
|
||||
}}
|
||||
|
||||
QMenu::icon {{
|
||||
padding-left: 8px;
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
|
||||
+8
-8
@@ -16,12 +16,12 @@ class TodaySummaryCard(QFrame):
|
||||
self.setObjectName("today_summary_card")
|
||||
self.setStyleSheet("""
|
||||
QFrame#today_summary_card {
|
||||
background-color: rgba(76, 175, 80, 0.08);
|
||||
border: 1px solid rgba(76, 175, 80, 0.4);
|
||||
background-color: rgba(81, 207, 102, 0.08);
|
||||
border: 1px solid rgba(81, 207, 102, 0.40);
|
||||
border-radius: 8px;
|
||||
padding: 6px;
|
||||
}
|
||||
QLabel { padding: 1px; }
|
||||
QLabel { padding: 1px; background: transparent; border: none; }
|
||||
""")
|
||||
self.setVisible(False)
|
||||
|
||||
@@ -30,7 +30,7 @@ class TodaySummaryCard(QFrame):
|
||||
layout.setSpacing(2)
|
||||
|
||||
header = QHBoxLayout()
|
||||
title = QLabel("📋 오늘의 요약")
|
||||
title = QLabel("오늘의 요약")
|
||||
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||
header.addWidget(title)
|
||||
header.addStretch()
|
||||
@@ -43,9 +43,9 @@ class TodaySummaryCard(QFrame):
|
||||
|
||||
self.total_label = QLabel("")
|
||||
self.detail_label = QLabel("")
|
||||
self.detail_label.setStyleSheet("color: #888; font-size: 11px;")
|
||||
self.detail_label.setStyleSheet("color: #909296; font-size: 11px;")
|
||||
self.salary_label = QLabel("")
|
||||
self.salary_label.setStyleSheet("color: #4caf50; font-weight: bold;")
|
||||
self.salary_label.setStyleSheet("color: #51CF66; font-weight: bold;")
|
||||
|
||||
layout.addWidget(self.total_label)
|
||||
layout.addWidget(self.detail_label)
|
||||
@@ -70,7 +70,7 @@ class TodaySummaryCard(QFrame):
|
||||
"""
|
||||
h = int(total_hours)
|
||||
m = int((total_hours - h) * 60)
|
||||
self.total_label.setText(f"⏱ 총 근무: {h}시간 {m}분")
|
||||
self.total_label.setText(f"총 근무: {h}시간 {m}분")
|
||||
|
||||
details = []
|
||||
if lunch_minutes > 0:
|
||||
@@ -85,7 +85,7 @@ class TodaySummaryCard(QFrame):
|
||||
self.detail_label.setVisible(bool(details))
|
||||
|
||||
if salary_text:
|
||||
self.salary_label.setText(f"💰 {salary_text}")
|
||||
self.salary_label.setText(f"{salary_text}")
|
||||
self.salary_label.setVisible(True)
|
||||
else:
|
||||
self.salary_label.setVisible(False)
|
||||
|
||||
+50
-18
@@ -13,6 +13,9 @@ Windows에서 실행 중인 .exe를 자기 자신이 덮어쓸 수 없는 제약
|
||||
3. new_exe → target_exe 이동
|
||||
4. target_exe 재실행 + 업데이터 자가 종료
|
||||
실패 시 .bak 복원
|
||||
|
||||
빌드: console=False (windowed) — 사용자 눈엔 cmd 창이 안 보임.
|
||||
모든 진단 출력은 ~/.clockout_logs/updater.log 로 append.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import argparse
|
||||
@@ -21,9 +24,30 @@ import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ── windowed 모드에서도 로그가 유실되지 않도록 파일로 폴백 ────────
|
||||
_LOG_PATH = Path.home() / '.clockout_logs' / 'updater.log'
|
||||
|
||||
|
||||
def _log(msg: str) -> None:
|
||||
"""진단 메시지를 파일에 append. console=False라 stderr는 보이지 않음."""
|
||||
line = f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n"
|
||||
# stderr도 시도 (개발 환경 .py 직접 실행 시 보임)
|
||||
try:
|
||||
print(line, end='', file=sys.stderr)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(_LOG_PATH, 'a', encoding='utf-8') as f:
|
||||
f.write(line)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def is_pid_running(pid: int) -> bool:
|
||||
"""Windows에서 PID 실행 중인지 확인."""
|
||||
if sys.platform != 'win32':
|
||||
@@ -81,8 +105,7 @@ def replace_file(new_path: Path, target_path: Path,
|
||||
try:
|
||||
backup.unlink()
|
||||
except OSError as e:
|
||||
print(f"[updater] old backup unlink failed (continuing): {e}",
|
||||
file=sys.stderr)
|
||||
_log(f"[updater] old backup unlink failed (continuing): {e}")
|
||||
|
||||
# 2단계: target → backup 이동 (락 해제 대기 재시도)
|
||||
for attempt in range(max_retries):
|
||||
@@ -94,13 +117,12 @@ def replace_file(new_path: Path, target_path: Path,
|
||||
except OSError as e:
|
||||
last_err = e
|
||||
wait = 0.3 * (2 ** attempt)
|
||||
print(f"[updater] target move attempt {attempt+1}/{max_retries} "
|
||||
f"failed ({e}); waiting {wait:.1f}s", file=sys.stderr)
|
||||
_log(f"[updater] target move attempt {attempt+1}/{max_retries} "
|
||||
f"failed ({e}); waiting {wait:.1f}s")
|
||||
time.sleep(wait)
|
||||
else:
|
||||
# 모든 재시도 실패
|
||||
print(f"[updater] target move failed after {max_retries} attempts: {last_err}",
|
||||
file=sys.stderr)
|
||||
_log(f"[updater] target move failed after {max_retries} attempts: {last_err}")
|
||||
return None
|
||||
|
||||
# 3단계: new → target 이동
|
||||
@@ -111,19 +133,18 @@ def replace_file(new_path: Path, target_path: Path,
|
||||
except OSError as e:
|
||||
last_err = e
|
||||
wait = 0.3 * (2 ** attempt)
|
||||
print(f"[updater] new move attempt {attempt+1}/{max_retries} "
|
||||
f"failed ({e}); waiting {wait:.1f}s", file=sys.stderr)
|
||||
_log(f"[updater] new move attempt {attempt+1}/{max_retries} "
|
||||
f"failed ({e}); waiting {wait:.1f}s")
|
||||
time.sleep(wait)
|
||||
|
||||
# new 이동 실패 → backup으로 롤백 시도
|
||||
print(f"[updater] new move failed after {max_retries} attempts: {last_err}",
|
||||
file=sys.stderr)
|
||||
_log(f"[updater] new move failed after {max_retries} attempts: {last_err}")
|
||||
if backup.exists() and not target_path.exists():
|
||||
try:
|
||||
shutil.move(str(backup), str(target_path))
|
||||
print("[updater] rolled back from backup", file=sys.stderr)
|
||||
_log("[updater] rolled back from backup")
|
||||
except OSError as e:
|
||||
print(f"[updater] rollback also failed: {e}", file=sys.stderr)
|
||||
_log(f"[updater] rollback also failed: {e}")
|
||||
return None
|
||||
|
||||
|
||||
@@ -131,13 +152,20 @@ def launch(exe_path: Path) -> bool:
|
||||
"""새 exe 실행. 콘솔 분리(DETACHED_PROCESS)로 부모 핸들 안 남기기."""
|
||||
try:
|
||||
if sys.platform == 'win32':
|
||||
# CREATE_NO_WINDOW (0x08000000) | DETACHED_PROCESS (0x00000008)
|
||||
# — main.exe도 windowed 빌드라 사실상 무관하지만 안전을 위해.
|
||||
DETACHED_PROCESS = 0x00000008
|
||||
subprocess.Popen([str(exe_path)], creationflags=DETACHED_PROCESS, close_fds=True)
|
||||
CREATE_NO_WINDOW = 0x08000000
|
||||
subprocess.Popen(
|
||||
[str(exe_path)],
|
||||
creationflags=DETACHED_PROCESS | CREATE_NO_WINDOW,
|
||||
close_fds=True,
|
||||
)
|
||||
else:
|
||||
subprocess.Popen([str(exe_path)], close_fds=True)
|
||||
return True
|
||||
except OSError as e:
|
||||
print(f"[updater] launch failed: {e}", file=sys.stderr)
|
||||
_log(f"[updater] launch failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
@@ -149,15 +177,17 @@ def main() -> int:
|
||||
parser.add_argument('--no-launch', action='store_true', help='교체만 하고 실행 안 함')
|
||||
args = parser.parse_args()
|
||||
|
||||
_log(f"[updater] start pid={args.pid} new={args.new} target={args.target}")
|
||||
|
||||
new_exe = Path(args.new).resolve()
|
||||
target_exe = Path(args.target).resolve()
|
||||
|
||||
if not new_exe.exists():
|
||||
print(f"[updater] new exe not found: {new_exe}", file=sys.stderr)
|
||||
_log(f"[updater] new exe not found: {new_exe}")
|
||||
return 2
|
||||
|
||||
if not wait_for_exit(args.pid, timeout_sec=30):
|
||||
print(f"[updater] timeout waiting for PID {args.pid}", file=sys.stderr)
|
||||
_log(f"[updater] timeout waiting for PID {args.pid}")
|
||||
return 3
|
||||
|
||||
# Windows에서 PID 종료 직후에도 OS가 EXE 락을 잠시 유지하는 경우가 있음.
|
||||
@@ -166,13 +196,15 @@ def main() -> int:
|
||||
|
||||
backup = replace_file(new_exe, target_exe)
|
||||
if backup is None:
|
||||
_log("[updater] replace_file failed — aborting")
|
||||
return 4
|
||||
|
||||
if args.no_launch:
|
||||
_log("[updater] --no-launch set, exiting after replace")
|
||||
return 0
|
||||
|
||||
if not launch(target_exe):
|
||||
# 시작 실패 시 롤백
|
||||
_log("[updater] launch failed — rolling back")
|
||||
try:
|
||||
target_exe.unlink()
|
||||
shutil.move(str(backup), str(target_exe))
|
||||
@@ -181,7 +213,7 @@ def main() -> int:
|
||||
pass
|
||||
return 5
|
||||
|
||||
# 백업은 다음 업데이트까지 보관 (롤백 가능). 정책 변경 시 여기서 unlink.
|
||||
_log("[updater] update complete, new app launched")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -33,7 +33,7 @@ exe = EXE(
|
||||
upx=True,
|
||||
upx_exclude=[],
|
||||
runtime_tmpdir=None,
|
||||
console=True, # 업데이트 진행 메시지를 보여주기 위해 콘솔 유지
|
||||
console=False, # cmd 창 깜빡임 제거 — stderr는 ~/.clockout_logs/updater.log 로 폴백
|
||||
disable_windowed_traceback=False,
|
||||
argv_emulation=False,
|
||||
target_arch=None,
|
||||
|
||||
@@ -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))
|
||||
@@ -0,0 +1,98 @@
|
||||
"""
|
||||
공공데이터포털 — 한국천문연구원 특일정보 OpenAPI 클라이언트.
|
||||
|
||||
엔드포인트: getRestDeInfo (국경일/공휴일 — 임시공휴일 포함)
|
||||
공식 문서: https://www.data.go.kr/data/15012690/openapi.do
|
||||
|
||||
`holidays` 패키지가 누락하는 임시공휴일·근로자의 날 등을
|
||||
정부 공인 데이터로 보강하기 위해 사용.
|
||||
|
||||
설계:
|
||||
- 네트워크 실패는 silent (None 반환) — 호출자가 fallback 처리
|
||||
- API 키는 코드 내 박혀있으나 dev 본인 계정의 특일정보 API 한정 키
|
||||
(50명 이내 사용 환경에서 일일 한도 1000회 충분)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
|
||||
# 공공데이터포털 dev 키 (특일정보 API 한정).
|
||||
# 노출 시 data.go.kr 마이페이지에서 즉시 폐기/재발급 가능.
|
||||
_SERVICE_KEY = 'fa419259319e31d2fcd4f959e65da817fe2f19894bff340a63889db7a8ffac93'
|
||||
|
||||
_BASE = 'https://apis.data.go.kr/B090041/openapi/service/SpcdeInfoService'
|
||||
_USER_AGENT = 'ClockOutCalculator/2.10 (KASI special-day client)'
|
||||
|
||||
|
||||
def fetch_korean_holidays(year: int, timeout: int = 10) -> Optional[List[Dict]]:
|
||||
"""해당 연도의 한국 공휴일 전체를 정부 API에서 받아 반환.
|
||||
|
||||
Returns:
|
||||
성공: [{'date': '2026-05-01', 'name': '근로자의 날', 'is_holiday': True}, ...]
|
||||
실패: None (네트워크 오류, 인증 실패, 응답 파싱 실패 등)
|
||||
"""
|
||||
params = {
|
||||
'serviceKey': _SERVICE_KEY,
|
||||
'solYear': str(year),
|
||||
'_type': 'json',
|
||||
'numOfRows': '100',
|
||||
'pageNo': '1',
|
||||
}
|
||||
url = f"{_BASE}/getRestDeInfo?" + urllib.parse.urlencode(params)
|
||||
req = urllib.request.Request(url, headers={'User-Agent': _USER_AGENT})
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
data = json.loads(resp.read())
|
||||
except (urllib.error.URLError, urllib.error.HTTPError,
|
||||
json.JSONDecodeError, OSError, TimeoutError):
|
||||
return None
|
||||
return _parse_response(data)
|
||||
|
||||
|
||||
def _parse_response(data: Dict) -> Optional[List[Dict]]:
|
||||
"""API 응답 JSON을 표준 형식으로 정규화.
|
||||
|
||||
API 응답 패턴:
|
||||
- resultCode == '00' → 정상
|
||||
- items.item: 단일 결과면 dict, 여러 개면 list
|
||||
- items가 빈 문자열일 때 (totalCount=0)도 정상으로 간주
|
||||
"""
|
||||
try:
|
||||
response = data.get('response') or {}
|
||||
header = response.get('header') or {}
|
||||
if header.get('resultCode') != '00':
|
||||
return None
|
||||
body = response.get('body') or {}
|
||||
items_root = body.get('items')
|
||||
if not items_root:
|
||||
return [] # 그 해 공휴일 없음 (드물지만 정상 응답)
|
||||
item = items_root.get('item') if isinstance(items_root, dict) else None
|
||||
if item is None:
|
||||
return []
|
||||
if isinstance(item, dict):
|
||||
item = [item]
|
||||
out = []
|
||||
for entry in item:
|
||||
locdate = entry.get('locdate')
|
||||
name = entry.get('dateName')
|
||||
is_holiday = (entry.get('isHoliday') == 'Y')
|
||||
if not locdate or not name:
|
||||
continue
|
||||
# locdate: 20260501 (int 또는 str)
|
||||
ds = str(locdate)
|
||||
if len(ds) != 8 or not ds.isdigit():
|
||||
continue
|
||||
iso = f"{ds[0:4]}-{ds[4:6]}-{ds[6:8]}"
|
||||
out.append({'date': iso, 'name': str(name), 'is_holiday': is_holiday})
|
||||
return out
|
||||
except (AttributeError, TypeError, KeyError):
|
||||
return None
|
||||
|
||||
|
||||
def is_configured() -> bool:
|
||||
"""키가 설정되어 있는지 (테스트/빈 키 환경 가드)."""
|
||||
return bool(_SERVICE_KEY) and len(_SERVICE_KEY) > 10
|
||||
+52
-39
@@ -57,62 +57,75 @@ class SystemTrayIcon(QSystemTrayIcon):
|
||||
return QIcon(pixmap)
|
||||
|
||||
def setup_menu(self):
|
||||
"""트레이 메뉴 설정"""
|
||||
"""트레이 메뉴 설정 — 라인 아이콘 + 앱 다크 톤."""
|
||||
menu = QMenu()
|
||||
|
||||
show_action = QAction(tr('tray.open'), self)
|
||||
show_action.triggered.connect(self.show_window)
|
||||
menu.addAction(show_action)
|
||||
# (action, 라인 아이콘 이름) — 테마 전환 시 재틴팅용으로 보관
|
||||
self._icon_actions = []
|
||||
|
||||
mini_action = QAction(tr('tray.mini_widget'), self)
|
||||
mini_action.triggered.connect(self._open_mini_widget)
|
||||
menu.addAction(mini_action)
|
||||
def add(text, slot, icon_name=None):
|
||||
action = QAction(text, self)
|
||||
action.triggered.connect(slot)
|
||||
menu.addAction(action)
|
||||
if icon_name:
|
||||
self._icon_actions.append((action, icon_name))
|
||||
return action
|
||||
|
||||
add(tr('tray.open'), self.show_window, 'home')
|
||||
add(tr('tray.mini_widget'), self._open_mini_widget, 'external-link')
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
lunch_action = QAction(tr('tray.toggle_lunch'), self)
|
||||
lunch_action.triggered.connect(self._toggle_lunch)
|
||||
menu.addAction(lunch_action)
|
||||
|
||||
break_out_action = QAction(tr('btn.break_out'), self)
|
||||
break_out_action.triggered.connect(self._break_out)
|
||||
menu.addAction(break_out_action)
|
||||
|
||||
break_in_action = QAction(tr('btn.break_in'), self)
|
||||
break_in_action.triggered.connect(self._break_in)
|
||||
menu.addAction(break_in_action)
|
||||
add(tr('tray.toggle_lunch'), self._toggle_lunch, 'coffee')
|
||||
add(tr('btn.break_out'), self._break_out)
|
||||
add(tr('btn.break_in'), self._break_in)
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
clock_out_action = QAction("✅ " + tr('btn.clock_out'), self)
|
||||
clock_out_action.triggered.connect(self.quick_clock_out)
|
||||
menu.addAction(clock_out_action)
|
||||
add(tr('btn.clock_out'), self.quick_clock_out, 'logout')
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
stats_action = QAction("📊 " + tr('menu.stats'), self)
|
||||
stats_action.triggered.connect(lambda: self._call_parent('show_stats'))
|
||||
menu.addAction(stats_action)
|
||||
|
||||
cal_action = QAction("📅 " + tr('menu.calendar'), self)
|
||||
cal_action.triggered.connect(lambda: self._call_parent('show_calendar'))
|
||||
menu.addAction(cal_action)
|
||||
|
||||
schedule_action = QAction("🗓️ 스케줄", self)
|
||||
schedule_action.triggered.connect(lambda: self._call_parent('show_schedule'))
|
||||
menu.addAction(schedule_action)
|
||||
|
||||
help_action = QAction("📖 " + tr('menu.help'), self)
|
||||
help_action.triggered.connect(lambda: self._call_parent('show_help'))
|
||||
menu.addAction(help_action)
|
||||
add(tr('menu.stats'), lambda: self._call_parent('show_stats'), 'chart')
|
||||
add(tr('menu.calendar'), lambda: self._call_parent('show_calendar'), 'calendar')
|
||||
add('스케줄', lambda: self._call_parent('show_schedule'), 'repeat')
|
||||
add(tr('menu.help'), lambda: self._call_parent('show_help'), 'help')
|
||||
|
||||
menu.addSeparator()
|
||||
|
||||
quit_action = QAction(tr('tray.quit'), self)
|
||||
quit_action.triggered.connect(self.quit_app)
|
||||
menu.addAction(quit_action)
|
||||
add(tr('tray.quit'), self.quit_app)
|
||||
|
||||
self.setContextMenu(menu)
|
||||
self.refresh_theme()
|
||||
|
||||
def refresh_theme(self):
|
||||
"""트레이 메뉴에 현재 앱 테마 QSS + 라인 아이콘 색을 (재)적용.
|
||||
|
||||
QMenu()는 부모가 없어 메인 윈도우 스타일시트를 자동 상속하지 않으므로
|
||||
명시적으로 적용한다. 테마 변경 시 main_window.apply_theme에서 호출.
|
||||
"""
|
||||
menu = self.contextMenu()
|
||||
if menu is None:
|
||||
return
|
||||
# 다크 QSS 적용 (메인 윈도우 스타일 우선, 없으면 dark 폴백)
|
||||
qss = self.parent_window.styleSheet() if self.parent_window else ''
|
||||
if not qss:
|
||||
try:
|
||||
from ui.styles import get_theme
|
||||
qss = get_theme('dark')
|
||||
except Exception:
|
||||
qss = ''
|
||||
if qss:
|
||||
menu.setStyleSheet(qss)
|
||||
# 라인 아이콘 틴팅 (메뉴 텍스트 색과 동일하게)
|
||||
try:
|
||||
from ui.icons import get_icon
|
||||
from ui.styles import ThemeColors
|
||||
color = ThemeColors.get('text_primary')
|
||||
for action, name in getattr(self, '_icon_actions', []):
|
||||
action.setIcon(get_icon(name, color, 16))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _call_parent(self, method_name: str):
|
||||
if self.parent_window and hasattr(self.parent_window, method_name):
|
||||
|
||||
@@ -188,8 +188,15 @@ def apply_update(new_exe: Path) -> bool:
|
||||
|
||||
pid = os.getpid()
|
||||
try:
|
||||
DETACHED_PROCESS = 0x00000008 if sys.platform == 'win32' else 0
|
||||
creationflags = DETACHED_PROCESS if sys.platform == 'win32' else 0
|
||||
# CREATE_NO_WINDOW + DETACHED_PROCESS — updater.exe도 windowed 빌드라
|
||||
# 정상적으로는 콘솔이 안 뜨지만, 안전하게 두 플래그 모두 적용해서
|
||||
# 어떤 환경에서도 cmd 창 깜빡임이 보이지 않도록.
|
||||
if sys.platform == 'win32':
|
||||
DETACHED_PROCESS = 0x00000008
|
||||
CREATE_NO_WINDOW = 0x08000000
|
||||
creationflags = DETACHED_PROCESS | CREATE_NO_WINDOW
|
||||
else:
|
||||
creationflags = 0
|
||||
subprocess.Popen(
|
||||
[str(updater_exe),
|
||||
'--pid', str(pid),
|
||||
|
||||
Reference in New Issue
Block a user