diff --git a/system1-factory/api/db/migrations/20260331_fix_deduct_days_precision.sql b/system1-factory/api/db/migrations/20260331_fix_deduct_days_precision.sql index 052bef2..3923205 100644 --- a/system1-factory/api/db/migrations/20260331_fix_deduct_days_precision.sql +++ b/system1-factory/api/db/migrations/20260331_fix_deduct_days_precision.sql @@ -7,3 +7,6 @@ DELETE svb FROM sp_vacation_balances svb JOIN vacation_types vt ON svb.vacation_type_id = vt.id WHERE svb.balance_type = 'COMPANY_GRANT' AND vt.type_code IN ('CARRYOVER', 'LONG_SERVICE', 'ANNUAL_FULL', 'ANNUAL_HALF', 'ANNUAL_QUARTER'); +-- 조퇴 휴가 유형 추가 (0.75일 = 반차+반반차) +INSERT IGNORE INTO vacation_types (type_code, type_name, deduct_days, is_active, priority) +VALUES ('EARLY_LEAVE', '조퇴', 0.75, 1, 10); diff --git a/system1-factory/web/pages/attendance/annual-overview.html b/system1-factory/web/pages/attendance/annual-overview.html index 0094fc3..f2c9e15 100644 --- a/system1-factory/web/pages/attendance/annual-overview.html +++ b/system1-factory/web/pages/attendance/annual-overview.html @@ -430,6 +430,7 @@ let currentModalUserId = null; let currentModalName = ''; let editBackup = {}; + let earlyLeaveVtId = null; // EARLY_LEAVE vacation_type_id (동적 조회) const DAYS_KR = ['일', '월', '화', '수', '목', '금', '토']; // 경조사 유형 @@ -477,6 +478,15 @@ document.getElementById('tableBody').innerHTML = '로딩 중...'; try { + // EARLY_LEAVE 유형 ID 조회 (최초 1회) + if (!earlyLeaveVtId) { + try { + var vtRes = await axios.get('/attendance/vacation-types'); + var elType = (vtRes.data.data || []).find(function(t) { return t.type_code === 'EARLY_LEAVE'; }); + earlyLeaveVtId = elType ? elType.id : null; + } catch(e) {} + } + // 작업자 로드 const workersRes = await axios.get('/workers?limit=100'); workers = (workersRes.data.data || []) @@ -761,15 +771,15 @@ // 월별 그룹핑 const monthly = {}; - const totals = { ANNUAL_FULL: 0, ANNUAL_HALF: 0, ANNUAL_QUARTER: 0, other: 0 }; + const totals = { ANNUAL_FULL: 0, ANNUAL_HALF: 0, ANNUAL_QUARTER: 0, EARLY_LEAVE: 0, other: 0 }; records.forEach(r => { const d = new Date(r.record_date); const m = d.getMonth() + 1; - if (!monthly[m]) monthly[m] = { ANNUAL_FULL: 0, ANNUAL_HALF: 0, ANNUAL_QUARTER: 0, other: 0, total: 0 }; + if (!monthly[m]) monthly[m] = { ANNUAL_FULL: 0, ANNUAL_HALF: 0, ANNUAL_QUARTER: 0, EARLY_LEAVE: 0, other: 0, total: 0 }; const days = parseFloat(r.vacation_days) || 1; const code = r.vacation_type_code || 'other'; - if (['ANNUAL_FULL', 'ANNUAL_HALF', 'ANNUAL_QUARTER'].includes(code)) { + if (['ANNUAL_FULL', 'ANNUAL_HALF', 'ANNUAL_QUARTER', 'EARLY_LEAVE'].includes(code)) { monthly[m][code] += days; totals[code] += days; } else { @@ -779,12 +789,12 @@ monthly[m].total += days; }); - const grandTotal = totals.ANNUAL_FULL + totals.ANNUAL_HALF + totals.ANNUAL_QUARTER + totals.other; + const grandTotal = totals.ANNUAL_FULL + totals.ANNUAL_HALF + totals.ANNUAL_QUARTER + totals.EARLY_LEAVE + totals.other; // 월별 요약 테이블 var html = '

월별 요약

'; html += ''; - html += ''; + html += ''; html += ''; for (var m = 1; m <= 12; m++) { if (!monthly[m]) continue; @@ -793,6 +803,7 @@ html += ''; html += ''; html += ''; + html += ''; html += ''; } html += ''; @@ -800,6 +811,7 @@ html += ''; html += ''; html += ''; + html += ''; html += ''; html += '
연차반차반반차합계연차반차반반차조퇴합계
' + (md.ANNUAL_FULL > 0 ? fmtNum(md.ANNUAL_FULL) : '-') + '' + (md.ANNUAL_HALF > 0 ? fmtNum(md.ANNUAL_HALF) : '-') + '' + (md.ANNUAL_QUARTER > 0 ? fmtNum(md.ANNUAL_QUARTER) : '-') + '' + (md.EARLY_LEAVE > 0 ? fmtNum(md.EARLY_LEAVE) : '-') + '' + fmtNum(md.total) + '
' + fmtNum(totals.ANNUAL_FULL) + '' + fmtNum(totals.ANNUAL_HALF) + '' + fmtNum(totals.ANNUAL_QUARTER) + '' + fmtNum(totals.EARLY_LEAVE) + '' + fmtNum(grandTotal) + '
'; @@ -866,6 +878,7 @@ + '' + '' + '' + + (earlyLeaveVtId ? '' : '') + '' + '' + ''; diff --git a/system1-factory/web/pages/attendance/work-status.html b/system1-factory/web/pages/attendance/work-status.html index 3354146..ce3dbc9 100644 --- a/system1-factory/web/pages/attendance/work-status.html +++ b/system1-factory/web/pages/attendance/work-status.html @@ -308,13 +308,14 @@ let hasCheckinData = false; let isAlreadySaved = false; let isSaving = false; + let earlyLeaveTypeId = null; const attendanceTypes = [ { value: 'normal', label: '정시근무', hours: 8, isLeave: false }, { value: 'annual', label: '연차', hours: 0, isLeave: true }, { value: 'half', label: '반차', hours: 4, isLeave: true }, { value: 'quarter', label: '반반차', hours: 6, isLeave: true }, - { value: 'early', label: '조퇴', hours: 2, isLeave: false }, + { value: 'early', label: '조퇴', hours: 2, isLeave: true }, { value: 'overtime', label: '연장근로', hours: 8, isLeave: false } ]; @@ -347,6 +348,15 @@ if (!selectedDate) return alert('날짜를 선택해주세요.'); try { + // EARLY_LEAVE 유형 ID 조회 (최초 1회) + if (!earlyLeaveTypeId) { + try { + const vtRes = await axios.get('/attendance/vacation-types'); + const earlyType = (vtRes.data.data || []).find(t => t.type_code === 'EARLY_LEAVE'); + earlyLeaveTypeId = earlyType?.id || null; + } catch(e) {} + } + const [workersRes, recordsRes] = await Promise.all([ axios.get('/workers?limit=100'), axios.get(`/attendance/daily-records?date=${selectedDate}`).catch(() => ({ data: { data: [] } })) @@ -743,13 +753,27 @@ 'overtime': 1 // NORMAL (시간으로 구분) }; - // vacation_types: 1=ANNUAL_FULL, 2=ANNUAL_HALF, 3=ANNUAL_QUARTER + // vacation_types: 1=ANNUAL_FULL, 2=ANNUAL_HALF, 3=ANNUAL_QUARTER, EARLY_LEAVE=동적 const vacationTypeIdMap = { 'annual': 1, 'half': 2, 'quarter': 3, + 'early': earlyLeaveTypeId, }; + // 조퇴가 있는데 vacation_type_id가 없으면 저장 차단 + const hasEarlyWithoutType = workers.some(w => { + const s = workStatus[w.user_id]; + return s && s.type === 'early' && !earlyLeaveTypeId; + }); + if (hasEarlyWithoutType) { + alert('조퇴 휴가 유형이 등록되지 않았습니다. 관리자에게 문의해주세요.'); + isSaving = false; + saveBtn.disabled = false; + saveBtn.textContent = '저장'; + return; + } + // 미입사자 제외하고 저장할 데이터 생성 const recordsToSave = workers .filter(w => !workStatus[w.user_id]?.isNotHired)