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 += '' + (md.ANNUAL_FULL > 0 ? fmtNum(md.ANNUAL_FULL) : '-') + ' | ';
html += '' + (md.ANNUAL_HALF > 0 ? fmtNum(md.ANNUAL_HALF) : '-') + ' | ';
html += '' + (md.ANNUAL_QUARTER > 0 ? fmtNum(md.ANNUAL_QUARTER) : '-') + ' | ';
+ html += '' + (md.EARLY_LEAVE > 0 ? fmtNum(md.EARLY_LEAVE) : '-') + ' | ';
html += '' + fmtNum(md.total) + ' | ';
}
html += '';
@@ -800,6 +811,7 @@
html += '| ' + fmtNum(totals.ANNUAL_FULL) + ' | ';
html += '' + fmtNum(totals.ANNUAL_HALF) + ' | ';
html += '' + fmtNum(totals.ANNUAL_QUARTER) + ' | ';
+ html += '' + fmtNum(totals.EARLY_LEAVE) + ' | ';
html += '' + fmtNum(grandTotal) + ' |
';
html += '
';
@@ -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)