feat(attendance): 주말+회사 휴무일 통합 처리
- monthlyComparisonModel: getCompanyHolidays 추가 - monthlyComparisonController: isHoliday에 company_holidays 포함 + holiday_name - proxyInputModel: getDailyStatus에 is_holiday/holiday_name 추가 - proxy-input.js: 휴무일 배너 + both_missing 작업자 비활성화 (특근자는 활성 유지) - 마이그레이션: 2026년 공휴일 16건 일괄 INSERT Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -22,10 +22,11 @@ function determineStatus(report, attendance, isHoliday) {
|
|||||||
|
|
||||||
// 날짜별 비교 데이터 생성
|
// 날짜별 비교 데이터 생성
|
||||||
async function buildComparisonData(userId, year, month) {
|
async function buildComparisonData(userId, year, month) {
|
||||||
const [reports, attendances, confirmation] = await Promise.all([
|
const [reports, attendances, confirmation, holidays] = await Promise.all([
|
||||||
Model.getWorkReports(userId, year, month),
|
Model.getWorkReports(userId, year, month),
|
||||||
Model.getAttendanceRecords(userId, year, month),
|
Model.getAttendanceRecords(userId, year, month),
|
||||||
Model.getConfirmation(userId, year, month)
|
Model.getConfirmation(userId, year, month),
|
||||||
|
Model.getCompanyHolidays(year, month)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 날짜 맵 생성
|
// 날짜 맵 생성
|
||||||
@@ -57,7 +58,7 @@ async function buildComparisonData(userId, year, month) {
|
|||||||
const date = new Date(year, month - 1, day);
|
const date = new Date(year, month - 1, day);
|
||||||
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
const dateStr = `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||||
const dayOfWeek = date.getDay();
|
const dayOfWeek = date.getDay();
|
||||||
const isHoliday = dayOfWeek === 0 || dayOfWeek === 6;
|
const isHoliday = dayOfWeek === 0 || dayOfWeek === 6 || holidays.dateSet.has(dateStr);
|
||||||
|
|
||||||
const report = reportMap[dateStr] || null;
|
const report = reportMap[dateStr] || null;
|
||||||
const attend = attendMap[dateStr] || null;
|
const attend = attendMap[dateStr] || null;
|
||||||
@@ -89,6 +90,7 @@ async function buildComparisonData(userId, year, month) {
|
|||||||
date: dateStr,
|
date: dateStr,
|
||||||
day_of_week: DAYS_KR[dayOfWeek],
|
day_of_week: DAYS_KR[dayOfWeek],
|
||||||
is_holiday: isHoliday,
|
is_holiday: isHoliday,
|
||||||
|
holiday_name: holidays.nameMap[dateStr] || (dayOfWeek === 0 || dayOfWeek === 6 ? '주말' : null),
|
||||||
work_report: report ? {
|
work_report: report ? {
|
||||||
total_hours: parseFloat(report.total_hours),
|
total_hours: parseFloat(report.total_hours),
|
||||||
entries: [{ project_name: report.project_names || '', work_type: report.work_type_names || '', hours: parseFloat(report.total_hours) }]
|
entries: [{ project_name: report.project_names || '', work_type: report.work_type_names || '', hours: parseFloat(report.total_hours) }]
|
||||||
|
|||||||
@@ -13,3 +13,20 @@ VALUES ('EARLY_LEAVE', '조퇴', 0.75, 1, 10);
|
|||||||
-- 작업자 월간 확인 페이지 등록
|
-- 작업자 월간 확인 페이지 등록
|
||||||
INSERT IGNORE INTO pages (page_key, page_name, page_path, category, display_order)
|
INSERT IGNORE INTO pages (page_key, page_name, page_path, category, display_order)
|
||||||
VALUES ('attendance.my_monthly_confirm', '월간 근무 확인', '/pages/attendance/my-monthly-confirm.html', '근태 관리', 25);
|
VALUES ('attendance.my_monthly_confirm', '월간 근무 확인', '/pages/attendance/my-monthly-confirm.html', '근태 관리', 25);
|
||||||
|
-- 2026년 법정 공휴일 + 대체공휴일 일괄 등록
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-01-01', '신정', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-02-16', '설날 연휴', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-02-17', '설날', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-02-18', '설날 연휴', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-03-01', '삼일절', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-03-02', '대체공휴일(삼일절)', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-05-05', '어린이날', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-05-24', '석가탄신일', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-06-06', '현충일', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-08-15', '광복절', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-09-24', '추석 연휴', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-09-25', '추석', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-09-26', '추석 연휴', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-10-03', '개천절', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-10-09', '한글날', 'PAID', 1);
|
||||||
|
INSERT IGNORE INTO company_holidays (holiday_date, holiday_name, holiday_type, created_by) VALUES ('2026-12-25', '크리스마스', 'PAID', 1);
|
||||||
|
|||||||
@@ -2,6 +2,26 @@
|
|||||||
const { getDb } = require('../dbPool');
|
const { getDb } = require('../dbPool');
|
||||||
|
|
||||||
const MonthlyComparisonModel = {
|
const MonthlyComparisonModel = {
|
||||||
|
// 0. 해당 월의 회사 휴무일 조회
|
||||||
|
async getCompanyHolidays(year, month) {
|
||||||
|
const db = await getDb();
|
||||||
|
const [rows] = await db.query(
|
||||||
|
`SELECT holiday_date, holiday_name FROM company_holidays
|
||||||
|
WHERE YEAR(holiday_date) = ? AND MONTH(holiday_date) = ?`,
|
||||||
|
[year, month]
|
||||||
|
);
|
||||||
|
const dateSet = new Set();
|
||||||
|
const nameMap = {};
|
||||||
|
rows.forEach(r => {
|
||||||
|
const d = r.holiday_date instanceof Date
|
||||||
|
? r.holiday_date.toISOString().split('T')[0]
|
||||||
|
: String(r.holiday_date).split('T')[0];
|
||||||
|
dateSet.add(d);
|
||||||
|
nameMap[d] = r.holiday_name;
|
||||||
|
});
|
||||||
|
return { dateSet, nameMap };
|
||||||
|
},
|
||||||
|
|
||||||
// 1. 작업보고서 일별 합산
|
// 1. 작업보고서 일별 합산
|
||||||
async getWorkReports(userId, year, month) {
|
async getWorkReports(userId, year, month) {
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
|
|||||||
@@ -110,6 +110,16 @@ const ProxyInputModel = {
|
|||||||
WHERE dar.record_date = ? AND dar.vacation_type_id IS NOT NULL
|
WHERE dar.record_date = ? AND dar.vacation_type_id IS NOT NULL
|
||||||
`, [date]);
|
`, [date]);
|
||||||
|
|
||||||
|
// 5. 해당 날짜가 회사 휴무일인지 확인
|
||||||
|
const [holidayRows] = await db.query(
|
||||||
|
`SELECT holiday_date, holiday_name FROM company_holidays WHERE holiday_date = ?`,
|
||||||
|
[date]
|
||||||
|
);
|
||||||
|
const isCompanyHoliday = holidayRows.length > 0;
|
||||||
|
const holidayName = isCompanyHoliday ? holidayRows[0].holiday_name : null;
|
||||||
|
const dateObj = new Date(date);
|
||||||
|
const isWeekend = dateObj.getDay() === 0 || dateObj.getDay() === 6;
|
||||||
|
|
||||||
// 메모리에서 조합
|
// 메모리에서 조합
|
||||||
const tbmMap = {};
|
const tbmMap = {};
|
||||||
tbmAssignments.forEach(ta => {
|
tbmAssignments.forEach(ta => {
|
||||||
@@ -158,6 +168,8 @@ const ProxyInputModel = {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
date,
|
date,
|
||||||
|
is_holiday: isWeekend || isCompanyHoliday,
|
||||||
|
holiday_name: isCompanyHoliday ? holidayName : (isWeekend ? '주말' : null),
|
||||||
summary: {
|
summary: {
|
||||||
total_active_workers: workers.length,
|
total_active_workers: workers.length,
|
||||||
tbm_completed: tbmCompleted,
|
tbm_completed: tbmCompleted,
|
||||||
|
|||||||
@@ -227,6 +227,19 @@
|
|||||||
}
|
}
|
||||||
@keyframes ds-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
@keyframes ds-shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }
|
||||||
|
|
||||||
|
/* 휴무일 배너 */
|
||||||
|
.pi-holiday-banner {
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: #f0fdf4;
|
||||||
|
border: 1px solid #bbf7d0;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #166534;
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
/* 연차 비활성화 */
|
/* 연차 비활성화 */
|
||||||
.pi-card.vacation-disabled { opacity: 0.5; }
|
.pi-card.vacation-disabled { opacity: 0.5; }
|
||||||
.pi-card.vacation-disabled .pi-card-form { pointer-events: none; }
|
.pi-card.vacation-disabled .pi-card-form { pointer-events: none; }
|
||||||
|
|||||||
@@ -128,7 +128,32 @@ async function loadWorkers() {
|
|||||||
}
|
}
|
||||||
if (!res || !res.success) { cardsEl.innerHTML = '<div class="pi-empty"><p>데이터를 불러올 수 없습니다</p></div>'; return; }
|
if (!res || !res.success) { cardsEl.innerHTML = '<div class="pi-empty"><p>데이터를 불러올 수 없습니다</p></div>'; return; }
|
||||||
|
|
||||||
|
// 휴무일 정보 저장
|
||||||
|
window._isHoliday = res.data.is_holiday || false;
|
||||||
|
window._holidayName = res.data.holiday_name || null;
|
||||||
|
|
||||||
|
// 휴무일 뱃지 표시
|
||||||
|
var holidayBanner = document.getElementById('holidayBanner');
|
||||||
|
if (holidayBanner) {
|
||||||
|
if (window._isHoliday) {
|
||||||
|
holidayBanner.classList.remove('hidden');
|
||||||
|
holidayBanner.textContent = '휴무일' + (window._holidayName ? ' (' + window._holidayName + ')' : '');
|
||||||
|
} else {
|
||||||
|
holidayBanner.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
missingWorkers = (res.data.workers || []).filter(w => w.status !== 'complete');
|
missingWorkers = (res.data.workers || []).filter(w => w.status !== 'complete');
|
||||||
|
|
||||||
|
// 휴무일에 both_missing인 작업자에게 휴무 플래그 추가
|
||||||
|
if (window._isHoliday) {
|
||||||
|
missingWorkers.forEach(function(w) {
|
||||||
|
if (w.status === 'both_missing' && !w.vacation_type_code) {
|
||||||
|
w._isHolidayOff = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
document.getElementById('missingNum').textContent = missingWorkers.length;
|
document.getElementById('missingNum').textContent = missingWorkers.length;
|
||||||
|
|
||||||
if (missingWorkers.length === 0) {
|
if (missingWorkers.length === 0) {
|
||||||
@@ -147,8 +172,10 @@ function renderCards() {
|
|||||||
const cardsEl = document.getElementById('workerCards');
|
const cardsEl = document.getElementById('workerCards');
|
||||||
cardsEl.innerHTML = missingWorkers.map(w => {
|
cardsEl.innerHTML = missingWorkers.map(w => {
|
||||||
const isFullVacation = w.vacation_type_code === 'ANNUAL_FULL';
|
const isFullVacation = w.vacation_type_code === 'ANNUAL_FULL';
|
||||||
|
const isHolidayOff = !!w._isHolidayOff;
|
||||||
|
const isDisabled = isFullVacation || isHolidayOff;
|
||||||
const hasVacation = !!w.vacation_type_code;
|
const hasVacation = !!w.vacation_type_code;
|
||||||
const statusLabel = isFullVacation ? ''
|
const statusLabel = isDisabled ? ''
|
||||||
: ({ both_missing: 'TBM+보고서 미입력', tbm_only: '보고서만 미입력', report_only: 'TBM만 미입력' }[w.status] || '');
|
: ({ both_missing: 'TBM+보고서 미입력', tbm_only: '보고서만 미입력', report_only: 'TBM만 미입력' }[w.status] || '');
|
||||||
const fd = workerFormData[w.user_id] || getDefaultFormData(w);
|
const fd = workerFormData[w.user_id] || getDefaultFormData(w);
|
||||||
if (hasVacation && !isFullVacation && w.vacation_hours != null) {
|
if (hasVacation && !isFullVacation && w.vacation_hours != null) {
|
||||||
@@ -156,13 +183,14 @@ function renderCards() {
|
|||||||
}
|
}
|
||||||
workerFormData[w.user_id] = fd;
|
workerFormData[w.user_id] = fd;
|
||||||
const sel = selectedIds.has(w.user_id);
|
const sel = selectedIds.has(w.user_id);
|
||||||
const vacBadge = hasVacation ? '<span class="pi-vac-badge">' + escHtml(w.vacation_type_name) + '</span>' : '';
|
var badgeText = isHolidayOff ? '휴무' : (hasVacation ? w.vacation_type_name : '');
|
||||||
const disabledClass = isFullVacation ? ' vacation-disabled' : '';
|
const vacBadge = badgeText ? '<span class="pi-vac-badge">' + escHtml(badgeText) + '</span>' : '';
|
||||||
|
const disabledClass = isDisabled ? ' vacation-disabled' : '';
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="pi-card ${sel ? 'selected' : ''}${disabledClass}" id="card-${w.user_id}">
|
<div class="pi-card ${sel ? 'selected' : ''}${disabledClass}" id="card-${w.user_id}">
|
||||||
<div class="pi-card-header" onclick="toggleWorker(${w.user_id})">
|
<div class="pi-card-header" onclick="toggleWorker(${w.user_id})">
|
||||||
<div class="pi-card-check">${isFullVacation ? '' : (sel ? '<i class="fas fa-check text-xs"></i>' : '')}</div>
|
<div class="pi-card-check">${isDisabled ? '' : (sel ? '<i class="fas fa-check text-xs"></i>' : '')}</div>
|
||||||
<div>
|
<div>
|
||||||
<div class="pi-card-name">${escHtml(w.worker_name)} ${vacBadge}</div>
|
<div class="pi-card-name">${escHtml(w.worker_name)} ${vacBadge}</div>
|
||||||
<div class="pi-card-meta">${escHtml(w.job_type)} · ${escHtml(w.department_name)}</div>
|
<div class="pi-card-meta">${escHtml(w.job_type)} · ${escHtml(w.department_name)}</div>
|
||||||
@@ -237,7 +265,7 @@ function escHtml(s) { return (s || '').replace(/&/g, '&').replace(/</g, '<
|
|||||||
// ===== Worker Toggle =====
|
// ===== Worker Toggle =====
|
||||||
function toggleWorker(userId) {
|
function toggleWorker(userId) {
|
||||||
var worker = missingWorkers.find(function(w) { return w.user_id === userId; });
|
var worker = missingWorkers.find(function(w) { return w.user_id === userId; });
|
||||||
if (worker && worker.vacation_type_code === 'ANNUAL_FULL') return;
|
if (worker && (worker.vacation_type_code === 'ANNUAL_FULL' || worker._isHolidayOff)) return;
|
||||||
if (selectedIds.has(userId)) {
|
if (selectedIds.has(userId)) {
|
||||||
selectedIds.delete(userId);
|
selectedIds.delete(userId);
|
||||||
} else {
|
} else {
|
||||||
@@ -307,10 +335,10 @@ function applyBulk(field, value) {
|
|||||||
|
|
||||||
if (hasExisting) {
|
if (hasExisting) {
|
||||||
if (!confirm('이미 입력된 값이 있습니다. 덮어쓰시겠습니까?')) {
|
if (!confirm('이미 입력된 값이 있습니다. 덮어쓰시겠습니까?')) {
|
||||||
// 빈 필드만 채움 (연차 작업자 skip)
|
// 빈 필드만 채움 (연차/휴무 작업자 skip)
|
||||||
for (const uid of selectedIds) {
|
for (const uid of selectedIds) {
|
||||||
var bw = missingWorkers.find(function(w) { return w.user_id === uid; });
|
var bw = missingWorkers.find(function(w) { return w.user_id === uid; });
|
||||||
if (bw && bw.vacation_type_code === 'ANNUAL_FULL') continue;
|
if (bw && (bw.vacation_type_code === 'ANNUAL_FULL' || bw._isHolidayOff)) continue;
|
||||||
if (!workerFormData[uid][field] || workerFormData[uid][field] === '') {
|
if (!workerFormData[uid][field] || workerFormData[uid][field] === '') {
|
||||||
workerFormData[uid][field] = value;
|
workerFormData[uid][field] = value;
|
||||||
}
|
}
|
||||||
@@ -323,7 +351,7 @@ function applyBulk(field, value) {
|
|||||||
|
|
||||||
for (const uid of selectedIds) {
|
for (const uid of selectedIds) {
|
||||||
var bw2 = missingWorkers.find(function(w) { return w.user_id === uid; });
|
var bw2 = missingWorkers.find(function(w) { return w.user_id === uid; });
|
||||||
if (bw2 && bw2.vacation_type_code === 'ANNUAL_FULL') continue;
|
if (bw2 && (bw2.vacation_type_code === 'ANNUAL_FULL' || bw2._isHolidayOff)) continue;
|
||||||
workerFormData[uid][field] = value;
|
workerFormData[uid][field] = value;
|
||||||
if (field === 'work_type_id') workerFormData[uid].task_id = '';
|
if (field === 'work_type_id') workerFormData[uid].task_id = '';
|
||||||
}
|
}
|
||||||
@@ -354,7 +382,7 @@ async function saveProxyInput() {
|
|||||||
// 연차 작업자 선택 해제 (안전장치)
|
// 연차 작업자 선택 해제 (안전장치)
|
||||||
for (const uid of selectedIds) {
|
for (const uid of selectedIds) {
|
||||||
const ww = missingWorkers.find(x => x.user_id === uid);
|
const ww = missingWorkers.find(x => x.user_id === uid);
|
||||||
if (ww && ww.vacation_type_code === 'ANNUAL_FULL') {
|
if (ww && (ww.vacation_type_code === 'ANNUAL_FULL' || ww._isHolidayOff)) {
|
||||||
selectedIds.delete(uid);
|
selectedIds.delete(uid);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Holiday Banner -->
|
||||||
|
<div class="pi-holiday-banner hidden" id="holidayBanner"></div>
|
||||||
|
|
||||||
<!-- Bulk Actions -->
|
<!-- Bulk Actions -->
|
||||||
<div class="pi-bulk hidden" id="bulkBar">
|
<div class="pi-bulk hidden" id="bulkBar">
|
||||||
<span class="pi-bulk-label"><i class="fas fa-layer-group mr-1"></i>일괄 설정 (<span id="selectedCount">0</span>명)</span>
|
<span class="pi-bulk-label"><i class="fas fa-layer-group mr-1"></i>일괄 설정 (<span id="selectedCount">0</span>명)</span>
|
||||||
|
|||||||
Reference in New Issue
Block a user