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

@@ -43,6 +43,10 @@
display: flex; justify-content: space-between; align-items: center;
}
.panel-header .btn { padding: 0.25rem 0.5rem; font-size: 0.7rem; }
.panel-header .count {
background: #e5e7eb; padding: 0.1rem 0.5rem; border-radius: 0.25rem;
font-size: 0.75rem; font-weight: 500;
}
.work-type-list { padding: 0; margin: 0; list-style: none; }
.work-type-item {
padding: 0.6rem 0.75rem; border-bottom: 1px solid #f3f4f6;
@@ -156,12 +160,9 @@
<div class="work-type-panel">
<div class="panel-header">
<span>공정 목록</span>
<span class="count" id="totalTaskCount">0</span>
</div>
<ul class="work-type-list" id="workTypeList">
<li class="work-type-item active" data-id="" onclick="filterByWorkType('')">
<span>전체</span>
<span class="count" id="totalCount">0</span>
</li>
</ul>
</div>
@@ -322,7 +323,8 @@
`;
});
list.innerHTML = html;
document.getElementById('totalCount').textContent = tasks.length;
const totalEl = document.getElementById('totalTaskCount');
if (totalEl) totalEl.textContent = tasks.length;
}
function filterByWorkType(id) {

View File

@@ -1,143 +1,762 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>연간 연차 현황 | 테크니컬코리아</title>
<!-- 모던 디자인 시스템 적용 -->
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/annual-vacation-overview.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
<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>
<script src="https://instant.page/5.2.0" type="module"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0"></script>
<script type="module" src="/js/annual-vacation-overview.js" defer></script>
<script src="/js/app-init.js?v=2" defer></script>
<style>
.page-wrapper {
padding: 1.5rem;
max-width: 1200px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.page-title { font-size: 1.5rem; font-weight: 700; margin: 0; }
.page-desc { color: #6b7280; font-size: 0.875rem; margin-top: 0.25rem; }
.controls { display: flex; gap: 0.5rem; align-items: center; }
.controls select {
padding: 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.875rem;
font-weight: 500;
}
.btn-primary { background: #3b82f6; color: white; }
.btn-success { background: #10b981; color: white; }
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.75rem; }
/* 범례 */
.legend-box {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
padding: 0.5rem 1rem;
background: #f9fafb;
border-radius: 0.5rem;
font-size: 0.8rem;
color: #6b7280;
flex-wrap: wrap;
align-items: center;
}
.legend-item { display: flex; align-items: center; gap: 0.25rem; }
.legend-dot {
width: 12px; height: 12px; border-radius: 2px;
}
.dot-carryover { background: #fef3c7; border: 1px solid #f59e0b; }
.dot-annual { background: #dbeafe; border: 1px solid #3b82f6; }
.dot-longservice { background: #f3e8ff; border: 1px solid #a855f7; }
.dot-special { background: #fce7f3; border: 1px solid #ec4899; }
/* 테이블 */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
background: white;
}
.data-table th, .data-table td {
padding: 0.6rem 0.5rem;
text-align: center;
border: 1px solid #e5e7eb;
}
.data-table th {
background: #f9fafb;
font-weight: 600;
font-size: 0.8rem;
}
.data-table th.col-carryover { background: #fef3c7; color: #92400e; }
.data-table th.col-annual { background: #dbeafe; color: #1e40af; }
.data-table th.col-longservice { background: #f3e8ff; color: #7c3aed; }
.data-table th.col-special { background: #fce7f3; color: #be185d; }
.data-table th.col-total { background: #dcfce7; color: #166534; }
.data-table td.worker-name { text-align: left; font-weight: 500; }
.data-table tr:hover { background: #f9fafb; }
/* 입력 필드 */
.num-input {
width: 55px;
padding: 0.25rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
text-align: center;
font-size: 0.8rem;
}
.num-input:focus {
outline: none;
border-color: #3b82f6;
}
.num-input.negative { color: #dc2626; }
/* 경조사 버튼 */
.special-btn {
padding: 0.2rem 0.5rem;
font-size: 0.75rem;
background: #fce7f3;
color: #be185d;
border: 1px solid #f9a8d4;
border-radius: 0.25rem;
cursor: pointer;
}
.special-btn:hover { background: #fbcfe8; }
.special-count {
display: inline-block;
min-width: 20px;
padding: 0.125rem 0.375rem;
background: #be185d;
color: white;
border-radius: 10px;
font-size: 0.7rem;
margin-left: 0.25rem;
}
/* 잔여 */
.remaining { font-weight: 700; }
.remaining.positive { color: #059669; }
.remaining.zero { color: #6b7280; }
.remaining.negative { color: #dc2626; }
/* 저장 바 */
.save-bar {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 1rem;
padding: 0.75rem 1rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
}
.save-status { font-size: 0.875rem; color: #6b7280; }
/* 경조사 모달 */
.modal-overlay {
display: none;
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal-overlay.active { display: flex; }
.modal-content {
background: white;
border-radius: 0.75rem;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
padding: 1.5rem;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-title { font-size: 1rem; font-weight: 600; }
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: #6b7280;
}
.special-list { margin-bottom: 1rem; }
.special-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem;
border-bottom: 1px solid #f3f4f6;
}
.special-item select {
flex: 1;
padding: 0.375rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
font-size: 0.8rem;
}
.special-item input {
width: 60px;
padding: 0.375rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
text-align: center;
font-size: 0.8rem;
}
.special-item .delete-btn {
padding: 0.25rem 0.5rem;
background: #fee2e2;
color: #dc2626;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.75rem;
}
.add-special-btn {
width: 100%;
padding: 0.5rem;
background: #f3f4f6;
border: 1px dashed #9ca3af;
border-radius: 0.375rem;
cursor: pointer;
font-size: 0.8rem;
color: #6b7280;
}
.add-special-btn:hover { background: #e5e7eb; }
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
padding-top: 0.75rem;
border-top: 1px solid #e5e7eb;
}
.loading { text-align: center; padding: 2rem; color: #6b7280; }
</style>
</head>
<body class="has-sidebar">
<div id="navbar-container"></div>
<div id="sidebar-container"></div>
<body>
<!-- 메인 컨테이너 -->
<div class="page-container">
<!-- 네비게이션 헤더 -->
<div id="navbar-container"></div>
<!-- 메인 콘텐츠 -->
<main class="main-content">
<div class="content-wrapper">
<!-- 페이지 헤더 -->
<div class="page-header">
<main class="main-content">
<div class="page-wrapper">
<div class="page-header">
<div>
<h1 class="page-title">연간 연차 현황</h1>
<p class="page-description">모든 작업자간 휴가 현황을 차트로 확인합니다</p>
<p class="page-desc">작업자차 발생 및 사용 현황을 관리합니다</p>
</div>
<div class="controls">
<select id="yearSelect"></select>
<button class="btn btn-primary" onclick="loadData()">조회</button>
</div>
<!-- 필터 섹션 -->
<section class="filter-section">
<div class="card">
<div class="card-body">
<div class="filter-controls">
<div class="form-group">
<label for="yearSelect">조회 연도</label>
<select id="yearSelect" class="form-select">
<!-- JavaScript로 동적 생성 -->
</select>
</div>
<button id="refreshBtn" class="btn btn-primary">
조회
</button>
</div>
</div>
</div>
</section>
<!-- 탭 네비게이션 -->
<section class="tabs-section">
<div class="tabs-nav">
<button class="tab-btn active" data-tab="annualUsage">연간 사용 기록</button>
<button class="tab-btn" data-tab="monthlyDetails">월별 상세 기록</button>
</div>
</section>
<!-- 탭 1: 연간 사용 기록 -->
<section id="annualUsageTab" class="tab-content active">
<div class="card">
<div class="card-header">
<h2 class="card-title">월별 휴가 사용 현황</h2>
</div>
<div class="card-body">
<div class="chart-container">
<canvas id="annualUsageChart"></canvas>
</div>
</div>
</div>
</section>
<!-- 탭 2: 월별 상세 기록 -->
<section id="monthlyDetailsTab" class="tab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">월별 상세 기록</h2>
<div class="month-controls">
<select id="monthSelect" class="form-select">
<option value="1">1월</option>
<option value="2">2월</option>
<option value="3">3월</option>
<option value="4">4월</option>
<option value="5">5월</option>
<option value="6">6월</option>
<option value="7">7월</option>
<option value="8">8월</option>
<option value="9">9월</option>
<option value="10">10월</option>
<option value="11">11월</option>
<option value="12">12월</option>
</select>
<button id="exportExcelBtn" class="btn btn-sm btn-secondary">
엑셀 다운로드
</button>
</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>작업자명</th>
<th>휴가 유형</th>
<th>시작일</th>
<th>종료일</th>
<th>사용 일수</th>
<th>사유</th>
<th>상태</th>
</tr>
</thead>
<tbody id="monthlyTableBody">
<tr>
<td colspan="7" class="loading-state">
<div class="spinner"></div>
<p>데이터를 불러오는 중...</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
</div>
</main>
<!-- 범례 -->
<div class="legend-box">
<span style="font-weight:500;">차감 우선순위:</span>
<div class="legend-item"><div class="legend-dot dot-carryover"></div>1. 이월</div>
<div class="legend-item"><div class="legend-dot dot-annual"></div>2. 정기연차</div>
<div class="legend-item"><div class="legend-dot dot-longservice"></div>3. 장기근속</div>
<div class="legend-item"><div class="legend-dot dot-special"></div>4. 경조사</div>
</div>
<!-- 테이블 -->
<div class="card">
<div class="card-body" style="padding:0;overflow-x:auto;">
<table class="data-table">
<thead>
<tr>
<th style="width:40px">No</th>
<th style="min-width:70px">이름</th>
<th class="col-carryover" style="width:80px">이월</th>
<th class="col-annual" style="width:80px">정기연차</th>
<th class="col-longservice" style="width:80px">장기근속</th>
<th class="col-special" style="width:100px">경조사</th>
<th class="col-total" style="width:70px">총 발생</th>
<th class="col-total" style="width:70px">총 사용</th>
<th class="col-total" style="width:70px">잔여</th>
</tr>
</thead>
<tbody id="tableBody">
<tr><td colspan="9" class="loading">로딩 중...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- 저장 바 -->
<div class="save-bar">
<span class="save-status" id="saveStatus">변경사항이 있으면 저장 버튼을 눌러주세요</span>
<button class="btn btn-success" onclick="saveAll()">저장</button>
</div>
</div>
</main>
<!-- 경조사 모달 -->
<div class="modal-overlay" id="specialModal">
<div class="modal-content">
<div class="modal-header">
<h3 class="modal-title" id="specialModalTitle">경조사 휴가</h3>
<button class="modal-close" onclick="closeSpecialModal()">&times;</button>
</div>
<div class="modal-body">
<div class="special-list" id="specialList">
<!-- 동적 생성 -->
</div>
<button class="add-special-btn" onclick="addSpecialItem()">+ 경조사 추가</button>
</div>
<div class="modal-footer">
<button class="btn btn-primary" onclick="saveSpecialAndClose()">확인</button>
</div>
</div>
</div>
<!-- 알림 토스트 -->
<div class="toast-container" id="toastContainer"></div>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
// axios 설정
(function() {
const check = setInterval(() => {
if (window.API_BASE_URL) {
clearInterval(check);
axios.defaults.baseURL = window.API_BASE_URL;
const token = localStorage.getItem('token');
if (token) axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
}, 50);
})();
// 전역 변수
let workers = [];
let currentYear = new Date().getFullYear();
let vacationData = {}; // { workerId: { carryover, annual, longService, specials: [{type, days}], totalUsed } }
let currentWorkerId = null;
// 경조사 유형
const specialTypes = [
{ code: 'WEDDING', name: '결혼', defaultDays: 5 },
{ code: 'SPOUSE_BIRTH', name: '배우자 출산', defaultDays: 10 },
{ code: 'CHILD_WEDDING', name: '자녀 결혼', defaultDays: 1 },
{ code: 'CONDOLENCE_PARENT', name: '부모 사망', defaultDays: 5 },
{ code: 'CONDOLENCE_SPOUSE_PARENT', name: '배우자 부모 사망', defaultDays: 5 },
{ code: 'CONDOLENCE_GRANDPARENT', name: '조부모 사망', defaultDays: 3 },
{ code: 'CONDOLENCE_SIBLING', name: '형제자매 사망', defaultDays: 3 },
{ code: 'OTHER', name: '기타', defaultDays: 1 }
];
// 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxios();
initYearSelector();
await loadData();
});
function waitForAxios() {
return new Promise(resolve => {
const check = setInterval(() => {
if (axios.defaults.baseURL) { clearInterval(check); resolve(); }
}, 50);
setTimeout(() => { clearInterval(check); resolve(); }, 5000);
});
}
function initYearSelector() {
const select = document.getElementById('yearSelect');
const now = new Date().getFullYear();
for (let y = now - 2; y <= now + 1; y++) {
const opt = document.createElement('option');
opt.value = y;
opt.textContent = `${y}`;
if (y === now) opt.selected = true;
select.appendChild(opt);
}
}
async function loadData() {
currentYear = parseInt(document.getElementById('yearSelect').value);
document.getElementById('tableBody').innerHTML = '<tr><td colspan="9" class="loading">로딩 중...</td></tr>';
try {
// 작업자 로드
const workersRes = await axios.get('/workers?limit=100');
workers = (workersRes.data.data || [])
.filter(w => w.status === 'active' && w.employment_status === 'employed')
.sort((a, b) => a.worker_name.localeCompare(b.worker_name, 'ko'));
// 휴가 잔액 로드
let balances = [];
try {
const balancesRes = await axios.get(`/vacation-balances/year/${currentYear}`);
balances = balancesRes.data.data || [];
} catch (e) { console.log('휴가 잔액 로드 실패'); }
// 데이터 정리
vacationData = {};
workers.forEach(w => {
vacationData[w.worker_id] = {
carryover: 0,
annual: 0,
longService: 0,
specials: [],
totalUsed: 0
};
});
// 잔액 데이터 매핑
balances.forEach(b => {
if (!vacationData[b.worker_id]) return;
const code = b.type_code || '';
const data = vacationData[b.worker_id];
if (code === 'CARRYOVER' || b.type_name === '이월') {
data.carryover = b.total_days || 0;
data.totalUsed += b.used_days || 0;
} else if (code === 'ANNUAL' || b.type_name === '정기연차' || b.type_name === '연차') {
data.annual = b.total_days || 0;
data.totalUsed += b.used_days || 0;
} else if (code === 'LONG_SERVICE' || b.type_name === '장기근속') {
data.longService = b.total_days || 0;
data.totalUsed += b.used_days || 0;
} else if (code.startsWith('SPECIAL_') || specialTypes.some(st => st.code === code)) {
data.specials.push({
type: code,
typeName: b.type_name,
days: b.total_days || 0,
id: b.id
});
data.totalUsed += b.used_days || 0;
}
});
renderTable();
} catch (error) {
console.error('데이터 로드 오류:', error);
document.getElementById('tableBody').innerHTML = '<tr><td colspan="9" class="loading" style="color:#ef4444;">데이터 로드 실패</td></tr>';
}
}
function renderTable() {
const tbody = document.getElementById('tableBody');
if (workers.length === 0) {
tbody.innerHTML = '<tr><td colspan="9" class="loading">작업자가 없습니다</td></tr>';
return;
}
tbody.innerHTML = workers.map((w, idx) => {
const d = vacationData[w.worker_id] || { carryover: 0, annual: 0, longService: 0, specials: [], totalUsed: 0 };
const carryover = parseFloat(d.carryover) || 0;
const annual = parseFloat(d.annual) || 0;
const longService = parseFloat(d.longService) || 0;
const totalUsed = parseFloat(d.totalUsed) || 0;
const specialTotal = (d.specials || []).reduce((sum, s) => sum + (parseFloat(s.days) || 0), 0);
const totalGenerated = carryover + annual + longService + specialTotal;
const remaining = totalGenerated - totalUsed;
const remainingClass = remaining > 0 ? 'positive' : remaining < 0 ? 'negative' : 'zero';
return `
<tr data-worker-id="${w.worker_id}">
<td>${idx + 1}</td>
<td class="worker-name">${w.worker_name}</td>
<td>
<input type="number" class="num-input ${carryover < 0 ? 'negative' : ''}"
value="${carryover}" step="0.5"
data-field="carryover"
onchange="updateField(${w.worker_id}, 'carryover', this.value)">
</td>
<td>
<input type="number" class="num-input"
value="${annual}" step="0.5"
data-field="annual"
onchange="updateField(${w.worker_id}, 'annual', this.value)">
</td>
<td>
<input type="number" class="num-input"
value="${longService}" step="0.5"
data-field="longService"
onchange="updateField(${w.worker_id}, 'longService', this.value)">
</td>
<td>
<button class="special-btn" onclick="openSpecialModal(${w.worker_id}, '${w.worker_name}')">
${(d.specials || []).length > 0 ? `${specialTotal}` : '추가'}
${(d.specials || []).length > 0 ? `<span class="special-count">${d.specials.length}</span>` : ''}
</button>
</td>
<td style="font-weight:600;color:#059669;">${totalGenerated.toFixed(2)}</td>
<td style="color:#6b7280;">${totalUsed > 0 ? totalUsed.toFixed(2) : '-'}</td>
<td class="remaining ${remainingClass}">${remaining.toFixed(2)}</td>
</tr>
`;
}).join('');
}
function updateField(workerId, field, value) {
const val = parseFloat(value) || 0;
if (!vacationData[workerId]) {
vacationData[workerId] = { carryover: 0, annual: 0, longService: 0, specials: [], totalUsed: 0 };
}
vacationData[workerId][field] = val;
// 입력 스타일 업데이트
const input = document.querySelector(`tr[data-worker-id="${workerId}"] input[data-field="${field}"]`);
if (input) {
input.classList.toggle('negative', val < 0);
}
// 행 합계 업데이트
updateRowTotals(workerId);
markChanged();
}
function updateRowTotals(workerId) {
const row = document.querySelector(`tr[data-worker-id="${workerId}"]`);
if (!row) return;
const d = vacationData[workerId];
if (!d) return;
const carryover = parseFloat(d.carryover) || 0;
const annual = parseFloat(d.annual) || 0;
const longService = parseFloat(d.longService) || 0;
const totalUsed = parseFloat(d.totalUsed) || 0;
const specialTotal = (d.specials || []).reduce((sum, s) => sum + (parseFloat(s.days) || 0), 0);
const totalGenerated = carryover + annual + longService + specialTotal;
const remaining = totalGenerated - totalUsed;
const cells = row.querySelectorAll('td');
cells[6].textContent = totalGenerated.toFixed(2);
cells[7].textContent = totalUsed > 0 ? totalUsed.toFixed(2) : '-';
cells[8].textContent = remaining.toFixed(2);
cells[8].className = `remaining ${remaining > 0 ? 'positive' : remaining < 0 ? 'negative' : 'zero'}`;
}
function markChanged() {
document.getElementById('saveStatus').textContent = '변경사항이 있습니다. 저장 버튼을 눌러주세요.';
document.getElementById('saveStatus').style.color = '#f59e0b';
}
// ===== 경조사 모달 =====
function openSpecialModal(workerId, workerName) {
currentWorkerId = workerId;
document.getElementById('specialModalTitle').textContent = `${workerName} - 경조사 휴가`;
renderSpecialList();
document.getElementById('specialModal').classList.add('active');
}
function closeSpecialModal() {
document.getElementById('specialModal').classList.remove('active');
currentWorkerId = null;
}
function renderSpecialList() {
const container = document.getElementById('specialList');
const specials = vacationData[currentWorkerId]?.specials || [];
if (specials.length === 0) {
container.innerHTML = '<p style="text-align:center;color:#9ca3af;padding:1rem;">경조사 휴가가 없습니다</p>';
return;
}
container.innerHTML = specials.map((s, idx) => `
<div class="special-item" data-idx="${idx}">
<select onchange="updateSpecialType(${idx}, this.value)">
${specialTypes.map(st => `
<option value="${st.code}" ${s.type === st.code ? 'selected' : ''}>${st.name}</option>
`).join('')}
</select>
<input type="number" value="${s.days}" step="0.5" min="0"
onchange="updateSpecialDays(${idx}, this.value)">
<span>일</span>
<button class="delete-btn" onclick="deleteSpecialItem(${idx})">삭제</button>
</div>
`).join('');
}
function addSpecialItem() {
if (!vacationData[currentWorkerId]) return;
vacationData[currentWorkerId].specials.push({
type: 'WEDDING',
typeName: '결혼',
days: 5
});
renderSpecialList();
}
function updateSpecialType(idx, typeCode) {
const specials = vacationData[currentWorkerId]?.specials;
if (!specials || !specials[idx]) return;
const typeInfo = specialTypes.find(st => st.code === typeCode);
specials[idx].type = typeCode;
specials[idx].typeName = typeInfo?.name || typeCode;
specials[idx].days = typeInfo?.defaultDays || specials[idx].days;
renderSpecialList();
}
function updateSpecialDays(idx, value) {
const specials = vacationData[currentWorkerId]?.specials;
if (!specials || !specials[idx]) return;
specials[idx].days = parseFloat(value) || 0;
}
function deleteSpecialItem(idx) {
const specials = vacationData[currentWorkerId]?.specials;
if (!specials) return;
specials.splice(idx, 1);
renderSpecialList();
}
function saveSpecialAndClose() {
updateRowTotals(currentWorkerId);
renderTable(); // 경조사 버튼 텍스트 업데이트
markChanged();
closeSpecialModal();
}
// ESC로 모달 닫기
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeSpecialModal();
});
document.getElementById('specialModal').addEventListener('click', e => {
if (e.target.id === 'specialModal') closeSpecialModal();
});
// ===== 저장 =====
async function saveAll() {
const balancesToSave = [];
// 휴가 유형 ID 매핑 (서버에서 가져와야 하지만 일단 하드코딩)
// 실제로는 vacation_types 테이블에서 조회해야 함
const typeIdMap = {
'CARRYOVER': null,
'ANNUAL': null,
'LONG_SERVICE': null
};
// 휴가 유형 ID 조회
try {
const typesRes = await axios.get('/vacation-types');
const types = typesRes.data.data || [];
types.forEach(t => {
if (t.type_code === 'CARRYOVER' || t.type_name === '이월') typeIdMap['CARRYOVER'] = t.id;
if (t.type_code === 'ANNUAL' || t.type_name === '정기연차' || t.type_name === '연차') typeIdMap['ANNUAL'] = t.id;
if (t.type_code === 'LONG_SERVICE' || t.type_name === '장기근속') typeIdMap['LONG_SERVICE'] = t.id;
});
} catch (e) {
console.error('휴가 유형 로드 실패:', e);
}
// 필요한 유형이 없으면 생성 (deduct_days 필수)
if (!typeIdMap['CARRYOVER']) {
try {
const res = await axios.post('/vacation-types', { type_code: 'CARRYOVER', type_name: '이월', deduct_days: 1, priority: 1 });
typeIdMap['CARRYOVER'] = res.data.data?.id;
} catch (e) { console.error('이월 유형 생성 실패'); }
}
if (!typeIdMap['ANNUAL']) {
try {
const res = await axios.post('/vacation-types', { type_code: 'ANNUAL', type_name: '정기연차', deduct_days: 1, priority: 2 });
typeIdMap['ANNUAL'] = res.data.data?.id;
} catch (e) { console.error('정기연차 유형 생성 실패'); }
}
if (!typeIdMap['LONG_SERVICE']) {
try {
const res = await axios.post('/vacation-types', { type_code: 'LONG_SERVICE', type_name: '장기근속', deduct_days: 1, priority: 3 });
typeIdMap['LONG_SERVICE'] = res.data.data?.id;
} catch (e) { console.error('장기근속 유형 생성 실패'); }
}
// 데이터 수집
for (const w of workers) {
const d = vacationData[w.worker_id];
if (!d) continue;
if (typeIdMap['CARRYOVER']) {
balancesToSave.push({
worker_id: w.worker_id,
vacation_type_id: typeIdMap['CARRYOVER'],
year: currentYear,
total_days: d.carryover
});
}
if (typeIdMap['ANNUAL']) {
balancesToSave.push({
worker_id: w.worker_id,
vacation_type_id: typeIdMap['ANNUAL'],
year: currentYear,
total_days: d.annual
});
}
if (typeIdMap['LONG_SERVICE']) {
balancesToSave.push({
worker_id: w.worker_id,
vacation_type_id: typeIdMap['LONG_SERVICE'],
year: currentYear,
total_days: d.longService
});
}
// 경조사 데이터 저장
for (const special of d.specials) {
if (special.days > 0) {
// 경조사 유형 ID 조회 또는 생성
let specialTypeId = typeIdMap[special.type];
if (!specialTypeId) {
try {
const typesRes = await axios.get('/vacation-types');
const existingType = (typesRes.data.data || []).find(t => t.type_code === special.type);
if (existingType) {
specialTypeId = existingType.id;
typeIdMap[special.type] = specialTypeId;
}
} catch (e) {}
}
if (!specialTypeId) {
try {
const typeInfo = specialTypes.find(st => st.code === special.type);
const res = await axios.post('/vacation-types', {
type_code: special.type,
type_name: typeInfo?.name || special.type,
deduct_days: typeInfo?.defaultDays || 1,
priority: 4
});
specialTypeId = res.data.data?.id;
typeIdMap[special.type] = specialTypeId;
} catch (e) { console.error(`${special.type} 유형 생성 실패`); }
}
if (specialTypeId) {
balancesToSave.push({
worker_id: w.worker_id,
vacation_type_id: specialTypeId,
year: currentYear,
total_days: special.days,
notes: special.typeName || special.type
});
}
}
}
}
if (balancesToSave.length === 0) {
alert('저장할 데이터가 없습니다.');
return;
}
try {
const res = await axios.post('/vacation-balances/bulk-upsert', { balances: balancesToSave });
if (res.data.success) {
alert(res.data.message);
document.getElementById('saveStatus').textContent = '저장 완료';
document.getElementById('saveStatus').style.color = '#10b981';
await loadData();
} else {
alert('저장 실패: ' + res.data.message);
}
} catch (error) {
console.error('저장 오류:', error);
alert('저장 중 오류: ' + (error.response?.data?.message || error.message));
}
}
</script>
</body>
</html>

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>
@@ -394,5 +395,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>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,554 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>내 연차 정보 | 테크니컬코리아</title>
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=7">
<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>
<style>
.page-wrapper {
padding: 1.5rem;
max-width: 1000px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.page-title { font-size: 1.5rem; font-weight: 700; margin: 0; }
.page-desc { color: #6b7280; font-size: 0.875rem; margin-top: 0.25rem; }
/* 작업자 선택 (관리자용) */
.admin-controls {
display: none;
gap: 0.5rem;
align-items: center;
margin-bottom: 1rem;
padding: 0.75rem 1rem;
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 0.5rem;
}
.admin-controls.visible { display: flex; }
.admin-controls label { font-weight: 500; color: #92400e; }
.admin-controls select {
padding: 0.4rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
font-size: 0.875rem;
min-width: 150px;
}
/* 카드 그리드 */
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
gap: 1rem;
margin-bottom: 1.5rem;
}
/* 연차 카드 */
.vacation-card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
padding: 1.25rem;
}
.vacation-card h3 {
font-size: 0.9rem;
font-weight: 600;
margin: 0 0 1rem 0;
padding-bottom: 0.5rem;
border-bottom: 2px solid #3b82f6;
color: #1e40af;
}
.vacation-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid #f3f4f6;
}
.vacation-item:last-child { border-bottom: none; }
.vacation-item .label {
display: flex;
align-items: center;
gap: 0.5rem;
color: #374151;
font-size: 0.875rem;
}
.vacation-item .dot {
width: 10px;
height: 10px;
border-radius: 2px;
}
.dot-carryover { background: #fbbf24; }
.dot-annual { background: #3b82f6; }
.dot-longservice { background: #a855f7; }
.dot-special { background: #ec4899; }
.vacation-item .days {
font-weight: 700;
font-size: 1rem;
}
.days.positive { color: #059669; }
.days.zero { color: #9ca3af; }
.days.negative { color: #dc2626; }
/* 총 합계 */
.vacation-total {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 2px solid #e5e7eb;
font-weight: 600;
}
.vacation-total .label { font-size: 0.9rem; color: #111827; }
.vacation-total .days { font-size: 1.25rem; }
/* 연장근로 카드 */
.overtime-card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
padding: 1.25rem;
}
.overtime-card h3 {
font-size: 0.9rem;
font-weight: 600;
margin: 0 0 1rem 0;
padding-bottom: 0.5rem;
border-bottom: 2px solid #f97316;
color: #c2410c;
}
.overtime-controls {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.overtime-controls select {
padding: 0.4rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
font-size: 0.875rem;
}
.overtime-summary {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
margin-bottom: 1rem;
}
.overtime-stat {
text-align: center;
padding: 1rem;
background: #fff7ed;
border-radius: 0.5rem;
}
.overtime-stat .value {
font-size: 1.5rem;
font-weight: 700;
color: #ea580c;
}
.overtime-stat .label {
font-size: 0.75rem;
color: #9a3412;
margin-top: 0.25rem;
}
/* 월별 상세 */
.overtime-detail {
max-height: 200px;
overflow-y: auto;
}
.overtime-day {
display: flex;
justify-content: space-between;
padding: 0.4rem 0;
border-bottom: 1px solid #f3f4f6;
font-size: 0.8rem;
}
.overtime-day:last-child { border-bottom: none; }
.overtime-day .date { color: #6b7280; }
.overtime-day .hours { font-weight: 600; color: #ea580c; }
/* 로딩/에러 */
.loading, .error, .no-data {
text-align: center;
padding: 2rem;
color: #6b7280;
}
.error { color: #dc2626; }
/* 안내 메시지 */
.info-message {
padding: 1rem;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 0.5rem;
color: #1e40af;
font-size: 0.875rem;
margin-bottom: 1rem;
}
</style>
</head>
<body class="has-sidebar">
<div id="navbar-container"></div>
<div id="sidebar-container"></div>
<main class="main-content">
<div class="page-wrapper">
<div class="page-header">
<div>
<h1 class="page-title">내 연차 정보</h1>
<p class="page-desc" id="workerNameDisplay">연차 잔여 현황 및 월간 연장근로 시간을 확인합니다</p>
</div>
</div>
<!-- 관리자용 작업자 선택 -->
<div class="admin-controls" id="adminControls">
<label>작업자 선택:</label>
<select id="workerSelect" onchange="onWorkerChange()">
<option value="">-- 선택 --</option>
</select>
</div>
<!-- 작업자 미연결 안내 -->
<div class="info-message" id="noWorkerMessage" style="display:none;">
현재 계정에 연결된 작업자 정보가 없습니다. 관리자에게 문의하세요.
</div>
<!-- 정보 그리드 -->
<div class="info-grid" id="infoGrid" style="display:none;">
<!-- 연차 잔여 현황 -->
<div class="vacation-card">
<h3 id="vacationCardTitle">연차 잔여 현황</h3>
<div id="vacationList">
<div class="loading">로딩 중...</div>
</div>
</div>
<!-- 월간 연장근로 -->
<div class="overtime-card">
<h3>월간 연장근로 현황</h3>
<div class="overtime-controls">
<select id="yearSelect" onchange="loadOvertimeData()"></select>
<select id="monthSelect" onchange="loadOvertimeData()"></select>
</div>
<div id="overtimeContent">
<div class="loading">로딩 중...</div>
</div>
</div>
</div>
</div>
</main>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
// axios 설정
(function() {
const check = setInterval(() => {
if (window.API_BASE_URL) {
clearInterval(check);
axios.defaults.baseURL = window.API_BASE_URL;
const token = localStorage.getItem('token');
if (token) axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
}, 50);
})();
// 전역 변수
let currentUser = null;
let currentWorkerId = null;
let isAdmin = false;
let workers = [];
// 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxios();
await initPage();
});
function waitForAxios() {
return new Promise(resolve => {
const check = setInterval(() => {
if (axios.defaults.baseURL) { clearInterval(check); resolve(); }
}, 50);
setTimeout(() => { clearInterval(check); resolve(); }, 5000);
});
}
async function initPage() {
// 현재 사용자 정보 가져오기
const userStr = localStorage.getItem('user');
if (userStr) {
try {
currentUser = JSON.parse(userStr);
} catch (e) {
console.error('사용자 정보 파싱 실패');
}
}
// 관리자 여부 확인
const userRole = (currentUser?.role || currentUser?.access_level || '').toLowerCase();
isAdmin = ['admin', 'system admin', 'system', 'system_admin'].includes(userRole);
// 연도/월 선택기 초기화
initDateSelectors();
if (isAdmin) {
// 관리자: 작업자 선택 UI 표시
document.getElementById('adminControls').classList.add('visible');
await loadWorkers();
} else {
// 일반 사용자: 본인 worker_id 사용
if (currentUser?.worker_id) {
currentWorkerId = currentUser.worker_id;
document.getElementById('infoGrid').style.display = 'grid';
await loadAllData();
} else {
// worker_id가 없는 경우
document.getElementById('noWorkerMessage').style.display = 'block';
}
}
}
function initDateSelectors() {
const now = new Date();
const yearSelect = document.getElementById('yearSelect');
const monthSelect = document.getElementById('monthSelect');
// 연도 (올해 ± 1년)
for (let y = now.getFullYear() - 1; y <= now.getFullYear() + 1; y++) {
const opt = document.createElement('option');
opt.value = y;
opt.textContent = `${y}`;
if (y === now.getFullYear()) opt.selected = true;
yearSelect.appendChild(opt);
}
// 월
for (let m = 1; m <= 12; m++) {
const opt = document.createElement('option');
opt.value = m;
opt.textContent = `${m}`;
if (m === now.getMonth() + 1) opt.selected = true;
monthSelect.appendChild(opt);
}
}
async function loadWorkers() {
try {
const res = await axios.get('/workers?limit=100');
workers = (res.data.data || [])
.filter(w => w.status === 'active' && w.employment_status === 'employed')
.sort((a, b) => a.worker_name.localeCompare(b.worker_name, 'ko'));
const select = document.getElementById('workerSelect');
workers.forEach(w => {
const opt = document.createElement('option');
opt.value = w.worker_id;
opt.textContent = w.worker_name;
select.appendChild(opt);
});
} catch (e) {
console.error('작업자 목록 로드 실패:', e);
}
}
async function onWorkerChange() {
const workerId = document.getElementById('workerSelect').value;
if (!workerId) {
document.getElementById('infoGrid').style.display = 'none';
return;
}
currentWorkerId = parseInt(workerId);
const worker = workers.find(w => w.worker_id === currentWorkerId);
document.getElementById('workerNameDisplay').textContent =
`${worker?.worker_name || ''}님의 연차 잔여 현황 및 월간 연장근로 시간`;
document.getElementById('infoGrid').style.display = 'grid';
await loadAllData();
}
async function loadAllData() {
await Promise.all([
loadVacationData(),
loadOvertimeData()
]);
}
// ===== 연차 잔여 현황 =====
async function loadVacationData() {
const container = document.getElementById('vacationList');
container.innerHTML = '<div class="loading">로딩 중...</div>';
const year = new Date().getFullYear();
document.getElementById('vacationCardTitle').textContent = `연차 잔여 현황 (${year}년)`;
try {
const res = await axios.get(`/vacation-balances/worker/${currentWorkerId}/year/${year}`);
const balances = res.data.data || [];
if (balances.length === 0) {
container.innerHTML = '<div class="no-data">등록된 연차 정보가 없습니다</div>';
return;
}
// 유형별 정리
const typeOrder = ['CARRYOVER', 'ANNUAL', 'LONG_SERVICE'];
const typeNames = {
'CARRYOVER': '이월',
'ANNUAL': '정기연차',
'LONG_SERVICE': '장기근속'
};
const dotClasses = {
'CARRYOVER': 'dot-carryover',
'ANNUAL': 'dot-annual',
'LONG_SERVICE': 'dot-longservice'
};
let totalDays = 0;
let usedDays = 0;
let html = '';
// 정렬된 순서로 표시
const sortedBalances = balances.sort((a, b) => {
const aIdx = typeOrder.indexOf(a.type_code);
const bIdx = typeOrder.indexOf(b.type_code);
if (aIdx === -1 && bIdx === -1) return 0;
if (aIdx === -1) return 1;
if (bIdx === -1) return -1;
return aIdx - bIdx;
});
sortedBalances.forEach(b => {
const total = parseFloat(b.total_days) || 0;
const used = parseFloat(b.used_days) || 0;
const remaining = total - used;
totalDays += total;
usedDays += used;
const dotClass = dotClasses[b.type_code] || 'dot-special';
const typeName = typeNames[b.type_code] || b.type_name || b.type_code;
const remainingClass = remaining > 0 ? 'positive' : remaining < 0 ? 'negative' : 'zero';
html += `
<div class="vacation-item">
<span class="label">
<span class="dot ${dotClass}"></span>
${typeName}
</span>
<span class="days ${remainingClass}">
${remaining.toFixed(1)}
<small style="color:#9ca3af;font-weight:normal;">(${total.toFixed(1)} - ${used.toFixed(1)})</small>
</span>
</div>
`;
});
// 총 합계
const totalRemaining = totalDays - usedDays;
const totalClass = totalRemaining > 0 ? 'positive' : totalRemaining < 0 ? 'negative' : 'zero';
html += `
<div class="vacation-total">
<span class="label">총 잔여</span>
<span class="days ${totalClass}">${totalRemaining.toFixed(1)}일</span>
</div>
`;
container.innerHTML = html;
} catch (e) {
console.error('연차 데이터 로드 실패:', e);
container.innerHTML = '<div class="error">데이터를 불러오는데 실패했습니다</div>';
}
}
// ===== 월간 연장근로 =====
async function loadOvertimeData() {
const container = document.getElementById('overtimeContent');
container.innerHTML = '<div class="loading">로딩 중...</div>';
const year = parseInt(document.getElementById('yearSelect').value);
const month = parseInt(document.getElementById('monthSelect').value);
// 해당 월의 시작일/종료일
const startDate = `${year}-${String(month).padStart(2, '0')}-01`;
const lastDay = new Date(year, month, 0).getDate();
const endDate = `${year}-${String(month).padStart(2, '0')}-${lastDay}`;
try {
// 근태 기록에서 연장근로 데이터 조회
const res = await axios.get(`/attendance/records?start_date=${startDate}&end_date=${endDate}&worker_id=${currentWorkerId}`);
const records = res.data.data || [];
// 8시간 초과분 계산
let totalOvertimeHours = 0;
const overtimeDays = [];
records.forEach(r => {
const hours = parseFloat(r.total_work_hours) || 0;
if (hours > 8) {
const overtime = hours - 8;
totalOvertimeHours += overtime;
overtimeDays.push({
date: r.record_date,
hours: overtime
});
}
});
// 총 근무일수
const workDays = records.filter(r => (parseFloat(r.total_work_hours) || 0) > 0).length;
// 렌더링
let html = `
<div class="overtime-summary">
<div class="overtime-stat">
<div class="value">${totalOvertimeHours.toFixed(1)}h</div>
<div class="label">총 연장근로</div>
</div>
<div class="overtime-stat">
<div class="value">${overtimeDays.length}일</div>
<div class="label">연장근로 일수</div>
</div>
</div>
`;
if (overtimeDays.length > 0) {
html += '<div class="overtime-detail">';
overtimeDays.sort((a, b) => a.date.localeCompare(b.date)).forEach(d => {
const dateObj = new Date(d.date);
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
const dayName = dayNames[dateObj.getDay()];
const displayDate = `${dateObj.getMonth() + 1}/${dateObj.getDate()} (${dayName})`;
html += `
<div class="overtime-day">
<span class="date">${displayDate}</span>
<span class="hours">+${d.hours.toFixed(1)}h</span>
</div>
`;
});
html += '</div>';
} else {
html += '<div class="no-data" style="padding:1rem;">연장근로 기록이 없습니다</div>';
}
container.innerHTML = html;
} catch (e) {
console.error('연장근로 데이터 로드 실패:', e);
container.innerHTML = '<div class="error">데이터를 불러오는데 실패했습니다</div>';
}
}
</script>
</body>
</html>

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>

View File

@@ -15,6 +15,7 @@
<!-- 모던 디자인 시스템 적용 -->
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/modern-dashboard.css?v=2">
<link rel="stylesheet" href="/css/mobile.css?v=1">
<link rel="icon" type="image/png" href="/img/favicon.png">
<!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) -->
@@ -509,6 +510,22 @@
</div>
</div>
<!-- 모바일 하단 네비게이션 -->
<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>

View File

@@ -6,7 +6,7 @@
<title>일일순회점검 | (주)테크니컬코리아</title>
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
<link rel="stylesheet" href="/css/daily-patrol.css?v=3">
<link rel="stylesheet" href="/css/daily-patrol.css?v=4">
<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>
@@ -197,6 +197,6 @@
}, 50);
})();
</script>
<script src="/js/daily-patrol.js?v=3"></script>
<script src="/js/daily-patrol.js?v=6"></script>
</body>
</html>

View File

@@ -0,0 +1,297 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>구역 상세 | (주)테크니컬코리아</title>
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/admin-pages.css?v=8">
<link rel="stylesheet" href="/css/zone-detail.css?v=3">
<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>
</head>
<body>
<!-- 네비게이션 바 -->
<div id="navbar-container"></div>
<!-- 메인 레이아웃 -->
<div class="page-container">
<main class="main-content">
<div class="dashboard-main">
<!-- 페이지 헤더 -->
<div class="zone-header">
<div class="zone-header-left">
<button class="btn btn-back" onclick="goBack()">
<span></span> 돌아가기
</button>
</div>
<div class="zone-header-center">
<h1 id="zoneName" class="zone-title">작업장</h1>
<p id="zoneCategory" class="zone-subtitle">공장</p>
</div>
<div class="zone-header-right">
<span id="currentDate" class="current-date"></span>
</div>
</div>
<!-- 요약 카드 -->
<div id="summaryCards" class="summary-cards">
<!-- JS에서 렌더링 -->
</div>
<!-- 탭 네비게이션 -->
<div class="tab-navigation">
<button class="tab-btn active" data-tab="map" onclick="switchTab('map')">
🗺️ 구역 현황
</button>
<button class="tab-btn" data-tab="issues" onclick="switchTab('issues')">
🚨 안전신고/부적합
</button>
<button class="tab-btn" data-tab="equipment" onclick="switchTab('equipment')">
⚙️ 설비/수리
</button>
<button class="tab-btn" data-tab="visits" onclick="switchTab('visits')">
🚶 출입현황
</button>
<button class="tab-btn" data-tab="tbm" onclick="switchTab('tbm')">
📋 TBM
</button>
<button class="tab-btn" data-tab="patrol" onclick="switchTab('patrol')">
🔍 순회점검
</button>
</div>
<!-- 탭 콘텐츠 -->
<div class="tab-contents">
<!-- 구역 현황 탭 -->
<div id="tab-map" class="tab-content active">
<div class="map-editor-section">
<div class="map-editor-header">
<h3>구역 현황</h3>
<div class="map-editor-actions">
<button class="btn btn-primary btn-sm" id="addItemBtn" onclick="startAddItem()">
현황 등록
</button>
</div>
</div>
<div class="map-editor-container">
<div id="zoneMapContainer" class="zone-map-container">
<div class="map-placeholder">지도를 로딩 중...</div>
</div>
<div class="map-legend">
<div class="legend-title">주의 수준</div>
<div class="legend-items">
<div class="legend-item"><span class="legend-color" style="background: #10b981;"></span> 양호</div>
<div class="legend-item"><span class="legend-color" style="background: #f59e0b;"></span> 주의</div>
<div class="legend-item"><span class="legend-color" style="background: #ef4444;"></span> 관리필요</div>
</div>
<div class="legend-title" style="margin-top: 1rem;">설비 상태</div>
<div class="legend-items">
<div class="legend-item"><span style="margin-right: 4px;">⚙️</span> 정상 가동</div>
<div class="legend-item"><span style="margin-right: 4px;">🔧</span> 수리 필요</div>
<div class="legend-item"><span style="margin-right: 4px;">⚠️</span> 점검중</div>
<div class="legend-item"><span style="margin-right: 4px;">📤</span> 타 작업장 이동</div>
<div class="legend-item"><span style="margin-right: 4px;">📥</span> 임시 배치</div>
</div>
</div>
</div>
<div id="zoneItemsList" class="zone-items-list">
<!-- JS에서 렌더링 -->
</div>
</div>
</div>
<!-- 안전신고/부적합 탭 -->
<div id="tab-issues" class="tab-content">
<div id="issuesContent" class="content-loading">
<div class="loading-spinner"></div>
<p>로딩 중...</p>
</div>
</div>
<!-- 설비/수리 탭 -->
<div id="tab-equipment" class="tab-content">
<div id="equipmentContent" class="content-loading">
<div class="loading-spinner"></div>
<p>로딩 중...</p>
</div>
</div>
<!-- 출입현황 탭 -->
<div id="tab-visits" class="tab-content">
<div id="visitsContent" class="content-loading">
<div class="loading-spinner"></div>
<p>로딩 중...</p>
</div>
</div>
<!-- TBM 탭 -->
<div id="tab-tbm" class="tab-content">
<div id="tbmContent" class="content-loading">
<div class="loading-spinner"></div>
<p>로딩 중...</p>
</div>
</div>
<!-- 순회점검 탭 -->
<div id="tab-patrol" class="tab-content">
<div id="patrolContent" class="content-loading">
<div class="loading-spinner"></div>
<p>로딩 중...</p>
</div>
</div>
</div>
</div>
</main>
</div>
<!-- 현황 등록/수정 모달 -->
<div id="zoneItemModal" class="modal-overlay" style="display: none;">
<div class="modal-container" style="max-width: 520px;">
<div class="modal-header">
<h2 id="zoneItemModalTitle">현황 등록</h2>
<button class="btn-close" onclick="closeZoneItemModal()">&times;</button>
</div>
<div class="modal-body">
<form id="zoneItemForm">
<input type="hidden" id="zoneItemId">
<input type="hidden" id="zoneItemX">
<input type="hidden" id="zoneItemY">
<input type="hidden" id="zoneItemWidth">
<input type="hidden" id="zoneItemHeight">
<!-- 프로젝트 여부 -->
<div class="form-group">
<label>프로젝트 여부 *</label>
<div class="radio-group">
<label class="radio-label">
<input type="radio" name="zoneItemProjectType" value="project" onchange="onProjectTypeChange(this.value)">
<span class="radio-text">프로젝트</span>
</label>
<label class="radio-label">
<input type="radio" name="zoneItemProjectType" value="non_project" onchange="onProjectTypeChange(this.value)" checked>
<span class="radio-text">프로젝트 아님</span>
</label>
<label class="radio-label">
<input type="radio" name="zoneItemProjectType" value="unknown" onchange="onProjectTypeChange(this.value)">
<span class="radio-text">판단 못함</span>
</label>
</div>
</div>
<!-- 프로젝트 선택 (프로젝트일 경우만 표시) -->
<div class="form-group" id="projectSelectGroup" style="display: none;">
<label for="zoneItemProject">프로젝트 선택</label>
<select id="zoneItemProject" class="form-control">
<option value="">프로젝트를 선택하세요</option>
<!-- JS에서 동적 로드 -->
</select>
</div>
<!-- 명칭 -->
<div class="form-group">
<label for="zoneItemName">명칭 *</label>
<input type="text" id="zoneItemName" class="form-control" placeholder="예: A사 제품, 작업 자재, 이동 설비" required>
</div>
<!-- 상태/유형 + 주의수준 -->
<div class="form-row">
<div class="form-group" style="flex: 1.5;">
<label for="zoneItemType">상태/유형</label>
<div class="select-with-add">
<select id="zoneItemType" class="form-control">
<option value="working">작업중</option>
<option value="temp_storage">임시적치</option>
<option value="moved_equipment">이동설비</option>
<option value="unreported">미신고품</option>
</select>
<button type="button" class="btn-add-option" onclick="addCustomType()" title="유형 추가">+</button>
</div>
</div>
<div class="form-group" style="flex: 1;">
<label for="zoneItemWarning">주의 수준</label>
<select id="zoneItemWarning" class="form-control">
<option value="good">양호</option>
<option value="caution">주의</option>
<option value="needs_management">관리필요</option>
</select>
</div>
</div>
<!-- 상세 설명 -->
<div class="form-group">
<label for="zoneItemDesc">상세 설명</label>
<textarea id="zoneItemDesc" class="form-control" rows="2" placeholder="현황에 대한 상세 설명, 주의사항, 담당자 등"></textarea>
</div>
<!-- 사진 등록 -->
<div class="form-group">
<label>사진</label>
<div class="photo-upload-area">
<input type="file" id="zoneItemPhoto" accept="image/*" multiple onchange="onPhotoSelected(event)" style="display: none;">
<div id="photoPreviewList" class="photo-preview-list">
<!-- 미리보기 이미지들 -->
</div>
<button type="button" class="btn-add-photo" onclick="document.getElementById('zoneItemPhoto').click()">
<span class="photo-icon">📷</span>
<span>사진 추가</span>
</button>
</div>
</div>
<!-- 표시 색상 -->
<div class="form-group">
<label>표시 색상</label>
<div class="color-picker-row">
<input type="color" id="zoneItemColor" class="form-control color-input" value="#3b82f6">
<div class="color-presets">
<button type="button" class="color-preset" style="background: #10b981;" onclick="setItemColor('#10b981')" title="양호"></button>
<button type="button" class="color-preset" style="background: #f59e0b;" onclick="setItemColor('#f59e0b')" title="주의"></button>
<button type="button" class="color-preset" style="background: #ef4444;" onclick="setItemColor('#ef4444')" title="관리필요"></button>
<button type="button" class="color-preset" style="background: #3b82f6;" onclick="setItemColor('#3b82f6')" title="기본"></button>
<button type="button" class="color-preset" style="background: #8b5cf6;" onclick="setItemColor('#8b5cf6')" title="기타"></button>
</div>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeZoneItemModal()">취소</button>
<button type="button" class="btn btn-danger" id="deleteZoneItemBtn" onclick="deleteZoneItem()" style="display: none;">삭제</button>
<button type="button" class="btn btn-primary" onclick="saveZoneItem()">저장</button>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="module">
import '/js/api-config.js?v=3';
</script>
<script>
(function() {
const checkApiConfig = setInterval(() => {
if (window.API_BASE_URL) {
clearInterval(checkApiConfig);
axios.defaults.baseURL = window.API_BASE_URL;
const token = localStorage.getItem('token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.response.use(
response => response,
error => {
if (error.response?.status === 401) {
alert('세션이 만료되었습니다. 다시 로그인해주세요.');
window.location.href = '/pages/login.html';
}
return Promise.reject(error);
}
);
}
}, 50);
})();
</script>
<script src="/js/zone-detail.js?v=6"></script>
</body>
</html>

View File

@@ -702,13 +702,11 @@
updateTime: function() {
const now = new Date();
const timeString = now.toLocaleTimeString('ko-KR', {
hour12: false,
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const timeString = `${hours}${minutes}${seconds}`;
const timeElement = document.querySelector('.time-value');
if (timeElement) {
timeElement.textContent = timeString;
@@ -1490,9 +1488,10 @@
}
const hours = parseFloat(work.work_hours) || 0;
if (work.work_status === 'error' || work.error_type_id) {
// work_status_id: 1=정상, 2=오류
if (work.work_status_id === 2 || work.error_type_id) {
vacationData.errorHours += hours;
const errorTypeName = work.error_type_name || work.error_description || '설계미스';
const errorTypeName = work.error_type_name || work.error_category_name || work.error_description || '미지정 오류';
if (!vacationData.errorDetails.has(errorTypeName)) {
vacationData.errorDetails.set(errorTypeName, 0);
}
@@ -1527,11 +1526,12 @@
const workTypeData = workTypeMap.get(combinedKey);
const hours = parseFloat(work.work_hours) || 0;
if (work.work_status === 'error' || work.error_type_id) {
// work_status_id: 1=정상, 2=오류
if (work.work_status_id === 2 || work.error_type_id) {
workTypeData.errorHours += hours;
// 오류 유형별 세분화
const errorTypeName = work.error_type_name || work.error_description || '설계미스';
const errorTypeName = work.error_type_name || work.error_category_name || work.error_description || '미지정 오류';
if (!workTypeData.errorDetails.has(errorTypeName)) {
workTypeData.errorDetails.set(errorTypeName, 0);
}

View File

@@ -6,6 +6,7 @@
<title>일일 작업보고서 작성 | (주)테크니컬코리아</title>
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/daily-work-report.css?v=12">
<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>
@@ -178,5 +179,19 @@
<!-- 기존 UI 로직 (점진적 마이그레이션) -->
<script type="module" src="/js/daily-work-report.js?v=29"></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>

View File

@@ -7,6 +7,7 @@
<link rel="stylesheet" href="/css/design-system.css">
<link rel="stylesheet" href="/css/common.css?v=2">
<link rel="stylesheet" href="/css/tbm.css?v=1">
<link rel="stylesheet" href="/css/mobile.css?v=1">
<link rel="icon" type="image/png" href="/img/favicon.png">
<!-- 최적화된 로딩: API 설정 → 앱 초기화 (병렬 컴포넌트 로딩) -->
<script src="/js/api-base.js"></script>
@@ -668,5 +669,19 @@
<!-- 기존 UI 로직 (점진적 마이그레이션) -->
<script type="module" src="/js/tbm.js?v=4"></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>