Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f5751460e3 | ||
|
|
e7e85dcf7b | ||
|
|
130c61ea62 | ||
|
|
5fb8655a47 | ||
|
|
da5f91984b | ||
|
|
3db4ed2351 | ||
|
|
97dd4e39f7 | ||
|
|
47296dd35b | ||
|
|
c98ca361cd | ||
|
|
d41e5cb921 |
149
CHANGELOG.md
149
CHANGELOG.md
@ -4,6 +4,155 @@ 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.2] — 2026-06-04
|
||||
|
||||
### Fixed
|
||||
- **통계 차트가 빌드(main.exe)에서 안 뜨던 진짜 원인** — frozen 빌드에서 numpy C-확장
|
||||
`numpy.core._multiarray_tests`가 누락(`numpy.testing` 제외의 영향)되어 matplotlib import가
|
||||
`ModuleNotFoundError`로 실패 → "matplotlib 필요" 폴백. `main.spec`에 해당 모듈 hiddenimport
|
||||
추가 + `numpy.testing` 제외 제거. (디버그 로그로 원인 확인: chart_widget이 실패 사유를 기록)
|
||||
- **도전과제 라이트 테마 가독성** — 헤더 강조 숫자/등급 배지/진행 숫자/진행 바를 라이트에서
|
||||
대비 높은 색으로 조정 (다크는 기존 비비드 색 유지).
|
||||
|
||||
## [2.11.1] — 2026-06-04
|
||||
|
||||
### Fixed
|
||||
- **빌드(main.exe)에서 통계 차트가 표시되지 않던 문제** — frozen 빌드는 PyInstaller가
|
||||
matplotlib `QtAgg`(backend_qtagg)만 번들하는데 `chart_widget`이 `backend_qt5agg`를
|
||||
import해 실패 → "matplotlib 필요" 폴백만 보였음. **backend_qtagg 우선 import**(+ qt5agg
|
||||
폴백) + 실패 원인 로깅, `main.spec`에 `backend_qtagg`/`PyQt5.sip` 명시.
|
||||
- **통계·도움말·도전과제 화면이 라이트 테마에서도 다크로 고정되던 문제** — `dark_components`와
|
||||
세 화면(+통계 차트 배경/그리드/텍스트)을 현재 테마(`ThemeColors`)에 따르도록 변경.
|
||||
다크 기본값은 그대로, 라이트 전환 시 함께 라이트로. 다크 등급 카드/차트 막대 등 강조색은 유지.
|
||||
|
||||
## [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 버그 (사용자 보고)
|
||||
- **휴일에 출근해도 정상 출근으로 처리되어 추가근무 적립이 안 되던 문제**
|
||||
- `update_display()` 1Hz 루프에 `is_non_working_day` 분기 누락으로 휴일에도
|
||||
"남은 시간 8h"부터 카운트다운 → 실제 출근 즉시 적립이 시작되지 않음
|
||||
- 수정: 출근 직후부터 음수 remaining 표시, "공휴일 근무 (전체 적립)" 그룹 타이틀
|
||||
- 진행바: 휴일은 100% 고정 (의미 없음)
|
||||
- 예상 퇴근: "휴일 근무 (정해진 퇴근시각 없음)"
|
||||
- **휴일 "퇴근 30분 전" 알림 게이팅** — 휴일엔 정해진 퇴근시각이 없으니 무의미한 알림 스킵
|
||||
- **자동복구 퇴근 3곳의 `// 30) * 30` 하드코딩** → 사용자 `overtime_unit` (15/30/60) 설정 적용
|
||||
- 4곳에 중복되던 휴일 연장 계산 로직을 `TimeCalculator.calculate_holiday_overtime()` 헬퍼로 통합
|
||||
|
||||
### Added — 연차 미리등록 + 통합 스케줄 + 반복 연차 (Phase 1+2)
|
||||
|
||||
#### Phase 1 — 연차 미리등록 + 자동 적용
|
||||
- **DB:** `get_leave_minutes_for(date)` / `has_full_day_leave(date)` /
|
||||
`get_leave_records_by_date(date)` / `get_leave_records_by_range(start, end)`
|
||||
- **TimeCalculator:** `effective_work_minutes(date_obj, db)` — 부분 연차만큼 정규 근무 차감
|
||||
- **종일 연차일 자동 처리:**
|
||||
- 자동 출근감지 스킵 (event_monitor 호출 안 함)
|
||||
- "🌴 오늘은 휴가" 카드 표시, 카운트다운 제거
|
||||
- 메인/미니 위젯/트레이 모두 일관된 휴가 상태 표시
|
||||
- **종일 연차 + 출근 override:** 휴일처럼 전체 시간 적립 (사용자 확인 후)
|
||||
- **부분 연차 (반차/반반차/시간):** 기존 leave_used 경로로 카운트다운 단축
|
||||
- **AddLeaveDialog 검증 강화:** 미래 1년 setMaximumDate / 주말·공휴일 차단 / 같은 날 1일 초과 차단
|
||||
- **leave_calendar_view:** 예정(파랑) / 사용완료(녹·노·보) 색상 분리
|
||||
|
||||
#### Phase 2 — 통합 스케줄 + 반복 연차
|
||||
- **`recurring_leaves` 테이블** (pattern/leave_type/days/start_date/end_date/memo)
|
||||
- **`core/recurring_leaves.py`:** weekly / biweekly / monthly 패턴 파서 + expand_for_range/date
|
||||
- **자동 합산:** `get_leave_minutes_for()` / `has_full_day_leave()`가 반복 패턴 인스턴스도 함께 검사
|
||||
- **`ui/recurring_leave_dialog.py`:** 매주/격주 요일 또는 매월 N일 입력
|
||||
- **`ui/schedule_view.py`:** 월간 통합 캘린더 (휴일·연차·반복 색상 구분 + 우클릭 삭제)
|
||||
- **진입점:** MainWindow.show_schedule(), 트레이 "🗓️ 스케줄", LeaveView "🗓️ 스케줄"
|
||||
|
||||
### Changed
|
||||
- **근로자의 날(5/1) 자동 추가** — `holidays.KR` 패키지가 누락하는 노동자 휴일을
|
||||
`add_korean_holidays_auto()`에서 명시적 보강 (매년 반복)
|
||||
|
||||
### Tests
|
||||
- pytest: 122 → **175** (+53)
|
||||
- `tests/test_recurring_leaves.py` 32개 (패턴 파싱/매칭/expand/describe)
|
||||
- `tests/test_database.py` +12 (TestLeaveQueriesByDate + TestRecurringLeavesDB)
|
||||
- `tests/test_time_calculator.py` +9 (TestHolidayOvertime)
|
||||
- 통합 시나리오: 48 → **53** (+5)
|
||||
- S52A 휴일 hot-path / S52B 종일 연차 / S52C 반복 패턴 / S52D 반차 effective / S52E 종일 effective
|
||||
|
||||
## [2.8.0] — 2026-05-01
|
||||
|
||||
### Added — 도전과제 시스템 + 디자인 리뉴얼
|
||||
|
||||
@ -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 = []
|
||||
@ -696,6 +701,74 @@ def s51_accessibility_keys():
|
||||
assert db.get_setting_bool('high_contrast') is True
|
||||
|
||||
|
||||
@case("S52B. 미리 등록 종일 연차: has_full_day_leave True + 시간 환산")
|
||||
def s52b_planned_leave():
|
||||
db = fresh_db('s52b')
|
||||
db.add_leave_record('2026-05-15', '연차', 1.0, '여행')
|
||||
assert db.has_full_day_leave('2026-05-15')
|
||||
assert db.get_leave_minutes_for('2026-05-15') == 480
|
||||
# 다른 날엔 영향 없음
|
||||
assert not db.has_full_day_leave('2026-05-16')
|
||||
|
||||
|
||||
@case("S52C. 반복 패턴 (매주 금요일 반차) → 다음 금요일 자동 차감")
|
||||
def s52c_recurring_leave():
|
||||
db = fresh_db('s52c')
|
||||
db.add_recurring_leave('weekly:friday', '반차', 0.5, '2026-01-01')
|
||||
# 2026-05-01 = Friday
|
||||
assert db.get_leave_minutes_for('2026-05-01') == 240
|
||||
# Monday
|
||||
assert db.get_leave_minutes_for('2026-05-04') == 0
|
||||
# 종일 아님
|
||||
assert not db.has_full_day_leave('2026-05-01')
|
||||
|
||||
|
||||
@case("S52D. effective_work_minutes: 반차 등록 시 work_minutes 절반")
|
||||
def s52d_effective():
|
||||
from core.time_calculator import TimeCalculator
|
||||
db = fresh_db('s52d')
|
||||
db.add_leave_record('2026-05-15', '오전반차', 0.5)
|
||||
calc = TimeCalculator(work_minutes=480)
|
||||
target = datetime(2026, 5, 15)
|
||||
assert calc.effective_work_minutes(target, db) == 240
|
||||
# 다른 날엔 변화 없음
|
||||
other = datetime(2026, 5, 16)
|
||||
assert calc.effective_work_minutes(other, db) == 480
|
||||
|
||||
|
||||
@case("S52E. effective_work_minutes: 종일 연차 시 0")
|
||||
def s52e_full_day():
|
||||
from core.time_calculator import TimeCalculator
|
||||
db = fresh_db('s52e')
|
||||
db.add_leave_record('2026-05-15', '연차', 1.0)
|
||||
calc = TimeCalculator(work_minutes=480)
|
||||
assert calc.effective_work_minutes(datetime(2026, 5, 15), db) == 0
|
||||
|
||||
|
||||
@case("S52A. 휴일 hot-path: is_non_working_day → 출근 직후부터 즉시 연장 적립")
|
||||
def s52a_holiday_hotpath():
|
||||
"""update_display 분기 회귀 — 휴일에 출근 1분 = 적립 0, 30분 = 적립 30."""
|
||||
from core.time_calculator import TimeCalculator
|
||||
db = fresh_db('s52a')
|
||||
holiday_date = '2026-05-01' # 근로자의 날
|
||||
db.add_holiday(holiday_date, '근로자의 날', is_recurring=True)
|
||||
|
||||
calc = TimeCalculator(work_minutes=480, lunch_duration_minutes=60)
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
# 휴일 인식
|
||||
assert calc.is_non_working_day(ci, db)
|
||||
assert calc.get_day_type(ci, db) == 'holiday'
|
||||
|
||||
# 출근 1분 후: 적립 0 (30분 단위 절삭)
|
||||
now1 = ci + timedelta(minutes=1)
|
||||
actual, earned = calc.calculate_holiday_overtime(ci, now1)
|
||||
assert actual == 1 and earned == 0
|
||||
# 출근 30분 후: 30분 적립 (평일이라면 0, 휴일은 즉시 시작)
|
||||
now30 = ci + timedelta(minutes=30)
|
||||
actual, earned = calc.calculate_holiday_overtime(ci, now30)
|
||||
assert actual == 30 and earned == 30
|
||||
|
||||
|
||||
@case("S52. CSV import + overtime 적립까지 정상 동작")
|
||||
def s52_csv_overtime():
|
||||
from utils.csv_importer import parse_csv, import_records
|
||||
@ -764,6 +837,11 @@ def main():
|
||||
s49_discord_empty()
|
||||
s50_goals()
|
||||
s51_accessibility_keys()
|
||||
s52a_holiday_hotpath()
|
||||
s52b_planned_leave()
|
||||
s52c_recurring_leave()
|
||||
s52d_effective()
|
||||
s52e_full_day()
|
||||
s52_csv_overtime()
|
||||
|
||||
print()
|
||||
|
||||
230
core/database.py
230
core/database.py
@ -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()
|
||||
@ -209,6 +253,24 @@ class Database:
|
||||
)
|
||||
''')
|
||||
|
||||
# 반복 연차 패턴 테이블 (P2)
|
||||
# pattern 형식:
|
||||
# 'weekly:friday' / 'weekly:mon,wed' (요일 영문 소문자, 콤마 구분)
|
||||
# 'biweekly:friday' (격주)
|
||||
# 'monthly:15' (매월 N일)
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS recurring_leaves (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pattern TEXT NOT NULL,
|
||||
leave_type TEXT NOT NULL,
|
||||
days REAL NOT NULL,
|
||||
start_date DATE NOT NULL,
|
||||
end_date DATE,
|
||||
memo TEXT,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
def migrate_break_records_cascade(self):
|
||||
"""break_records 테이블에 CASCADE 제약조건 추가 (마이그레이션).
|
||||
|
||||
@ -708,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',
|
||||
@ -915,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):
|
||||
"""연장근무 사용 (음수 잔액 허용 - 선사용 후적립 가능)"""
|
||||
@ -962,12 +1037,108 @@ class Database:
|
||||
def get_today_leave_minutes(self) -> int:
|
||||
"""오늘 사용한 연차/반차 시간 조회 (분)"""
|
||||
from datetime import date
|
||||
today = date.today().isoformat()
|
||||
return self.get_leave_minutes_for(date.today().isoformat())
|
||||
|
||||
def get_leave_minutes_for(self, date_str: str) -> int:
|
||||
"""특정 날짜에 등록된 연차 합계를 분 단위로 반환.
|
||||
|
||||
구체 leave_records + 매치되는 recurring_leaves 인스턴스 합산.
|
||||
예정/사용 구분 없음.
|
||||
"""
|
||||
days = self._effective_leave_days_for(date_str)
|
||||
return int(days * self.get_work_minutes())
|
||||
|
||||
def has_full_day_leave(self, date_str: str) -> bool:
|
||||
"""해당 날짜에 종일(또는 그 이상) 연차가 등록되어 있는지.
|
||||
|
||||
구체 + 반복 패턴 모두 검사.
|
||||
"""
|
||||
return self._effective_leave_days_for(date_str) >= 1.0
|
||||
|
||||
def _effective_leave_days_for(self, date_str: str) -> float:
|
||||
"""구체 leave_records + 반복 패턴 매치를 합산한 일수."""
|
||||
with self._conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT SUM(days) FROM leave_records WHERE date = ?', (today,))
|
||||
days = cursor.fetchone()[0] or 0.0
|
||||
return int(days * self.get_work_minutes())
|
||||
cursor.execute('SELECT SUM(days) FROM leave_records WHERE date = ?',
|
||||
(date_str,))
|
||||
concrete_days = float(cursor.fetchone()[0] or 0.0)
|
||||
|
||||
# 반복 패턴 — 매번 호출되니 lazy import + 가벼운 query
|
||||
try:
|
||||
from core.recurring_leaves import expand_for_date
|
||||
from datetime import datetime as _dt
|
||||
target = _dt.strptime(date_str, '%Y-%m-%d').date()
|
||||
recs = self.get_recurring_leaves(active_on=date_str)
|
||||
recurring_days = sum(o.days for o in expand_for_date(recs, target))
|
||||
except Exception:
|
||||
recurring_days = 0.0
|
||||
|
||||
return concrete_days + recurring_days
|
||||
|
||||
def get_leave_records_by_date(self, date_str: str) -> List[Dict]:
|
||||
"""해당 날짜에 등록된 leave_records 전체 (디스플레이/편집용)."""
|
||||
with self._conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('SELECT * FROM leave_records WHERE date = ? ORDER BY id',
|
||||
(date_str,))
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
def get_leave_records_by_range(self, start_date: str, end_date: str) -> List[Dict]:
|
||||
"""기간 내 leave_records (스케줄 화면용). start/end inclusive."""
|
||||
with self._conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
SELECT * FROM leave_records
|
||||
WHERE date BETWEEN ? AND ?
|
||||
ORDER BY date ASC, id ASC
|
||||
''', (start_date, end_date))
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
# ===== 반복 연차 (recurring_leaves) — P2 =====
|
||||
|
||||
def add_recurring_leave(self, pattern: str, leave_type: str, days: float,
|
||||
start_date: str, end_date: str = None,
|
||||
memo: str = None) -> int:
|
||||
"""반복 연차 패턴 등록.
|
||||
|
||||
Args:
|
||||
pattern: 'weekly:friday' / 'biweekly:mon' / 'monthly:15' 등
|
||||
leave_type: '연차' / '반차' / '시간' 등 (표시용)
|
||||
days: 한 회당 차감 일수 (0.5 = 반차)
|
||||
start_date: 시작일 'YYYY-MM-DD'
|
||||
end_date: 종료일 또는 None(=무기한)
|
||||
memo: 옵션
|
||||
"""
|
||||
with self._conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('''
|
||||
INSERT INTO recurring_leaves
|
||||
(pattern, leave_type, days, start_date, end_date, memo)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
''', (pattern, leave_type, days, start_date, end_date, memo))
|
||||
conn.commit()
|
||||
return cursor.lastrowid
|
||||
|
||||
def get_recurring_leaves(self, active_on: str = None) -> List[Dict]:
|
||||
"""반복 패턴 목록. active_on 지정 시 그날 유효한 것만."""
|
||||
with self._conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
if active_on:
|
||||
cursor.execute('''
|
||||
SELECT * FROM recurring_leaves
|
||||
WHERE start_date <= ?
|
||||
AND (end_date IS NULL OR end_date >= ?)
|
||||
ORDER BY id
|
||||
''', (active_on, active_on))
|
||||
else:
|
||||
cursor.execute('SELECT * FROM recurring_leaves ORDER BY id')
|
||||
return [dict(row) for row in cursor.fetchall()]
|
||||
|
||||
def delete_recurring_leave(self, rec_id: int) -> None:
|
||||
with self._conn() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute('DELETE FROM recurring_leaves WHERE id = ?', (rec_id,))
|
||||
conn.commit()
|
||||
|
||||
def add_initial_overtime_balance(self, minutes: int):
|
||||
"""초기 연장근무 잔액 추가"""
|
||||
@ -1599,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` 패키지로 음력 명절 포함 한국 공휴일 자동 등록.
|
||||
|
||||
@ -1624,26 +1821,37 @@ 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이 누락하는 근로자의 날 명시적 보강
|
||||
extra = [(f"{y}-05-01", "근로자의 날")]
|
||||
for date_str, name in extra:
|
||||
if not self.is_holiday(date_str):
|
||||
self.add_holiday(date_str, name, is_recurring=True)
|
||||
added += 1
|
||||
return added
|
||||
|
||||
def copy_recurring_holidays(self, from_year: int, to_year: int):
|
||||
|
||||
98
core/i18n.py
98
core/i18n.py
@ -21,14 +21,14 @@ _DICT = {
|
||||
'menu.help': '도움말',
|
||||
'menu.settings': '설정',
|
||||
'btn.clock_out': '퇴근하기',
|
||||
'btn.clock_out_cancel': '🔄 퇴근 취소',
|
||||
'btn.clock_out_cancel': '퇴근 취소',
|
||||
'btn.lunch_add': '점심시간 추가',
|
||||
'btn.lunch_applied': '점심시간 (적용됨)',
|
||||
'btn.dinner_add': '저녁시간 추가',
|
||||
'btn.dinner_applied': '저녁시간 (적용됨)',
|
||||
'btn.break_out': '🚪 외출 시작',
|
||||
'btn.break_in': '↩️ 복귀',
|
||||
'btn.save': '💾 저장',
|
||||
'btn.break_out': '외출 시작',
|
||||
'btn.break_in': '복귀',
|
||||
'btn.save': '저장',
|
||||
'btn.close': '닫기',
|
||||
'btn.apply': '적용',
|
||||
'btn.cancel': '취소',
|
||||
@ -40,10 +40,10 @@ _DICT = {
|
||||
|
||||
# === 윈도우/다이얼로그 제목 ===
|
||||
'window.main_title': '퇴근시간 계산기',
|
||||
'window.settings': '⚙️ 설정',
|
||||
'window.help': '📖 사용 설명서',
|
||||
'window.stats': '📊 근무 통계',
|
||||
'window.calendar': '📅 캘린더',
|
||||
'window.settings': '설정',
|
||||
'window.help': '사용 설명서',
|
||||
'window.stats': '근무 통계',
|
||||
'window.calendar': '캘린더',
|
||||
'window.mini_widget': '퇴근시간',
|
||||
'window.clock_in_dialog': '출근 시간',
|
||||
'window.break_view': '외출 관리',
|
||||
@ -125,8 +125,8 @@ _DICT = {
|
||||
|
||||
# === 트레이 ===
|
||||
'tray.open': '프로그램 열기',
|
||||
'tray.mini_widget': '📌 미니 위젯',
|
||||
'tray.toggle_lunch': '🍱 점심시간 토글',
|
||||
'tray.mini_widget': '미니 위젯',
|
||||
'tray.toggle_lunch': '점심시간 토글',
|
||||
'tray.quit': '종료',
|
||||
'tray.tooltip_remaining': '퇴근까지: {time}',
|
||||
'tray.tooltip_overtime': '추가 근무 중: {time}',
|
||||
@ -166,12 +166,12 @@ _DICT = {
|
||||
'cal.edit_record': '기록 편집',
|
||||
|
||||
# === HelpView (각 탭의 큰 HTML은 별도 키) ===
|
||||
'help.tab_intro': '👋 시작하기',
|
||||
'help.tab_work_hours': '🕘 근무시간',
|
||||
'help.tab_overtime': '🏦 연장근무',
|
||||
'help.tab_leave': '🌴 연차/휴가',
|
||||
'help.tab_break': '🚪 외출/저녁',
|
||||
'help.tab_faq': '❓ 자주 묻는 질문',
|
||||
'help.tab_intro': '시작하기',
|
||||
'help.tab_work_hours': '근무시간',
|
||||
'help.tab_overtime': '연장근무',
|
||||
'help.tab_leave': '연차/휴가',
|
||||
'help.tab_break': '외출/저녁',
|
||||
'help.tab_faq': '자주 묻는 질문',
|
||||
|
||||
# === clock_in_dialog ===
|
||||
'dlg.clock_in.prompt': '오늘의 출근시간을 입력해주세요',
|
||||
@ -204,17 +204,18 @@ _DICT = {
|
||||
'view.overtime.title': '연장근무 내역',
|
||||
'view.overtime.balance_zero': '잔액: 0분',
|
||||
'view.overtime.balance_fmt': '현재 잔액: {h}시간 {m}분 ({total}분)',
|
||||
'view.overtime.earned_group': '💰 적립 내역',
|
||||
'view.overtime.used_group': '📤 사용 내역',
|
||||
'view.overtime.earned_group': '적립 내역',
|
||||
'view.overtime.used_group': '사용 내역',
|
||||
'view.overtime.col_date': '날짜',
|
||||
'view.overtime.col_earned': '적립',
|
||||
'view.overtime.col_used': '사용',
|
||||
'view.overtime.col_memo': '메모',
|
||||
'view.overtime.col_reason': '사유',
|
||||
'view.overtime.btn_add_earned': '➕ 수동 적립',
|
||||
'view.overtime.btn_add_used': '➕ 수동 사용',
|
||||
'view.overtime.menu_delete': '❌ 삭제',
|
||||
'view.overtime.btn_add_earned': '수동 적립',
|
||||
'view.overtime.btn_add_used': '수동 사용',
|
||||
'view.overtime.menu_delete': '삭제',
|
||||
'view.overtime.delete_confirm_body': '다음 사용 기록을 삭제하시겠습니까?\n\n날짜: {date}\n시간: {time}\n사유: {reason}',
|
||||
'view.overtime.delete_earned_confirm_body': '다음 적립 기록을 삭제하시겠습니까?\n\n날짜: {date}\n적립: {time}\n\n삭제 시 잔액에서 차감됩니다.',
|
||||
'view.overtime.manual_earned_title': '추가근무 수동 적립',
|
||||
'view.overtime.manual_used_title': '추가근무 수동 사용',
|
||||
'view.overtime.field_date': '날짜:',
|
||||
@ -238,13 +239,13 @@ _DICT = {
|
||||
'view.leave.balance_zero': '잔여: 0일',
|
||||
'view.leave.balance_fmt': '잔여: {days}일 (총 {hours}시간)',
|
||||
'view.leave.btn_set_balance': '잔여 설정',
|
||||
'view.leave.used_group': '📤 사용 내역',
|
||||
'view.leave.used_group': '사용 내역',
|
||||
'view.leave.col_date': '날짜',
|
||||
'view.leave.col_type': '구분',
|
||||
'view.leave.col_used': '사용',
|
||||
'view.leave.col_reason': '사유',
|
||||
'view.leave.btn_add': '➕ 연차 사용 추가',
|
||||
'view.leave.btn_calendar': '📅 캘린더 보기',
|
||||
'view.leave.btn_add': '연차 사용 추가',
|
||||
'view.leave.btn_calendar': '캘린더 보기',
|
||||
'view.leave.delete_confirm_body': '다음 연차 사용 기록을 삭제하시겠습니까?\n\n날짜: {date}\n구분: {type}\n사용: {days}',
|
||||
'view.leave.set_title': '연차 시간 설정',
|
||||
'view.leave.set_prompt': '연차 잔여 시간을 입력하세요 (0.5시간 단위):\n예) 8시간 = 1일, 4시간 = 0.5일(반차), 2시간 = 0.25일, 0.5시간 = 30분',
|
||||
@ -278,14 +279,14 @@ _DICT = {
|
||||
'menu.help': 'Help',
|
||||
'menu.settings': 'Settings',
|
||||
'btn.clock_out': 'Clock Out',
|
||||
'btn.clock_out_cancel': '🔄 Cancel Clock-out',
|
||||
'btn.clock_out_cancel': 'Cancel Clock-out',
|
||||
'btn.lunch_add': 'Add Lunch',
|
||||
'btn.lunch_applied': 'Lunch (Applied)',
|
||||
'btn.dinner_add': 'Add Dinner',
|
||||
'btn.dinner_applied': 'Dinner (Applied)',
|
||||
'btn.break_out': '🚪 Start Break',
|
||||
'btn.break_in': '↩️ Return',
|
||||
'btn.save': '💾 Save',
|
||||
'btn.break_out': 'Start Break',
|
||||
'btn.break_in': 'Return',
|
||||
'btn.save': 'Save',
|
||||
'btn.close': 'Close',
|
||||
'btn.apply': 'Apply',
|
||||
'btn.cancel': 'Cancel',
|
||||
@ -297,10 +298,10 @@ _DICT = {
|
||||
|
||||
# === Windows ===
|
||||
'window.main_title': 'Clock-out Time Calculator',
|
||||
'window.settings': '⚙️ Settings',
|
||||
'window.help': '📖 User Guide',
|
||||
'window.stats': '📊 Statistics',
|
||||
'window.calendar': '📅 Calendar',
|
||||
'window.settings': 'Settings',
|
||||
'window.help': 'User Guide',
|
||||
'window.stats': 'Statistics',
|
||||
'window.calendar': 'Calendar',
|
||||
'window.mini_widget': 'Clock-out',
|
||||
'window.clock_in_dialog': 'Clock-in Time',
|
||||
'window.break_view': 'Break Management',
|
||||
@ -382,8 +383,8 @@ _DICT = {
|
||||
|
||||
# === Tray ===
|
||||
'tray.open': 'Open Program',
|
||||
'tray.mini_widget': '📌 Mini Widget',
|
||||
'tray.toggle_lunch': '🍱 Toggle Lunch',
|
||||
'tray.mini_widget': 'Mini Widget',
|
||||
'tray.toggle_lunch': 'Toggle Lunch',
|
||||
'tray.quit': 'Quit',
|
||||
'tray.tooltip_remaining': 'Until clock-out: {time}',
|
||||
'tray.tooltip_overtime': 'Overtime: {time}',
|
||||
@ -423,12 +424,12 @@ _DICT = {
|
||||
'cal.edit_record': 'Edit record',
|
||||
|
||||
# === HelpView ===
|
||||
'help.tab_intro': '👋 Getting Started',
|
||||
'help.tab_work_hours': '🕘 Work Hours',
|
||||
'help.tab_overtime': '🏦 Overtime',
|
||||
'help.tab_leave': '🌴 Leave',
|
||||
'help.tab_break': '🚪 Break/Dinner',
|
||||
'help.tab_faq': '❓ FAQ',
|
||||
'help.tab_intro': 'Getting Started',
|
||||
'help.tab_work_hours': 'Work Hours',
|
||||
'help.tab_overtime': 'Overtime',
|
||||
'help.tab_leave': 'Leave',
|
||||
'help.tab_break': 'Break/Dinner',
|
||||
'help.tab_faq': 'FAQ',
|
||||
|
||||
# === clock_in_dialog ===
|
||||
'dlg.clock_in.prompt': "Enter today's clock-in time",
|
||||
@ -461,17 +462,18 @@ _DICT = {
|
||||
'view.overtime.title': 'Overtime History',
|
||||
'view.overtime.balance_zero': 'Balance: 0 min',
|
||||
'view.overtime.balance_fmt': 'Current balance: {h}h {m}m ({total} min)',
|
||||
'view.overtime.earned_group': '💰 Earned',
|
||||
'view.overtime.used_group': '📤 Used',
|
||||
'view.overtime.earned_group': 'Earned',
|
||||
'view.overtime.used_group': 'Used',
|
||||
'view.overtime.col_date': 'Date',
|
||||
'view.overtime.col_earned': 'Earned',
|
||||
'view.overtime.col_used': 'Used',
|
||||
'view.overtime.col_memo': 'Memo',
|
||||
'view.overtime.col_reason': 'Reason',
|
||||
'view.overtime.btn_add_earned': '➕ Manual Earn',
|
||||
'view.overtime.btn_add_used': '➕ Manual Use',
|
||||
'view.overtime.menu_delete': '❌ Delete',
|
||||
'view.overtime.btn_add_earned': 'Manual Earn',
|
||||
'view.overtime.btn_add_used': 'Manual Use',
|
||||
'view.overtime.menu_delete': 'Delete',
|
||||
'view.overtime.delete_confirm_body': 'Delete this usage record?\n\nDate: {date}\nTime: {time}\nReason: {reason}',
|
||||
'view.overtime.delete_earned_confirm_body': 'Delete this accrual record?\n\nDate: {date}\nEarned: {time}\n\nThe balance will be reduced accordingly.',
|
||||
'view.overtime.manual_earned_title': 'Manual Overtime Earn',
|
||||
'view.overtime.manual_used_title': 'Manual Overtime Use',
|
||||
'view.overtime.field_date': 'Date:',
|
||||
@ -495,13 +497,13 @@ _DICT = {
|
||||
'view.leave.balance_zero': 'Balance: 0 days',
|
||||
'view.leave.balance_fmt': 'Balance: {days} days ({hours}h total)',
|
||||
'view.leave.btn_set_balance': 'Set Balance',
|
||||
'view.leave.used_group': '📤 Used',
|
||||
'view.leave.used_group': 'Used',
|
||||
'view.leave.col_date': 'Date',
|
||||
'view.leave.col_type': 'Type',
|
||||
'view.leave.col_used': 'Used',
|
||||
'view.leave.col_reason': 'Reason',
|
||||
'view.leave.btn_add': '➕ Add Leave Usage',
|
||||
'view.leave.btn_calendar': '📅 Calendar',
|
||||
'view.leave.btn_add': 'Add Leave Usage',
|
||||
'view.leave.btn_calendar': 'Calendar',
|
||||
'view.leave.delete_confirm_body': 'Delete this leave record?\n\nDate: {date}\nType: {type}\nUsed: {days}',
|
||||
'view.leave.set_title': 'Set Leave Hours',
|
||||
'view.leave.set_prompt': 'Enter leave hours remaining (0.5h step):\ne.g. 8h = 1d, 4h = 0.5d (half), 2h = 0.25d, 0.5h = 30min',
|
||||
|
||||
153
core/recurring_leaves.py
Normal file
153
core/recurring_leaves.py
Normal file
@ -0,0 +1,153 @@
|
||||
"""
|
||||
반복 연차 패턴 파싱 + 일자 확장.
|
||||
|
||||
지원 패턴:
|
||||
- 'weekly:friday' — 매주 금요일
|
||||
- 'weekly:mon,wed,fri' — 매주 월·수·금
|
||||
- 'biweekly:friday' — 격주 금요일 (start_date 기준)
|
||||
- 'monthly:15' — 매월 15일 (해당 월에 그 일이 없으면 스킵)
|
||||
|
||||
반복 인스턴스는 DB에 영속화하지 않고 호출 시점에 expand_for_range()로 펼친다.
|
||||
같은 날짜에 leave_records(구체 인스턴스)가 이미 있으면 호출자가 합산 로직 책임.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime, timedelta
|
||||
from typing import List, Dict, Optional
|
||||
|
||||
|
||||
_WEEKDAY_MAP = {
|
||||
'mon': 0, 'monday': 0,
|
||||
'tue': 1, 'tuesday': 1,
|
||||
'wed': 2, 'wednesday': 2,
|
||||
'thu': 3, 'thursday': 3,
|
||||
'fri': 4, 'friday': 4,
|
||||
'sat': 5, 'saturday': 5,
|
||||
'sun': 6, 'sunday': 6,
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Occurrence:
|
||||
"""반복 패턴이 펼친 한 인스턴스."""
|
||||
date: date
|
||||
leave_type: str
|
||||
days: float
|
||||
pattern: str
|
||||
memo: str = ''
|
||||
recurring_id: Optional[int] = None
|
||||
|
||||
|
||||
def _parse_pattern(pattern: str):
|
||||
"""('weekly'|'biweekly'|'monthly', 추가정보) 튜플 반환. 잘못된 패턴은 None."""
|
||||
if not pattern or ':' not in pattern:
|
||||
return None
|
||||
kind, rest = pattern.split(':', 1)
|
||||
kind = kind.strip().lower()
|
||||
rest = rest.strip().lower()
|
||||
if kind in ('weekly', 'biweekly'):
|
||||
days = [d.strip() for d in rest.split(',') if d.strip()]
|
||||
weekdays = [_WEEKDAY_MAP[d] for d in days if d in _WEEKDAY_MAP]
|
||||
if not weekdays:
|
||||
return None
|
||||
return (kind, weekdays)
|
||||
if kind == 'monthly':
|
||||
try:
|
||||
day_of_month = int(rest)
|
||||
if 1 <= day_of_month <= 31:
|
||||
return (kind, day_of_month)
|
||||
except ValueError:
|
||||
return None
|
||||
return None
|
||||
|
||||
|
||||
def matches(rec: Dict, target_date: date) -> bool:
|
||||
"""단일 패턴 rec이 target_date에 매치되는지."""
|
||||
start = _parse_date(rec.get('start_date'))
|
||||
end = _parse_date(rec.get('end_date'))
|
||||
if start is None:
|
||||
return False
|
||||
if target_date < start:
|
||||
return False
|
||||
if end is not None and target_date > end:
|
||||
return False
|
||||
|
||||
parsed = _parse_pattern(rec.get('pattern', ''))
|
||||
if parsed is None:
|
||||
return False
|
||||
kind, info = parsed
|
||||
|
||||
if kind == 'weekly':
|
||||
return target_date.weekday() in info
|
||||
|
||||
if kind == 'biweekly':
|
||||
if target_date.weekday() not in info:
|
||||
return False
|
||||
# start_date의 주(월요일 기준)와 target의 주의 격주 여부
|
||||
weeks = (target_date - start).days // 7
|
||||
return weeks % 2 == 0
|
||||
|
||||
if kind == 'monthly':
|
||||
return target_date.day == info
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def expand_for_range(records: List[Dict], start: date, end: date) -> List[Occurrence]:
|
||||
"""여러 반복 패턴을 [start, end] 범위에서 펼친다.
|
||||
|
||||
반환은 날짜 오름차순. 같은 날 여러 패턴이 매치되면 모두 포함.
|
||||
"""
|
||||
out: List[Occurrence] = []
|
||||
if start > end:
|
||||
return out
|
||||
cur = start
|
||||
while cur <= end:
|
||||
for r in records:
|
||||
try:
|
||||
if matches(r, cur):
|
||||
out.append(Occurrence(
|
||||
date=cur,
|
||||
leave_type=r.get('leave_type') or '연차',
|
||||
days=float(r.get('days') or 0),
|
||||
pattern=r.get('pattern', ''),
|
||||
memo=r.get('memo') or '',
|
||||
recurring_id=r.get('id'),
|
||||
))
|
||||
except Exception:
|
||||
# 잘못된 패턴 1개가 전체를 망치지 않도록
|
||||
continue
|
||||
cur += timedelta(days=1)
|
||||
return out
|
||||
|
||||
|
||||
def expand_for_date(records: List[Dict], target_date: date) -> List[Occurrence]:
|
||||
"""단일 날짜에 매치되는 인스턴스만."""
|
||||
return expand_for_range(records, target_date, target_date)
|
||||
|
||||
|
||||
def _parse_date(s: Optional[str]) -> Optional[date]:
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(s, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
_KO_WEEKDAY_NAMES = ['월', '화', '수', '목', '금', '토', '일']
|
||||
|
||||
|
||||
def describe_pattern(pattern: str) -> str:
|
||||
"""사용자에게 보여줄 패턴 설명. ko."""
|
||||
parsed = _parse_pattern(pattern)
|
||||
if parsed is None:
|
||||
return pattern
|
||||
kind, info = parsed
|
||||
if kind in ('weekly', 'biweekly'):
|
||||
names = [_KO_WEEKDAY_NAMES[w] for w in info]
|
||||
prefix = '매주' if kind == 'weekly' else '격주'
|
||||
return f"{prefix} {','.join(names)}요일"
|
||||
if kind == 'monthly':
|
||||
return f"매월 {info}일"
|
||||
return pattern
|
||||
@ -236,6 +236,55 @@ class TimeCalculator:
|
||||
normal_clock_out = self.calculate_clock_out_time(clock_in, include_lunch, include_dinner)
|
||||
return normal_clock_out + timedelta(minutes=target_overtime_minutes)
|
||||
|
||||
def effective_work_minutes(self, date_obj: datetime, db) -> int:
|
||||
"""해당 날짜의 실효 근무 시간(분).
|
||||
|
||||
등록된 연차(반차/시간연차)만큼 정규 근무시간에서 차감.
|
||||
종일 연차(>= work_minutes)면 0 반환.
|
||||
|
||||
Args:
|
||||
date_obj: 기준 날짜 (datetime; 시각은 무시)
|
||||
db: Database 인스턴스 (None이면 차감 없음)
|
||||
|
||||
Returns:
|
||||
실효 work_minutes (>= 0)
|
||||
"""
|
||||
if db is None:
|
||||
return self.work_minutes
|
||||
date_str = date_obj.strftime("%Y-%m-%d")
|
||||
leave_min = db.get_leave_minutes_for(date_str)
|
||||
return max(0, self.work_minutes - leave_min)
|
||||
|
||||
def calculate_holiday_overtime(self, clock_in: datetime, current_time: datetime,
|
||||
include_lunch: bool = False,
|
||||
include_dinner: bool = False,
|
||||
break_minutes: int = 0,
|
||||
unit_minutes: int = 30) -> Tuple[int, int]:
|
||||
"""
|
||||
휴일/주말 근무: 모든 시간을 연장근무로 계산.
|
||||
|
||||
Args:
|
||||
clock_in: 출근 시간
|
||||
current_time: 현재(또는 퇴근) 시간
|
||||
include_lunch/dinner: 식사 시간 차감 여부
|
||||
break_minutes: 외출 시간 (분) — 연장근무에서 제외
|
||||
unit_minutes: 적립 단위 (15/30/60)
|
||||
|
||||
Returns:
|
||||
(실제 연장 분, 적립 분) — 둘 다 0 이상.
|
||||
"""
|
||||
elapsed_minutes = int((current_time - clock_in).total_seconds() / 60)
|
||||
if include_lunch:
|
||||
elapsed_minutes -= self.lunch_duration_minutes
|
||||
if include_dinner:
|
||||
elapsed_minutes -= self.dinner_duration_minutes
|
||||
elapsed_minutes -= break_minutes
|
||||
elapsed_minutes = max(0, elapsed_minutes)
|
||||
|
||||
unit = unit_minutes if unit_minutes > 0 else 30
|
||||
earned = (elapsed_minutes // unit) * unit
|
||||
return elapsed_minutes, earned
|
||||
|
||||
def is_weekend(self, date_obj: datetime) -> bool:
|
||||
"""
|
||||
주말 여부 확인
|
||||
|
||||
@ -4,4 +4,4 @@
|
||||
릴리스 시 이 값을 올린 후 git tag → push.
|
||||
CHANGELOG.md의 최상단 항목과 일치시킬 것.
|
||||
"""
|
||||
__version__ = '2.8.0'
|
||||
__version__ = '2.11.2'
|
||||
|
||||
BIN
font/NanumSquareB.otf
Normal file
BIN
font/NanumSquareB.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareB.ttf
Normal file
BIN
font/NanumSquareB.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquareEB.otf
Normal file
BIN
font/NanumSquareEB.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareEB.ttf
Normal file
BIN
font/NanumSquareEB.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquareL.otf
Normal file
BIN
font/NanumSquareL.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareL.ttf
Normal file
BIN
font/NanumSquareL.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquareOTF_acB.otf
Normal file
BIN
font/NanumSquareOTF_acB.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareOTF_acEB.otf
Normal file
BIN
font/NanumSquareOTF_acEB.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareOTF_acL.otf
Normal file
BIN
font/NanumSquareOTF_acL.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareOTF_acR.otf
Normal file
BIN
font/NanumSquareOTF_acR.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareR.otf
Normal file
BIN
font/NanumSquareR.otf
Normal file
Binary file not shown.
BIN
font/NanumSquareR.ttf
Normal file
BIN
font/NanumSquareR.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquare_acB.ttf
Normal file
BIN
font/NanumSquare_acB.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquare_acEB.ttf
Normal file
BIN
font/NanumSquare_acEB.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquare_acL.ttf
Normal file
BIN
font/NanumSquare_acL.ttf
Normal file
Binary file not shown.
BIN
font/NanumSquare_acR.ttf
Normal file
BIN
font/NanumSquare_acR.ttf
Normal file
Binary file not shown.
5
main.py
5
main.py
@ -96,8 +96,9 @@ def main():
|
||||
)
|
||||
return 1
|
||||
|
||||
# 폰트 설정
|
||||
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():
|
||||
|
||||
19
main.spec
19
main.spec
@ -14,20 +14,35 @@ 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_qtagg', # frozen 차트 백엔드 (chart_widget 우선 import)
|
||||
'matplotlib.backends.backend_qt5agg',
|
||||
'PyQt5.QtSvg',
|
||||
'PyQt5.sip', # matplotlib qt_compat가 sip 사용
|
||||
'numpy.core._multiarray_tests', # numpy import 체인이 참조 (frozen 차트 깨짐 방지)
|
||||
],
|
||||
hookspath=[],
|
||||
hooksconfig={},
|
||||
runtime_hooks=[],
|
||||
excludes=['pandas', 'numpy.testing', 'PyQt5.QtWebEngineWidgets'],
|
||||
# numpy.testing 제외 금지 — numpy.core._multiarray_tests 참조가 끊겨 matplotlib import 실패함
|
||||
excludes=['pandas', 'PyQt5.QtWebEngineWidgets'],
|
||||
noarchive=False,
|
||||
optimize=0,
|
||||
)
|
||||
|
||||
9
tests/conftest.py
Normal file
9
tests/conftest.py
Normal file
@ -0,0 +1,9 @@
|
||||
"""
|
||||
pytest 공통 설정.
|
||||
|
||||
모든 테스트는 백그라운드 휴일 동기화를 끔 — Database 생성 시 spawn되는
|
||||
holiday-sync 스레드가 DB 파일을 lock해서 다음 테스트의 fixture cleanup이 깨짐.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ['CLOCKOUT_DISABLE_HOLIDAY_SYNC'] = '1'
|
||||
@ -108,3 +108,97 @@ class TestConsecutiveOvertimeDays:
|
||||
fresh_db.update_clock_out(d, '20:00:00', total_hours=11.0,
|
||||
overtime_minutes=120, overtime_earned=120)
|
||||
assert fresh_db.get_consecutive_overtime_days() == 3
|
||||
|
||||
|
||||
class TestLeaveQueriesByDate:
|
||||
def test_get_leave_minutes_for_no_records(self, fresh_db):
|
||||
assert fresh_db.get_leave_minutes_for('2026-05-01') == 0
|
||||
|
||||
def test_full_day_leave_detected(self, fresh_db):
|
||||
fresh_db.add_leave_record('2026-05-15', '연차', 1.0, '여행')
|
||||
assert fresh_db.has_full_day_leave('2026-05-15')
|
||||
assert fresh_db.get_leave_minutes_for('2026-05-15') == 480
|
||||
|
||||
def test_half_day_not_full(self, fresh_db):
|
||||
fresh_db.add_leave_record('2026-05-15', '반차', 0.5)
|
||||
assert not fresh_db.has_full_day_leave('2026-05-15')
|
||||
assert fresh_db.get_leave_minutes_for('2026-05-15') == 240
|
||||
|
||||
def test_two_halves_become_full(self, fresh_db):
|
||||
fresh_db.add_leave_record('2026-05-15', '오전반차', 0.5)
|
||||
fresh_db.add_leave_record('2026-05-15', '오후반차', 0.5)
|
||||
assert fresh_db.has_full_day_leave('2026-05-15')
|
||||
assert fresh_db.get_leave_minutes_for('2026-05-15') == 480
|
||||
|
||||
def test_records_by_date(self, fresh_db):
|
||||
fresh_db.add_leave_record('2026-05-15', '연차', 1.0, '메모')
|
||||
recs = fresh_db.get_leave_records_by_date('2026-05-15')
|
||||
assert len(recs) == 1
|
||||
assert recs[0]['leave_type'] == '연차'
|
||||
assert recs[0]['memo'] == '메모'
|
||||
|
||||
def test_records_by_range(self, fresh_db):
|
||||
fresh_db.add_leave_record('2026-05-01', '연차', 1.0)
|
||||
fresh_db.add_leave_record('2026-05-10', '반차', 0.5)
|
||||
fresh_db.add_leave_record('2026-06-01', '연차', 1.0)
|
||||
recs = fresh_db.get_leave_records_by_range('2026-05-01', '2026-05-31')
|
||||
assert len(recs) == 2
|
||||
# 날짜 정렬
|
||||
assert recs[0]['date'] == '2026-05-01'
|
||||
assert recs[1]['date'] == '2026-05-10'
|
||||
|
||||
|
||||
class TestRecurringLeavesDB:
|
||||
def test_add_and_list(self, fresh_db):
|
||||
rid = fresh_db.add_recurring_leave(
|
||||
'weekly:friday', '반차', 0.5, '2026-05-01', '2026-12-31', '단축'
|
||||
)
|
||||
assert rid > 0
|
||||
recs = fresh_db.get_recurring_leaves()
|
||||
assert len(recs) == 1
|
||||
assert recs[0]['pattern'] == 'weekly:friday'
|
||||
assert recs[0]['memo'] == '단축'
|
||||
|
||||
def test_active_on_filter(self, fresh_db):
|
||||
# 종료일이 지난 패턴
|
||||
fresh_db.add_recurring_leave('weekly:fri', '반차', 0.5,
|
||||
'2025-01-01', '2025-12-31')
|
||||
# 아직 시작 안 한 패턴
|
||||
fresh_db.add_recurring_leave('weekly:mon', '반차', 0.5,
|
||||
'2027-01-01', None)
|
||||
# 현재 활성 패턴
|
||||
fresh_db.add_recurring_leave('monthly:15', '연차', 1.0,
|
||||
'2026-01-01', None)
|
||||
active = fresh_db.get_recurring_leaves(active_on='2026-05-15')
|
||||
assert len(active) == 1
|
||||
assert active[0]['pattern'] == 'monthly:15'
|
||||
|
||||
def test_delete(self, fresh_db):
|
||||
rid = fresh_db.add_recurring_leave('weekly:fri', '반차', 0.5,
|
||||
'2026-01-01')
|
||||
assert len(fresh_db.get_recurring_leaves()) == 1
|
||||
fresh_db.delete_recurring_leave(rid)
|
||||
assert fresh_db.get_recurring_leaves() == []
|
||||
|
||||
def test_recurring_contributes_to_leave_minutes(self, fresh_db):
|
||||
# 매주 금요일 반차
|
||||
fresh_db.add_recurring_leave('weekly:friday', '반차', 0.5,
|
||||
'2026-01-01')
|
||||
# 2026-05-01 = Friday → 240분
|
||||
assert fresh_db.get_leave_minutes_for('2026-05-01') == 240
|
||||
# 2026-05-04 = Monday → 0분
|
||||
assert fresh_db.get_leave_minutes_for('2026-05-04') == 0
|
||||
|
||||
def test_concrete_plus_recurring_sum(self, fresh_db):
|
||||
# 매주 금요일 반차 + 그날 별도 반반차 추가
|
||||
fresh_db.add_recurring_leave('weekly:friday', '반차', 0.5, '2026-01-01')
|
||||
fresh_db.add_leave_record('2026-05-01', '반반차', 0.25)
|
||||
# 0.5 + 0.25 = 0.75일 = 360분
|
||||
assert fresh_db.get_leave_minutes_for('2026-05-01') == 360
|
||||
assert not fresh_db.has_full_day_leave('2026-05-01')
|
||||
|
||||
def test_concrete_plus_recurring_full_day(self, fresh_db):
|
||||
fresh_db.add_recurring_leave('weekly:friday', '반차', 0.5, '2026-01-01')
|
||||
fresh_db.add_leave_record('2026-05-01', '오후반차', 0.5)
|
||||
# 0.5 + 0.5 = 1.0일
|
||||
assert fresh_db.has_full_day_leave('2026-05-01')
|
||||
|
||||
166
tests/test_holiday_api.py
Normal file
166
tests/test_holiday_api.py
Normal file
@ -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):
|
||||
|
||||
72
tests/test_overtime_accrual_guard.py
Normal file
72
tests/test_overtime_accrual_guard.py
Normal file
@ -0,0 +1,72 @@
|
||||
"""연장근무 자동 적립 가드 테스트.
|
||||
|
||||
auto_overtime(자동 적립)가 OFF면, 자동 퇴근 경로(근무일 경계 롤오버 등)에서도
|
||||
은행 적립을 하지 않아야 한다 — clock_out() 대화상자에서 '아니오'를 고른 것과 동일한 의미.
|
||||
|
||||
handle_workday_rollover는 위젯 의존이 tail(load_today_data/update_overtime_balance)뿐이라,
|
||||
__new__로 만든 인스턴스에 필요한 속성만 채워 단위 테스트한다 (QApplication 불필요).
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime, timedelta, time as dtime
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from core.database import Database
|
||||
from core.time_calculator import TimeCalculator
|
||||
from ui.main_window import MainWindow
|
||||
|
||||
|
||||
def _rollover_balance(db, monkeypatch):
|
||||
"""어제 미퇴근 상태에서 근무일 경계 롤오버를 실행하고 적립 잔액을 반환."""
|
||||
from PyQt5.QtWidgets import QMessageBox
|
||||
monkeypatch.setattr(QMessageBox, 'information',
|
||||
staticmethod(lambda *a, **k: QMessageBox.Ok))
|
||||
|
||||
today = datetime.now().date()
|
||||
y = today - timedelta(days=1)
|
||||
db.add_work_record(y.isoformat(), '09:00:00', is_manual=True) # 어제: 미퇴근
|
||||
|
||||
w = MainWindow.__new__(MainWindow) # __init__ 우회 (위젯/타이머 없음)
|
||||
w.db = db
|
||||
w.time_calc = TimeCalculator(work_minutes=480)
|
||||
w.clock_in_time = datetime.combine(y, dtime(9, 0, 0))
|
||||
w.is_clocked_in = True
|
||||
w.midnight_rollover_handled = False
|
||||
w.is_on_break = False
|
||||
w.lunch_break_enabled = False
|
||||
w.dinner_break_enabled = False
|
||||
w.load_today_data = lambda: None # tail UI refresh stub
|
||||
w.update_overtime_balance = lambda: None # tail UI refresh stub
|
||||
|
||||
w.handle_workday_rollover(datetime.combine(today, dtime(7, 0, 0)))
|
||||
return db.get_total_overtime_balance()
|
||||
|
||||
|
||||
def test_rollover_does_not_accrue_when_auto_overtime_off(tmp_path, monkeypatch):
|
||||
db = Database(str(tmp_path / 'off.db'))
|
||||
db.set_setting('auto_overtime', 'false')
|
||||
assert _rollover_balance(db, monkeypatch) == 0
|
||||
|
||||
|
||||
def test_rollover_accrues_when_auto_overtime_on(tmp_path, monkeypatch):
|
||||
db = Database(str(tmp_path / 'on.db'))
|
||||
db.set_setting('auto_overtime', 'true')
|
||||
assert _rollover_balance(db, monkeypatch) > 0
|
||||
|
||||
|
||||
def test_delete_overtime_earned_reduces_balance(tmp_path):
|
||||
"""적립(은행) 기록 삭제 시 잔액이 그만큼 감소한다."""
|
||||
from datetime import date
|
||||
db = Database(str(tmp_path / 'del.db'))
|
||||
today = date.today().isoformat()
|
||||
db.add_overtime_earned(None, 90, today)
|
||||
assert db.get_total_overtime_balance() == 90
|
||||
|
||||
bank_id = db.get_connection().execute(
|
||||
'SELECT id FROM overtime_bank').fetchone()[0]
|
||||
assert db.delete_overtime_earned(bank_id) is True
|
||||
assert db.get_total_overtime_balance() == 0
|
||||
|
||||
# 없는 id 삭제는 False
|
||||
assert db.delete_overtime_earned(999999) is False
|
||||
153
tests/test_recurring_leaves.py
Normal file
153
tests/test_recurring_leaves.py
Normal file
@ -0,0 +1,153 @@
|
||||
"""
|
||||
core.recurring_leaves 단위 테스트.
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from core.recurring_leaves import (
|
||||
matches, expand_for_range, expand_for_date, describe_pattern, _parse_pattern,
|
||||
)
|
||||
|
||||
|
||||
class TestParsePattern:
|
||||
@pytest.mark.parametrize("pattern,expected_kind", [
|
||||
('weekly:friday', 'weekly'),
|
||||
('weekly:fri', 'weekly'),
|
||||
('weekly:mon,wed,fri', 'weekly'),
|
||||
('biweekly:friday', 'biweekly'),
|
||||
('monthly:15', 'monthly'),
|
||||
('monthly:1', 'monthly'),
|
||||
])
|
||||
def test_valid(self, pattern, expected_kind):
|
||||
result = _parse_pattern(pattern)
|
||||
assert result is not None
|
||||
assert result[0] == expected_kind
|
||||
|
||||
@pytest.mark.parametrize("pattern", [
|
||||
'', 'weekly', 'weekly:', 'weekly:xyz',
|
||||
'monthly:0', 'monthly:32', 'monthly:abc',
|
||||
'unknown:fri', None,
|
||||
])
|
||||
def test_invalid(self, pattern):
|
||||
assert _parse_pattern(pattern) is None
|
||||
|
||||
|
||||
class TestMatches:
|
||||
def _rec(self, pattern, start='2026-01-01', end=None, days=0.5, leave_type='반차'):
|
||||
return {
|
||||
'pattern': pattern,
|
||||
'start_date': start,
|
||||
'end_date': end,
|
||||
'days': days,
|
||||
'leave_type': leave_type,
|
||||
}
|
||||
|
||||
def test_weekly_single_day(self):
|
||||
rec = self._rec('weekly:friday')
|
||||
assert matches(rec, date(2026, 5, 1)) # Fri
|
||||
assert not matches(rec, date(2026, 5, 2)) # Sat
|
||||
assert not matches(rec, date(2026, 5, 4)) # Mon
|
||||
|
||||
def test_weekly_multiple_days(self):
|
||||
rec = self._rec('weekly:mon,wed,fri')
|
||||
assert matches(rec, date(2026, 5, 4)) # Mon
|
||||
assert matches(rec, date(2026, 5, 6)) # Wed
|
||||
assert matches(rec, date(2026, 5, 8)) # Fri
|
||||
assert not matches(rec, date(2026, 5, 5)) # Tue
|
||||
assert not matches(rec, date(2026, 5, 7)) # Thu
|
||||
|
||||
def test_biweekly_alignment(self):
|
||||
# start_date 2026-01-02 = Friday (week 0)
|
||||
rec = self._rec('biweekly:friday', start='2026-01-02')
|
||||
assert matches(rec, date(2026, 1, 2)) # week 0
|
||||
assert not matches(rec, date(2026, 1, 9)) # week 1
|
||||
assert matches(rec, date(2026, 1, 16)) # week 2
|
||||
|
||||
def test_monthly(self):
|
||||
rec = self._rec('monthly:15')
|
||||
assert matches(rec, date(2026, 1, 15))
|
||||
assert matches(rec, date(2026, 5, 15))
|
||||
assert not matches(rec, date(2026, 5, 14))
|
||||
assert not matches(rec, date(2026, 5, 16))
|
||||
|
||||
def test_monthly_skipped_in_short_month(self):
|
||||
# 31일은 30일 달에는 매치되지 않음
|
||||
rec = self._rec('monthly:31')
|
||||
assert matches(rec, date(2026, 1, 31))
|
||||
assert not matches(rec, date(2026, 4, 30)) # 4월 31일 없음
|
||||
|
||||
def test_before_start(self):
|
||||
rec = self._rec('weekly:friday', start='2026-05-01')
|
||||
assert matches(rec, date(2026, 5, 1))
|
||||
assert not matches(rec, date(2026, 4, 24)) # 시작 전
|
||||
|
||||
def test_after_end(self):
|
||||
rec = self._rec('weekly:friday', start='2026-01-01', end='2026-04-30')
|
||||
assert matches(rec, date(2026, 4, 24)) # 종료일 이전 금요일
|
||||
assert not matches(rec, date(2026, 5, 1)) # 종료일 이후
|
||||
|
||||
def test_no_end_means_forever(self):
|
||||
rec = self._rec('weekly:friday', start='2026-01-01', end=None)
|
||||
assert matches(rec, date(2030, 1, 4)) # 4년 후 금요일
|
||||
|
||||
def test_invalid_pattern_returns_false(self):
|
||||
rec = self._rec('garbage:xyz')
|
||||
assert not matches(rec, date(2026, 5, 1))
|
||||
|
||||
|
||||
class TestExpandRange:
|
||||
def _rec(self, pattern, start='2026-01-01'):
|
||||
return {
|
||||
'id': 1, 'pattern': pattern, 'start_date': start, 'end_date': None,
|
||||
'days': 0.5, 'leave_type': '반차', 'memo': '',
|
||||
}
|
||||
|
||||
def test_expand_weekly_one_month(self):
|
||||
rec = self._rec('weekly:friday')
|
||||
occs = expand_for_range([rec], date(2026, 5, 1), date(2026, 5, 31))
|
||||
# 5월 금요일: 1, 8, 15, 22, 29 = 5회
|
||||
assert len(occs) == 5
|
||||
assert all(o.date.weekday() == 4 for o in occs)
|
||||
|
||||
def test_expand_empty_when_outside(self):
|
||||
rec = self._rec('weekly:friday', start='2027-01-01')
|
||||
occs = expand_for_range([rec], date(2026, 5, 1), date(2026, 5, 31))
|
||||
assert occs == []
|
||||
|
||||
def test_expand_invalid_range(self):
|
||||
# start > end
|
||||
rec = self._rec('weekly:friday')
|
||||
occs = expand_for_range([rec], date(2026, 5, 31), date(2026, 5, 1))
|
||||
assert occs == []
|
||||
|
||||
def test_expand_multiple_recs(self):
|
||||
rec_fri = self._rec('weekly:friday')
|
||||
rec_mon = self._rec('weekly:monday')
|
||||
rec_mon['id'] = 2
|
||||
occs = expand_for_range([rec_fri, rec_mon], date(2026, 5, 1), date(2026, 5, 7))
|
||||
# 5/1=Fri (rec_fri), 5/4=Mon (rec_mon)
|
||||
assert len(occs) == 2
|
||||
|
||||
def test_expand_for_date_single(self):
|
||||
rec = self._rec('monthly:15')
|
||||
occs = expand_for_date([rec], date(2026, 5, 15))
|
||||
assert len(occs) == 1
|
||||
assert occs[0].date == date(2026, 5, 15)
|
||||
|
||||
|
||||
class TestDescribePattern:
|
||||
def test_weekly_korean(self):
|
||||
assert '매주' in describe_pattern('weekly:friday')
|
||||
assert '금' in describe_pattern('weekly:friday')
|
||||
|
||||
def test_biweekly(self):
|
||||
assert '격주' in describe_pattern('biweekly:friday')
|
||||
|
||||
def test_monthly(self):
|
||||
assert '매월' in describe_pattern('monthly:15')
|
||||
assert '15' in describe_pattern('monthly:15')
|
||||
@ -98,3 +98,77 @@ class TestDayType:
|
||||
mon = datetime(2026, 5, 4)
|
||||
assert not calc.is_weekend(mon)
|
||||
assert calc.get_day_type(mon) == 'normal'
|
||||
|
||||
|
||||
class TestHolidayOvertime:
|
||||
"""휴일/주말 근무 적립 — 출근 직후부터 모든 시간이 연장으로."""
|
||||
|
||||
def test_zero_elapsed_returns_zero(self, calc_8h):
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
actual, earned = calc_8h.calculate_holiday_overtime(ci, ci)
|
||||
assert actual == 0 and earned == 0
|
||||
|
||||
def test_one_minute_elapsed_no_lunch(self, calc_8h):
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
now = ci + timedelta(minutes=1)
|
||||
actual, earned = calc_8h.calculate_holiday_overtime(ci, now)
|
||||
assert actual == 1
|
||||
assert earned == 0 # 30분 단위 절삭
|
||||
|
||||
def test_30min_elapsed_truncates_to_30(self, calc_8h):
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
now = ci + timedelta(minutes=30)
|
||||
actual, earned = calc_8h.calculate_holiday_overtime(ci, now)
|
||||
assert actual == 30 and earned == 30
|
||||
|
||||
def test_29min_elapsed_truncates_to_zero(self, calc_8h):
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
now = ci + timedelta(minutes=29)
|
||||
actual, earned = calc_8h.calculate_holiday_overtime(ci, now)
|
||||
assert actual == 29 and earned == 0
|
||||
|
||||
def test_lunch_subtracted(self, calc_8h):
|
||||
# 8h 근무 + 점심 60m → 9h 일했지만 점심 차감 = 8h 적립
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
now = ci + timedelta(hours=9)
|
||||
actual, earned = calc_8h.calculate_holiday_overtime(
|
||||
ci, now, include_lunch=True
|
||||
)
|
||||
assert actual == 8 * 60
|
||||
assert earned == 8 * 60
|
||||
|
||||
def test_break_minutes_subtracted(self, calc_8h):
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
now = ci + timedelta(hours=2)
|
||||
# 외출 30분 → 90분 적립
|
||||
actual, earned = calc_8h.calculate_holiday_overtime(
|
||||
ci, now, break_minutes=30
|
||||
)
|
||||
assert actual == 90 and earned == 90
|
||||
|
||||
def test_unit_minutes_15(self, calc_8h):
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
now = ci + timedelta(minutes=44)
|
||||
# 44분 → 30분 적립 (15분 단위)
|
||||
actual, earned = calc_8h.calculate_holiday_overtime(
|
||||
ci, now, unit_minutes=15
|
||||
)
|
||||
assert actual == 44 and earned == 30
|
||||
|
||||
def test_unit_minutes_60(self, calc_8h):
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
now = ci + timedelta(minutes=119)
|
||||
# 119분 → 60분 적립 (60분 단위)
|
||||
actual, earned = calc_8h.calculate_holiday_overtime(
|
||||
ci, now, unit_minutes=60
|
||||
)
|
||||
assert actual == 119 and earned == 60
|
||||
|
||||
def test_negative_clamped_to_zero(self, calc_8h):
|
||||
# 점심 60m + 저녁 60m = 120m 차감되는데 1시간만 일하면 음수
|
||||
ci = datetime(2026, 5, 1, 9, 0)
|
||||
now = ci + timedelta(hours=1)
|
||||
actual, earned = calc_8h.calculate_holiday_overtime(
|
||||
ci, now, include_lunch=True, include_dinner=True
|
||||
)
|
||||
assert actual == 0 and earned == 0
|
||||
|
||||
@ -17,6 +17,7 @@ from PyQt5.QtGui import QFont
|
||||
|
||||
from core.achievements import get_all_with_status, get_stats
|
||||
from ui.styles import apply_dark_titlebar
|
||||
from ui.dark_components import tc, tabs_qss, button_qss, scroll_qss, ACCENT_GOLD, _is_dark
|
||||
|
||||
|
||||
# 등급별 색상 팔레트 (배경 그라디언트, 외곽선, 강조)
|
||||
@ -85,13 +86,13 @@ 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(f"QDialog {{ background: {tc('bg')}; }}")
|
||||
self.init_ui()
|
||||
apply_dark_titlebar(self, dark=True)
|
||||
apply_dark_titlebar(self) # 현재 테마에 맞춰
|
||||
|
||||
def _increment_view_count(self) -> None:
|
||||
try:
|
||||
@ -136,14 +137,7 @@ class AchievementsView(QDialog):
|
||||
btn_row.addStretch()
|
||||
close_btn = QPushButton("닫기")
|
||||
close_btn.setMinimumWidth(100)
|
||||
close_btn.setStyleSheet("""
|
||||
QPushButton {
|
||||
background: #2a2a36; color: #e0e0e8;
|
||||
border: 1px solid #44446a; border-radius: 6px;
|
||||
padding: 8px 20px; font-size: 10pt;
|
||||
}
|
||||
QPushButton:hover { background: #3a3a4a; border-color: #6b9eff; }
|
||||
""")
|
||||
close_btn.setStyleSheet(button_qss('default'))
|
||||
close_btn.clicked.connect(self.accept)
|
||||
btn_row.addWidget(close_btn)
|
||||
layout.addLayout(btn_row)
|
||||
@ -153,14 +147,13 @@ class AchievementsView(QDialog):
|
||||
# ----- 헤더 -----
|
||||
def _build_header(self, stats: dict) -> QWidget:
|
||||
container = QFrame()
|
||||
container.setStyleSheet("""
|
||||
QFrame {
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #1a1a30, stop:1 #2a1a3a);
|
||||
border: 1px solid #3a3a5a;
|
||||
container.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background: {tc('panel')};
|
||||
border: 1px solid {tc('border')};
|
||||
border-radius: 12px;
|
||||
}
|
||||
QLabel { background: transparent; border: none; color: #e8e8f4; }
|
||||
}}
|
||||
QLabel {{ background: transparent; border: none; color: {tc('text')}; }}
|
||||
""")
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(20, 16, 20, 16)
|
||||
@ -172,22 +165,28 @@ class AchievementsView(QDialog):
|
||||
num_row = QHBoxLayout()
|
||||
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>")
|
||||
# 헤더 강조 숫자색 — 다크는 비비드, 라이트는 동일 색조 진하게(가독성)
|
||||
if _is_dark():
|
||||
c_earned, c_secret, c_pct = '#ffd24a', '#ff90b8', '#4adef0'
|
||||
else:
|
||||
c_earned, c_secret, c_pct = '#C8950A', '#C2185B', '#0E7490'
|
||||
|
||||
big = QLabel(f"<span style='font-size: 32pt; font-weight: bold; color: {c_earned};'>{stats['earned']}</span>"
|
||||
f"<span style='font-size: 18pt; color: {tc('text_dim')};'> / {stats['total']}</span>")
|
||||
big.setTextFormat(Qt.RichText)
|
||||
num_row.addWidget(big)
|
||||
|
||||
spacer = QFrame()
|
||||
spacer.setFrameShape(QFrame.VLine)
|
||||
spacer.setStyleSheet("color: #3a3a5a;")
|
||||
spacer.setStyleSheet(f"color: {tc('border')};")
|
||||
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: 18pt; font-weight: bold; color: #ff90b8;'>"
|
||||
f"<span style='font-size: 9pt; color: {tc('text_dim')};'>🌑 시크릿</span><br>"
|
||||
f"<span style='font-size: 18pt; font-weight: bold; color: {c_secret};'>"
|
||||
f"{stats['secret_earned']}</span>"
|
||||
f"<span style='font-size: 12pt; color: #888;'> / {stats['secret_total']}</span>"
|
||||
f"<span style='font-size: 12pt; color: {tc('text_dim')};'> / {stats['secret_total']}</span>"
|
||||
f"</div>"
|
||||
)
|
||||
secret_lbl.setTextFormat(Qt.RichText)
|
||||
@ -197,8 +196,8 @@ 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: 24pt; font-weight: bold; color: #4adef0;'>"
|
||||
f"<span style='font-size: 9pt; color: {tc('text_dim')};'>달성률</span><br>"
|
||||
f"<span style='font-size: 24pt; font-weight: bold; color: {c_pct};'>"
|
||||
f"{pct:.1f}%</span></div>"
|
||||
)
|
||||
pct_lbl.setTextFormat(Qt.RichText)
|
||||
@ -214,17 +213,17 @@ class AchievementsView(QDialog):
|
||||
bar.setTextVisible(False)
|
||||
bar.setMinimumHeight(8)
|
||||
bar.setMaximumHeight(8)
|
||||
bar.setStyleSheet("""
|
||||
QProgressBar {
|
||||
background: #1a1a26;
|
||||
bar.setStyleSheet(f"""
|
||||
QProgressBar {{
|
||||
background: {tc('panel2')};
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
}
|
||||
QProgressBar::chunk {
|
||||
}}
|
||||
QProgressBar::chunk {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 #4adef0, stop:0.5 #6b9eff, stop:1 #ff90b8);
|
||||
border-radius: 4px;
|
||||
}
|
||||
}}
|
||||
""")
|
||||
layout.addWidget(bar)
|
||||
|
||||
@ -235,17 +234,7 @@ class AchievementsView(QDialog):
|
||||
def _build_grid_tab(self, items: list, secret_mode: bool = False) -> QWidget:
|
||||
scroll = QScrollArea()
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setStyleSheet("""
|
||||
QScrollArea { background: transparent; border: none; }
|
||||
QScrollBar:vertical {
|
||||
background: #1a1a24; width: 10px; border-radius: 5px;
|
||||
}
|
||||
QScrollBar::handle:vertical {
|
||||
background: #44446a; border-radius: 5px; min-height: 30px;
|
||||
}
|
||||
QScrollBar::handle:vertical:hover { background: #6b9eff; }
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { height: 0; }
|
||||
""")
|
||||
scroll.setStyleSheet(scroll_qss())
|
||||
container = QWidget()
|
||||
container.setStyleSheet("background: transparent;")
|
||||
grid = QGridLayout()
|
||||
@ -256,7 +245,7 @@ class AchievementsView(QDialog):
|
||||
empty = QLabel("(아직 없음)")
|
||||
empty.setAlignment(Qt.AlignCenter)
|
||||
empty.setStyleSheet(
|
||||
"color: #666; padding: 60px; font-size: 12pt; background: transparent;"
|
||||
f"color: {tc('text_faint')}; padding: 60px; font-size: 12pt; background: transparent;"
|
||||
)
|
||||
grid.addWidget(empty, 0, 0)
|
||||
else:
|
||||
@ -279,11 +268,18 @@ class AchievementsView(QDialog):
|
||||
tier = item['tier'] or 'bronze'
|
||||
theme = TIER_THEMES.get(tier, TIER_THEMES['bronze'])
|
||||
|
||||
# 시크릿 미발견은 회색 톤으로
|
||||
# 라이트 테마: 카드 배경을 패널색으로(등급색은 보더/강조로 유지), 다크: 등급 그라디언트
|
||||
light = not _is_dark()
|
||||
if is_locked_secret:
|
||||
bg_top, bg_bot = '#1a1a26', '#0e0e16'
|
||||
border = '#3a3a4a'
|
||||
text_color = '#666'
|
||||
if light:
|
||||
bg_top = bg_bot = tc('panel'); border = tc('border')
|
||||
else:
|
||||
bg_top, bg_bot = '#1a1a26', '#0e0e16'; border = '#3a3a4a'
|
||||
text_color = tc('text_faint')
|
||||
elif light:
|
||||
bg_top = bg_bot = tc('panel')
|
||||
border = theme['border_strong'] if is_earned else theme['border']
|
||||
text_color = tc('text') if is_earned else tc('text_dim')
|
||||
else:
|
||||
bg_top = theme['bg_top']
|
||||
bg_bot = theme['bg_bot']
|
||||
@ -342,7 +338,7 @@ class AchievementsView(QDialog):
|
||||
name = QLabel(name_text)
|
||||
name.setStyleSheet(
|
||||
f"font-size: 12pt; font-weight: bold; "
|
||||
f"color: {'#ffffff' if is_earned else '#d0d0e0'}; "
|
||||
f"color: {tc('text') if is_earned else tc('text_dim')}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
name.setWordWrap(True)
|
||||
@ -353,8 +349,8 @@ class AchievementsView(QDialog):
|
||||
cat_label = QLabel(f" {theme['label']} {theme['name']} · {cat_text} ")
|
||||
cat_label.setStyleSheet(
|
||||
f"font-size: 8.5pt; "
|
||||
f"color: {theme['border_strong']}; "
|
||||
f"background: rgba(255,255,255,0.05); "
|
||||
f"color: {theme['border_strong'] if _is_dark() else tc('text_dim')}; "
|
||||
f"background: {'rgba(255,255,255,0.05)' if _is_dark() else tc('panel2')}; "
|
||||
f"border: 1px solid {theme['border']}; "
|
||||
f"border-radius: 8px; "
|
||||
f"padding: 1px 4px;"
|
||||
@ -378,7 +374,7 @@ class AchievementsView(QDialog):
|
||||
desc = QLabel(desc_text)
|
||||
desc.setWordWrap(True)
|
||||
desc.setStyleSheet(
|
||||
f"color: #a0a0b8; font-size: 9.5pt; "
|
||||
f"color: {tc('text_dim')}; font-size: 9.5pt; "
|
||||
f"background: transparent; border: none; padding: 0;"
|
||||
)
|
||||
outer.addWidget(desc)
|
||||
@ -387,9 +383,9 @@ class AchievementsView(QDialog):
|
||||
if is_earned:
|
||||
earned = QLabel(f" ✓ {item['earned_date']} 달성 ")
|
||||
earned.setStyleSheet(
|
||||
f"color: {theme['border_strong']}; "
|
||||
f"color: {theme['border_strong'] if _is_dark() else tc('text')}; "
|
||||
f"font-weight: bold; font-size: 9.5pt; "
|
||||
f"background: rgba(255,255,255,0.08); "
|
||||
f"background: {'rgba(255,255,255,0.08)' if _is_dark() else tc('panel2')}; "
|
||||
f"border: 1px solid {theme['border']}; "
|
||||
f"border-radius: 6px; padding: 4px 8px;"
|
||||
)
|
||||
@ -415,7 +411,7 @@ class AchievementsView(QDialog):
|
||||
pb.setMaximumHeight(10)
|
||||
pb.setStyleSheet(f"""
|
||||
QProgressBar {{
|
||||
background: rgba(0,0,0,0.4);
|
||||
background: {'rgba(0,0,0,0.4)' if _is_dark() else tc('panel2')};
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
}}
|
||||
@ -429,7 +425,7 @@ class AchievementsView(QDialog):
|
||||
|
||||
num = QLabel(f"{progress} / {target}")
|
||||
num.setStyleSheet(
|
||||
f"color: {theme['border_strong']}; font-size: 9pt; "
|
||||
f"color: {theme['border_strong'] if _is_dark() else tc('text_dim')}; font-size: 9pt; "
|
||||
f"font-weight: bold; background: transparent; border: none;"
|
||||
)
|
||||
num.setMinimumWidth(60)
|
||||
@ -453,32 +449,5 @@ class AchievementsView(QDialog):
|
||||
|
||||
# ----- 탭 QSS (다이얼로그 전용) -----
|
||||
def _tabs_qss(self) -> str:
|
||||
return """
|
||||
QTabWidget::pane {
|
||||
background: #14141c;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-radius: 10px;
|
||||
top: -1px;
|
||||
}
|
||||
QTabBar::tab {
|
||||
background: #1c1c28;
|
||||
color: #a0a0b8;
|
||||
padding: 9px 18px;
|
||||
border: 1px solid #2a2a3a;
|
||||
border-bottom: none;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
margin-right: 3px;
|
||||
font-size: 10pt;
|
||||
}
|
||||
QTabBar::tab:selected {
|
||||
background: #14141c;
|
||||
color: #ffd24a;
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid #ffd24a;
|
||||
}
|
||||
QTabBar::tab:hover:!selected {
|
||||
background: #2a2a36;
|
||||
color: #e0e0e8;
|
||||
}
|
||||
"""
|
||||
# 공통 테마 인식형 탭 스타일 (도전과제는 골드 강조 유지)
|
||||
return tabs_qss(ACCENT_GOLD)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -10,24 +10,49 @@ from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
|
||||
from PyQt5.QtCore import Qt
|
||||
|
||||
try:
|
||||
from matplotlib.figure import Figure
|
||||
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
||||
import matplotlib
|
||||
matplotlib.rcParams['font.family'] = ['Malgun Gothic', 'Apple SD Gothic Neo', 'sans-serif']
|
||||
from matplotlib.figure import Figure
|
||||
# frozen(main.exe) 빌드는 PyInstaller matplotlib hook이 'QtAgg'(backend_qtagg)만
|
||||
# 번들함 → backend_qt5agg import가 실패해 차트가 안 뜨던 문제.
|
||||
# 번들된 backend_qtagg를 우선 사용하고, 구버전(dev) 호환으로 qt5agg 폴백.
|
||||
try:
|
||||
from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas
|
||||
except Exception:
|
||||
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
||||
matplotlib.rcParams['font.family'] = ['NanumSquare', 'Malgun Gothic', 'Apple SD Gothic Neo', 'sans-serif']
|
||||
matplotlib.rcParams['axes.unicode_minus'] = False
|
||||
_MPL = True
|
||||
except ImportError:
|
||||
except Exception as _mpl_err:
|
||||
# ImportError 외 backend/sip 로딩 오류도 폴백 처리 + 실제 원인 기록(진단용)
|
||||
_MPL = False
|
||||
try:
|
||||
from utils.debug_log import dlog
|
||||
dlog(f"chart_widget: matplotlib unavailable: {type(_mpl_err).__name__}: {_mpl_err}")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# 다크 테마 색상 (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
|
||||
# 차트 색상 — 배경/그리드/텍스트는 현재 테마를 따름(_refresh_chart_colors),
|
||||
# 막대/선은 데이터 구분용 고정 색.
|
||||
_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 _refresh_chart_colors() -> None:
|
||||
"""배경/그리드/텍스트 색을 현재 앱 테마로 갱신 (라이트/다크 추종)."""
|
||||
global _CHART_BG, _CHART_GRID, _CHART_TEXT
|
||||
try:
|
||||
from ui.styles import ThemeColors
|
||||
_CHART_BG = ThemeColors.get('bg_secondary')
|
||||
_CHART_GRID = ThemeColors.get('border_subtle')
|
||||
_CHART_TEXT = ThemeColors.get('text_secondary')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _apply_dark_axes(ax) -> None:
|
||||
@ -43,7 +68,8 @@ def _apply_dark_axes(ax) -> None:
|
||||
|
||||
|
||||
def _apply_dark_figure(fig) -> None:
|
||||
"""figure 배경을 다크 톤으로."""
|
||||
"""figure 배경을 현재 테마 톤으로 (모든 draw_* 진입점에서 호출됨)."""
|
||||
_refresh_chart_colors()
|
||||
fig.patch.set_facecolor(_CHART_BG)
|
||||
|
||||
|
||||
@ -55,7 +81,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)
|
||||
|
||||
@ -64,6 +90,7 @@ def make_chart_widget(parent=None) -> QWidget:
|
||||
"""차트가 그려질 빈 캔버스 위젯. matplotlib 없으면 fallback."""
|
||||
if not _MPL:
|
||||
return _Fallback("차트 표시에는 matplotlib가 필요합니다.\npip install matplotlib")
|
||||
_refresh_chart_colors()
|
||||
widget = QWidget(parent)
|
||||
widget.setStyleSheet(f"background: {_CHART_BG}; border-radius: 8px;")
|
||||
layout = QVBoxLayout()
|
||||
|
||||
@ -82,10 +82,13 @@ class NotificationOrchestrator:
|
||||
except Exception as e:
|
||||
dlog(f"discord weekly_report failed: {e}")
|
||||
|
||||
def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float) -> None:
|
||||
def tick(self, now: datetime, expected_clock_out: datetime, remaining_seconds: float,
|
||||
is_holiday: bool = False) -> None:
|
||||
n = self.notifier
|
||||
# 1초마다 체크: 30분 전, 점심/저녁 미등록, 연장 적립
|
||||
n.check_clock_out_soon(expected_clock_out, now)
|
||||
# "퇴근 30분 전" 알림은 휴일/주말엔 무의미 (정해진 퇴근시각 없음)
|
||||
if not is_holiday:
|
||||
n.check_clock_out_soon(expected_clock_out, now)
|
||||
# 점심/저녁/장시간 휴식 알림은 휴일에도 그대로 — 식사·건강은 휴일에도 챙김
|
||||
n.check_lunch_reminder(self.window.clock_in_time,
|
||||
self.window.lunch_break_enabled, now)
|
||||
n.check_dinner_reminder(self.window.clock_in_time,
|
||||
|
||||
@ -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 = {
|
||||
@ -76,26 +78,59 @@ CARD_THEMES = {
|
||||
}
|
||||
|
||||
|
||||
# ── 테마 연동 ──────────────────────────────────────────────────
|
||||
# 통계/도움말/도전과제 다이얼로그는 열 때마다 새로 생성되므로, 빌드 시점에 현재
|
||||
# 앱 테마(ThemeColors)를 읽으면 라이트/다크를 자동으로 따른다.
|
||||
|
||||
def _pal() -> dict:
|
||||
"""현재 앱 테마 팔레트를 dark_components 역할명으로 매핑."""
|
||||
from ui.styles import ThemeColors
|
||||
g = ThemeColors.get
|
||||
return {
|
||||
'bg': g('bg_primary'), 'panel': g('bg_secondary'), 'panel2': g('bg_tertiary'),
|
||||
'border': g('border_subtle'), 'border_strong': g('border_default'),
|
||||
'text': g('text_primary'), 'text_dim': g('text_secondary'),
|
||||
'text_faint': g('text_tertiary'),
|
||||
'blue': g('accent_primary'), 'green': g('accent_success'),
|
||||
'red': g('accent_danger'),
|
||||
'blue_hover': g('accent_primary_hover'), 'blue_pressed': g('accent_primary_pressed'),
|
||||
'green_hover': g('accent_success_hover'), 'red_hover': g('accent_danger_hover'),
|
||||
}
|
||||
|
||||
|
||||
def _is_dark() -> bool:
|
||||
from ui.styles import ThemeColors, DARK_COLORS
|
||||
return ThemeColors.current is DARK_COLORS
|
||||
|
||||
|
||||
def tc(role: str) -> str:
|
||||
"""뷰에서 단일 색을 테마 인식형으로 가져올 때 사용 (예: tc('text'))."""
|
||||
return _pal().get(role, '#FF00FF')
|
||||
|
||||
|
||||
# ── QSS 헬퍼 ───────────────────────────────────────────────────
|
||||
|
||||
def dialog_qss() -> str:
|
||||
"""다이얼로그 전체 배경."""
|
||||
return f"QDialog {{ background: {DARK_BG}; }}"
|
||||
"""다이얼로그 전체 배경 (현재 테마)."""
|
||||
return f"QDialog {{ background: {_pal()['bg']}; }}"
|
||||
|
||||
|
||||
def tabs_qss(accent: str = ACCENT_GOLD) -> str:
|
||||
def tabs_qss(accent: str = None) -> str:
|
||||
p = _pal()
|
||||
if accent is None:
|
||||
accent = p['blue']
|
||||
return f"""
|
||||
QTabWidget::pane {{
|
||||
background: {DARK_PANEL};
|
||||
border: 1px solid {DARK_BORDER};
|
||||
background: {p['panel']};
|
||||
border: 1px solid {p['border']};
|
||||
border-radius: 10px;
|
||||
top: -1px;
|
||||
}}
|
||||
QTabBar::tab {{
|
||||
background: {DARK_PANEL_2};
|
||||
color: {DARK_TEXT_DIM};
|
||||
background: {p['panel2']};
|
||||
color: {p['text_dim']};
|
||||
padding: 9px 18px;
|
||||
border: 1px solid {DARK_BORDER};
|
||||
border: 1px solid {p['border']};
|
||||
border-bottom: none;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
@ -103,88 +138,90 @@ def tabs_qss(accent: str = ACCENT_GOLD) -> str:
|
||||
font-size: 10pt;
|
||||
}}
|
||||
QTabBar::tab:selected {{
|
||||
background: {DARK_PANEL};
|
||||
background: {p['panel']};
|
||||
color: {accent};
|
||||
font-weight: bold;
|
||||
border-bottom: 2px solid {accent};
|
||||
}}
|
||||
QTabBar::tab:hover:!selected {{
|
||||
background: #2a2a36;
|
||||
color: {DARK_TEXT};
|
||||
background: {p['border_strong']};
|
||||
color: {p['text']};
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
def scroll_qss() -> str:
|
||||
p = _pal()
|
||||
return f"""
|
||||
QScrollArea {{ background: transparent; border: none; }}
|
||||
QScrollBar:vertical {{
|
||||
background: {DARK_PANEL_2}; width: 10px; border-radius: 5px;
|
||||
background: {p['panel2']}; width: 10px; border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::handle:vertical {{
|
||||
background: {DARK_BORDER_STRONG}; border-radius: 5px; min-height: 30px;
|
||||
background: {p['border_strong']}; border-radius: 5px; min-height: 30px;
|
||||
}}
|
||||
QScrollBar::handle:vertical:hover {{ background: {ACCENT_BLUE}; }}
|
||||
QScrollBar::handle:vertical:hover {{ background: {p['blue']}; }}
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
|
||||
QScrollBar:horizontal {{
|
||||
background: {DARK_PANEL_2}; height: 10px; border-radius: 5px;
|
||||
background: {p['panel2']}; height: 10px; border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::handle:horizontal {{
|
||||
background: {DARK_BORDER_STRONG}; border-radius: 5px; min-width: 30px;
|
||||
background: {p['border_strong']}; border-radius: 5px; min-width: 30px;
|
||||
}}
|
||||
QScrollBar::handle:horizontal:hover {{ background: {ACCENT_BLUE}; }}
|
||||
QScrollBar::handle:horizontal:hover {{ background: {p['blue']}; }}
|
||||
"""
|
||||
|
||||
|
||||
def button_qss(variant: str = 'default') -> str:
|
||||
""" variant: default | primary | success | danger | ghost """
|
||||
""" variant: default | primary | success | danger | ghost (현재 테마) """
|
||||
p = _pal()
|
||||
if variant == 'primary':
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {ACCENT_BLUE}; color: white;
|
||||
border: none; border-radius: 6px;
|
||||
background: {p['blue']}; color: white;
|
||||
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: {p['blue_hover']}; }}
|
||||
QPushButton:pressed {{ background: {p['blue_pressed']}; }}
|
||||
QPushButton:disabled {{ background: {p['panel2']}; color: {p['text_faint']}; }}
|
||||
"""
|
||||
if variant == 'success':
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {ACCENT_GREEN}; color: #0e2a1a;
|
||||
border: none; border-radius: 6px;
|
||||
background: {p['green']}; color: white;
|
||||
border: none; border-radius: 8px;
|
||||
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: #6ae899; }}
|
||||
QPushButton:hover {{ background: {p['green_hover']}; }}
|
||||
"""
|
||||
if variant == 'danger':
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {ACCENT_RED}; color: white;
|
||||
border: none; border-radius: 6px;
|
||||
background: {p['red']}; color: white;
|
||||
border: none; border-radius: 8px;
|
||||
padding: 8px 18px; font-weight: bold; font-size: 10pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: #fc8896; }}
|
||||
QPushButton:hover {{ background: {p['red_hover']}; }}
|
||||
"""
|
||||
if variant == 'ghost':
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: transparent; color: {DARK_TEXT_DIM};
|
||||
border: 1px solid {DARK_BORDER_STRONG}; border-radius: 6px;
|
||||
background: transparent; color: {p['text_dim']};
|
||||
border: 1px solid {p['border_strong']}; border-radius: 8px;
|
||||
padding: 6px 14px; font-size: 9.5pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: {DARK_PANEL_2}; color: {DARK_TEXT};
|
||||
border-color: {ACCENT_BLUE}; }}
|
||||
QPushButton:hover {{ background: {p['panel2']}; color: {p['text']};
|
||||
border-color: {p['blue']}; }}
|
||||
"""
|
||||
# default
|
||||
return f"""
|
||||
QPushButton {{
|
||||
background: {DARK_PANEL_2}; color: {DARK_TEXT};
|
||||
border: 1px solid {DARK_BORDER_STRONG}; border-radius: 6px;
|
||||
background: {p['panel2']}; color: {p['text']};
|
||||
border: 1px solid {p['border_strong']}; border-radius: 8px;
|
||||
padding: 8px 18px; font-size: 10pt;
|
||||
}}
|
||||
QPushButton:hover {{ background: #2a2a36; border-color: {ACCENT_BLUE}; }}
|
||||
QPushButton:hover {{ background: {p['border_strong']}; border-color: {p['blue']}; }}
|
||||
"""
|
||||
|
||||
|
||||
@ -202,15 +239,15 @@ def build_gradient_header(title: str, big_value: str, subtitle: str = '',
|
||||
big_color: 큰 숫자 색
|
||||
extra_widgets: 우측에 배치할 위젯 (예: 추가 통계, 토글)
|
||||
"""
|
||||
p = _pal()
|
||||
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: {p['panel']};
|
||||
border: 1px solid {p['border']};
|
||||
border-radius: 8px;
|
||||
}}
|
||||
QLabel {{ background: transparent; border: none; color: {DARK_TEXT}; }}
|
||||
QLabel {{ background: transparent; border: none; color: {p['text']}; }}
|
||||
""")
|
||||
layout = QHBoxLayout()
|
||||
layout.setContentsMargins(20, 14, 20, 14)
|
||||
@ -222,13 +259,13 @@ def build_gradient_header(title: str, big_value: str, subtitle: str = '',
|
||||
if title:
|
||||
t = QLabel(title)
|
||||
t.setStyleSheet(
|
||||
f"font-size: 9pt; color: {DARK_TEXT_DIM}; "
|
||||
f"font-size: 9pt; color: {p['text_dim']}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
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: {p['text_dim']};'>"
|
||||
f" {subtitle}</span>" if subtitle else '')
|
||||
)
|
||||
big.setTextFormat(Qt.RichText)
|
||||
@ -252,29 +289,49 @@ def build_stat_card(title: str, value: str, subtitle: str = '',
|
||||
theme: str = 'blue', icon: str = '') -> QFrame:
|
||||
"""단일 통계 카드 — 제목, 큰 숫자, 부제, 좌측 큰 이모지."""
|
||||
t = CARD_THEMES.get(theme, CARD_THEMES['blue'])
|
||||
p = _pal()
|
||||
dark = _is_dark()
|
||||
# 다크: 등급색 그라디언트 카드 / 라이트: 패널 배경 + 가독성 위해 값은 기본 텍스트색
|
||||
if dark:
|
||||
card_bg = (f"qlineargradient(x1:0, y1:0, x2:0, y2:1, "
|
||||
f"stop:0 {t['bg_top']}, stop:1 {t['bg_bot']})")
|
||||
card_border = t['border']
|
||||
label_color = t['text']
|
||||
value_color = t['border_strong']
|
||||
else:
|
||||
card_bg = p['panel']
|
||||
card_border = p['border']
|
||||
label_color = p['text']
|
||||
value_color = p['text']
|
||||
card = QFrame()
|
||||
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred)
|
||||
card.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||
stop:0 {t['bg_top']}, stop:1 {t['bg_bot']});
|
||||
border: 1px solid {t['border']};
|
||||
background: {card_bg};
|
||||
border: 1px solid {card_border};
|
||||
border-radius: 10px;
|
||||
}}
|
||||
QLabel {{ background: transparent; border: none; color: {t['text']}; }}
|
||||
QLabel {{ background: transparent; border: none; color: {label_color}; }}
|
||||
""")
|
||||
outer = QHBoxLayout()
|
||||
outer.setContentsMargins(16, 12, 16, 12)
|
||||
outer.setSpacing(12)
|
||||
|
||||
if icon:
|
||||
icon_lbl = QLabel(icon)
|
||||
icon_lbl.setStyleSheet(
|
||||
f"font-size: 28pt; background: transparent; border: none; "
|
||||
f"color: {t['border_strong']};"
|
||||
)
|
||||
icon_lbl = QLabel()
|
||||
icon_lbl.setMinimumWidth(48)
|
||||
icon_lbl.setAlignment(Qt.AlignCenter)
|
||||
from ui.icons import get_icon, _PATHS
|
||||
if icon in _PATHS:
|
||||
# 라인 아이콘(이름) → 등급 색으로 틴팅한 픽스맵
|
||||
icon_lbl.setPixmap(get_icon(icon, t['border_strong'], 30).pixmap(30, 30))
|
||||
else:
|
||||
# 이모지/텍스트 폴백 (구버전 호환)
|
||||
icon_lbl.setText(icon)
|
||||
icon_lbl.setStyleSheet(
|
||||
f"font-size: 28pt; background: transparent; border: none; "
|
||||
f"color: {t['border_strong']};"
|
||||
)
|
||||
outer.addWidget(icon_lbl)
|
||||
|
||||
text_box = QVBoxLayout()
|
||||
@ -282,13 +339,13 @@ def build_stat_card(title: str, value: str, subtitle: str = '',
|
||||
|
||||
title_lbl = QLabel(title)
|
||||
title_lbl.setStyleSheet(
|
||||
f"font-size: 9.5pt; color: {DARK_TEXT_DIM}; "
|
||||
f"font-size: 9.5pt; color: {p['text_dim']}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
text_box.addWidget(title_lbl)
|
||||
|
||||
val_lbl = QLabel(
|
||||
f"<span style='font-size: 18pt; font-weight: bold; color: {t['border_strong']};'>"
|
||||
f"<span style='font-size: 18pt; font-weight: bold; color: {value_color};'>"
|
||||
f"{value}</span>"
|
||||
)
|
||||
val_lbl.setTextFormat(Qt.RichText)
|
||||
@ -298,7 +355,7 @@ def build_stat_card(title: str, value: str, subtitle: str = '',
|
||||
if subtitle:
|
||||
sub_lbl = QLabel(subtitle)
|
||||
sub_lbl.setStyleSheet(
|
||||
f"font-size: 9pt; color: {DARK_TEXT_DIM}; "
|
||||
f"font-size: 9pt; color: {p['text_dim']}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
sub_lbl.setWordWrap(True)
|
||||
@ -313,16 +370,25 @@ def build_section_card(title: str, content: QWidget,
|
||||
theme: str = 'gray', icon: str = '') -> QFrame:
|
||||
"""제목 + 내용 큰 카드 (세로 레이아웃)."""
|
||||
t = CARD_THEMES.get(theme, CARD_THEMES['gray'])
|
||||
p = _pal()
|
||||
if _is_dark():
|
||||
card_bg = (f"qlineargradient(x1:0, y1:0, x2:0, y2:1, "
|
||||
f"stop:0 {t['bg_top']}, stop:1 {t['bg_bot']})")
|
||||
card_border = t['border']
|
||||
label_color = t['text']
|
||||
else:
|
||||
card_bg = p['panel']
|
||||
card_border = p['border']
|
||||
label_color = p['text']
|
||||
card = QFrame()
|
||||
card.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
|
||||
card.setStyleSheet(f"""
|
||||
QFrame {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:0, y2:1,
|
||||
stop:0 {t['bg_top']}, stop:1 {t['bg_bot']});
|
||||
border: 1px solid {t['border']};
|
||||
background: {card_bg};
|
||||
border: 1px solid {card_border};
|
||||
border-radius: 10px;
|
||||
}}
|
||||
QLabel {{ background: transparent; border: none; color: {t['text']}; }}
|
||||
QLabel {{ background: transparent; border: none; color: {label_color}; }}
|
||||
""")
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(16, 12, 16, 14)
|
||||
@ -330,15 +396,20 @@ def build_section_card(title: str, content: QWidget,
|
||||
|
||||
head = QHBoxLayout()
|
||||
if icon:
|
||||
i = QLabel(icon)
|
||||
i.setStyleSheet(
|
||||
f"font-size: 16pt; color: {t['border_strong']}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
i = QLabel()
|
||||
from ui.icons import get_icon, _PATHS
|
||||
if icon in _PATHS:
|
||||
i.setPixmap(get_icon(icon, t['border_strong'], 18).pixmap(18, 18))
|
||||
else:
|
||||
i.setText(icon)
|
||||
i.setStyleSheet(
|
||||
f"font-size: 16pt; color: {t['border_strong']}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
head.addWidget(i)
|
||||
title_lbl = QLabel(title)
|
||||
title_lbl.setStyleSheet(
|
||||
f"font-size: 12pt; font-weight: bold; color: {DARK_TEXT}; "
|
||||
f"font-size: 12pt; font-weight: bold; color: {p['text']}; "
|
||||
f"background: transparent; border: none;"
|
||||
)
|
||||
head.addWidget(title_lbl)
|
||||
@ -372,9 +443,11 @@ def style_progressbar(pb: QProgressBar, theme: str = 'blue',
|
||||
|
||||
|
||||
def transparent_label(text: str, size: int = 10, weight: str = 'normal',
|
||||
color: str = DARK_TEXT) -> QLabel:
|
||||
"""글로벌 QSS와 격리된 다크 라벨 (배경 없음, 외곽선 없음)."""
|
||||
color: str = None) -> QLabel:
|
||||
"""글로벌 QSS와 격리된 라벨 (배경 없음, 외곽선 없음). color 미지정 시 현재 테마 텍스트색."""
|
||||
lbl = QLabel(text)
|
||||
if color is None:
|
||||
color = _pal()['text']
|
||||
weight_str = 'bold' if weight == 'bold' else 'normal'
|
||||
lbl.setStyleSheet(
|
||||
f"font-size: {size}pt; font-weight: {weight_str}; color: {color}; "
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -10,10 +10,7 @@ from PyQt5.QtCore import Qt
|
||||
|
||||
from core.i18n import tr, tr_html
|
||||
from ui.styles import apply_dark_titlebar
|
||||
from ui.dark_components import (
|
||||
dialog_qss, tabs_qss, button_qss,
|
||||
DARK_BG, DARK_PANEL, DARK_BORDER, DARK_TEXT, ACCENT_GOLD,
|
||||
)
|
||||
from ui.dark_components import dialog_qss, tabs_qss, button_qss, tc
|
||||
|
||||
|
||||
class HelpView(QDialog):
|
||||
@ -37,7 +34,7 @@ class HelpView(QDialog):
|
||||
self.resize(820, 760)
|
||||
self.setStyleSheet(dialog_qss())
|
||||
self.init_ui()
|
||||
apply_dark_titlebar(self, dark=True)
|
||||
apply_dark_titlebar(self) # 현재 테마에 맞춰
|
||||
|
||||
def init_ui(self):
|
||||
main_layout = QVBoxLayout()
|
||||
@ -45,15 +42,14 @@ 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"font-size: 18pt; font-weight: bold; color: {tc('text')}; "
|
||||
f"background: transparent; border: none; padding: 4px 0;"
|
||||
)
|
||||
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 +59,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)
|
||||
@ -89,7 +85,7 @@ class HelpView(QDialog):
|
||||
|
||||
def _make_tab(self, html: str) -> QWidget:
|
||||
container = QWidget()
|
||||
container.setStyleSheet(f"background: {DARK_PANEL};")
|
||||
container.setStyleSheet(f"background: {tc('panel')};")
|
||||
layout = QVBoxLayout()
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
@ -100,21 +96,21 @@ class HelpView(QDialog):
|
||||
browser.setHtml(styled_html)
|
||||
browser.setStyleSheet(f"""
|
||||
QTextBrowser {{
|
||||
background: {DARK_PANEL};
|
||||
color: {DARK_TEXT};
|
||||
background: {tc('panel')};
|
||||
color: {tc('text')};
|
||||
border: none;
|
||||
padding: 16px 20px;
|
||||
font-size: 10.5pt;
|
||||
selection-background-color: {ACCENT_GOLD};
|
||||
selection-color: #1a1a26;
|
||||
selection-background-color: {tc('blue')};
|
||||
selection-color: #ffffff;
|
||||
}}
|
||||
QScrollBar:vertical {{
|
||||
background: {DARK_PANEL}; width: 10px; border-radius: 5px;
|
||||
background: {tc('panel')}; width: 10px; border-radius: 5px;
|
||||
}}
|
||||
QScrollBar::handle:vertical {{
|
||||
background: {DARK_BORDER}; border-radius: 5px; min-height: 30px;
|
||||
background: {tc('border_strong')}; border-radius: 5px; min-height: 30px;
|
||||
}}
|
||||
QScrollBar::handle:vertical:hover {{ background: {ACCENT_GOLD}; }}
|
||||
QScrollBar::handle:vertical:hover {{ background: {tc('blue')}; }}
|
||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }}
|
||||
""")
|
||||
layout.addWidget(browser)
|
||||
@ -123,61 +119,67 @@ class HelpView(QDialog):
|
||||
|
||||
def _inject_dark_styles(self, html: str) -> str:
|
||||
"""HelpHTML 내용에 다크 톤 CSS 주입 (제목/링크/코드/테이블)."""
|
||||
# 현재 테마 색으로 (라이트/다크 모두 가독성 확보)
|
||||
text = tc('text')
|
||||
dim = tc('text_dim')
|
||||
blue = tc('blue')
|
||||
green = tc('green')
|
||||
panel2 = tc('panel2')
|
||||
border = tc('border')
|
||||
css = f"""
|
||||
<style>
|
||||
body, p, li {{
|
||||
color: #e8e8f4;
|
||||
color: {text};
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
}}
|
||||
h1, h2, h3, h4 {{
|
||||
color: #ffd24a;
|
||||
color: {blue};
|
||||
margin-top: 1.2em;
|
||||
margin-bottom: 0.5em;
|
||||
}}
|
||||
h2 {{ font-size: 16pt; border-bottom: 2px solid #44446a; padding-bottom: 6px; }}
|
||||
h3 {{ font-size: 13pt; color: #6b9eff; }}
|
||||
h4 {{ font-size: 11pt; color: #4ade80; }}
|
||||
b, strong {{ color: #ff90b8; }}
|
||||
h2 {{ font-size: 16pt; border-bottom: 2px solid {border}; padding-bottom: 6px; }}
|
||||
h3 {{ font-size: 13pt; color: {blue}; }}
|
||||
h4 {{ font-size: 11pt; color: {green}; }}
|
||||
b, strong {{ color: {text}; }}
|
||||
code {{
|
||||
background: #1c1c28;
|
||||
color: #ffd24a;
|
||||
background: {panel2};
|
||||
color: {blue};
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}}
|
||||
pre {{
|
||||
background: #1c1c28;
|
||||
border: 1px solid #2a2a3a;
|
||||
background: {panel2};
|
||||
border: 1px solid {border};
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
color: #e8e8f4;
|
||||
color: {text};
|
||||
}}
|
||||
ul, ol {{ margin-left: 0; padding-left: 24px; }}
|
||||
li {{ margin-bottom: 4px; }}
|
||||
a {{ color: #4adef0; text-decoration: none; }}
|
||||
a {{ color: {blue}; text-decoration: none; }}
|
||||
a:hover {{ text-decoration: underline; }}
|
||||
table {{ border-collapse: collapse; margin: 10px 0; }}
|
||||
th {{
|
||||
background: #2a2a3a;
|
||||
color: #ffd24a;
|
||||
background: {panel2};
|
||||
color: {text};
|
||||
padding: 8px 12px;
|
||||
border: 1px solid #44446a;
|
||||
border: 1px solid {border};
|
||||
text-align: left;
|
||||
}}
|
||||
td {{
|
||||
padding: 6px 12px;
|
||||
border: 1px solid #2a2a3a;
|
||||
color: #e8e8f4;
|
||||
border: 1px solid {border};
|
||||
color: {text};
|
||||
}}
|
||||
hr {{ border: none; border-top: 1px solid #2a2a3a; margin: 16px 0; }}
|
||||
hr {{ border: none; border-top: 1px solid {border}; margin: 16px 0; }}
|
||||
blockquote {{
|
||||
border-left: 3px solid #6b9eff;
|
||||
border-left: 3px solid {blue};
|
||||
margin-left: 0;
|
||||
padding: 4px 16px;
|
||||
color: #a0a0b8;
|
||||
background: rgba(107, 158, 255, 0.05);
|
||||
color: {dim};
|
||||
}}
|
||||
</style>
|
||||
"""
|
||||
|
||||
82
ui/icons.py
Normal file
82
ui/icons.py
Normal file
@ -0,0 +1,82 @@
|
||||
"""모노크롬 라인 아이콘 (Lucide 스타일) — 테마 색으로 틴팅한 QIcon 생성.
|
||||
|
||||
이모지를 대체하는 세련된 벡터 아이콘. QtSvg로 24x24 stroke path를 렌더링하고
|
||||
(name, color, size)별로 캐시. 색은 호출 시점의 테마 색을 받으므로 테마 전환 시
|
||||
재호출하면 자동으로 재틴팅된다.
|
||||
|
||||
사용:
|
||||
from ui.icons import get_icon
|
||||
btn.setIcon(get_icon('settings')) # 기본: text_secondary 색
|
||||
btn.setIcon(get_icon('logout', '#FFFFFF')) # 색 지정
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PyQt5.QtCore import QByteArray, QRectF, Qt
|
||||
from PyQt5.QtGui import QIcon, QPixmap, QPainter
|
||||
from PyQt5.QtSvg import QSvgRenderer
|
||||
|
||||
from ui.styles import ThemeColors
|
||||
|
||||
# 24x24 viewBox 기준 내부 path 마크업 (Lucide). stroke 기반, fill 없음.
|
||||
_PATHS = {
|
||||
'chart': '<line x1="18" y1="20" x2="18" y2="10"/><line x1="12" y1="20" x2="12" y2="4"/><line x1="6" y1="20" x2="6" y2="14"/>',
|
||||
'calendar': '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
|
||||
'report': '<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><line x1="10" y1="9" x2="8" y2="9"/>',
|
||||
'award': '<circle cx="12" cy="8" r="6"/><path d="M15.477 12.89 17 22l-5-3-5 3 1.523-9.11"/>',
|
||||
'help': '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
|
||||
'settings': '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
|
||||
'logout': '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>',
|
||||
'rotate-ccw': '<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>',
|
||||
'edit': '<path d="M17 3a2.85 2.85 0 0 1 4 4L7.5 20.5 2 22l1.5-5.5z"/><path d="m15 5 4 4"/>',
|
||||
'clock': '<circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/>',
|
||||
'trash': '<polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/>',
|
||||
'flame': '<path d="M8.5 14.5A2.5 2.5 0 0 0 11 12c0-1.38-.5-2-1-3-1.072-2.143-.224-4.054 2-6 .5 2.5 2 4.9 4 6.5 2 1.6 3 3.5 3 5.5a7 7 0 1 1-14 0c0-1.153.433-2.294 1-3a2.5 2.5 0 0 0 2.5 2.5z"/>',
|
||||
'trending-up': '<polyline points="22 7 13.5 15.5 8.5 10.5 2 17"/><polyline points="16 7 22 7 22 13"/>',
|
||||
'search': '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
|
||||
'external-link': '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
|
||||
'coffee': '<path d="M17 8h1a4 4 0 1 1 0 8h-1"/><path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z"/><line x1="6" y1="2" x2="6" y2="4"/><line x1="10" y1="2" x2="10" y2="4"/><line x1="14" y1="2" x2="14" y2="4"/>',
|
||||
'repeat': '<polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/>',
|
||||
'home': '<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>',
|
||||
}
|
||||
|
||||
_SVG_TMPL = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" '
|
||||
'fill="none" stroke="{color}" stroke-width="2" '
|
||||
'stroke-linecap="round" stroke-linejoin="round">{paths}</svg>'
|
||||
)
|
||||
|
||||
_cache: dict = {}
|
||||
|
||||
|
||||
def get_icon(name: str, color: str = None, size: int = 18) -> QIcon:
|
||||
"""이름·색·크기로 틴팅된 QIcon 반환 (캐시됨). 미정의 이름은 빈 QIcon."""
|
||||
if color is None:
|
||||
color = ThemeColors.get('text_secondary')
|
||||
key = (name, color, size)
|
||||
cached = _cache.get(key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
paths = _PATHS.get(name)
|
||||
if paths is None:
|
||||
return QIcon()
|
||||
|
||||
svg = _SVG_TMPL.format(color=color, paths=paths).encode('utf-8')
|
||||
renderer = QSvgRenderer(QByteArray(svg))
|
||||
|
||||
dpr = 2 # 2x 렌더 후 devicePixelRatio 지정 → HiDPI에서도 선명
|
||||
pm = QPixmap(size * dpr, size * dpr)
|
||||
pm.fill(Qt.transparent)
|
||||
painter = QPainter(pm)
|
||||
renderer.render(painter, QRectF(0, 0, size * dpr, size * dpr))
|
||||
painter.end()
|
||||
pm.setDevicePixelRatio(dpr)
|
||||
|
||||
icon = QIcon(pm)
|
||||
_cache[key] = icon
|
||||
return icon
|
||||
|
||||
|
||||
def clear_cache() -> None:
|
||||
"""테마 전환 등으로 캐시를 비울 때 사용 (보통은 키가 색을 포함하므로 불필요)."""
|
||||
_cache.clear()
|
||||
@ -22,7 +22,7 @@ class LeaveCalendarView(QDialog):
|
||||
def __init__(self, parent=None, db=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle("📅 연차 캘린더")
|
||||
self.setWindowTitle("연차 캘린더")
|
||||
self.setModal(True)
|
||||
self.setMinimumSize(540, 480)
|
||||
self._build_ui()
|
||||
@ -37,19 +37,19 @@ 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()
|
||||
layout.addLayout(header)
|
||||
|
||||
# 범례
|
||||
# 범례 (사용 완료 + 예정 분리)
|
||||
legend = QHBoxLayout()
|
||||
for label, color in [("🟩 종일(1.0)", "#4caf50"),
|
||||
("🟨 반차(0.5)", "#ffc107"),
|
||||
("🟪 반반차(0.25)", "#9c27b0")]:
|
||||
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)
|
||||
@ -62,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)
|
||||
|
||||
# 닫기 버튼
|
||||
@ -76,7 +76,9 @@ class LeaveCalendarView(QDialog):
|
||||
self.setLayout(layout)
|
||||
|
||||
def _mark_dates(self):
|
||||
"""연차 사용 일자에 색상 표시."""
|
||||
"""연차 일자 색상 표시. 미래 일자는 '예정'으로 파랑 톤."""
|
||||
from datetime import date as _date
|
||||
today = _date.today()
|
||||
records = self.db.get_all_leave_records(limit=365)
|
||||
for r in records:
|
||||
try:
|
||||
@ -85,12 +87,18 @@ class LeaveCalendarView(QDialog):
|
||||
continue
|
||||
qd = QDate(d.year, d.month, d.day)
|
||||
days = float(r.get('days') or 0)
|
||||
if days >= 1.0:
|
||||
color = QColor("#4caf50")
|
||||
elif days >= 0.5:
|
||||
color = QColor("#ffc107")
|
||||
is_planned = d > today
|
||||
if is_planned:
|
||||
# 미래 = 파랑 계열 (음영으로 종일/부분 구분)
|
||||
color = QColor("#1976d2") if days >= 1.0 else QColor("#64b5f6")
|
||||
else:
|
||||
color = QColor("#9c27b0")
|
||||
# 과거/오늘 = 사용 완료 색상
|
||||
if days >= 1.0:
|
||||
color = QColor("#4caf50")
|
||||
elif days >= 0.5:
|
||||
color = QColor("#ffc107")
|
||||
else:
|
||||
color = QColor("#9c27b0")
|
||||
fmt = QTextCharFormat()
|
||||
fmt.setBackground(QBrush(color))
|
||||
fmt.setForeground(QBrush(QColor("white")))
|
||||
@ -109,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))
|
||||
|
||||
@ -90,6 +90,11 @@ class LeaveView(QDialog):
|
||||
cal_button.clicked.connect(self._show_calendar)
|
||||
button_layout.addWidget(cal_button)
|
||||
|
||||
schedule_button = QPushButton("스케줄")
|
||||
schedule_button.setToolTip("휴일 + 연차 + 반복 패턴 통합 보기")
|
||||
schedule_button.clicked.connect(self._show_schedule)
|
||||
button_layout.addWidget(schedule_button)
|
||||
|
||||
close_button = QPushButton(tr('btn.close'))
|
||||
close_button.clicked.connect(self.close)
|
||||
button_layout.addWidget(close_button)
|
||||
@ -102,6 +107,13 @@ class LeaveView(QDialog):
|
||||
dlg = LeaveCalendarView(self, self.db)
|
||||
dlg.exec_()
|
||||
|
||||
def _show_schedule(self):
|
||||
from ui.schedule_view import ScheduleView
|
||||
dlg = ScheduleView(self, self.db)
|
||||
dlg.exec_()
|
||||
# 닫고 돌아오면 잔액/리스트 갱신
|
||||
self.load_data()
|
||||
|
||||
def load_data(self):
|
||||
"""데이터 로드"""
|
||||
# 잔액 업데이트
|
||||
@ -134,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 "")
|
||||
|
||||
@ -249,6 +261,8 @@ class AddLeaveDialog(QDialog):
|
||||
self.date_edit = QDateEdit()
|
||||
self.date_edit.setDate(QDate.currentDate())
|
||||
self.date_edit.setCalendarPopup(True)
|
||||
# 미래 1년까지 등록 가능 (Phase 1: 미리 등록)
|
||||
self.date_edit.setMaximumDate(QDate.currentDate().addYears(1))
|
||||
row1.addWidget(date_label)
|
||||
row1.addWidget(self.date_edit)
|
||||
row1.addSpacing(8)
|
||||
@ -345,6 +359,38 @@ class AddLeaveDialog(QDialog):
|
||||
)
|
||||
return
|
||||
|
||||
# 휴일/주말 검증 — 차감 의미 없으므로 차단
|
||||
from datetime import datetime as _dt
|
||||
date_dt = _dt.strptime(date, "%Y-%m-%d")
|
||||
if date_dt.weekday() in (5, 6): # 토/일
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"주말 등록 불가",
|
||||
"주말에는 연차를 등록할 수 없습니다. (이미 비근무일)"
|
||||
)
|
||||
return
|
||||
if self.db.is_holiday(date):
|
||||
holiday = self.db.get_holiday(date)
|
||||
name = (holiday or {}).get('name', '공휴일')
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"공휴일 등록 불가",
|
||||
f"{date}는 이미 공휴일({name})입니다.\n연차를 차감할 필요가 없습니다."
|
||||
)
|
||||
return
|
||||
|
||||
# 같은 날 중복 누적 검증 (이미 등록된 + 신규 days <= 1.0)
|
||||
existing_min = self.db.get_leave_minutes_for(date)
|
||||
existing_days = existing_min / max(1, self.db.get_work_minutes())
|
||||
if existing_days + days > 1.0001: # 부동소수점 여유
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"중복 등록 초과",
|
||||
f"{date}에 이미 {existing_days:.2f}일이 등록되어 있어\n"
|
||||
f"추가 {days:.2f}일을 더하면 1일을 초과합니다."
|
||||
)
|
||||
return
|
||||
|
||||
# 확인 메시지
|
||||
hours = days * 8
|
||||
reply = QMessageBox.question(
|
||||
|
||||
@ -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,16 +661,31 @@ 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:
|
||||
# 출근 기록 없음 — 종일 연차일이면 자동 감지·수동 입력 모두 스킵
|
||||
today_str = datetime.now().date().isoformat()
|
||||
if self.db.has_full_day_leave(today_str):
|
||||
self.is_clocked_in = False
|
||||
self.clock_out_button.setEnabled(False)
|
||||
# 점심/저녁/외출/잔액 갱신만 수행
|
||||
self.lunch_button.setChecked(False)
|
||||
self.update_lunch_status()
|
||||
self.dinner_button.setChecked(False)
|
||||
self.update_dinner_status()
|
||||
self.update_break_status()
|
||||
self.update_overtime_balance()
|
||||
self.update_leave_balance()
|
||||
return
|
||||
|
||||
# 출근 기록 없음 - 자동 감지 시도
|
||||
auto_clock_in = self.event_monitor.get_work_start_time()
|
||||
|
||||
@ -701,6 +756,14 @@ class MainWindow(QMainWindow):
|
||||
self.start_new_workday(now)
|
||||
return
|
||||
|
||||
# 종일 연차일 — 출근 안 한 상태에서 전용 카드만 표시 후 종료.
|
||||
# (수동 출근 override는 handle_clock_in 경로에서 별도 처리)
|
||||
if not self.is_clocked_in:
|
||||
today_str = now.date().isoformat()
|
||||
if self.db.has_full_day_leave(today_str):
|
||||
self._render_full_day_leave_state(today_str)
|
||||
return
|
||||
|
||||
# 출근하지 않았으면 여기서 종료
|
||||
if not self.is_clocked_in or not self.clock_in_time:
|
||||
return
|
||||
@ -728,72 +791,107 @@ class MainWindow(QMainWindow):
|
||||
# 총 차감 시간 (추가근무 + 연차/반차)
|
||||
total_time_off = overtime_used_today + leave_used_today
|
||||
|
||||
# 남은 시간 계산 (외출 시간 반영, 추가근무/반차 사용 시간 차감)
|
||||
remaining = self.time_calc.calculate_remaining_time(
|
||||
self.clock_in_time,
|
||||
include_lunch=self.lunch_break_enabled,
|
||||
include_dinner=self.dinner_break_enabled,
|
||||
current_time=now,
|
||||
break_minutes=break_minutes
|
||||
)
|
||||
# 사용한 추가근무 + 반차만큼 남은 시간 감소 (일찍 퇴근 가능)
|
||||
remaining -= timedelta(minutes=total_time_off)
|
||||
# 휴일/주말 또는 종일연차 override → 출근 직후부터 모든 시간이 연장근무로 흐름.
|
||||
is_non_working = self.time_calc.is_non_working_day(now, self.db)
|
||||
is_full_day_leave = self.db.has_full_day_leave(now.date().isoformat())
|
||||
is_holiday = is_non_working or is_full_day_leave
|
||||
|
||||
if is_holiday:
|
||||
# 표시는 초 단위로 부드럽게 — 적립(분 절삭)은 퇴근 시 별도 계산.
|
||||
# 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에
|
||||
# 그대로 반영되어 카운트다운이 단축됨.
|
||||
remaining = self.time_calc.calculate_remaining_time(
|
||||
self.clock_in_time,
|
||||
include_lunch=self.lunch_break_enabled,
|
||||
include_dinner=self.dinner_break_enabled,
|
||||
current_time=now,
|
||||
break_minutes=break_minutes
|
||||
)
|
||||
# 사용한 추가근무 + 반차만큼 남은 시간 감소 (일찍 퇴근 가능)
|
||||
remaining -= timedelta(minutes=total_time_off)
|
||||
|
||||
# 남은 시간 표시 및 추가 근무 처리
|
||||
if remaining.total_seconds() < 0:
|
||||
# 추가 근무 중
|
||||
self.remaining_time_group.setTitle("추가 근무 중")
|
||||
# 추가 근무 중 (휴일/연차 override면 출근 직후부터 항상 이 분기)
|
||||
day_type = self.time_calc.get_day_type(now, self.db)
|
||||
if is_full_day_leave and not is_non_working:
|
||||
self.remaining_time_group.setTitle("연차 override (전체 적립)")
|
||||
elif day_type == 'weekend':
|
||||
self.remaining_time_group.setTitle("주말 근무 (전체 적립)")
|
||||
elif day_type == 'holiday':
|
||||
self.remaining_time_group.setTitle("공휴일 근무 (전체 적립)")
|
||||
else:
|
||||
self.remaining_time_group.setTitle("추가 근무 중")
|
||||
# + 기호로 표시
|
||||
total_seconds = int(abs(remaining.total_seconds()))
|
||||
hours = total_seconds // 3600
|
||||
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)
|
||||
|
||||
# 진행률 업데이트
|
||||
# - 외출 시간: 필요 근무시간 증가 (일을 안 한 시간이므로 더 일해야 함)
|
||||
# - 추가근무 사용: 필요 근무시간 감소 (미리 일한 것을 사용하므로 덜 일해도 됨)
|
||||
progress = self.time_calc.calculate_work_progress(
|
||||
self.clock_in_time,
|
||||
include_lunch=self.lunch_break_enabled,
|
||||
include_dinner=self.dinner_break_enabled,
|
||||
current_time=now,
|
||||
break_minutes=break_minutes,
|
||||
overtime_used_minutes=total_time_off
|
||||
)
|
||||
self.progress_bar.setValue(int(progress * 100))
|
||||
# 휴일은 정해진 근무시간이 없으므로 게이지 의미 없음 → 100%로 채워둠.
|
||||
if is_holiday:
|
||||
self.progress_bar.setValue(100)
|
||||
else:
|
||||
progress = self.time_calc.calculate_work_progress(
|
||||
self.clock_in_time,
|
||||
include_lunch=self.lunch_break_enabled,
|
||||
include_dinner=self.dinner_break_enabled,
|
||||
current_time=now,
|
||||
break_minutes=break_minutes,
|
||||
overtime_used_minutes=total_time_off
|
||||
)
|
||||
self.progress_bar.setValue(int(progress * 100))
|
||||
|
||||
# 예상 퇴근 시간 (외출 시간 포함)
|
||||
# 추가근무 사용 시간만큼 일찍 퇴근 가능하므로 실제 퇴근 시간에서 차감
|
||||
expected_clock_out = self.time_calc.calculate_clock_out_time(
|
||||
self.clock_in_time,
|
||||
include_lunch=self.lunch_break_enabled,
|
||||
include_dinner=self.dinner_break_enabled,
|
||||
break_minutes=break_minutes
|
||||
)
|
||||
# 추가근무 + 반차 사용한 만큼 예상 퇴근 시간을 앞당김
|
||||
expected_clock_out -= timedelta(minutes=total_time_off)
|
||||
self._set_text_if_changed(
|
||||
self.expected_time_label,
|
||||
f"예상 퇴근: {self.format_time(expected_clock_out)}"
|
||||
)
|
||||
# 휴일은 정해진 퇴근 시각이 없음 → 출근 시각을 그대로 표시 (= 즉시 적립 시작 의미)
|
||||
if is_holiday:
|
||||
expected_clock_out = self.clock_in_time
|
||||
self._set_text_if_changed(
|
||||
self.expected_time_label,
|
||||
"휴일 근무 (정해진 퇴근시각 없음)"
|
||||
)
|
||||
else:
|
||||
expected_clock_out = self.time_calc.calculate_clock_out_time(
|
||||
self.clock_in_time,
|
||||
include_lunch=self.lunch_break_enabled,
|
||||
include_dinner=self.dinner_break_enabled,
|
||||
break_minutes=break_minutes
|
||||
)
|
||||
# 추가근무 + 반차 사용한 만큼 예상 퇴근 시간을 앞당김
|
||||
expected_clock_out -= timedelta(minutes=total_time_off)
|
||||
self._set_text_if_changed(
|
||||
self.expected_time_label,
|
||||
f"예상 퇴근: {self.format_time(expected_clock_out)}"
|
||||
)
|
||||
|
||||
# 알림은 NotificationOrchestrator로 위임 (5분 throttle 포함)
|
||||
self._notif_orch.tick(now, expected_clock_out, remaining.total_seconds())
|
||||
# 휴일이면 "퇴근 30분 전" 알림은 의미 없으므로 플래그로 게이팅.
|
||||
self._notif_orch.tick(now, expected_clock_out, remaining.total_seconds(),
|
||||
is_holiday=is_holiday)
|
||||
|
||||
# 트레이 / 미니 위젯 갱신
|
||||
if remaining.total_seconds() < 0:
|
||||
@ -812,6 +910,35 @@ class MainWindow(QMainWindow):
|
||||
date_str = f"{now.year}년 {now.month}월 {now.day}일 {weekday}요일"
|
||||
self.date_label.setText(date_str)
|
||||
|
||||
def _render_full_day_leave_state(self, today_str: str) -> None:
|
||||
"""오늘이 종일 연차이고 출근 안 한 상태 → 카운트다운 대신 휴가 카드 표시."""
|
||||
records = self.db.get_leave_records_by_date(today_str)
|
||||
# 가장 큰 일수의 leave_type을 대표로 표시 (보통 1.0짜리 1건)
|
||||
if records:
|
||||
primary = max(records, key=lambda r: r.get('days') or 0)
|
||||
label = primary.get('leave_type') or '연차'
|
||||
memo = primary.get('memo') or ''
|
||||
else:
|
||||
label = '연차'
|
||||
memo = ''
|
||||
|
||||
self.remaining_time_group.setTitle("오늘은 휴가")
|
||||
self.remaining_time_label.setText("연차 사용 중")
|
||||
self.remaining_time_label.setStyleSheet(
|
||||
f"color: {ThemeColors.get('status_normal')}; font-size: 18px;"
|
||||
)
|
||||
self.progress_bar.setValue(100)
|
||||
if memo:
|
||||
self._set_text_if_changed(self.expected_time_label,
|
||||
f"{label} — {memo}")
|
||||
else:
|
||||
self._set_text_if_changed(self.expected_time_label,
|
||||
f"{label}")
|
||||
# 트레이/미니 위젯
|
||||
self.tray_icon.update_time_display("🌴 휴가")
|
||||
if getattr(self, '_mini_widget', None) is not None and self._mini_widget.isVisible():
|
||||
self._mini_widget.update_remaining("🌴 휴가")
|
||||
|
||||
def toggle_lunch_break(self):
|
||||
"""점심시간 토글 — MealController 위임."""
|
||||
self._meal.toggle_lunch()
|
||||
@ -1156,16 +1283,14 @@ class MainWindow(QMainWindow):
|
||||
unit_minutes = 30
|
||||
|
||||
if is_non_working_day:
|
||||
# 주말/공휴일: 모든 시간을 연장근무로 처리 (외출 시간 제외)
|
||||
work_minutes = int(total_hours * 60)
|
||||
if self.lunch_break_enabled:
|
||||
work_minutes -= self.time_calc.lunch_duration_minutes
|
||||
if self.dinner_break_enabled:
|
||||
work_minutes -= self.time_calc.dinner_duration_minutes
|
||||
work_minutes -= break_minutes
|
||||
work_minutes = max(0, work_minutes)
|
||||
overtime_earned = (work_minutes // unit_minutes) * unit_minutes
|
||||
overtime_actual = work_minutes
|
||||
# 주말/공휴일: 모든 시간을 연장근무로 처리 (식사·외출 시간 제외)
|
||||
overtime_actual, overtime_earned = 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,
|
||||
unit_minutes=unit_minutes,
|
||||
)
|
||||
else:
|
||||
# 평일: 정상 연장근무 계산 (외출 시간 포함)
|
||||
overtime_actual, overtime_earned = self.time_calc.calculate_overtime(
|
||||
@ -1213,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"
|
||||
@ -1266,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()
|
||||
@ -1290,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):
|
||||
"""근무일 경계 처리: 경계시간 직전 퇴근, 경계시간에 출근
|
||||
|
||||
@ -1342,26 +1479,30 @@ class MainWindow(QMainWindow):
|
||||
break_minutes = cursor.fetchone()[0] or 0
|
||||
conn.close()
|
||||
|
||||
# 추가근무 계산
|
||||
# 추가근무 계산 (사용자 설정 적립 단위 적용)
|
||||
unit_minutes = self.db.get_setting_int('overtime_unit', 30)
|
||||
if unit_minutes not in (15, 30, 60):
|
||||
unit_minutes = 30
|
||||
if is_non_working_day:
|
||||
work_minutes = int(total_hours * 60)
|
||||
if self.lunch_break_enabled:
|
||||
work_minutes -= self.time_calc.lunch_duration_minutes
|
||||
if self.dinner_break_enabled:
|
||||
work_minutes -= self.time_calc.dinner_duration_minutes
|
||||
work_minutes -= break_minutes
|
||||
# 음수 방지
|
||||
work_minutes = max(0, work_minutes)
|
||||
overtime_earned = (work_minutes // 30) * 30
|
||||
overtime_actual = work_minutes
|
||||
overtime_actual, overtime_earned = self.time_calc.calculate_holiday_overtime(
|
||||
self.clock_in_time, workday_end,
|
||||
include_lunch=self.lunch_break_enabled,
|
||||
include_dinner=self.dinner_break_enabled,
|
||||
break_minutes=break_minutes,
|
||||
unit_minutes=unit_minutes,
|
||||
)
|
||||
else:
|
||||
overtime_actual, overtime_earned = self.time_calc.calculate_overtime(
|
||||
self.clock_in_time, workday_end,
|
||||
include_lunch=self.lunch_break_enabled,
|
||||
include_dinner=self.dinner_break_enabled,
|
||||
break_minutes=break_minutes
|
||||
break_minutes=break_minutes,
|
||||
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,
|
||||
@ -1448,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):
|
||||
"""이전 퇴근 기록들(퇴근 안 한)에 대해 자동으로 종료 시간 등록"""
|
||||
@ -1498,28 +1639,31 @@ class MainWindow(QMainWindow):
|
||||
lunch_enabled = bool(record.get('lunch_break', False))
|
||||
dinner_enabled = bool(record.get('dinner_break', False))
|
||||
|
||||
unit_minutes = self.db.get_setting_int('overtime_unit', 30)
|
||||
if unit_minutes not in (15, 30, 60):
|
||||
unit_minutes = 30
|
||||
if is_non_working_day:
|
||||
# 주말/공휴일: 모든 시간을 연장근무로 처리 (점심/저녁/외출 제외)
|
||||
work_minutes = int(total_hours * 60)
|
||||
if lunch_enabled:
|
||||
work_minutes -= self.time_calc.lunch_duration_minutes
|
||||
if dinner_enabled:
|
||||
work_minutes -= self.time_calc.dinner_duration_minutes
|
||||
work_minutes -= break_minutes
|
||||
# 음수 방지
|
||||
work_minutes = max(0, work_minutes)
|
||||
# 30분 단위로 절삭
|
||||
overtime_earned = (work_minutes // 30) * 30
|
||||
overtime_actual = work_minutes
|
||||
# 주말/공휴일: 모든 시간을 연장근무로 처리 (식사·외출 제외)
|
||||
overtime_actual, overtime_earned = self.time_calc.calculate_holiday_overtime(
|
||||
clock_in_time, shutdown_time,
|
||||
include_lunch=lunch_enabled,
|
||||
include_dinner=dinner_enabled,
|
||||
break_minutes=break_minutes,
|
||||
unit_minutes=unit_minutes,
|
||||
)
|
||||
else:
|
||||
# 평일: 정상 연장근무 계산 (외출 시간 포함)
|
||||
overtime_actual, overtime_earned = self.time_calc.calculate_overtime(
|
||||
clock_in_time, shutdown_time,
|
||||
include_lunch=lunch_enabled,
|
||||
include_dinner=dinner_enabled,
|
||||
break_minutes=break_minutes
|
||||
break_minutes=break_minutes,
|
||||
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(
|
||||
@ -1569,25 +1713,29 @@ class MainWindow(QMainWindow):
|
||||
lunch_enabled = bool(record.get('lunch_break', False))
|
||||
dinner_enabled = bool(record.get('dinner_break', False))
|
||||
|
||||
unit_minutes = self.db.get_setting_int('overtime_unit', 30)
|
||||
if unit_minutes not in (15, 30, 60):
|
||||
unit_minutes = 30
|
||||
if is_non_working_day:
|
||||
work_minutes = int(total_hours * 60)
|
||||
if lunch_enabled:
|
||||
work_minutes -= self.time_calc.lunch_duration_minutes
|
||||
if dinner_enabled:
|
||||
work_minutes -= self.time_calc.dinner_duration_minutes
|
||||
work_minutes -= break_minutes
|
||||
# 음수 방지
|
||||
work_minutes = max(0, work_minutes)
|
||||
overtime_earned = (work_minutes // 30) * 30
|
||||
overtime_actual = work_minutes
|
||||
overtime_actual, overtime_earned = self.time_calc.calculate_holiday_overtime(
|
||||
clock_in_time, fallback_time,
|
||||
include_lunch=lunch_enabled,
|
||||
include_dinner=dinner_enabled,
|
||||
break_minutes=break_minutes,
|
||||
unit_minutes=unit_minutes,
|
||||
)
|
||||
else:
|
||||
overtime_actual, overtime_earned = self.time_calc.calculate_overtime(
|
||||
clock_in_time, fallback_time,
|
||||
include_lunch=lunch_enabled,
|
||||
include_dinner=dinner_enabled,
|
||||
break_minutes=break_minutes
|
||||
break_minutes=break_minutes,
|
||||
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,
|
||||
@ -1605,6 +1753,20 @@ class MainWindow(QMainWindow):
|
||||
|
||||
def manual_clock_in(self):
|
||||
"""수동 출근 시간 입력"""
|
||||
# 종일 연차 등록일이면 override 의도 확인
|
||||
today_str = datetime.now().date().isoformat()
|
||||
if self.db.has_full_day_leave(today_str):
|
||||
reply = QMessageBox.question(
|
||||
self,
|
||||
"종일 연차 등록됨",
|
||||
"오늘은 종일 연차로 등록되어 있습니다.\n"
|
||||
"그래도 출근하시겠어요?\n\n"
|
||||
"(출근 시 모든 시간이 연장근무로 적립됩니다.)",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply != QMessageBox.Yes:
|
||||
return
|
||||
|
||||
# 기본값: 기존 출근시간이 있으면 그것을, 없으면 None
|
||||
default_time = self.clock_in_time if self.clock_in_time else None
|
||||
|
||||
@ -1645,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,
|
||||
@ -1676,6 +1838,12 @@ class MainWindow(QMainWindow):
|
||||
dialog = CalendarView(self, self.db)
|
||||
dialog.exec_()
|
||||
|
||||
def show_schedule(self):
|
||||
"""통합 스케줄(휴일+연차+반복) 창 표시."""
|
||||
from ui.schedule_view import ScheduleView
|
||||
dlg = ScheduleView(self, self.db)
|
||||
dlg.exec_()
|
||||
|
||||
def show_leave_management(self):
|
||||
"""휴가 관리 창 표시"""
|
||||
dialog = LeaveView(self, self.db)
|
||||
@ -1684,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())
|
||||
@ -1696,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)
|
||||
|
||||
@ -1724,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]:
|
||||
|
||||
@ -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())
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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()
|
||||
|
||||
199
ui/recurring_leave_dialog.py
Normal file
199
ui/recurring_leave_dialog.py
Normal file
@ -0,0 +1,199 @@
|
||||
"""
|
||||
반복 연차 등록/관리 다이얼로그.
|
||||
|
||||
지원: 매주/격주 요일, 매월 N일.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from datetime import date
|
||||
|
||||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QComboBox, QDateEdit, QSpinBox,
|
||||
QDoubleSpinBox, QLineEdit, QGroupBox,
|
||||
QListWidget, QListWidgetItem, QMessageBox,
|
||||
QCheckBox, QButtonGroup, QRadioButton)
|
||||
from PyQt5.QtCore import QDate, Qt
|
||||
|
||||
from core.recurring_leaves import describe_pattern
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
_KO_WEEKDAYS = [('월', 'mon'), ('화', 'tue'), ('수', 'wed'),
|
||||
('목', 'thu'), ('금', 'fri'), ('토', 'sat'), ('일', 'sun')]
|
||||
|
||||
|
||||
class RecurringLeaveDialog(QDialog):
|
||||
"""반복 연차 패턴 추가/삭제."""
|
||||
|
||||
def __init__(self, parent=None, db=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle("반복 연차 관리")
|
||||
self.setMinimumSize(540, 480)
|
||||
self._build_ui()
|
||||
self._reload_list()
|
||||
apply_dark_titlebar(self)
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# 기존 패턴 목록
|
||||
list_group = QGroupBox("등록된 반복 패턴")
|
||||
lg = QVBoxLayout()
|
||||
self.list_widget = QListWidget()
|
||||
self.list_widget.setMinimumHeight(160)
|
||||
lg.addWidget(self.list_widget)
|
||||
del_btn = QPushButton("선택 삭제")
|
||||
del_btn.clicked.connect(self._delete_selected)
|
||||
lg.addWidget(del_btn)
|
||||
list_group.setLayout(lg)
|
||||
layout.addWidget(list_group)
|
||||
|
||||
# 신규 등록
|
||||
add_group = QGroupBox("신규 패턴 추가")
|
||||
ag = QVBoxLayout()
|
||||
|
||||
# 패턴 종류
|
||||
kind_row = QHBoxLayout()
|
||||
kind_row.addWidget(QLabel("주기:"))
|
||||
self.kind_group = QButtonGroup(self)
|
||||
self.rb_weekly = QRadioButton("매주")
|
||||
self.rb_weekly.setChecked(True)
|
||||
self.rb_biweekly = QRadioButton("격주")
|
||||
self.rb_monthly = QRadioButton("매월 N일")
|
||||
for rb in (self.rb_weekly, self.rb_biweekly, self.rb_monthly):
|
||||
self.kind_group.addButton(rb)
|
||||
kind_row.addWidget(rb)
|
||||
kind_row.addStretch()
|
||||
ag.addLayout(kind_row)
|
||||
|
||||
# 요일 체크박스 (weekly/biweekly)
|
||||
wd_row = QHBoxLayout()
|
||||
wd_row.addWidget(QLabel("요일:"))
|
||||
self.weekday_checks = []
|
||||
for ko, en in _KO_WEEKDAYS:
|
||||
cb = QCheckBox(ko)
|
||||
self.weekday_checks.append((cb, en))
|
||||
wd_row.addWidget(cb)
|
||||
wd_row.addStretch()
|
||||
ag.addLayout(wd_row)
|
||||
|
||||
# 매월 N일
|
||||
month_row = QHBoxLayout()
|
||||
month_row.addWidget(QLabel("매월:"))
|
||||
self.day_of_month = QSpinBox()
|
||||
self.day_of_month.setRange(1, 31)
|
||||
self.day_of_month.setValue(15)
|
||||
self.day_of_month.setSuffix("일")
|
||||
month_row.addWidget(self.day_of_month)
|
||||
month_row.addStretch()
|
||||
ag.addLayout(month_row)
|
||||
|
||||
# 차감 일수
|
||||
days_row = QHBoxLayout()
|
||||
days_row.addWidget(QLabel("차감:"))
|
||||
self.days_combo = QComboBox()
|
||||
self.days_combo.addItem("1.0일 (종일)", 1.0)
|
||||
self.days_combo.addItem("0.5일 (반차)", 0.5)
|
||||
self.days_combo.addItem("0.25일 (반반차)", 0.25)
|
||||
days_row.addWidget(self.days_combo)
|
||||
days_row.addStretch()
|
||||
ag.addLayout(days_row)
|
||||
|
||||
# 시작/종료 날짜
|
||||
date_row = QHBoxLayout()
|
||||
date_row.addWidget(QLabel("시작:"))
|
||||
self.start_edit = QDateEdit()
|
||||
self.start_edit.setDate(QDate.currentDate())
|
||||
self.start_edit.setCalendarPopup(True)
|
||||
date_row.addWidget(self.start_edit)
|
||||
|
||||
date_row.addWidget(QLabel("종료:"))
|
||||
self.end_edit = QDateEdit()
|
||||
self.end_edit.setDate(QDate.currentDate().addMonths(6))
|
||||
self.end_edit.setCalendarPopup(True)
|
||||
date_row.addWidget(self.end_edit)
|
||||
self.no_end_check = QCheckBox("종료 없음 (무기한)")
|
||||
self.no_end_check.toggled.connect(
|
||||
lambda v: self.end_edit.setEnabled(not v)
|
||||
)
|
||||
date_row.addWidget(self.no_end_check)
|
||||
date_row.addStretch()
|
||||
ag.addLayout(date_row)
|
||||
|
||||
# 메모
|
||||
memo_row = QHBoxLayout()
|
||||
memo_row.addWidget(QLabel("메모:"))
|
||||
self.memo_edit = QLineEdit()
|
||||
self.memo_edit.setPlaceholderText("예: 육아 단축근무")
|
||||
memo_row.addWidget(self.memo_edit)
|
||||
ag.addLayout(memo_row)
|
||||
|
||||
# 추가 버튼
|
||||
add_btn = QPushButton("추가")
|
||||
add_btn.setObjectName("btn_primary")
|
||||
add_btn.clicked.connect(self._save)
|
||||
ag.addWidget(add_btn)
|
||||
|
||||
add_group.setLayout(ag)
|
||||
layout.addWidget(add_group)
|
||||
|
||||
# 닫기
|
||||
close_btn = QPushButton("닫기")
|
||||
close_btn.clicked.connect(self.close)
|
||||
layout.addWidget(close_btn)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
def _reload_list(self):
|
||||
self.list_widget.clear()
|
||||
for r in self.db.get_recurring_leaves():
|
||||
desc = describe_pattern(r['pattern'])
|
||||
end = r.get('end_date') or '무기한'
|
||||
text = (f"[{r['id']}] {desc} · {r['days']}일 ({r['leave_type']}) "
|
||||
f"· {r['start_date']} ~ {end}")
|
||||
if r.get('memo'):
|
||||
text += f" — {r['memo']}"
|
||||
item = QListWidgetItem(text)
|
||||
item.setData(Qt.UserRole, r['id'])
|
||||
self.list_widget.addItem(item)
|
||||
|
||||
def _delete_selected(self):
|
||||
item = self.list_widget.currentItem()
|
||||
if not item:
|
||||
return
|
||||
rec_id = item.data(Qt.UserRole)
|
||||
reply = QMessageBox.question(
|
||||
self, "삭제 확인",
|
||||
f"이 반복 패턴을 삭제하시겠습니까?\n\n{item.text()}",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
self.db.delete_recurring_leave(rec_id)
|
||||
self._reload_list()
|
||||
|
||||
def _build_pattern(self) -> str | None:
|
||||
if self.rb_monthly.isChecked():
|
||||
return f"monthly:{self.day_of_month.value()}"
|
||||
# weekly/biweekly
|
||||
chosen = [en for cb, en in self.weekday_checks if cb.isChecked()]
|
||||
if not chosen:
|
||||
return None
|
||||
prefix = 'weekly' if self.rb_weekly.isChecked() else 'biweekly'
|
||||
return f"{prefix}:" + ",".join(chosen)
|
||||
|
||||
def _save(self):
|
||||
pattern = self._build_pattern()
|
||||
if not pattern:
|
||||
QMessageBox.warning(self, "입력 오류", "최소 한 개 요일을 선택하세요.")
|
||||
return
|
||||
days = self.days_combo.currentData()
|
||||
leave_type = self.days_combo.currentText().split(' ')[1].strip('()')
|
||||
start = self.start_edit.date().toString('yyyy-MM-dd')
|
||||
end = None if self.no_end_check.isChecked() else self.end_edit.date().toString('yyyy-MM-dd')
|
||||
memo = self.memo_edit.text().strip()
|
||||
|
||||
self.db.add_recurring_leave(pattern, leave_type, days, start, end, memo)
|
||||
QMessageBox.information(self, "추가 완료",
|
||||
f"반복 패턴이 등록되었습니다.\n{describe_pattern(pattern)}")
|
||||
self.memo_edit.clear()
|
||||
self._reload_list()
|
||||
315
ui/schedule_view.py
Normal file
315
ui/schedule_view.py
Normal file
@ -0,0 +1,315 @@
|
||||
"""
|
||||
통합 스케줄 화면 — 휴일 + 연차(예정/사용) + 반복 패턴.
|
||||
|
||||
기능:
|
||||
- 월별 캘린더 + 색상 코드 (휴일 빨강, 종일 연차 녹/파, 반차 노랑, 반반차 보라, 반복 회색)
|
||||
- 클릭한 날짜의 상세 (연차 추가/삭제, 휴일 정보, 매치되는 반복 패턴)
|
||||
- 반복 패턴 관리 → RecurringLeaveDialog
|
||||
"""
|
||||
from __future__ import annotations
|
||||
from datetime import datetime, date, timedelta
|
||||
from typing import List
|
||||
|
||||
from PyQt5.QtWidgets import (QDialog, QVBoxLayout, QHBoxLayout, QLabel,
|
||||
QPushButton, QCalendarWidget, QListWidget,
|
||||
QListWidgetItem, QMessageBox, QMenu,
|
||||
QGroupBox, QSplitter, QWidget)
|
||||
from PyQt5.QtCore import Qt, QDate
|
||||
from PyQt5.QtGui import QTextCharFormat, QColor, QBrush
|
||||
|
||||
from core.recurring_leaves import expand_for_range, describe_pattern
|
||||
from ui.styles import apply_dark_titlebar
|
||||
|
||||
|
||||
# 색상 팔레트
|
||||
_C_HOLIDAY = QColor("#e53935") # 빨강
|
||||
_C_LEAVE_FULL_PAST = QColor("#4caf50") # 녹색 (사용)
|
||||
_C_LEAVE_HALF_PAST = QColor("#ffc107") # 노랑 (반차 사용)
|
||||
_C_LEAVE_QUART_PAST = QColor("#9c27b0") # 보라 (반반차 사용)
|
||||
_C_LEAVE_FULL_PLAN = QColor("#1976d2") # 진한 파랑 (예정 종일)
|
||||
_C_LEAVE_PART_PLAN = QColor("#64b5f6") # 옅은 파랑 (예정 반차/반반차)
|
||||
_C_RECURRING = QColor("#78909c") # 회색 (반복 패턴 매치)
|
||||
_C_TODAY = QColor("#ff9800") # 주황 (오늘 강조 보더)
|
||||
|
||||
|
||||
class ScheduleView(QDialog):
|
||||
"""월간 통합 스케줄 다이얼로그."""
|
||||
|
||||
def __init__(self, parent=None, db=None):
|
||||
super().__init__(parent)
|
||||
self.db = db
|
||||
self.setWindowTitle("스케줄")
|
||||
self.setMinimumSize(820, 560)
|
||||
self._build_ui()
|
||||
self._reload()
|
||||
apply_dark_titlebar(self)
|
||||
|
||||
def _build_ui(self):
|
||||
layout = QVBoxLayout()
|
||||
|
||||
# 상단 툴바
|
||||
bar = QHBoxLayout()
|
||||
title = QLabel("월간 스케줄 — 휴일 + 연차 + 반복 패턴")
|
||||
title.setStyleSheet("font-weight: bold; font-size: 13px;")
|
||||
bar.addWidget(title)
|
||||
bar.addStretch()
|
||||
|
||||
rec_btn = QPushButton("반복 패턴 관리")
|
||||
rec_btn.clicked.connect(self._open_recurring_dialog)
|
||||
bar.addWidget(rec_btn)
|
||||
|
||||
add_btn = QPushButton("연차 등록")
|
||||
add_btn.clicked.connect(self._open_add_leave_dialog)
|
||||
bar.addWidget(add_btn)
|
||||
|
||||
layout.addLayout(bar)
|
||||
|
||||
# 범례
|
||||
legend = QHBoxLayout()
|
||||
for label, color in [("공휴일", _C_HOLIDAY),
|
||||
("연차 사용", _C_LEAVE_FULL_PAST),
|
||||
("연차 예정", _C_LEAVE_FULL_PLAN),
|
||||
("반차/반반차", _C_LEAVE_HALF_PAST),
|
||||
("반복 패턴", _C_RECURRING)]:
|
||||
sw = QLabel(f" {label} ")
|
||||
sw.setStyleSheet(
|
||||
f"background-color: {color.name()}; color: white; "
|
||||
f"padding: 2px 6px; border-radius: 3px;"
|
||||
)
|
||||
legend.addWidget(sw)
|
||||
legend.addStretch()
|
||||
layout.addLayout(legend)
|
||||
|
||||
# 캘린더 + 상세 splitter
|
||||
splitter = QSplitter(Qt.Horizontal)
|
||||
|
||||
# 좌측: 캘린더
|
||||
self.calendar = QCalendarWidget()
|
||||
self.calendar.setVerticalHeaderFormat(QCalendarWidget.NoVerticalHeader)
|
||||
self.calendar.clicked.connect(self._on_date_click)
|
||||
self.calendar.currentPageChanged.connect(self._on_page_change)
|
||||
splitter.addWidget(self.calendar)
|
||||
|
||||
# 우측: 상세 패널
|
||||
right = QWidget()
|
||||
right_layout = QVBoxLayout()
|
||||
|
||||
self.detail_title = QLabel("날짜를 선택하세요")
|
||||
self.detail_title.setStyleSheet("font-weight: bold; font-size: 14px;")
|
||||
right_layout.addWidget(self.detail_title)
|
||||
|
||||
self.detail_list = QListWidget()
|
||||
self.detail_list.setContextMenuPolicy(Qt.CustomContextMenu)
|
||||
self.detail_list.customContextMenuRequested.connect(self._on_list_menu)
|
||||
right_layout.addWidget(self.detail_list, 1)
|
||||
|
||||
right.setLayout(right_layout)
|
||||
splitter.addWidget(right)
|
||||
splitter.setSizes([520, 280])
|
||||
|
||||
layout.addWidget(splitter, 1)
|
||||
|
||||
close_btn = QPushButton("닫기")
|
||||
close_btn.clicked.connect(self.close)
|
||||
layout.addWidget(close_btn)
|
||||
|
||||
self.setLayout(layout)
|
||||
|
||||
# ------------------------------------------------------------- reload
|
||||
|
||||
def _reload(self):
|
||||
"""현재 화면 월에 대해 색상/리스트 갱신."""
|
||||
# 모든 날짜 포맷 초기화
|
||||
self.calendar.setDateTextFormat(QDate(), QTextCharFormat())
|
||||
|
||||
y = self.calendar.yearShown()
|
||||
m = self.calendar.monthShown()
|
||||
# 한 달 + 양 옆 1주씩 (캘린더에 보이는 모든 날)
|
||||
first = date(y, m, 1)
|
||||
if m == 12:
|
||||
last = date(y + 1, 1, 1) - timedelta(days=1)
|
||||
else:
|
||||
last = date(y, m + 1, 1) - timedelta(days=1)
|
||||
view_start = first - timedelta(days=7)
|
||||
view_end = last + timedelta(days=7)
|
||||
|
||||
# 휴일
|
||||
holidays = self.db.get_holidays_in_range(view_start.isoformat(),
|
||||
view_end.isoformat()) \
|
||||
if hasattr(self.db, 'get_holidays_in_range') else []
|
||||
if not holidays:
|
||||
holidays = self._fallback_holidays(view_start, view_end)
|
||||
|
||||
for h in holidays:
|
||||
d = self._parse_date(h.get('date'))
|
||||
if d is None:
|
||||
continue
|
||||
self._paint(d, _C_HOLIDAY, fg='white')
|
||||
|
||||
# 연차 (구체)
|
||||
leaves = self.db.get_leave_records_by_range(view_start.isoformat(),
|
||||
view_end.isoformat())
|
||||
today = date.today()
|
||||
for r in leaves:
|
||||
d = self._parse_date(r.get('date'))
|
||||
if d is None:
|
||||
continue
|
||||
days = float(r.get('days') or 0)
|
||||
is_planned = d > today
|
||||
if is_planned:
|
||||
color = _C_LEAVE_FULL_PLAN if days >= 1.0 else _C_LEAVE_PART_PLAN
|
||||
else:
|
||||
if days >= 1.0:
|
||||
color = _C_LEAVE_FULL_PAST
|
||||
elif days >= 0.5:
|
||||
color = _C_LEAVE_HALF_PAST
|
||||
else:
|
||||
color = _C_LEAVE_QUART_PAST
|
||||
self._paint(d, color, fg='white')
|
||||
|
||||
# 반복 패턴 인스턴스
|
||||
recurring = self.db.get_recurring_leaves()
|
||||
for occ in expand_for_range(recurring, view_start, view_end):
|
||||
# 같은 날짜에 구체 leave가 있으면 그 색상이 우선 (덮어쓰지 않음)
|
||||
existing = self.calendar.dateTextFormat(
|
||||
QDate(occ.date.year, occ.date.month, occ.date.day))
|
||||
if existing.background() != QBrush():
|
||||
continue
|
||||
self._paint(occ.date, _C_RECURRING, fg='white')
|
||||
|
||||
def _paint(self, d: date, color: QColor, fg: str = 'white'):
|
||||
qd = QDate(d.year, d.month, d.day)
|
||||
fmt = QTextCharFormat()
|
||||
fmt.setBackground(QBrush(color))
|
||||
fmt.setForeground(QBrush(QColor(fg)))
|
||||
self.calendar.setDateTextFormat(qd, fmt)
|
||||
|
||||
# ------------------------------------------------------------- events
|
||||
|
||||
def _on_date_click(self, qd: QDate):
|
||||
d = date(qd.year(), qd.month(), qd.day())
|
||||
date_str = d.isoformat()
|
||||
weekday_kr = ['월', '화', '수', '목', '금', '토', '일']
|
||||
self.detail_title.setText(f"{date_str} ({weekday_kr[d.weekday()]}요일)")
|
||||
self.detail_list.clear()
|
||||
|
||||
# 휴일
|
||||
holiday = self.db.get_holiday(date_str) if hasattr(self.db, 'get_holiday') else None
|
||||
if holiday:
|
||||
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()]}요일)")
|
||||
self.detail_list.addItem(item)
|
||||
|
||||
# 연차 (구체)
|
||||
for r in self.db.get_leave_records_by_date(date_str):
|
||||
days = float(r.get('days') or 0)
|
||||
t = r.get('leave_type', '연차')
|
||||
memo = r.get('memo') or ''
|
||||
label = f"{t} {days}일"
|
||||
if memo:
|
||||
label += f" — {memo}"
|
||||
label += f" [id={r['id']}]"
|
||||
item = QListWidgetItem(label)
|
||||
item.setData(Qt.UserRole, ('concrete', r['id']))
|
||||
self.detail_list.addItem(item)
|
||||
|
||||
# 반복 패턴 매치
|
||||
recurring = self.db.get_recurring_leaves(active_on=date_str)
|
||||
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})"
|
||||
)
|
||||
item.setData(Qt.UserRole, ('recurring', occ.recurring_id))
|
||||
self.detail_list.addItem(item)
|
||||
|
||||
if self.detail_list.count() == 0:
|
||||
self.detail_list.addItem("일정 없음")
|
||||
|
||||
def _on_page_change(self, year: int, month: int):
|
||||
self._reload()
|
||||
|
||||
def _on_list_menu(self, pos):
|
||||
item = self.detail_list.currentItem()
|
||||
if not item:
|
||||
return
|
||||
data = item.data(Qt.UserRole)
|
||||
if not data:
|
||||
return
|
||||
kind, _id = data
|
||||
menu = QMenu(self)
|
||||
del_act = menu.addAction("삭제")
|
||||
chosen = menu.exec_(self.detail_list.viewport().mapToGlobal(pos))
|
||||
if chosen == del_act:
|
||||
self._delete_record(kind, _id)
|
||||
|
||||
def _delete_record(self, kind: str, _id: int):
|
||||
if kind == 'concrete':
|
||||
reply = QMessageBox.question(
|
||||
self, "삭제 확인",
|
||||
"이 연차 기록을 삭제하시겠습니까? (잔액이 자동 복구됩니다.)",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
self.db.delete_leave_record(_id)
|
||||
self._reload()
|
||||
# 상세 갱신
|
||||
d = self.calendar.selectedDate()
|
||||
self._on_date_click(d)
|
||||
elif kind == 'recurring':
|
||||
reply = QMessageBox.question(
|
||||
self, "삭제 확인",
|
||||
"이 반복 패턴을 삭제하시겠습니까? (이후 모든 인스턴스 제거)",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if reply == QMessageBox.Yes:
|
||||
self.db.delete_recurring_leave(_id)
|
||||
self._reload()
|
||||
d = self.calendar.selectedDate()
|
||||
self._on_date_click(d)
|
||||
|
||||
def _open_recurring_dialog(self):
|
||||
from ui.recurring_leave_dialog import RecurringLeaveDialog
|
||||
dlg = RecurringLeaveDialog(self, self.db)
|
||||
dlg.exec_()
|
||||
self._reload()
|
||||
|
||||
def _open_add_leave_dialog(self):
|
||||
from ui.leave_view import AddLeaveDialog
|
||||
dlg = AddLeaveDialog(self, self.db)
|
||||
# 선택된 날짜로 기본값 설정
|
||||
d = self.calendar.selectedDate()
|
||||
if d.isValid():
|
||||
dlg.date_edit.setDate(d)
|
||||
if dlg.exec_() == dlg.Accepted:
|
||||
self._reload()
|
||||
self._on_date_click(d)
|
||||
|
||||
# ------------------------------------------------------------- helpers
|
||||
|
||||
@staticmethod
|
||||
def _parse_date(s):
|
||||
if not s:
|
||||
return None
|
||||
try:
|
||||
return datetime.strptime(s, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
def _fallback_holidays(self, view_start: date, view_end: date) -> List[dict]:
|
||||
"""get_holidays_in_range가 없는 경우 fallback (LIKE 쿼리)."""
|
||||
if not hasattr(self.db, 'get_holiday'):
|
||||
return []
|
||||
# 전체 공휴일을 조회하기엔 비싸서 캘린더에선 일자별 lazy lookup으로 대체
|
||||
# 여기서는 month start ~ end 범위만 매일 한 번씩 조회 (월 ~31회)
|
||||
out = []
|
||||
cur = view_start
|
||||
while cur <= view_end:
|
||||
h = self.db.get_holiday(cur.isoformat())
|
||||
if h:
|
||||
out.append(h)
|
||||
cur += timedelta(days=1)
|
||||
return out
|
||||
@ -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'):
|
||||
|
||||
@ -15,7 +15,7 @@ from core.i18n import tr
|
||||
from ui.styles import apply_dark_titlebar
|
||||
from ui.dark_components import (
|
||||
dialog_qss, tabs_qss, button_qss, build_stat_card, build_section_card,
|
||||
transparent_label, ACCENT_GOLD, ACCENT_GREEN, DARK_TEXT, DARK_TEXT_DIM,
|
||||
transparent_label, tc,
|
||||
)
|
||||
|
||||
|
||||
@ -27,7 +27,7 @@ class StatsView(QDialog):
|
||||
self.db = db if db else Database()
|
||||
self.init_ui()
|
||||
self.load_stats()
|
||||
apply_dark_titlebar(self, dark=True)
|
||||
apply_dark_titlebar(self) # 현재 테마에 맞춰 타이틀바
|
||||
|
||||
def init_ui(self):
|
||||
"""UI 초기화"""
|
||||
@ -42,9 +42,9 @@ 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"font-size: 18pt; font-weight: bold; color: {tc('text')}; "
|
||||
f"background: transparent; border: none; padding: 4px 0;"
|
||||
)
|
||||
layout.addWidget(title)
|
||||
@ -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)
|
||||
@ -143,9 +143,9 @@ class StatsView(QDialog):
|
||||
# 추정 급여 (옵션 활성 시)
|
||||
self.salary_label = QLabel("")
|
||||
self.salary_label.setStyleSheet(
|
||||
f"background: rgba(74, 222, 128, 0.12); "
|
||||
f"border: 1px solid {ACCENT_GREEN}; border-radius: 8px; "
|
||||
f"color: {ACCENT_GREEN}; font-weight: bold; "
|
||||
f"background: rgba(81, 207, 102, 0.12); "
|
||||
f"border: 1px solid {tc('green')}; border-radius: 8px; "
|
||||
f"color: {tc('green')}; font-weight: bold; "
|
||||
f"padding: 10px 14px; font-size: 11pt;"
|
||||
)
|
||||
self.salary_label.setVisible(False)
|
||||
@ -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)
|
||||
@ -179,17 +179,17 @@ class StatsView(QDialog):
|
||||
self.pattern_text.setWordWrap(True)
|
||||
self.pattern_text.setAlignment(Qt.AlignTop | Qt.AlignLeft)
|
||||
self.pattern_text.setStyleSheet(
|
||||
f"font-size: 11pt; color: {DARK_TEXT}; "
|
||||
f"font-size: 11pt; color: {tc('text')}; "
|
||||
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
|
||||
|
||||
282
ui/styles.py
282
ui/styles.py
@ -33,8 +33,8 @@ def _ensure_icons():
|
||||
for name, color_hex, points in [
|
||||
('up_light', '#4A4A68', [(4, 7), (8, 3), (12, 7)]),
|
||||
('down_light', '#4A4A68', [(4, 5), (8, 9), (12, 5)]),
|
||||
('up_dark', '#A0A0B8', [(4, 7), (8, 3), (12, 7)]),
|
||||
('down_dark', '#A0A0B8', [(4, 5), (8, 9), (12, 5)]),
|
||||
('up_dark', '#909296', [(4, 7), (8, 3), (12, 7)]),
|
||||
('down_dark', '#909296', [(4, 5), (8, 9), (12, 5)]),
|
||||
]:
|
||||
path = os.path.join(_arrow_dir, f'{name}.png')
|
||||
if not os.path.exists(path):
|
||||
@ -78,6 +78,9 @@ LIGHT_COLORS = {
|
||||
'bg_primary': '#F5F5F7',
|
||||
'bg_secondary': '#FFFFFF',
|
||||
'bg_tertiary': '#EDEDF0',
|
||||
# 인터랙션 표면
|
||||
'surface_hover': '#E2E3E7',
|
||||
'surface_pressed': '#D5D6DB',
|
||||
# 텍스트 계층
|
||||
'text_primary': '#1A1A2E',
|
||||
'text_secondary': '#4A4A68',
|
||||
@ -85,9 +88,15 @@ LIGHT_COLORS = {
|
||||
'text_inverse': '#FFFFFF',
|
||||
# 액센트
|
||||
'accent_primary': '#3B82F6',
|
||||
'accent_primary_hover': '#2F74EE',
|
||||
'accent_primary_pressed': '#2563EB',
|
||||
'accent_success': '#10B981',
|
||||
'accent_success_hover': '#0EA372',
|
||||
'accent_success_pressed': '#0C8F63',
|
||||
'accent_warning': '#F59E0B',
|
||||
'accent_danger': '#EF4444',
|
||||
'accent_danger_hover': '#DC2626',
|
||||
'accent_danger_pressed': '#B91C1C',
|
||||
# 테두리
|
||||
'border_subtle': '#E5E7EB',
|
||||
'border_default': '#D1D5DB',
|
||||
@ -120,40 +129,58 @@ LIGHT_COLORS = {
|
||||
}
|
||||
|
||||
DARK_COLORS = {
|
||||
'bg_primary': '#111118',
|
||||
'bg_secondary': '#1C1C2E',
|
||||
'bg_tertiary': '#282842',
|
||||
'text_primary': '#ECECF4',
|
||||
'text_secondary': '#B0B0C8',
|
||||
'text_tertiary': '#808098',
|
||||
# 배경 계층 — 모던 다크 (Notion/Linear 톤)
|
||||
'bg_primary': '#1A1B1E', # 앱 배경
|
||||
'bg_secondary': '#25262B', # 카드 / 패널
|
||||
'bg_tertiary': '#2C2E33', # 기본 버튼 / 미묘한 채움
|
||||
# 인터랙션 표면
|
||||
'surface_hover': '#34363D',
|
||||
'surface_pressed': '#3A3D44',
|
||||
# 텍스트 계층
|
||||
'text_primary': '#E9ECEF',
|
||||
'text_secondary': '#909296',
|
||||
'text_tertiary': '#6C6E73',
|
||||
'text_inverse': '#FFFFFF',
|
||||
'accent_primary': '#6B9EFF',
|
||||
'accent_success': '#4ADE80',
|
||||
'accent_warning': '#FCD34D',
|
||||
'accent_danger': '#FB7185',
|
||||
'border_subtle': '#32324E',
|
||||
'border_default': '#44446A',
|
||||
'border_focus': '#6B9EFF',
|
||||
'badge_overtime_bg': '#3D2008',
|
||||
'badge_overtime_text': '#FDE68A',
|
||||
'badge_leave_bg': '#1E2D5F',
|
||||
'badge_leave_text': '#A5D0FE',
|
||||
'badge_total_bg': '#0A3324',
|
||||
'badge_total_text': '#86EFAC',
|
||||
'progress_bg': '#282842',
|
||||
'progress_start': '#6B9EFF',
|
||||
'progress_end': '#4ADE80',
|
||||
'status_overtime': '#FB7185',
|
||||
'status_warning': '#FCD34D',
|
||||
'status_normal': '#4ADE80',
|
||||
'status_break_active': '#FB7185',
|
||||
'status_break_idle': '#808098',
|
||||
'cal_normal': '#1A4D3A',
|
||||
'cal_overtime': '#5C1A1A',
|
||||
'cal_incomplete': '#5C3A10',
|
||||
'scrollbar_bg': '#111118',
|
||||
'scrollbar_handle': '#44446A',
|
||||
'scrollbar_hover': '#5A5A88',
|
||||
# 액센트 — 단일 포인트 컬러 (주요 버튼 + 포커스 전용)
|
||||
'accent_primary': '#4DABF7',
|
||||
'accent_primary_hover': '#69B6F8',
|
||||
'accent_primary_pressed': '#3D97E0',
|
||||
'accent_success': '#51CF66',
|
||||
'accent_success_hover': '#69DB7C',
|
||||
'accent_success_pressed': '#43B85A',
|
||||
'accent_warning': '#FAB005',
|
||||
'accent_danger': '#FA5252',
|
||||
'accent_danger_hover': '#FF6B6B',
|
||||
'accent_danger_pressed': '#E64545',
|
||||
# 테두리
|
||||
'border_subtle': '#2C2E33',
|
||||
'border_default': '#373A40',
|
||||
'border_focus': '#4DABF7',
|
||||
# 배지 — 플랫 (미묘한 배경 + 색조 텍스트로 미니멀 유지)
|
||||
'badge_overtime_bg': '#2C2E33',
|
||||
'badge_overtime_text': '#FAB005',
|
||||
'badge_leave_bg': '#2C2E33',
|
||||
'badge_leave_text': '#4DABF7',
|
||||
'badge_total_bg': '#2C2E33',
|
||||
'badge_total_text': '#51CF66',
|
||||
# 프로그레스 — 단일 accent 솔리드
|
||||
'progress_bg': '#2C2E33',
|
||||
'progress_start': '#4DABF7',
|
||||
'progress_end': '#4DABF7',
|
||||
# 상태 색상 (동적 텍스트 피드백)
|
||||
'status_overtime': '#51CF66', # 퇴근 가능(연장근무 진입) = 그린
|
||||
'status_warning': '#FAB005',
|
||||
'status_normal': '#51CF66',
|
||||
'status_break_active': '#FA5252',
|
||||
'status_break_idle': '#6C6E73',
|
||||
# 캘린더 날짜 배경 — 미묘한 다크 틴트
|
||||
'cal_normal': '#1E3A2A',
|
||||
'cal_overtime': '#3A2122',
|
||||
'cal_incomplete': '#3A331E',
|
||||
# 스크롤바
|
||||
'scrollbar_bg': '#1A1B1E',
|
||||
'scrollbar_handle': '#373A40',
|
||||
'scrollbar_hover': '#4DABF7',
|
||||
}
|
||||
|
||||
|
||||
@ -192,7 +219,7 @@ QMainWindow, QDialog {{
|
||||
}}
|
||||
|
||||
QWidget {{
|
||||
font-family: "Segoe UI", "맑은 고딕", sans-serif;
|
||||
font-family: "NanumSquare", "NanumSquareOTF", "Malgun Gothic", "맑은 고딕", sans-serif;
|
||||
font-size: 9.5pt;
|
||||
color: {c['text_primary']};
|
||||
}}
|
||||
@ -206,14 +233,14 @@ QWidget#central_widget {{
|
||||
════════════════════════════════════════ */
|
||||
|
||||
QLabel#app_title {{
|
||||
font-size: 12pt;
|
||||
font-size: 13pt;
|
||||
font-weight: bold;
|
||||
color: {c['text_primary']};
|
||||
padding: 2px;
|
||||
}}
|
||||
|
||||
QLabel#date_label {{
|
||||
font-size: 9pt;
|
||||
font-size: 9.5pt;
|
||||
color: {c['text_secondary']};
|
||||
padding-bottom: 4px;
|
||||
}}
|
||||
@ -221,7 +248,7 @@ QLabel#date_label {{
|
||||
QLabel#section_title {{
|
||||
font-size: 9.5pt;
|
||||
font-weight: bold;
|
||||
color: {c['text_primary']};
|
||||
color: {c['text_secondary']};
|
||||
}}
|
||||
|
||||
QLabel#field_label {{
|
||||
@ -229,29 +256,30 @@ QLabel#field_label {{
|
||||
color: {c['text_secondary']};
|
||||
}}
|
||||
|
||||
/* 출근/현재 시각 — 한 줄 나란히 표시되는 중간 크기 모노스페이스 */
|
||||
QLabel#time_value {{
|
||||
font-family: "Consolas", "D2Coding", monospace;
|
||||
font-size: 11pt;
|
||||
font-size: 15pt;
|
||||
font-weight: bold;
|
||||
color: {c['text_primary']};
|
||||
}}
|
||||
|
||||
/* 히어로 — 남은 시간 (화면에서 가장 큰 결과 표시). 카드 안에 투명 배치 */
|
||||
QLabel#time_display {{
|
||||
font-family: "Consolas", "D2Coding", monospace;
|
||||
font-size: 22pt;
|
||||
font-size: 30pt;
|
||||
font-weight: bold;
|
||||
color: {c['text_primary']};
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-radius: 10px;
|
||||
padding: 10px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 4px 0;
|
||||
}}
|
||||
|
||||
QLabel#expected_time {{
|
||||
font-size: 10pt;
|
||||
font-size: 11.5pt;
|
||||
font-weight: bold;
|
||||
color: {c['text_primary']};
|
||||
padding: 4px;
|
||||
color: {c['text_secondary']};
|
||||
padding: 2px;
|
||||
}}
|
||||
|
||||
QLabel#dialog_title {{
|
||||
@ -295,7 +323,7 @@ QLabel#badge_overtime {{
|
||||
qproperty-alignment: AlignCenter;
|
||||
background: {c['badge_overtime_bg']};
|
||||
color: {c['badge_overtime_text']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
QLabel#badge_leave {{
|
||||
@ -306,7 +334,7 @@ QLabel#badge_leave {{
|
||||
qproperty-alignment: AlignCenter;
|
||||
background: {c['badge_leave_bg']};
|
||||
color: {c['badge_leave_text']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
QLabel#badge_total {{
|
||||
@ -317,7 +345,7 @@ QLabel#badge_total {{
|
||||
qproperty-alignment: AlignCenter;
|
||||
background: {c['badge_total_bg']};
|
||||
color: {c['badge_total_text']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
QLabel#badge_balance {{
|
||||
@ -326,7 +354,7 @@ QLabel#badge_balance {{
|
||||
padding: 10px;
|
||||
background: {c['bg_tertiary']};
|
||||
color: {c['text_primary']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
QLabel#badge_success {{
|
||||
@ -335,7 +363,7 @@ QLabel#badge_success {{
|
||||
padding: 8px;
|
||||
background: {c['badge_total_bg']};
|
||||
color: {c['badge_total_text']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
@ -355,9 +383,9 @@ QLabel#separator {{
|
||||
QGroupBox {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-radius: 10px;
|
||||
border-radius: 8px;
|
||||
margin-top: 10px;
|
||||
padding: 14px;
|
||||
padding: 16px;
|
||||
padding-top: 28px;
|
||||
font-size: 9.5pt;
|
||||
color: {c['text_primary']};
|
||||
@ -378,52 +406,55 @@ QGroupBox::title {{
|
||||
버튼
|
||||
════════════════════════════════════════ */
|
||||
|
||||
/* 기본 버튼 — 그라데이션/베벨 없는 플랫 (border:none 기반) */
|
||||
QPushButton {{
|
||||
background: {c['bg_tertiary']};
|
||||
color: {c['text_primary']};
|
||||
border: 1px solid {c['border_default']};
|
||||
border-radius: 6px;
|
||||
padding: 7px 14px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 9pt;
|
||||
}}
|
||||
|
||||
QPushButton:hover {{
|
||||
background: {c['border_default']};
|
||||
background: {c['surface_hover']};
|
||||
}}
|
||||
|
||||
QPushButton:pressed {{
|
||||
background: {c['border_subtle']};
|
||||
background: {c['surface_pressed']};
|
||||
}}
|
||||
|
||||
QPushButton:disabled {{
|
||||
background: {c['bg_tertiary']};
|
||||
background: {c['bg_secondary']};
|
||||
color: {c['text_tertiary']};
|
||||
border-color: {c['border_subtle']};
|
||||
}}
|
||||
|
||||
QPushButton:checked {{
|
||||
background: {c['accent_primary']};
|
||||
color: {c['text_inverse']};
|
||||
border-color: {c['accent_primary']};
|
||||
}}
|
||||
|
||||
/* 퇴근 버튼 (primary action) */
|
||||
QPushButton:focus {{
|
||||
outline: none;
|
||||
}}
|
||||
|
||||
/* 퇴근 버튼 — 주요 액션 (단일 포인트 컬러) */
|
||||
QPushButton#clock_out_button {{
|
||||
background: {c['accent_success']};
|
||||
background: {c['accent_primary']};
|
||||
color: {c['text_inverse']};
|
||||
font-size: 11pt;
|
||||
font-weight: bold;
|
||||
padding: 8px;
|
||||
padding: 11px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
QPushButton#clock_out_button:hover {{
|
||||
background: {'#0EA572' if not is_dark else '#2BB885'};
|
||||
background: {c['accent_primary_hover']};
|
||||
}}
|
||||
|
||||
QPushButton#clock_out_button:pressed {{
|
||||
background: {'#0C8F63' if not is_dark else '#28A87A'};
|
||||
background: {c['accent_primary_pressed']};
|
||||
}}
|
||||
|
||||
/* 주요 액션 버튼 */
|
||||
@ -435,11 +466,11 @@ QPushButton#btn_primary {{
|
||||
}}
|
||||
|
||||
QPushButton#btn_primary:hover {{
|
||||
background: {c['accent_primary']}DD;
|
||||
background: {c['accent_primary_hover']};
|
||||
}}
|
||||
|
||||
QPushButton#btn_primary:pressed {{
|
||||
background: {c['accent_primary']}BB;
|
||||
background: {c['accent_primary_pressed']};
|
||||
}}
|
||||
|
||||
/* 위험 버튼 */
|
||||
@ -450,11 +481,11 @@ QPushButton#btn_danger {{
|
||||
}}
|
||||
|
||||
QPushButton#btn_danger:hover {{
|
||||
background: {c['accent_danger']}DD;
|
||||
background: {c['accent_danger_hover']};
|
||||
}}
|
||||
|
||||
QPushButton#btn_danger:pressed {{
|
||||
background: {c['accent_danger']}BB;
|
||||
background: {c['accent_danger_pressed']};
|
||||
}}
|
||||
|
||||
/* 성공 버튼 */
|
||||
@ -465,25 +496,44 @@ QPushButton#btn_success {{
|
||||
}}
|
||||
|
||||
QPushButton#btn_success:hover {{
|
||||
background: {c['accent_success']}DD;
|
||||
background: {c['accent_success_hover']};
|
||||
}}
|
||||
|
||||
QPushButton#btn_success:pressed {{
|
||||
background: {c['accent_success']}BB;
|
||||
background: {c['accent_success_pressed']};
|
||||
}}
|
||||
|
||||
/* 작은 버튼 */
|
||||
/* 작은 버튼 — 미묘한 표면 */
|
||||
QPushButton#btn_small {{
|
||||
font-size: 8.5pt;
|
||||
padding: 5px 10px;
|
||||
padding: 6px 10px;
|
||||
}}
|
||||
|
||||
QPushButton#btn_small:hover {{
|
||||
background: {c['accent_primary']}20;
|
||||
background: {c['surface_hover']};
|
||||
}}
|
||||
|
||||
QPushButton#btn_small:pressed {{
|
||||
background: {c['accent_primary']}35;
|
||||
background: {c['surface_pressed']};
|
||||
}}
|
||||
|
||||
/* 하단 네비게이션 — 라인 아이콘 + 라벨, 투명 배경 (Linear/Notion 풋터 톤) */
|
||||
QPushButton#nav_btn {{
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 4px;
|
||||
font-size: 8.5pt;
|
||||
color: {c['text_secondary']};
|
||||
}}
|
||||
|
||||
QPushButton#nav_btn:hover {{
|
||||
background: {c['surface_hover']};
|
||||
color: {c['text_primary']};
|
||||
}}
|
||||
|
||||
QPushButton#nav_btn:pressed {{
|
||||
background: {c['surface_pressed']};
|
||||
}}
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
@ -493,7 +543,7 @@ QPushButton#btn_small:pressed {{
|
||||
QLineEdit, QTextEdit, QComboBox {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_default']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
padding: 6px 8px;
|
||||
color: {c['text_primary']};
|
||||
font-size: 9.5pt;
|
||||
@ -503,21 +553,17 @@ QLineEdit, QTextEdit, QComboBox {{
|
||||
QSpinBox, QDoubleSpinBox, QDateEdit, QTimeEdit {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_default']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
padding: 6px 28px 6px 8px;
|
||||
color: {c['text_primary']};
|
||||
font-size: 9.5pt;
|
||||
min-height: 20px;
|
||||
}}
|
||||
|
||||
QLineEdit:focus, QTextEdit:focus, QComboBox:focus {{
|
||||
border: 2px solid {c['border_focus']};
|
||||
padding: 5px 7px;
|
||||
}}
|
||||
|
||||
/* 포커스 시 보더 컬러만 포인트 컬러로 (두께 유지 → 레이아웃 흔들림 없음) */
|
||||
QLineEdit:focus, QTextEdit:focus, QComboBox:focus,
|
||||
QSpinBox:focus, QDoubleSpinBox:focus, QDateEdit:focus, QTimeEdit:focus {{
|
||||
border: 2px solid {c['border_focus']};
|
||||
padding: 5px 27px 5px 7px;
|
||||
border: 1px solid {c['border_focus']};
|
||||
}}
|
||||
|
||||
/* 비활성 입력 필드 */
|
||||
@ -563,13 +609,13 @@ QTimeEdit::up-button, QTimeEdit::down-button {{
|
||||
QSpinBox::up-button, QDoubleSpinBox::up-button,
|
||||
QDateEdit::up-button, QTimeEdit::up-button {{
|
||||
subcontrol-position: top right;
|
||||
border-top-right-radius: 4px;
|
||||
border-top-right-radius: 7px;
|
||||
}}
|
||||
|
||||
QSpinBox::down-button, QDoubleSpinBox::down-button,
|
||||
QDateEdit::down-button, QTimeEdit::down-button {{
|
||||
subcontrol-position: bottom right;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom-right-radius: 7px;
|
||||
}}
|
||||
|
||||
QSpinBox::up-button:hover, QSpinBox::down-button:hover,
|
||||
@ -628,17 +674,17 @@ QCheckBox::indicator:hover {{
|
||||
QProgressBar {{
|
||||
border: none;
|
||||
background: {c['progress_bg']};
|
||||
border-radius: 4px;
|
||||
height: 8px;
|
||||
border-radius: 3px;
|
||||
min-height: 6px;
|
||||
max-height: 6px;
|
||||
text-align: center;
|
||||
color: transparent;
|
||||
font-size: 0px;
|
||||
}}
|
||||
|
||||
QProgressBar::chunk {{
|
||||
background: qlineargradient(x1:0, y1:0, x2:1, y2:0,
|
||||
stop:0 {c['progress_start']}, stop:1 {c['progress_end']});
|
||||
border-radius: 4px;
|
||||
background: {c['progress_start']};
|
||||
border-radius: 3px;
|
||||
}}
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
@ -648,7 +694,7 @@ QProgressBar::chunk {{
|
||||
QTableWidget {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
gridline-color: {c['border_subtle']};
|
||||
color: {c['text_primary']};
|
||||
font-size: 9pt;
|
||||
@ -667,23 +713,47 @@ QTableWidget::item:alternate {{
|
||||
background: {c['bg_tertiary']};
|
||||
}}
|
||||
|
||||
/* 헤더 위젯 배경 (세로헤더 빈 영역의 흰색 누수 방지) */
|
||||
QHeaderView {{
|
||||
background: {c['bg_secondary']};
|
||||
border: none;
|
||||
}}
|
||||
|
||||
QHeaderView::section {{
|
||||
background: {c['bg_tertiary']};
|
||||
color: {c['text_secondary']};
|
||||
padding: 8px;
|
||||
border: none;
|
||||
border-bottom: 2px solid {c['accent_primary']};
|
||||
font-weight: bold;
|
||||
font-size: 9pt;
|
||||
}}
|
||||
|
||||
QHeaderView::section:horizontal {{
|
||||
border-bottom: 2px solid {c['accent_primary']};
|
||||
}}
|
||||
|
||||
/* 세로헤더(행번호) — accent 밑줄 없이 미묘하게 */
|
||||
QHeaderView::section:vertical {{
|
||||
border-right: 1px solid {c['border_subtle']};
|
||||
color: {c['text_tertiary']};
|
||||
font-weight: normal;
|
||||
padding: 4px 8px;
|
||||
}}
|
||||
|
||||
/* 테이블 좌상단 코너 버튼 (흰색 누수 방지) */
|
||||
QTableView QTableCornerButton::section {{
|
||||
background: {c['bg_tertiary']};
|
||||
border: none;
|
||||
border-bottom: 2px solid {c['accent_primary']};
|
||||
}}
|
||||
|
||||
/* ════════════════════════════════════════
|
||||
탭 위젯
|
||||
════════════════════════════════════════ */
|
||||
|
||||
QTabWidget::pane {{
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
background: {c['bg_secondary']};
|
||||
top: -1px;
|
||||
}}
|
||||
@ -694,8 +764,8 @@ QTabBar::tab {{
|
||||
padding: 8px 20px;
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-bottom: none;
|
||||
border-top-left-radius: 6px;
|
||||
border-top-right-radius: 6px;
|
||||
border-top-left-radius: 8px;
|
||||
border-top-right-radius: 8px;
|
||||
margin-right: 2px;
|
||||
font-size: 10pt;
|
||||
}}
|
||||
@ -787,7 +857,7 @@ QScrollArea > QWidget > QWidget#scroll_content {{
|
||||
QCalendarWidget {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_subtle']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
font-size: 10pt;
|
||||
}}
|
||||
|
||||
@ -902,7 +972,7 @@ QToolTip {{
|
||||
QMenu {{
|
||||
background: {c['bg_secondary']};
|
||||
border: 1px solid {c['border_default']};
|
||||
border-radius: 6px;
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
color: {c['text_primary']};
|
||||
}}
|
||||
@ -916,6 +986,16 @@ QMenu::item:selected {{
|
||||
background: {c['accent_primary']};
|
||||
color: {c['text_inverse']};
|
||||
}}
|
||||
|
||||
QMenu::separator {{
|
||||
height: 1px;
|
||||
background: {c['border_subtle']};
|
||||
margin: 4px 8px;
|
||||
}}
|
||||
|
||||
QMenu::icon {{
|
||||
padding-left: 8px;
|
||||
}}
|
||||
"""
|
||||
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
68
updater.py
68
updater.py
@ -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
|
||||
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
84
utils/font_loader.py
Normal file
84
utils/font_loader.py
Normal file
@ -0,0 +1,84 @@
|
||||
"""번들 폰트(NanumSquare) 로딩.
|
||||
|
||||
`font/` 디렉토리의 TTF를 QFontDatabase에 등록해 OS 설치 없이도 사용.
|
||||
PyInstaller frozen(_MEIPASS) / 개발 실행(프로젝트 루트) 양쪽 경로를 지원하며,
|
||||
등록 실패 시 QSS 폰트 체인이 "Malgun Gothic"으로 자연 폴백한다.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
import os
|
||||
import sys
|
||||
|
||||
from PyQt5.QtGui import QFontDatabase, QFont
|
||||
|
||||
# 로드할 폰트 파일 — TTF 우선(Windows Qt에서 OTF보다 렌더 안정적).
|
||||
# L/R/B/EB 4단계 굵기 + _ac(라틴·숫자 보정) 변형을 함께 등록.
|
||||
_FONT_FILES = [
|
||||
'NanumSquareL.ttf',
|
||||
'NanumSquareR.ttf',
|
||||
'NanumSquareB.ttf',
|
||||
'NanumSquareEB.ttf',
|
||||
'NanumSquare_acR.ttf',
|
||||
'NanumSquare_acB.ttf',
|
||||
]
|
||||
|
||||
|
||||
def _font_dir() -> str:
|
||||
"""번들 font/ 디렉토리 절대 경로."""
|
||||
if getattr(sys, 'frozen', False):
|
||||
base = getattr(sys, '_MEIPASS', None) or os.path.dirname(sys.executable)
|
||||
else:
|
||||
base = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
return os.path.join(base, 'font')
|
||||
|
||||
|
||||
def load_bundled_fonts() -> list:
|
||||
"""번들 폰트를 등록하고, 등록된 family 이름 목록을 반환."""
|
||||
families: list = []
|
||||
fdir = _font_dir()
|
||||
if not os.path.isdir(fdir):
|
||||
return families
|
||||
for name in _FONT_FILES:
|
||||
path = os.path.join(fdir, name)
|
||||
if not os.path.exists(path):
|
||||
continue
|
||||
fid = QFontDatabase.addApplicationFont(path)
|
||||
if fid == -1:
|
||||
continue
|
||||
for fam in QFontDatabase.applicationFontFamilies(fid):
|
||||
if fam not in families:
|
||||
families.append(fam)
|
||||
return families
|
||||
|
||||
|
||||
def _pick_primary(families: list) -> str:
|
||||
"""등록된 family 중 기본 본문용(Regular 굵기) family 선택."""
|
||||
if 'NanumSquare' in families:
|
||||
return 'NanumSquare'
|
||||
for fam in families:
|
||||
low = fam.lower()
|
||||
if 'nanumsquare' in low and 'light' not in low and 'extra' not in low:
|
||||
return fam
|
||||
return 'Malgun Gothic'
|
||||
|
||||
|
||||
def apply_app_font(app, point_size: int = 9) -> str:
|
||||
"""앱 전역 기본 폰트를 NanumSquare로 설정.
|
||||
|
||||
Returns:
|
||||
실제 적용된 primary family 이름 (폴백 시 'Malgun Gothic').
|
||||
"""
|
||||
families = load_bundled_fonts()
|
||||
primary = _pick_primary(families)
|
||||
font = QFont(primary, point_size)
|
||||
font.setStyleStrategy(QFont.PreferAntialias)
|
||||
app.setFont(font)
|
||||
return primary
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
from PyQt5.QtWidgets import QApplication
|
||||
_app = QApplication(sys.argv)
|
||||
fams = load_bundled_fonts()
|
||||
print('font dir:', _font_dir())
|
||||
print('registered families:', fams)
|
||||
print('picked primary:', _pick_primary(fams))
|
||||
98
utils/holiday_api.py
Normal file
98
utils/holiday_api.py
Normal file
@ -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
|
||||
@ -57,58 +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)
|
||||
|
||||
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),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user