feat(vacation): 조퇴 연차 차감 처리 (0.75일 = 반차+반반차)
- EARLY_LEAVE vacation type 추가 (deduct_days=0.75) - work-status: isLeave=true + 동적 vacation_type_id 조회 + 실패 보호 - annual-overview: 월별 요약 테이블에 조퇴 컬럼 추가 + 편집 드롭다운 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -7,3 +7,6 @@ DELETE svb FROM sp_vacation_balances svb
|
|||||||
JOIN vacation_types vt ON svb.vacation_type_id = vt.id
|
JOIN vacation_types vt ON svb.vacation_type_id = vt.id
|
||||||
WHERE svb.balance_type = 'COMPANY_GRANT'
|
WHERE svb.balance_type = 'COMPANY_GRANT'
|
||||||
AND vt.type_code IN ('CARRYOVER', 'LONG_SERVICE', 'ANNUAL_FULL', 'ANNUAL_HALF', 'ANNUAL_QUARTER');
|
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);
|
||||||
|
|||||||
@@ -430,6 +430,7 @@
|
|||||||
let currentModalUserId = null;
|
let currentModalUserId = null;
|
||||||
let currentModalName = '';
|
let currentModalName = '';
|
||||||
let editBackup = {};
|
let editBackup = {};
|
||||||
|
let earlyLeaveVtId = null; // EARLY_LEAVE vacation_type_id (동적 조회)
|
||||||
const DAYS_KR = ['일', '월', '화', '수', '목', '금', '토'];
|
const DAYS_KR = ['일', '월', '화', '수', '목', '금', '토'];
|
||||||
|
|
||||||
// 경조사 유형
|
// 경조사 유형
|
||||||
@@ -477,6 +478,15 @@
|
|||||||
document.getElementById('tableBody').innerHTML = '<tr><td colspan="9" class="loading">로딩 중...</td></tr>';
|
document.getElementById('tableBody').innerHTML = '<tr><td colspan="9" class="loading">로딩 중...</td></tr>';
|
||||||
|
|
||||||
try {
|
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');
|
const workersRes = await axios.get('/workers?limit=100');
|
||||||
workers = (workersRes.data.data || [])
|
workers = (workersRes.data.data || [])
|
||||||
@@ -761,15 +771,15 @@
|
|||||||
|
|
||||||
// 월별 그룹핑
|
// 월별 그룹핑
|
||||||
const monthly = {};
|
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 => {
|
records.forEach(r => {
|
||||||
const d = new Date(r.record_date);
|
const d = new Date(r.record_date);
|
||||||
const m = d.getMonth() + 1;
|
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 days = parseFloat(r.vacation_days) || 1;
|
||||||
const code = r.vacation_type_code || 'other';
|
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;
|
monthly[m][code] += days;
|
||||||
totals[code] += days;
|
totals[code] += days;
|
||||||
} else {
|
} else {
|
||||||
@@ -779,12 +789,12 @@
|
|||||||
monthly[m].total += days;
|
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 = '<h4 style="font-size:0.85rem;font-weight:600;margin-bottom:0.5rem;">월별 요약</h4>';
|
var html = '<h4 style="font-size:0.85rem;font-weight:600;margin-bottom:0.5rem;">월별 요약</h4>';
|
||||||
html += '<table class="data-table" style="margin-bottom:1rem;"><thead><tr>';
|
html += '<table class="data-table" style="margin-bottom:1rem;"><thead><tr>';
|
||||||
html += '<th style="position:static !important;">월</th><th style="position:static !important;">연차</th><th style="position:static !important;">반차</th><th style="position:static !important;">반반차</th><th style="position:static !important;">합계</th>';
|
html += '<th style="position:static !important;">월</th><th style="position:static !important;">연차</th><th style="position:static !important;">반차</th><th style="position:static !important;">반반차</th><th style="position:static !important;">조퇴</th><th style="position:static !important;">합계</th>';
|
||||||
html += '</tr></thead><tbody>';
|
html += '</tr></thead><tbody>';
|
||||||
for (var m = 1; m <= 12; m++) {
|
for (var m = 1; m <= 12; m++) {
|
||||||
if (!monthly[m]) continue;
|
if (!monthly[m]) continue;
|
||||||
@@ -793,6 +803,7 @@
|
|||||||
html += '<td>' + (md.ANNUAL_FULL > 0 ? fmtNum(md.ANNUAL_FULL) : '-') + '</td>';
|
html += '<td>' + (md.ANNUAL_FULL > 0 ? fmtNum(md.ANNUAL_FULL) : '-') + '</td>';
|
||||||
html += '<td>' + (md.ANNUAL_HALF > 0 ? fmtNum(md.ANNUAL_HALF) : '-') + '</td>';
|
html += '<td>' + (md.ANNUAL_HALF > 0 ? fmtNum(md.ANNUAL_HALF) : '-') + '</td>';
|
||||||
html += '<td>' + (md.ANNUAL_QUARTER > 0 ? fmtNum(md.ANNUAL_QUARTER) : '-') + '</td>';
|
html += '<td>' + (md.ANNUAL_QUARTER > 0 ? fmtNum(md.ANNUAL_QUARTER) : '-') + '</td>';
|
||||||
|
html += '<td>' + (md.EARLY_LEAVE > 0 ? fmtNum(md.EARLY_LEAVE) : '-') + '</td>';
|
||||||
html += '<td style="font-weight:600">' + fmtNum(md.total) + '</td></tr>';
|
html += '<td style="font-weight:600">' + fmtNum(md.total) + '</td></tr>';
|
||||||
}
|
}
|
||||||
html += '<tr style="font-weight:700;border-top:2px solid #e5e7eb;">';
|
html += '<tr style="font-weight:700;border-top:2px solid #e5e7eb;">';
|
||||||
@@ -800,6 +811,7 @@
|
|||||||
html += '<td>' + fmtNum(totals.ANNUAL_FULL) + '</td>';
|
html += '<td>' + fmtNum(totals.ANNUAL_FULL) + '</td>';
|
||||||
html += '<td>' + fmtNum(totals.ANNUAL_HALF) + '</td>';
|
html += '<td>' + fmtNum(totals.ANNUAL_HALF) + '</td>';
|
||||||
html += '<td>' + fmtNum(totals.ANNUAL_QUARTER) + '</td>';
|
html += '<td>' + fmtNum(totals.ANNUAL_QUARTER) + '</td>';
|
||||||
|
html += '<td>' + fmtNum(totals.EARLY_LEAVE) + '</td>';
|
||||||
html += '<td style="color:#059669">' + fmtNum(grandTotal) + '</td></tr>';
|
html += '<td style="color:#059669">' + fmtNum(grandTotal) + '</td></tr>';
|
||||||
html += '</tbody></table>';
|
html += '</tbody></table>';
|
||||||
|
|
||||||
@@ -866,6 +878,7 @@
|
|||||||
+ '<option value="1"' + (currentTypeId==1?' selected':'') + '>연차</option>'
|
+ '<option value="1"' + (currentTypeId==1?' selected':'') + '>연차</option>'
|
||||||
+ '<option value="2"' + (currentTypeId==2?' selected':'') + '>반차</option>'
|
+ '<option value="2"' + (currentTypeId==2?' selected':'') + '>반차</option>'
|
||||||
+ '<option value="3"' + (currentTypeId==3?' selected':'') + '>반반차</option>'
|
+ '<option value="3"' + (currentTypeId==3?' selected':'') + '>반반차</option>'
|
||||||
|
+ (earlyLeaveVtId ? '<option value="' + earlyLeaveVtId + '"' + (currentTypeId==earlyLeaveVtId?' selected':'') + '>조퇴</option>' : '')
|
||||||
+ '</select>'
|
+ '</select>'
|
||||||
+ '<button class="btn-save" onclick="saveEdit(' + recordId + ',\'' + recordDate + '\',' + currentTypeId + ')">저장</button>'
|
+ '<button class="btn-save" onclick="saveEdit(' + recordId + ',\'' + recordDate + '\',' + currentTypeId + ')">저장</button>'
|
||||||
+ '<button class="btn-cancel" onclick="cancelEdit(' + recordId + ')">취소</button>';
|
+ '<button class="btn-cancel" onclick="cancelEdit(' + recordId + ')">취소</button>';
|
||||||
|
|||||||
@@ -308,13 +308,14 @@
|
|||||||
let hasCheckinData = false;
|
let hasCheckinData = false;
|
||||||
let isAlreadySaved = false;
|
let isAlreadySaved = false;
|
||||||
let isSaving = false;
|
let isSaving = false;
|
||||||
|
let earlyLeaveTypeId = null;
|
||||||
|
|
||||||
const attendanceTypes = [
|
const attendanceTypes = [
|
||||||
{ value: 'normal', label: '정시근무', hours: 8, isLeave: false },
|
{ value: 'normal', label: '정시근무', hours: 8, isLeave: false },
|
||||||
{ value: 'annual', label: '연차', hours: 0, isLeave: true },
|
{ value: 'annual', label: '연차', hours: 0, isLeave: true },
|
||||||
{ value: 'half', label: '반차', hours: 4, isLeave: true },
|
{ value: 'half', label: '반차', hours: 4, isLeave: true },
|
||||||
{ value: 'quarter', label: '반반차', hours: 6, 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 }
|
{ value: 'overtime', label: '연장근로', hours: 8, isLeave: false }
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -347,6 +348,15 @@
|
|||||||
if (!selectedDate) return alert('날짜를 선택해주세요.');
|
if (!selectedDate) return alert('날짜를 선택해주세요.');
|
||||||
|
|
||||||
try {
|
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([
|
const [workersRes, recordsRes] = await Promise.all([
|
||||||
axios.get('/workers?limit=100'),
|
axios.get('/workers?limit=100'),
|
||||||
axios.get(`/attendance/daily-records?date=${selectedDate}`).catch(() => ({ data: { data: [] } }))
|
axios.get(`/attendance/daily-records?date=${selectedDate}`).catch(() => ({ data: { data: [] } }))
|
||||||
@@ -743,13 +753,27 @@
|
|||||||
'overtime': 1 // NORMAL (시간으로 구분)
|
'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 = {
|
const vacationTypeIdMap = {
|
||||||
'annual': 1,
|
'annual': 1,
|
||||||
'half': 2,
|
'half': 2,
|
||||||
'quarter': 3,
|
'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
|
const recordsToSave = workers
|
||||||
.filter(w => !workStatus[w.user_id]?.isNotHired)
|
.filter(w => !workStatus[w.user_id]?.isNotHired)
|
||||||
|
|||||||
Reference in New Issue
Block a user