feat: 다수 기능 개선 - 순찰, 출근, 작업분석, 모바일 UI 등

- 순찰/점검 기능 개선 (zone-detail 페이지 추가)
- 출근/근태 시스템 개선 (연차 조회, 근무현황)
- 작업분석 대분류 그룹화 및 마이그레이션 스크립트
- 모바일 네비게이션 UI 추가
- NAS 배포 도구 및 문서 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-02-09 14:41:01 +09:00
parent 1548253f56
commit 2b1c7bfb88
633 changed files with 361224 additions and 1090 deletions

View File

@@ -6,6 +6,7 @@
<title>근무 현황 | (주)테크니컬코리아</title>
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
<link rel="stylesheet" href="/css/mobile.css?v=1">
<link rel="icon" type="image/png" href="/img/favicon.png">
<script src="/js/api-base.js"></script>
<script src="/js/app-init.js?v=2" defer></script>
@@ -93,9 +94,25 @@
.data-table tr.saved {
background: #f0fdf4;
}
.data-table tr.absent {
background: #fef2f2;
.data-table tr.leave {
background: #fefce8; /* 연한 노랑 - 연차/반차/반반차 */
}
.data-table tr.absent {
background: #fef2f2; /* 연한 빨강 - 결근 (연차정보 없음) */
}
.data-table tr.absent-no-leave {
background: #fee2e2; /* 더 진한 빨강 - 출근체크 안하고 연차정보도 없음 */
}
.leave-tag {
font-size: 0.65rem;
color: #a16207;
background: #fef3c7;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
margin-left: 0.5rem;
}
.status-leave { color: #a16207; }
.status-absent-warning { color: #dc2626; font-weight: 600; }
.worker-name {
font-weight: 500;
}
@@ -128,6 +145,23 @@
}
.status-present { color: #10b981; }
.status-absent { color: #ef4444; }
.status-not-hired { color: #9ca3af; font-style: italic; }
.data-table tr.not-hired {
background: #f3f4f6;
color: #9ca3af;
}
.data-table tr.not-hired .type-select,
.data-table tr.not-hired .overtime-input {
display: none;
}
.not-hired-tag {
font-size: 0.65rem;
color: #6b7280;
background: #e5e7eb;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
margin-left: 0.5rem;
}
/* 저장 영역 */
.save-bar {
@@ -196,6 +230,7 @@
<span><span class="dot dot-quarter"></span> 반반차 <strong id="quarterCount">0</strong></span>
<span><span class="dot dot-early"></span> 조퇴 <strong id="earlyCount">0</strong></span>
<span><span class="dot dot-overtime"></span> 연장 <strong id="overtimeCount">0</strong></span>
<span><span class="dot" style="background:#dc2626;"></span> 결근 <strong id="absentCount">0</strong></span>
</div>
<table class="data-table">
@@ -243,12 +278,12 @@
let isSaving = false;
const attendanceTypes = [
{ value: 'normal', label: '정시근무', hours: 8 },
{ value: 'annual', label: '연차', hours: 0 },
{ value: 'half', label: '반차', hours: 4 },
{ value: 'quarter', label: '반반차', hours: 6 },
{ value: 'early', label: '조퇴', hours: 2 },
{ value: 'overtime', label: '연장근로', hours: 8 }
{ 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: 0, isLeave: false },
{ value: 'overtime', label: '연장근로', hours: 8, isLeave: false }
];
document.addEventListener('DOMContentLoaded', async () => {
@@ -269,6 +304,12 @@
});
}
function formatDisplayDate(dateStr) {
if (!dateStr) return '-';
const [year, month, day] = dateStr.split('-');
return `${year}.${month}.${day}`;
}
async function loadWorkStatus() {
const selectedDate = document.getElementById('selectedDate').value;
if (!selectedDate) return alert('날짜를 선택해주세요.');
@@ -290,44 +331,88 @@
workers.forEach(w => {
const record = records.find(r => r.worker_id === w.worker_id);
// 입사일 이전인지 확인
const joinDate = w.join_date ? w.join_date.split('T')[0] : null;
const isBeforeJoin = joinDate && selectedDate < joinDate;
if (isBeforeJoin) {
// 입사 전 날짜
workStatus[w.worker_id] = {
isPresent: false,
type: 'not_hired',
hours: 0,
overtimeHours: 0,
isSaved: false,
hasLeaveInfo: false,
isNotHired: true,
joinDate: joinDate
};
return;
}
if (record) {
let type = 'normal';
let overtimeHours = 0;
if (record.is_present === 0) {
type = 'annual';
} else {
if (record.attendance_type_code) {
const codeMap = {
'REGULAR': 'normal',
'VACATION': 'annual',
'HALF_LEAVE': 'half',
'QUARTER_LEAVE': 'quarter',
'PARTIAL': 'early',
'OVERTIME': 'overtime'
};
type = codeMap[record.attendance_type_code] || 'normal';
}
if (record.total_work_hours > 8) {
type = 'overtime';
overtimeHours = record.total_work_hours - 8;
}
// 1. 휴가 유형 확인 (vacation_type_id 또는 vacation_type_code)
if (record.vacation_type_id || record.vacation_type_code) {
const vacationCodeMap = {
'ANNUAL_FULL': 'annual',
'ANNUAL_HALF': 'half',
'ANNUAL_QUARTER': 'quarter',
1: 'annual',
2: 'half',
3: 'quarter'
};
type = vacationCodeMap[record.vacation_type_code] || vacationCodeMap[record.vacation_type_id] || 'annual';
}
// 2. 근태 유형 확인
// work_attendance_types: 1=NORMAL, 2=LATE, 3=EARLY_LEAVE, 4=ABSENT, 5=VACATION
else if (record.attendance_type_code || record.attendance_type_id) {
const codeMap = {
'NORMAL': 'normal',
'REGULAR': 'normal',
'VACATION': 'annual',
'EARLY_LEAVE': 'early',
'ABSENT': 'normal', // 결근은 화면에서 따로 처리
1: 'normal', // NORMAL
2: 'normal', // LATE (지각도 출근으로 처리)
3: 'early', // EARLY_LEAVE
4: 'normal', // ABSENT (결근은 화면에서 따로 처리)
5: 'annual' // VACATION
};
type = codeMap[record.attendance_type_code] || codeMap[record.attendance_type_id] || 'normal';
}
// 3. 출근 안 한 경우 (is_present가 0이고 휴가 정보 없으면 결근 상태로 표시)
else if (record.is_present === 0) {
type = 'normal'; // 기본값, 사용자가 수정해야 함
}
// 연장근로 확인
if (record.total_work_hours > 8 && type === 'normal') {
type = 'overtime';
overtimeHours = record.total_work_hours - 8;
}
const typeInfo = attendanceTypes.find(t => t.value === type);
workStatus[w.worker_id] = {
isPresent: record.is_present === 1,
isPresent: record.is_present === 1 || typeInfo?.isLeave,
type: type,
hours: attendanceTypes.find(t => t.value === type)?.hours || 8,
hours: typeInfo !== undefined ? typeInfo.hours : 8,
overtimeHours: overtimeHours,
isSaved: record.attendance_type_id != null || record.total_work_hours > 0
isSaved: record.attendance_type_id != null || record.total_work_hours > 0 || record.vacation_type_id != null,
hasLeaveInfo: typeInfo?.isLeave || false
};
} else {
// 출근 체크 기록이 없는 경우 - 결근 상태
workStatus[w.worker_id] = {
isPresent: true,
isPresent: false,
type: 'normal',
hours: 8,
overtimeHours: 0,
isSaved: false
isSaved: false,
hasLeaveInfo: false
};
}
});
@@ -351,20 +436,71 @@
tbody.innerHTML = workers.map((w, idx) => {
const s = workStatus[w.worker_id];
// 미입사 상태 처리
if (s.isNotHired) {
return `
<tr class="not-hired">
<td>${idx + 1}</td>
<td>
<span class="worker-name">${w.worker_name}</span>
<span class="not-hired-tag">미입사</span>
</td>
<td class="status-not-hired">-</td>
<td><span style="color:#9ca3af;font-size:0.8rem;">입사일: ${formatDisplayDate(s.joinDate)}</span></td>
<td class="hours-cell">-</td>
<td class="hours-cell">-</td>
<td class="hours-cell">-</td>
</tr>
`;
}
const typeInfo = attendanceTypes.find(t => t.value === s.type);
const isLeaveType = typeInfo?.isLeave || false;
const showOvertimeInput = s.type === 'overtime';
const baseHours = s.hours;
const totalHours = s.type === 'overtime' ? baseHours + s.overtimeHours : baseHours;
const rowClass = s.isSaved ? 'saved' : (!s.isPresent ? 'absent' : '');
// 행 클래스 결정
let rowClass = '';
if (s.isSaved) {
rowClass = isLeaveType ? 'leave' : 'saved';
} else if (!s.isPresent) {
// 출근 안 했는데 연차 정보도 없으면 경고
rowClass = isLeaveType ? 'leave' : 'absent-no-leave';
}
// 출근 상태 텍스트 및 클래스 결정
let statusText = '';
let statusClass = '';
if (isLeaveType) {
statusText = typeInfo.label;
statusClass = 'status-leave';
} else if (s.isPresent) {
statusText = '출근';
statusClass = 'status-present';
} else {
statusText = '⚠️ 결근';
statusClass = 'status-absent-warning';
}
// 태그 표시
let tag = '';
if (s.isSaved) {
tag = '<span class="saved-tag">저장됨</span>';
} else if (isLeaveType) {
tag = '<span class="leave-tag">연차</span>';
}
return `
<tr class="${rowClass}">
<td>${idx + 1}</td>
<td>
<span class="worker-name">${w.worker_name}</span>
${s.isSaved ? '<span class="saved-tag">저장됨</span>' : ''}
${tag}
</td>
<td class="${s.isPresent ? 'status-present' : 'status-absent'}">
${s.isPresent ? '출근' : '결근'}
<td class="${statusClass}">
${statusText}
</td>
<td>
<select class="type-select" onchange="updateType(${w.worker_id}, this.value)">
@@ -387,9 +523,15 @@
}
function updateType(workerId, value) {
const type = attendanceTypes.find(t => t.value === value);
const typeInfo = attendanceTypes.find(t => t.value === value);
workStatus[workerId].type = value;
workStatus[workerId].hours = type ? type.hours : 8;
workStatus[workerId].hours = typeInfo !== undefined ? typeInfo.hours : 8;
workStatus[workerId].hasLeaveInfo = typeInfo?.isLeave || false;
// 연차 유형 선택 시 출근 상태로 간주 (결근 아님)
if (typeInfo?.isLeave) {
workStatus[workerId].isPresent = true;
}
if (value === 'overtime') {
workStatus[workerId].overtimeHours = workStatus[workerId].overtimeHours || 2;
@@ -418,11 +560,25 @@
}
function updateSummary() {
let normal = 0, annual = 0, half = 0, quarter = 0, early = 0, overtime = 0;
let normal = 0, annual = 0, half = 0, quarter = 0, early = 0, overtime = 0, absent = 0, notHired = 0;
Object.values(workStatus).forEach(s => {
// 미입사자 제외
if (s.isNotHired) {
notHired++;
return;
}
const typeInfo = attendanceTypes.find(t => t.value === s.type);
const isLeaveType = typeInfo?.isLeave || false;
// 출근 안 했고 연차 정보도 없으면 결근
if (!s.isPresent && !isLeaveType) {
absent++;
}
switch (s.type) {
case 'normal': normal++; break;
case 'normal': if (s.isPresent) normal++; break;
case 'annual': annual++; break;
case 'half': half++; break;
case 'quarter': quarter++; break;
@@ -437,6 +593,7 @@
document.getElementById('quarterCount').textContent = quarter;
document.getElementById('earlyCount').textContent = early;
document.getElementById('overtimeCount').textContent = overtime;
document.getElementById('absentCount').textContent = absent;
}
function updateSaveStatus() {
@@ -462,14 +619,14 @@
const saveBtn = document.getElementById('saveBtn');
// work_attendance_types: 1=REGULAR, 2=OVERTIME, 3=PARTIAL, 4=VACATION
// work_attendance_types: 1=NORMAL, 2=LATE, 3=EARLY_LEAVE, 4=ABSENT, 5=VACATION
const typeIdMap = {
'normal': 1,
'annual': 4,
'half': 4,
'quarter': 4,
'early': 3,
'overtime': 2
'normal': 1, // NORMAL
'annual': 5, // VACATION
'half': 5, // VACATION
'quarter': 5, // VACATION
'early': 3, // EARLY_LEAVE
'overtime': 1 // NORMAL (시간으로 구분)
};
// vacation_types: 1=ANNUAL_FULL, 2=ANNUAL_HALF, 3=ANNUAL_QUARTER
@@ -479,20 +636,23 @@
'quarter': 3,
};
const recordsToSave = workers.map(w => {
const s = workStatus[w.worker_id];
const totalHours = s.type === 'overtime' ? s.hours + s.overtimeHours : s.hours;
// 미입사자 제외하고 저장할 데이터 생성
const recordsToSave = workers
.filter(w => !workStatus[w.worker_id]?.isNotHired)
.map(w => {
const s = workStatus[w.worker_id];
const totalHours = s.type === 'overtime' ? s.hours + s.overtimeHours : s.hours;
return {
record_date: date,
worker_id: w.worker_id,
attendance_type_id: typeIdMap[s.type] || 1,
vacation_type_id: vacationTypeIdMap[s.type] || null,
total_work_hours: totalHours,
overtime_approved: s.type === 'overtime',
notes: s.type === 'overtime' ? `연장근로 ${s.overtimeHours}시간` : null
};
});
return {
record_date: date,
worker_id: w.worker_id,
attendance_type_id: typeIdMap[s.type] || 1,
vacation_type_id: vacationTypeIdMap[s.type] || null,
total_work_hours: totalHours,
overtime_approved: s.type === 'overtime',
notes: s.type === 'overtime' ? `연장근로 ${s.overtimeHours}시간` : null
};
});
isSaving = true;
saveBtn.disabled = true;
@@ -535,5 +695,19 @@
}
}
</script>
<!-- 모바일 하단 네비게이션 -->
<div id="mobile-nav-container"></div>
<script>
if (window.innerWidth <= 768) {
fetch('/components/mobile-nav.html')
.then(r => r.text())
.then(html => {
document.getElementById('mobile-nav-container').innerHTML = html;
const scripts = document.getElementById('mobile-nav-container').querySelectorAll('script');
scripts.forEach(s => eval(s.textContent));
});
}
</script>
</body>
</html>