security: 보안 강제 시스템 구축 + 하드코딩 비밀번호 제거

보안 감사 결과 CRITICAL 2건, HIGH 5건 발견 → 수정 완료 + 자동화 구축.

[보안 수정]
- issue-view.js: 하드코딩 비밀번호 → crypto.getRandomValues() 랜덤 생성
- pushSubscriptionController.js: ntfy 비밀번호 → process.env.NTFY_SUB_PASSWORD
- DEPLOY-GUIDE.md/PROGRESS.md/migration SQL: 평문 비밀번호 → placeholder
- docker-compose.yml/.env.example: NTFY_SUB_PASSWORD 환경변수 추가

[보안 강제 시스템 - 신규]
- scripts/security-scan.sh: 8개 규칙 (CRITICAL 2, HIGH 4, MEDIUM 2)
  3모드(staged/all/diff), severity, .securityignore, MEDIUM 임계값
- .githooks/pre-commit: 로컬 빠른 피드백
- .githooks/pre-receive-server.sh: Gitea 서버 최종 차단
  bypass 거버넌스([SECURITY-BYPASS: 사유] + 사용자 제한 + 로그)
- SECURITY-CHECKLIST.md: 10개 카테고리 자동/수동 구분
- docs/SECURITY-GUIDE.md: 운영자 가이드 (워크플로우, bypass, FAQ)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi Ahn
2026-04-10 09:44:21 +09:00
parent bbffa47a9d
commit ba9ef32808
257 changed files with 786 additions and 18 deletions

View File

@@ -0,0 +1 @@
# Placeholder file to create admin directory

View File

@@ -0,0 +1,503 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>출퇴근-작업보고서 대조 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<style>
.comparison-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin-bottom: 2rem;
}
.comparison-card {
background: white;
padding: 1.5rem;
border-radius: 0.5rem;
border: 1px solid #e5e7eb;
}
.comparison-title {
font-size: 1.125rem;
font-weight: 600;
margin-bottom: 1rem;
color: #111827;
}
.discrepancy-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 0.25rem;
font-size: 0.875rem;
font-weight: 500;
}
.badge-match {
background-color: #d1fae5;
color: #065f46;
}
.badge-mismatch {
background-color: #fee2e2;
color: #991b1b;
}
.badge-missing-attendance {
background-color: #fef3c7;
color: #92400e;
}
.badge-missing-report {
background-color: #dbeafe;
color: #1e40af;
}
.filter-section {
display: flex;
gap: 1rem;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.summary-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.stat-card {
background: white;
padding: 1rem;
border-radius: 0.5rem;
border: 1px solid #e5e7eb;
text-align: center;
}
.stat-label {
font-size: 0.875rem;
color: #6b7280;
margin-bottom: 0.25rem;
}
.stat-value {
font-size: 1.75rem;
font-weight: 600;
color: #111827;
}
.detail-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
padding: 0.5rem;
border-bottom: 1px solid #e5e7eb;
}
.detail-label {
font-weight: 500;
color: #6b7280;
}
.detail-value {
color: #111827;
}
</style>
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<div class="page-header">
<div class="page-title-section">
<h1 class="page-title">출퇴근-작업보고서 대조</h1>
<p class="page-description">출퇴근 기록과 작업보고서를 비교 분석합니다</p>
</div>
<div class="page-actions">
<button class="btn btn-primary" onclick="loadComparisonData()">새로고침</button>
</div>
</div>
<!-- 필터 섹션 -->
<div class="content-section">
<div class="card">
<div class="card-body">
<div class="filter-section">
<div style="flex: 1; min-width: 200px;">
<label for="startDate">시작일</label>
<input type="date" id="startDate" class="form-control">
</div>
<div style="flex: 1; min-width: 200px;">
<label for="endDate">종료일</label>
<input type="date" id="endDate" class="form-control">
</div>
<div style="flex: 1; min-width: 200px;">
<label for="workerFilter">작업자</label>
<select id="workerFilter" class="form-control">
<option value="">전체</option>
</select>
</div>
<div style="flex: 1; min-width: 200px;">
<label for="statusFilter">상태</label>
<select id="statusFilter" class="form-control">
<option value="">전체</option>
<option value="match">일치</option>
<option value="mismatch">불일치</option>
<option value="missing-attendance">출퇴근 누락</option>
<option value="missing-report">보고서 누락</option>
</select>
</div>
<div style="align-self: flex-end;">
<button class="btn btn-primary" onclick="loadComparisonData()">조회</button>
</div>
</div>
</div>
</div>
</div>
<!-- 요약 통계 -->
<div class="content-section">
<div class="summary-stats" id="summaryStats">
<!-- 요약 통계가 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
<!-- 대조 결과 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">대조 결과</h2>
<p class="text-muted">출퇴근 기록과 작업보고서의 시간을 비교합니다</p>
</div>
<div class="card-body">
<div id="comparisonList" class="data-table-container">
<!-- 대조 결과가 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/api-base.js?v=2026031401"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="module">
import '/js/api-config.js?v=2026031401
</script>
<script>
// axios 기본 설정
(function() {
const checkApiConfig = setInterval(() => {
if (window.API_BASE_URL) {
clearInterval(checkApiConfig);
axios.defaults.baseURL = window.API_BASE_URL;
const token = localStorage.getItem('sso_token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('sso_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => Promise.reject(error)
);
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>
// 전역 변수
let workers = [];
let comparisonData = [];
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
initializePage();
});
function waitForAxiosConfig() {
return new Promise((resolve) => {
const check = setInterval(() => {
if (axios.defaults.baseURL) {
clearInterval(check);
resolve();
}
}, 50);
setTimeout(() => {
clearInterval(check);
resolve();
}, 5000);
});
}
async function initializePage() {
// 기본 날짜 설정 (이번 주)
const today = new Date();
const weekAgo = new Date(today);
weekAgo.setDate(today.getDate() - 7);
document.getElementById('startDate').value = weekAgo.toISOString().split('T')[0];
document.getElementById('endDate').value = today.toISOString().split('T')[0];
try {
await loadWorkers();
await loadComparisonData();
} catch (error) {
console.error('초기화 오류:', error);
alert('페이지 초기화 중 오류가 발생했습니다.');
}
}
async function loadWorkers() {
try {
const response = await axios.get('/workers?limit=100');
if (response.data.success) {
workers = response.data.data.filter(w => w.employment_status === 'employed');
const select = document.getElementById('workerFilter');
workers.forEach(worker => {
const option = document.createElement('option');
option.value = worker.user_id;
option.textContent = worker.worker_name;
select.appendChild(option);
});
}
} catch (error) {
console.error('작업자 목록 로드 오류:', error);
}
}
async function loadComparisonData() {
const startDate = document.getElementById('startDate').value;
const endDate = document.getElementById('endDate').value;
const workerId = document.getElementById('workerFilter').value;
if (!startDate || !endDate) {
alert('시작일과 종료일을 선택해주세요.');
return;
}
try {
// 출퇴근 기록 로드
const attendanceResponse = await axios.get('/attendance/records', {
params: {
start_date: startDate,
end_date: endDate,
user_id: workerId || undefined
}
});
// 작업 보고서 로드
const reportsResponse = await axios.get('/daily-work-reports', {
params: {
start_date: startDate,
end_date: endDate,
user_id: workerId || undefined
}
});
const attendanceRecords = attendanceResponse.data.success ? attendanceResponse.data.data : [];
const workReports = reportsResponse.data.success ? reportsResponse.data.data : [];
// 데이터 비교
comparisonData = compareData(attendanceRecords, workReports);
// 필터 적용
const statusFilter = document.getElementById('statusFilter').value;
if (statusFilter) {
comparisonData = comparisonData.filter(item => item.status === statusFilter);
}
renderSummary();
renderComparisonList();
} catch (error) {
console.error('데이터 로드 오류:', error);
document.getElementById('comparisonList').innerHTML = `
<div class="empty-state">
<p>데이터를 불러오는 중 오류가 발생했습니다.</p>
</div>
`;
}
}
function compareData(attendanceRecords, workReports) {
const results = [];
const dateWorkerMap = new Map();
// 출퇴근 기록 맵핑
attendanceRecords.forEach(record => {
const key = `${record.attendance_date}_${record.user_id}`;
dateWorkerMap.set(key, {
date: record.attendance_date,
user_id: record.user_id,
worker_name: record.worker_name,
attendance: record,
reports: []
});
});
// 작업 보고서 맵핑
workReports.forEach(report => {
const key = `${report.report_date}_${report.user_id}`;
if (dateWorkerMap.has(key)) {
dateWorkerMap.get(key).reports.push(report);
} else {
dateWorkerMap.set(key, {
date: report.report_date,
user_id: report.user_id,
worker_name: report.worker_name,
attendance: null,
reports: [report]
});
}
});
// 비교 분석
dateWorkerMap.forEach(item => {
const attendanceHours = item.attendance?.total_hours || 0;
const reportTotalHours = item.reports.reduce((sum, r) => sum + (r.total_hours || 0), 0);
let status = 'match';
let message = '일치';
if (!item.attendance && item.reports.length > 0) {
status = 'missing-attendance';
message = '출퇴근 기록 누락';
} else if (item.attendance && item.reports.length === 0) {
status = 'missing-report';
message = '작업보고서 누락';
} else if (Math.abs(attendanceHours - reportTotalHours) > 0.5) {
status = 'mismatch';
message = `시간 불일치 (차이: ${Math.abs(attendanceHours - reportTotalHours).toFixed(1)}시간)`;
}
results.push({
...item,
attendanceHours,
reportTotalHours,
status,
message
});
});
// 날짜 역순 정렬
return results.sort((a, b) => b.date.localeCompare(a.date));
}
function renderSummary() {
const summaryStats = document.getElementById('summaryStats');
const total = comparisonData.length;
const matches = comparisonData.filter(item => item.status === 'match').length;
const mismatches = comparisonData.filter(item => item.status === 'mismatch').length;
const missingAttendance = comparisonData.filter(item => item.status === 'missing-attendance').length;
const missingReport = comparisonData.filter(item => item.status === 'missing-report').length;
summaryStats.innerHTML = `
<div class="stat-card">
<div class="stat-label">전체</div>
<div class="stat-value">${total}</div>
</div>
<div class="stat-card">
<div class="stat-label">일치</div>
<div class="stat-value" style="color: #059669;">${matches}</div>
</div>
<div class="stat-card">
<div class="stat-label">불일치</div>
<div class="stat-value" style="color: #dc2626;">${mismatches}</div>
</div>
<div class="stat-card">
<div class="stat-label">출퇴근 누락</div>
<div class="stat-value" style="color: #d97706;">${missingAttendance}</div>
</div>
<div class="stat-card">
<div class="stat-label">보고서 누락</div>
<div class="stat-value" style="color: #2563eb;">${missingReport}</div>
</div>
`;
}
function renderComparisonList() {
const container = document.getElementById('comparisonList');
if (comparisonData.length === 0) {
container.innerHTML = `
<div class="empty-state">
<p>비교 결과가 없습니다.</p>
</div>
`;
return;
}
const tableHTML = `
<table class="data-table">
<thead>
<tr>
<th>날짜</th>
<th>작업자</th>
<th>출퇴근 시간</th>
<th>보고서 시간</th>
<th>차이</th>
<th>상태</th>
</tr>
</thead>
<tbody>
${comparisonData.map(item => {
const diff = Math.abs(item.attendanceHours - item.reportTotalHours);
const badgeClass = `badge-${item.status}`;
return `
<tr>
<td>${item.date}</td>
<td><strong>${item.worker_name}</strong></td>
<td>${item.attendanceHours.toFixed(1)}시간</td>
<td>${item.reportTotalHours.toFixed(1)}시간</td>
<td>${diff.toFixed(1)}시간</td>
<td>
<span class="discrepancy-badge ${badgeClass}">
${item.message}
</span>
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`;
container.innerHTML = tableHTML;
}
</script>
<script>initAuth();</script>
</body>
</html>

View File

@@ -0,0 +1,4 @@
<!DOCTYPE html><html><head><meta charset="UTF-8"><script>
var h=location.hostname;var base=h.includes('technicalkorea.net')?'https://tkuser.technicalkorea.net':'http://'+h+':30380';
location.replace(base+'/?tab=departments');
</script></head><body>이동 중...</body></html>

View File

@@ -0,0 +1,359 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>설비 상세 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<link rel="stylesheet" href="/css/equipment-detail.css?v=2026031401">
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<!-- 뒤로가기 & 제목 -->
<div class="page-header eq-detail-header">
<div class="page-title-section">
<button class="btn-back" onclick="goBack()">
<span class="back-arrow">&larr;</span>
<span>뒤로</span>
</button>
<div class="eq-header-info">
<h1 class="page-title" id="equipmentTitle">설비 상세</h1>
<div class="eq-header-meta" id="equipmentMeta"></div>
</div>
</div>
<div class="eq-status-badge" id="equipmentStatus"></div>
</div>
<!-- 설비 기본 정보 카드 -->
<div class="eq-info-card" id="equipmentInfoCard">
<!-- JS에서 동적으로 렌더링 -->
</div>
<!-- 사진 섹션 -->
<div class="eq-section">
<div class="eq-section-header">
<h2 class="eq-section-title">설비 사진</h2>
<button class="btn btn-sm btn-outline" onclick="openPhotoModal()">+ 사진 추가</button>
</div>
<div class="eq-photo-grid" id="photoGrid">
<div class="eq-photo-empty">등록된 사진이 없습니다</div>
</div>
</div>
<!-- 위치 정보 섹션 -->
<div class="eq-section">
<div class="eq-section-header">
<h2 class="eq-section-title">위치 정보</h2>
</div>
<div class="eq-location-card" id="locationCard">
<div class="eq-location-info">
<div class="eq-location-row">
<span class="eq-location-label">원래 위치:</span>
<span class="eq-location-value" id="originalLocation">-</span>
</div>
<div class="eq-location-row" id="currentLocationRow" style="display: none;">
<span class="eq-location-label">현재 위치:</span>
<span class="eq-location-value eq-moved" id="currentLocation">-</span>
</div>
</div>
<div class="eq-map-preview" id="mapPreview">
<!-- 지도 미리보기 -->
</div>
</div>
</div>
<!-- 액션 버튼들 -->
<div class="eq-action-buttons">
<button class="btn btn-action btn-move" onclick="openMoveModal()">
<span class="btn-icon">&#x21C4;</span>
<span>임시이동</span>
</button>
<button class="btn btn-action btn-repair" onclick="openRepairModal()">
<span class="btn-icon">&#x1F527;</span>
<span>수리신청</span>
</button>
<button class="btn btn-action btn-export" onclick="openExportModal()">
<span class="btn-icon">&#x1F69A;</span>
<span>외부반출</span>
</button>
</div>
<!-- 수리 이력 섹션 -->
<div class="eq-section">
<div class="eq-section-header">
<h2 class="eq-section-title">수리 이력</h2>
</div>
<div class="eq-history-list" id="repairHistory">
<div class="eq-history-empty">수리 이력이 없습니다</div>
</div>
</div>
<!-- 외부반출 이력 섹션 -->
<div class="eq-section">
<div class="eq-section-header">
<h2 class="eq-section-title">외부반출 이력</h2>
</div>
<div class="eq-history-list" id="externalHistory">
<div class="eq-history-empty">외부반출 이력이 없습니다</div>
</div>
</div>
<!-- 이동 이력 섹션 -->
<div class="eq-section">
<div class="eq-section-header">
<h2 class="eq-section-title">이동 이력</h2>
</div>
<div class="eq-history-list" id="moveHistory">
<div class="eq-history-empty">이동 이력이 없습니다</div>
</div>
</div>
</div>
</div>
</div>
<!-- 사진 추가 모달 -->
<div id="photoModal" class="modal-overlay" style="display: none;">
<div class="modal-container" style="max-width: 500px;">
<div class="modal-header">
<h2>사진 추가</h2>
<button class="btn-close" onclick="closePhotoModal()">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>사진 선택</label>
<input type="file" id="photoInput" accept="image/*" onchange="previewPhoto(event)">
</div>
<div class="photo-preview-container" id="photoPreviewContainer" style="display: none;">
<img id="photoPreview" class="photo-preview">
</div>
<div class="form-group">
<label>설명 (선택)</label>
<input type="text" id="photoDescription" class="form-control" placeholder="사진 설명을 입력하세요">
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closePhotoModal()">취소</button>
<button type="button" class="btn btn-primary" onclick="uploadPhoto()">업로드</button>
</div>
</div>
</div>
<!-- 임시이동 모달 -->
<div id="moveModal" class="modal-overlay" style="display: none;">
<div class="modal-container" style="max-width: 600px;">
<div class="modal-header">
<h2>설비 임시 이동</h2>
<button class="btn-close" onclick="closeMoveModal()">&times;</button>
</div>
<div class="modal-body">
<div class="move-step" id="moveStep1">
<div class="form-group">
<label>이동할 공장 선택</label>
<select id="moveFactorySelect" class="form-control" onchange="loadMoveWorkplaces()">
<option value="">공장을 선택하세요</option>
</select>
</div>
<div class="form-group">
<label>이동할 작업장 선택</label>
<select id="moveWorkplaceSelect" class="form-control" onchange="loadMoveMap()">
<option value="">작업장을 선택하세요</option>
</select>
</div>
</div>
<div class="move-step" id="moveStep2" style="display: none;">
<p class="move-instruction">지도에서 이동할 위치를 클릭하세요</p>
<div class="move-map-container" id="moveMapContainer">
<!-- 지도가 여기에 표시됨 -->
</div>
<div class="form-group">
<label>이동 사유 (선택)</label>
<input type="text" id="moveReason" class="form-control" placeholder="이동 사유를 입력하세요">
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeMoveModal()">취소</button>
<button type="button" class="btn btn-primary" id="moveConfirmBtn" onclick="confirmMove()" disabled>이동 확인</button>
</div>
</div>
</div>
<!-- 수리신청 모달 -->
<div id="repairModal" class="modal-overlay" style="display: none;">
<div class="modal-container" style="max-width: 500px;">
<div class="modal-header">
<h2>수리 신청</h2>
<button class="btn-close" onclick="closeRepairModal()">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>수리 유형</label>
<select id="repairItemSelect" class="form-control">
<option value="">선택하세요</option>
</select>
</div>
<div class="form-group">
<label>상세 내용</label>
<textarea id="repairDescription" class="form-control" rows="3" placeholder="수리가 필요한 내용을 상세히 적어주세요"></textarea>
</div>
<div class="form-group">
<label>사진 첨부 (선택)</label>
<input type="file" id="repairPhotoInput" accept="image/*" multiple onchange="previewRepairPhotos(event)">
<div class="repair-photo-previews" id="repairPhotoPreviews"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeRepairModal()">취소</button>
<button type="button" class="btn btn-primary" onclick="submitRepairRequest()">신청</button>
</div>
</div>
</div>
<!-- 외부반출 모달 -->
<div id="exportModal" class="modal-overlay" style="display: none;">
<div class="modal-container" style="max-width: 500px;">
<div class="modal-header">
<h2>외부 반출</h2>
<button class="btn-close" onclick="closeExportModal()">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="isRepairExport" onchange="toggleRepairFields()">
<span>수리 외주 (외부 수리)</span>
</label>
</div>
<div class="form-group">
<label>반출일</label>
<input type="date" id="exportDate" class="form-control">
</div>
<div class="form-group">
<label>반입 예정일</label>
<input type="date" id="expectedReturnDate" class="form-control">
</div>
<div class="form-group">
<label>반출처 (업체명)</label>
<input type="text" id="exportDestination" class="form-control" placeholder="예: 삼성정비">
</div>
<div class="form-group">
<label>반출 사유</label>
<textarea id="exportReason" class="form-control" rows="2" placeholder="반출 사유를 입력하세요"></textarea>
</div>
<div class="form-group">
<label>비고</label>
<textarea id="exportNotes" class="form-control" rows="2" placeholder="기타 메모사항"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeExportModal()">취소</button>
<button type="button" class="btn btn-primary" onclick="submitExport()">반출 등록</button>
</div>
</div>
</div>
<!-- 반입 모달 -->
<div id="returnModal" class="modal-overlay" style="display: none;">
<div class="modal-container" style="max-width: 400px;">
<div class="modal-header">
<h2>설비 반입</h2>
<button class="btn-close" onclick="closeReturnModal()">&times;</button>
</div>
<div class="modal-body">
<input type="hidden" id="returnLogId">
<div class="form-group">
<label>반입일</label>
<input type="date" id="returnDate" class="form-control">
</div>
<div class="form-group">
<label>반입 후 상태</label>
<select id="returnStatus" class="form-control">
<option value="active">정상 가동</option>
<option value="maintenance">점검 필요</option>
<option value="repair_needed">추가 수리 필요</option>
</select>
</div>
<div class="form-group">
<label>비고</label>
<textarea id="returnNotes" class="form-control" rows="2" placeholder="반입 관련 메모"></textarea>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeReturnModal()">취소</button>
<button type="button" class="btn btn-primary" onclick="submitReturn()">반입 처리</button>
</div>
</div>
</div>
<!-- 사진 확대 모달 -->
<div id="photoViewModal" class="modal-overlay" style="display: none;">
<div class="photo-view-container" onclick="closePhotoView()">
<button class="photo-view-close">&times;</button>
<img id="photoViewImage" class="photo-view-image">
</div>
</div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/api-base.js?v=2026031401"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="module">
import '/js/api-config.js?v=2026031401
</script>
<script>
(function() {
const checkApiConfig = setInterval(() => {
if (window.API_BASE_URL) {
clearInterval(checkApiConfig);
axios.defaults.baseURL = window.API_BASE_URL;
const token = localStorage.getItem('sso_token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('sso_token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
},
error => Promise.reject(error)
);
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/equipment-detail.js?v=2026031401"></script>
<script>initAuth();</script>
</body>
</html>

View File

@@ -0,0 +1,235 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>설비 관리 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<link rel="stylesheet" href="/css/equipment-management.css?v=2026031401">
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<div class="page-header">
<div class="page-title-section">
<h1 class="page-title">설비 관리</h1>
<p class="page-description">작업장별 설비 정보를 등록하고 관리합니다</p>
</div>
<div class="page-actions">
<button class="btn btn-primary" onclick="openEquipmentModal()">
<span>+ 설비 추가</span>
</button>
</div>
</div>
<!-- 통계 요약 -->
<div id="statsSection" class="eq-stats-section">
<!-- JS에서 동적으로 렌더링 -->
</div>
<!-- 필터 영역 -->
<div class="eq-filter-section">
<div class="eq-filter-group">
<label for="filterWorkplace">작업장</label>
<select id="filterWorkplace" class="form-control" onchange="filterEquipments()">
<option value="">전체</option>
</select>
</div>
<div class="eq-filter-group">
<label for="filterType">설비 유형</label>
<select id="filterType" class="form-control" onchange="filterEquipments()">
<option value="">전체</option>
</select>
</div>
<div class="eq-filter-group">
<label for="filterStatus">상태</label>
<select id="filterStatus" class="form-control" onchange="filterEquipments()">
<option value="">전체</option>
<option value="active">활성</option>
<option value="maintenance">정비중</option>
<option value="inactive">비활성</option>
</select>
</div>
<div class="eq-filter-group eq-search-group">
<label for="searchInput">검색</label>
<input type="text" id="searchInput" class="form-control" placeholder="설비명, 코드, 제조사 검색..." oninput="filterEquipments()">
</div>
</div>
<!-- 설비 목록 -->
<div id="equipmentList" class="eq-table-container">
<!-- 설비 목록이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
<!-- 설비 추가/수정 모달 -->
<div id="equipmentModal" class="modal-overlay" style="display: none;">
<div class="modal-container" style="max-width: 720px;">
<div class="modal-header">
<h2 id="modalTitle">설비 추가</h2>
<button class="btn-close" onclick="closeEquipmentModal()">&times;</button>
</div>
<div class="modal-body eq-modal-body">
<form id="equipmentForm">
<input type="hidden" id="equipmentId">
<!-- 기본 정보 -->
<div class="eq-form-section">
<div class="eq-form-section-title">기본 정보</div>
<div class="eq-form-row">
<div class="form-group">
<label for="equipmentCode">관리번호 *</label>
<input type="text" id="equipmentCode" class="form-control" placeholder="예: TKP-001" required>
</div>
<div class="form-group">
<label for="equipmentName">설비명 *</label>
<input type="text" id="equipmentName" class="form-control" placeholder="예: TIG용접기" required>
</div>
</div>
<div class="eq-form-row">
<div class="form-group">
<label for="modelName">모델명</label>
<input type="text" id="modelName" class="form-control" placeholder="예: Perfect-500PT">
</div>
<div class="form-group">
<label for="specifications">규격</label>
<input type="text" id="specifications" class="form-control" placeholder="예: 500A/DC">
</div>
</div>
</div>
<!-- 제조사 및 구입 정보 -->
<div class="eq-form-section">
<div class="eq-form-section-title">제조사 및 구입 정보</div>
<div class="eq-form-row">
<div class="form-group">
<label for="manufacturer">제조사 (메이커)</label>
<input type="text" id="manufacturer" class="form-control" placeholder="예: 퍼펙트대대">
</div>
<div class="form-group">
<label for="supplier">구입처</label>
<input type="text" id="supplier" class="form-control" placeholder="예: 현대용접기">
</div>
</div>
<div class="eq-form-row">
<div class="form-group">
<label for="purchasePrice">구입가격 (원)</label>
<input type="number" id="purchasePrice" class="form-control" placeholder="예: 1600000">
</div>
<div class="form-group">
<label for="installationDate">구입일자</label>
<input type="date" id="installationDate" class="form-control">
</div>
</div>
</div>
<!-- 상세 정보 -->
<div class="eq-form-section">
<div class="eq-form-section-title">상세 정보</div>
<div class="eq-form-row">
<div class="form-group">
<label for="serialNumber">시리얼 번호 (S/N)</label>
<input type="text" id="serialNumber" class="form-control">
</div>
<div class="form-group">
<label for="equipmentStatus">상태</label>
<select id="equipmentStatus" class="form-control">
<option value="active">활성</option>
<option value="maintenance">정비중</option>
<option value="inactive">비활성</option>
</select>
</div>
</div>
<div class="eq-form-row">
<div class="form-group">
<label for="equipmentType">설비 유형</label>
<input type="text" id="equipmentType" class="form-control" placeholder="예: 용접기, 크레인 등">
</div>
<div class="form-group">
<label for="workplaceId">작업장</label>
<select id="workplaceId" class="form-control">
<option value="">선택 안함</option>
</select>
</div>
</div>
<div class="form-group">
<label for="notes">비고</label>
<textarea id="notes" class="form-control" rows="2" placeholder="기타 메모사항"></textarea>
</div>
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeEquipmentModal()">취소</button>
<button type="button" class="btn btn-primary" onclick="saveEquipment()">저장</button>
</div>
</div>
</div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/api-base.js?v=2026031401"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script type="module">
import '/js/api-config.js?v=2026031401
</script>
<script>
(function() {
const checkApiConfig = setInterval(() => {
if (window.API_BASE_URL) {
clearInterval(checkApiConfig);
axios.defaults.baseURL = window.API_BASE_URL;
const token = localStorage.getItem('sso_token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('sso_token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
},
error => Promise.reject(error)
);
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/equipment-management.js?v=2026031401"></script>
<script>initAuth();</script>
</body>
</html>

View File

@@ -0,0 +1,4 @@
<!DOCTYPE html><html><head><meta charset="UTF-8"><script>
var h=location.hostname;var base=h.includes('technicalkorea.net')?'https://tkuser.technicalkorea.net':'http://'+h+':30380';
location.replace(base+'/?tab=issueTypes');
</script></head><body>이동 중...</body></html>

View File

@@ -0,0 +1,4 @@
<!DOCTYPE html><html><head><meta charset="UTF-8"><script>
var h=location.hostname;var base=h.includes('technicalkorea.net')?'https://tkuser.technicalkorea.net':'http://'+h+':30380';
location.replace(base+'/?tab=notificationRecipients');
</script></head><body>이동 중...</body></html>

View File

@@ -0,0 +1,4 @@
<!DOCTYPE html><html><head><meta charset="UTF-8"><script>
var h=location.hostname;var base=h.includes('technicalkorea.net')?'https://tkuser.technicalkorea.net':'http://'+h+':30380';
location.replace(base+'/?tab=projects');
</script></head><body>이동 중...</body></html>

View File

@@ -0,0 +1,142 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>소모품 분석 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<div class="page-header">
<div class="page-title-section">
<h1 class="page-title">소모품 분석</h1>
<p class="page-description">월간 소모품 현황 분석 및 업체별 정산 관리</p>
</div>
</div>
<!-- 월 선택 + 기준일 전환 -->
<div class="flex items-center gap-3 mb-6 flex-wrap">
<input type="month" id="paMonth" class="px-3 py-2 border rounded-lg text-sm">
<div class="flex rounded-lg border overflow-hidden text-sm">
<button id="btnDatePurchase" onclick="setDateBasis('purchase')" class="px-3 py-2 bg-orange-600 text-white">구매일</button>
<button id="btnDateReceived" onclick="setDateBasis('received')" class="px-3 py-2 bg-white text-gray-600 hover:bg-gray-50">입고일</button>
</div>
<button onclick="loadAnalysis()" class="px-4 py-2 bg-orange-600 text-white rounded-lg text-sm hover:bg-orange-700">
<i class="fas fa-search mr-1"></i>조회
</button>
</div>
<!-- 분류별 요약 카드 -->
<div id="paCategorySummary" class="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-xl shadow-sm p-4 text-center text-gray-400 col-span-4">월을 선택하고 조회해주세요</div>
</div>
<!-- 업체별 요약 -->
<div class="bg-white rounded-xl shadow-sm p-5 mb-6">
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-building text-orange-500 mr-2"></i>업체별 요약</h2>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 text-xs uppercase">
<tr>
<th class="px-4 py-3 text-left">업체</th>
<th class="px-4 py-3 text-right">건수</th>
<th class="px-4 py-3 text-right">총액</th>
<th class="px-4 py-3 text-center">정산</th>
<th class="px-4 py-3 text-center">액션</th>
</tr>
</thead>
<tbody id="paVendorSummary" class="divide-y">
<tr><td colspan="5" class="px-4 py-8 text-center text-gray-400">-</td></tr>
</tbody>
</table>
</div>
</div>
<!-- 상세 구매 목록 -->
<div class="bg-white rounded-xl shadow-sm p-5 mb-6">
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-list text-orange-500 mr-2"></i>상세 소모품 목록</h2>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 text-xs uppercase">
<tr>
<th class="px-4 py-3 text-left">품목</th>
<th class="px-4 py-3 text-left">분류</th>
<th class="px-4 py-3 text-right">수량</th>
<th class="px-4 py-3 text-right">단가</th>
<th class="px-4 py-3 text-right">소계</th>
<th class="px-4 py-3 text-left">업체</th>
<th class="px-4 py-3 text-left">구매일</th>
<th class="px-4 py-3 text-left">비고</th>
</tr>
</thead>
<tbody id="paPurchaseList" class="divide-y">
<tr><td colspan="8" class="px-4 py-8 text-center text-gray-400">-</td></tr>
</tbody>
</table>
</div>
</div>
<!-- 가격 변동 -->
<div class="bg-white rounded-xl shadow-sm p-5">
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-exchange-alt text-orange-500 mr-2"></i>가격 변동 항목</h2>
<div id="paPriceChanges" class="overflow-x-auto">
<p class="text-gray-400 text-center py-4 text-sm">-</p>
</div>
</div>
<!-- 입고 목록 (입고일 기준 모드에서만 표시) -->
<div id="paReceivedSection" class="hidden bg-white rounded-xl shadow-sm p-5 mt-6">
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-box-open text-teal-500 mr-2"></i>월간 입고 내역</h2>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 text-xs uppercase">
<tr>
<th class="px-4 py-3 text-left">품목</th>
<th class="px-4 py-3 text-left">분류</th>
<th class="px-4 py-3 text-right">수량</th>
<th class="px-4 py-3 text-right">단가</th>
<th class="px-4 py-3 text-left">업체</th>
<th class="px-4 py-3 text-left">입고일</th>
<th class="px-4 py-3 text-left">보관위치</th>
<th class="px-4 py-3 text-left">상태</th>
</tr>
</thead>
<tbody id="paReceivedList" class="divide-y">
<tr><td colspan="8" class="px-4 py-8 text-center text-gray-400">-</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/static/js/purchase-analysis.js?v=2026040103"></script>
</body>
</html>

View File

@@ -0,0 +1,892 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>시설설비 관리 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<style>
.repair-page {
max-width: 1400px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.page-header h1 {
font-size: 1.5rem;
font-weight: 700;
margin: 0;
}
.stats-row {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.stat-card {
flex: 1;
background: white;
padding: 1rem 1.5rem;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
border-left: 4px solid var(--gray-300);
cursor: pointer;
transition: var(--transition-fast);
}
.stat-card:hover {
transform: translateY(-2px);
box-shadow: var(--shadow-md);
}
.stat-card.active {
background: var(--primary-50);
}
.stat-card.reported { border-left-color: var(--error-500); }
.stat-card.received { border-left-color: var(--warning-500); }
.stat-card.in_progress { border-left-color: var(--primary-500); }
.stat-card.completed { border-left-color: var(--success-500); }
.stat-value {
font-size: 1.75rem;
font-weight: 700;
}
.stat-label {
font-size: 0.875rem;
color: var(--text-tertiary);
}
.repair-table-container {
background: white;
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.repair-table {
width: 100%;
border-collapse: collapse;
}
.repair-table th,
.repair-table td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid var(--border-light);
}
.repair-table th {
background: var(--bg-secondary);
font-weight: 600;
font-size: 0.875rem;
color: var(--text-secondary);
}
.repair-table tr:hover {
background: var(--bg-secondary);
}
.status-badge {
display: inline-flex;
align-items: center;
padding: 0.25rem 0.75rem;
border-radius: var(--radius-full);
font-size: 0.75rem;
font-weight: 600;
}
.status-badge.reported {
background: var(--error-100);
color: var(--error-700);
}
.status-badge.received {
background: var(--warning-100);
color: var(--warning-700);
}
.status-badge.in_progress {
background: var(--primary-100);
color: var(--primary-700);
}
.status-badge.completed, .status-badge.resolved {
background: var(--success-100);
color: var(--success-700);
}
.action-btns {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
border-radius: var(--radius-md);
border: none;
cursor: pointer;
font-weight: 500;
transition: var(--transition-fast);
white-space: nowrap;
}
.btn-receive {
background: var(--warning-500);
color: white;
}
.btn-receive:hover {
background: var(--warning-600);
}
.btn-start {
background: var(--primary-500);
color: white;
}
.btn-start:hover {
background: var(--primary-600);
}
.btn-complete {
background: var(--success-500);
color: white;
}
.btn-complete:hover {
background: var(--success-600);
}
.btn-view {
background: var(--gray-100);
color: var(--text-secondary);
}
.btn-view:hover {
background: var(--gray-200);
}
.empty-state {
padding: 3rem;
text-align: center;
color: var(--text-tertiary);
}
.repair-desc {
max-width: 200px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.assignee-info {
font-size: 0.75rem;
color: var(--text-tertiary);
}
/* 모달 */
.modal-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0,0,0,0.5);
z-index: 1000;
align-items: center;
justify-content: center;
}
.modal-overlay.show {
display: flex;
}
.modal-content {
background: white;
border-radius: var(--radius-lg);
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
}
.modal-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid var(--border-light);
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-header h3 {
margin: 0;
font-size: 1.125rem;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
cursor: pointer;
color: var(--text-tertiary);
}
.modal-body {
padding: 1.5rem;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
font-size: 0.875rem;
}
.form-group input,
.form-group textarea,
.form-group select {
width: 100%;
padding: 0.75rem;
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
font-size: 0.875rem;
}
.form-group textarea {
min-height: 100px;
resize: vertical;
}
.modal-footer {
padding: 1rem 1.5rem;
border-top: 1px solid var(--border-light);
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.btn {
padding: 0.625rem 1.25rem;
border-radius: var(--radius-md);
font-weight: 500;
cursor: pointer;
border: none;
}
.btn-primary {
background: var(--primary-500);
color: white;
}
.btn-secondary {
background: var(--gray-100);
color: var(--text-primary);
}
.detail-row {
display: flex;
margin-bottom: 0.75rem;
font-size: 0.875rem;
}
.detail-label {
width: 80px;
color: var(--text-tertiary);
flex-shrink: 0;
}
.detail-value {
flex: 1;
color: var(--text-primary);
}
.photo-thumbnails {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
margin-top: 0.5rem;
}
.photo-thumb {
width: 60px;
height: 60px;
object-fit: cover;
border-radius: var(--radius-md);
cursor: pointer;
}
@media (max-width: 768px) {
.stats-row {
flex-wrap: wrap;
}
.stat-card {
flex: 1 1 calc(50% - 0.5rem);
}
.repair-table-container {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
}
.repair-table {
font-size: 0.875rem;
}
.repair-table th,
.repair-table td {
padding: 0.625rem 0.5rem;
font-size: 0.8rem;
}
.repair-table th:nth-child(3),
.repair-table td:nth-child(3),
.repair-table th:nth-child(5),
.repair-table td:nth-child(5) {
display: none;
}
.repair-desc {
max-width: 120px;
}
.modal-content {
width: 95% !important;
max-width: none !important;
}
}
</style>
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<div class="repair-page">
<div class="page-header">
<h1>시설설비 관리</h1>
</div>
<div class="stats-row">
<div class="stat-card reported" onclick="filterByStatus('reported')">
<div class="stat-value" id="reportedCount">0</div>
<div class="stat-label">신청</div>
</div>
<div class="stat-card received" onclick="filterByStatus('received')">
<div class="stat-value" id="receivedCount">0</div>
<div class="stat-label">접수</div>
</div>
<div class="stat-card in_progress" onclick="filterByStatus('in_progress')">
<div class="stat-value" id="inProgressCount">0</div>
<div class="stat-label">처리중</div>
</div>
<div class="stat-card completed" onclick="filterByStatus('completed')">
<div class="stat-value" id="completedCount">0</div>
<div class="stat-label">완료</div>
</div>
</div>
<div class="repair-table-container">
<table class="repair-table">
<thead>
<tr>
<th>신청일</th>
<th>작업장</th>
<th>유형</th>
<th>설명</th>
<th>담당자</th>
<th>상태</th>
<th>처리</th>
</tr>
</thead>
<tbody id="repairTableBody">
<tr>
<td colspan="7" class="empty-state">로딩중...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- 접수 모달 -->
<div class="modal-overlay" id="receiveModal">
<div class="modal-content">
<div class="modal-header">
<h3>접수 확인</h3>
<button class="modal-close" onclick="closeModal('receiveModal')">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>담당자 배정 *</label>
<select id="receiveAssignee" required>
<option value="">담당자 선택</option>
</select>
</div>
<div class="form-group">
<label>메모</label>
<textarea id="receiveNotes" placeholder="접수 시 메모 (선택사항)"></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('receiveModal')">취소</button>
<button class="btn btn-primary" onclick="submitReceive()">접수 확인</button>
</div>
</div>
</div>
<!-- 완료 모달 -->
<div class="modal-overlay" id="completeModal">
<div class="modal-content">
<div class="modal-header">
<h3>완료 처리</h3>
<button class="modal-close" onclick="closeModal('completeModal')">&times;</button>
</div>
<div class="modal-body">
<div class="form-group">
<label>처리 내용 *</label>
<textarea id="completeNotes" placeholder="처리 내용을 입력하세요..." required></textarea>
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('completeModal')">취소</button>
<button class="btn btn-primary" onclick="submitComplete()">완료 처리</button>
</div>
</div>
</div>
<!-- 상세 모달 -->
<div class="modal-overlay" id="detailModal">
<div class="modal-content">
<div class="modal-header">
<h3>상세 정보</h3>
<button class="modal-close" onclick="closeModal('detailModal')">&times;</button>
</div>
<div class="modal-body" id="detailContent">
</div>
<div class="modal-footer">
<button class="btn btn-secondary" onclick="closeModal('detailModal')">닫기</button>
</div>
</div>
</div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/api-base.js?v=2026031401"></script>
<script>
let currentReportId = null;
let allRepairs = [];
let workers = [];
let currentFilter = null;
document.addEventListener('DOMContentLoaded', function() {
setTimeout(() => {
loadWorkers();
loadRepairRequests();
}, 300);
});
async function loadWorkers() {
try {
const response = await window.apiCall('/workers?status=active');
if (response.success) {
workers = response.data || [];
populateAssigneeDropdown();
}
} catch (error) {
console.error('작업자 목록 로드 오류:', error);
}
}
function populateAssigneeDropdown() {
const select = document.getElementById('receiveAssignee');
select.innerHTML = '<option value="">담당자 선택</option>' +
workers.map(w => `<option value="${w.user_id}">${w.worker_name} (${w.job_type || ''})</option>`).join('');
}
async function loadRepairRequests() {
try {
const response = await window.apiCall('/work-issues?category_type=facility');
if (response.success) {
allRepairs = response.data || [];
updateStats();
renderTable();
}
} catch (error) {
console.error('수리 목록 로드 오류:', error);
document.getElementById('repairTableBody').innerHTML =
'<tr><td colspan="7" class="empty-state">데이터를 불러올 수 없습니다.</td></tr>';
}
}
function updateStats() {
const counts = {
reported: 0,
received: 0,
in_progress: 0,
completed: 0
};
allRepairs.forEach(r => {
if (counts.hasOwnProperty(r.status)) {
counts[r.status]++;
}
});
document.getElementById('reportedCount').textContent = counts.reported;
document.getElementById('receivedCount').textContent = counts.received;
document.getElementById('inProgressCount').textContent = counts.in_progress;
document.getElementById('completedCount').textContent = counts.completed;
// 활성 필터 표시
document.querySelectorAll('.stat-card').forEach(card => {
card.classList.remove('active');
});
if (currentFilter) {
document.querySelector(`.stat-card.${currentFilter}`)?.classList.add('active');
}
}
function filterByStatus(status) {
if (currentFilter === status) {
currentFilter = null; // 토글 off
} else {
currentFilter = status;
}
updateStats();
renderTable();
}
function renderTable() {
const tbody = document.getElementById('repairTableBody');
let filtered = allRepairs;
if (currentFilter) {
filtered = allRepairs.filter(r => r.status === currentFilter);
}
if (filtered.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="empty-state">수리 신청 내역이 없습니다.</td></tr>';
return;
}
tbody.innerHTML = filtered.map(r => `
<tr>
<td>${formatDate(r.report_date)}</td>
<td>${r.workplace_name || '-'}</td>
<td>${r.issue_item_name || '-'}</td>
<td class="repair-desc" title="${escapeHtml(r.additional_description || '')}">${escapeHtml(r.additional_description || '-')}</td>
<td>
${r.assigned_full_name || r.assigned_user_name || '-'}
${r.assigned_at ? `<div class="assignee-info">${formatDate(r.assigned_at)}</div>` : ''}
</td>
<td><span class="status-badge ${r.status}">${getStatusText(r.status)}</span></td>
<td class="action-btns">
${getActionButtons(r)}
</td>
</tr>
`).join('');
}
function getActionButtons(r) {
let buttons = '';
switch (r.status) {
case 'reported':
buttons = `<button class="btn-sm btn-receive" onclick="openReceiveModal(${r.report_id})">접수</button>`;
break;
case 'received':
buttons = `<button class="btn-sm btn-start" onclick="startProcessing(${r.report_id})">처리시작</button>`;
break;
case 'in_progress':
buttons = `<button class="btn-sm btn-complete" onclick="openCompleteModal(${r.report_id})">완료</button>`;
break;
}
buttons += `<button class="btn-sm btn-view" onclick="viewDetail(${r.report_id})">상세</button>`;
return buttons;
}
function getStatusText(status) {
const texts = {
'reported': '신청',
'received': '접수',
'in_progress': '처리중',
'completed': '완료',
'closed': '종료',
'resolved': '완료'
};
return texts[status] || status;
}
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('ko-KR', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
}
function escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// 접수 모달
function openReceiveModal(reportId) {
currentReportId = reportId;
document.getElementById('receiveNotes').value = '';
document.getElementById('receiveAssignee').value = '';
document.getElementById('receiveModal').classList.add('show');
}
async function submitReceive() {
const assigneeId = document.getElementById('receiveAssignee').value;
if (!assigneeId) {
alert('담당자를 선택해주세요.');
return;
}
try {
// 1. 접수 처리
const receiveRes = await window.apiCall(`/work-issues/${currentReportId}/receive`, {
method: 'PUT'
});
if (!receiveRes.success) {
throw new Error(receiveRes.message || '접수 처리 실패');
}
// 2. 담당자 배정
const assignRes = await window.apiCall(`/work-issues/${currentReportId}/assign`, {
method: 'PUT',
body: JSON.stringify({
assigned_user_id: parseInt(assigneeId)
})
});
// 3. 관련 알림 읽음 처리
await markRelatedNotificationAsRead(currentReportId);
alert('접수 완료되었습니다.');
closeModal('receiveModal');
loadRepairRequests();
} catch (error) {
console.error('접수 오류:', error);
alert('접수 처리 중 오류가 발생했습니다: ' + error.message);
}
}
// 처리 시작
async function startProcessing(reportId) {
if (!confirm('처리를 시작하시겠습니까?')) return;
try {
const response = await window.apiCall(`/work-issues/${reportId}/start`, {
method: 'PUT'
});
if (response.success) {
alert('처리가 시작되었습니다.');
loadRepairRequests();
} else {
throw new Error(response.message);
}
} catch (error) {
console.error('처리 시작 오류:', error);
alert('처리 시작 중 오류가 발생했습니다.');
}
}
// 완료 모달
function openCompleteModal(reportId) {
currentReportId = reportId;
document.getElementById('completeNotes').value = '';
document.getElementById('completeModal').classList.add('show');
}
async function submitComplete() {
const notes = document.getElementById('completeNotes').value.trim();
if (!notes) {
alert('처리 내용을 입력해주세요.');
return;
}
try {
const response = await window.apiCall(`/work-issues/${currentReportId}/complete`, {
method: 'PUT',
body: JSON.stringify({
resolution_notes: notes
})
});
if (response.success) {
alert('완료 처리되었습니다.');
closeModal('completeModal');
loadRepairRequests();
} else {
throw new Error(response.message);
}
} catch (error) {
console.error('완료 처리 오류:', error);
alert('완료 처리 중 오류가 발생했습니다.');
}
}
// 상세 보기
async function viewDetail(reportId) {
try {
const response = await window.apiCall(`/work-issues/${reportId}`);
if (response.success && response.data) {
const r = response.data;
let html = `
<div class="detail-row">
<span class="detail-label">신청일</span>
<span class="detail-value">${formatDate(r.report_date)}</span>
</div>
<div class="detail-row">
<span class="detail-label">신청자</span>
<span class="detail-value">${r.reporter_full_name || r.reporter_name || '-'}</span>
</div>
<div class="detail-row">
<span class="detail-label">작업장</span>
<span class="detail-value">${r.workplace_name || '-'}</span>
</div>
<div class="detail-row">
<span class="detail-label">유형</span>
<span class="detail-value">${r.issue_item_name || '-'}</span>
</div>
<div class="detail-row">
<span class="detail-label">상태</span>
<span class="detail-value"><span class="status-badge ${r.status}">${getStatusText(r.status)}</span></span>
</div>
<div class="detail-row">
<span class="detail-label">설명</span>
<span class="detail-value">${escapeHtml(r.additional_description) || '-'}</span>
</div>
`;
if (r.assigned_full_name || r.assigned_user_name) {
html += `
<div class="detail-row">
<span class="detail-label">담당자</span>
<span class="detail-value">${r.assigned_full_name || r.assigned_user_name}</span>
</div>
`;
}
if (r.resolution_notes) {
html += `
<div class="detail-row">
<span class="detail-label">처리내용</span>
<span class="detail-value">${escapeHtml(r.resolution_notes)}</span>
</div>
`;
}
// 사진
const photos = [r.photo_path1, r.photo_path2, r.photo_path3, r.photo_path4, r.photo_path5].filter(p => p);
if (photos.length > 0) {
html += `
<div class="detail-row">
<span class="detail-label">사진</span>
<span class="detail-value">
<div class="photo-thumbnails">
${photos.map(p => `<img src="${p}" class="photo-thumb" onclick="window.open('${p}', '_blank')">`).join('')}
</div>
</span>
</div>
`;
}
document.getElementById('detailContent').innerHTML = html;
document.getElementById('detailModal').classList.add('show');
}
} catch (error) {
console.error('상세 조회 오류:', error);
alert('상세 정보를 불러올 수 없습니다.');
}
}
function closeModal(modalId) {
document.getElementById(modalId).classList.remove('show');
currentReportId = null;
}
async function markRelatedNotificationAsRead(reportId) {
try {
const response = await window.apiCall('/notifications?limit=100');
if (response.success) {
const notifications = response.data || [];
const related = notifications.find(n =>
n.reference_type === 'work_issue_reports' &&
n.reference_id == reportId
);
if (related) {
await window.apiCall(`/notifications/${related.notification_id}/read`, {
method: 'POST'
});
}
}
} catch (e) {
console.warn('알림 읽음 처리 실패:', e);
}
}
// 모달 외부 클릭시 닫기
document.querySelectorAll('.modal-overlay').forEach(modal => {
modal.addEventListener('click', function(e) {
if (e.target === this) {
this.classList.remove('show');
}
});
});
</script>
<script>initAuth();</script>
</body>
</html>

View File

@@ -0,0 +1,4 @@
<!DOCTYPE html><html><head><meta charset="UTF-8"><script>
var h=location.hostname;var base=h.includes('technicalkorea.net')?'https://tkuser.technicalkorea.net':'http://'+h+':30380';
location.replace(base+'/?tab=tasks');
</script></head><body>이동 중...</body></html>

View File

@@ -0,0 +1,4 @@
<!DOCTYPE html><html><head><meta charset="UTF-8"><script>
var h=location.hostname;var base=h.includes('technicalkorea.net')?'https://tkuser.technicalkorea.net':'http://'+h+':30380';
location.replace(base+'/?tab=workplaces');
</script></head><body>이동 중...</body></html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,428 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>출근 체크 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<style>
.page-wrapper {
padding: 1.5rem;
max-width: 1200px;
}
.page-title {
font-size: 1.5rem;
font-weight: 700;
margin-bottom: 0.5rem;
}
.page-desc {
color: #64748b;
margin-bottom: 1.5rem;
}
.controls {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
align-items: center;
}
.controls input[type="date"] {
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;
}
.btn-primary { background: #3b82f6; color: white; }
.btn-outline { background: white; border: 1px solid #d1d5db; }
.btn-success { background: #10b981; color: white; }
/* 요약 바 */
.summary-bar {
display: flex;
gap: 1.5rem;
padding: 1rem;
background: white;
border-radius: 0.5rem;
margin-bottom: 1rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.summary-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
.summary-dot {
width: 10px;
height: 10px;
border-radius: 50%;
}
.dot-present { background: #10b981; }
.dot-absent { background: #ef4444; }
.dot-vacation { background: #3b82f6; }
.summary-count { font-weight: 700; }
.summary-label { color: #6b7280; font-size: 0.875rem; }
/* 작업자 목록 */
.worker-list {
background: white;
border-radius: 0.5rem;
padding: 1rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
.worker-chip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.75rem;
margin: 0.25rem;
border: 2px solid #e5e7eb;
border-radius: 2rem;
background: white;
cursor: pointer;
font-size: 0.875rem;
transition: all 0.15s;
}
.worker-chip:hover {
border-color: #9ca3af;
}
.worker-chip.present {
border-color: #10b981;
background: #ecfdf5;
}
.worker-chip.absent {
border-color: #ef4444;
background: #fef2f2;
}
.worker-chip.vacation {
border-color: #3b82f6;
background: #eff6ff;
cursor: default;
}
.chip-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #d1d5db;
}
.worker-chip.present .chip-dot { background: #10b981; }
.worker-chip.absent .chip-dot { background: #ef4444; }
.worker-chip.vacation .chip-dot { background: #3b82f6; }
.save-section {
text-align: center;
margin-top: 1.5rem;
}
.btn-save {
padding: 0.75rem 2rem;
font-size: 1rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 0.5rem;
cursor: pointer;
}
.btn-save:hover {
background: #2563eb;
}
.btn-save:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.btn-save.saved {
background: #10b981;
}
.status-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
}
.status-badge.saved {
background: #dcfce7;
color: #166534;
}
.status-badge.unsaved {
background: #fef3c7;
color: #92400e;
}
/* 모바일 최적화 */
@media (max-width: 768px) {
.summary-bar { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.5rem; padding: 0.75rem; }
.summary-item { flex-direction: column; text-align: center; }
.summary-count { font-size: 1.25rem; }
.controls { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
.controls input[type="date"] { grid-column: 1 / -1; }
.worker-chip { padding: 0.625rem 1rem; font-size: 1rem; }
.btn-save { width: 100%; font-size: 1.1rem; padding: 1rem; }
.save-section { position: sticky; bottom: 0; background: white; padding: 1rem; margin: 0 -1rem; box-shadow: 0 -2px 8px rgba(0,0,0,0.08); z-index: 20; }
}
</style>
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<h1 class="page-title">출근 체크</h1>
<p class="page-desc">클릭하여 출근/결근 상태를 변경하세요</p>
<div class="controls">
<input type="date" id="selectedDate">
<button class="btn btn-primary" onclick="loadCheckinData()">새로고침</button>
<button class="btn btn-outline" onclick="setAllPresent()">전체 출근</button>
<button class="btn btn-outline" onclick="setAllAbsent()">전체 결근</button>
</div>
<div class="summary-bar">
<div class="summary-item">
<span class="summary-dot dot-present"></span>
<span class="summary-count" id="presentCount">0</span>
<span class="summary-label">출근</span>
</div>
<div class="summary-item">
<span class="summary-dot dot-absent"></span>
<span class="summary-count" id="absentCount">0</span>
<span class="summary-label">결근</span>
</div>
<div class="summary-item">
<span class="summary-dot dot-vacation"></span>
<span class="summary-count" id="vacationCount">0</span>
<span class="summary-label">연차</span>
</div>
</div>
<div class="worker-list" id="workerList">
<!-- 작업자 칩이 여기에 렌더링됩니다 -->
</div>
<div class="save-section">
<div id="saveStatus" style="margin-bottom: 1rem;"></div>
<button id="saveBtn" class="btn-save" onclick="saveCheckin()">출근 체크 저장</button>
</div>
</div>
</div>
</div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/api-base.js?v=2026031401"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
(function() {
const checkApiConfig = setInterval(() => {
if (window.API_BASE_URL) {
clearInterval(checkApiConfig);
axios.defaults.baseURL = window.API_BASE_URL;
const token = localStorage.getItem('sso_token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
}
}, 50);
})();
let workers = [];
let checkinStatus = {};
let isAlreadySaved = false;
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
document.getElementById('selectedDate').value = new Date().toISOString().split('T')[0];
loadCheckinData();
// 날짜 변경 시 자동 로드
document.getElementById('selectedDate').addEventListener('change', loadCheckinData);
});
function waitForAxiosConfig() {
return new Promise((resolve) => {
const check = setInterval(() => {
if (axios.defaults.baseURL) {
clearInterval(check);
resolve();
}
}, 50);
setTimeout(() => { clearInterval(check); resolve(); }, 5000);
});
}
async function loadCheckinData() {
const selectedDate = document.getElementById('selectedDate').value;
if (!selectedDate) return alert('날짜를 선택해주세요.');
try {
const [workersRes, checkinRes, recordsRes] = await Promise.all([
axios.get('/workers?limit=100'),
axios.get(`/attendance/checkin-list?date=${selectedDate}`).catch(() => ({ data: { data: [] } })),
axios.get(`/attendance/daily-records?date=${selectedDate}`).catch(() => ({ data: { data: [] } }))
]);
const allWorkers = workersRes.data.data || [];
workers = allWorkers.filter(w => w.status === 'active' && (!w.employment_status || w.employment_status === 'employed'));
const checkinList = checkinRes.data.data || [];
const records = recordsRes.data.data || [];
// 이미 저장된 기록이 있는지 확인
isAlreadySaved = records.length > 0;
checkinStatus = {};
workers.forEach(w => {
const checkin = checkinList.find(c => c.user_id === w.user_id);
const record = records.find(r => r.user_id === w.user_id);
if (checkin?.vacation_status === 'approved' || record?.vacation_type_id) {
checkinStatus[w.user_id] = { status: 'vacation', vacationType: checkin?.vacation_type_name || record?.vacation_type_name || '연차' };
} else if (record && record.is_present === 0) {
checkinStatus[w.user_id] = { status: 'absent' };
} else if (record && record.is_present === 1) {
checkinStatus[w.user_id] = { status: 'present' };
} else {
// 기록이 없으면 기본 출근
checkinStatus[w.user_id] = { status: 'present' };
}
});
render();
updateSaveStatus();
} catch (e) {
console.error(e);
alert('데이터 로드 실패');
}
}
function render() {
const container = document.getElementById('workerList');
if (workers.length === 0) {
container.innerHTML = '<p style="color:#6b7280;text-align:center;padding:2rem;">작업자가 없습니다</p>';
return;
}
container.innerHTML = workers.map(w => {
const s = checkinStatus[w.user_id] || { status: 'present' };
const label = s.status === 'present' ? '출근' : s.status === 'absent' ? '결근' : (s.vacationType || '연차');
return `<span class="worker-chip ${s.status}" onclick="toggle(${w.user_id})"><span class="chip-dot"></span>${w.worker_name} <small style="color:#6b7280">${label}</small></span>`;
}).join('');
updateSummary();
}
function toggle(id) {
const s = checkinStatus[id];
if (s.status === 'vacation') return;
s.status = s.status === 'present' ? 'absent' : 'present';
render();
}
function setAllPresent() {
workers.forEach(w => {
if (checkinStatus[w.user_id]?.status !== 'vacation') {
checkinStatus[w.user_id] = { status: 'present' };
}
});
render();
}
function setAllAbsent() {
workers.forEach(w => {
if (checkinStatus[w.user_id]?.status !== 'vacation') {
checkinStatus[w.user_id] = { status: 'absent' };
}
});
render();
}
function updateSummary() {
let p = 0, a = 0, v = 0;
Object.values(checkinStatus).forEach(s => {
if (s.status === 'present') p++;
else if (s.status === 'absent') a++;
else v++;
});
document.getElementById('presentCount').textContent = p;
document.getElementById('absentCount').textContent = a;
document.getElementById('vacationCount').textContent = v;
}
function updateSaveStatus() {
const statusEl = document.getElementById('saveStatus');
const saveBtn = document.getElementById('saveBtn');
if (isAlreadySaved) {
statusEl.innerHTML = '<span class="status-badge saved">이 날짜는 이미 출근 체크가 완료되었습니다</span>';
saveBtn.textContent = '수정하여 다시 저장';
saveBtn.classList.add('saved');
} else {
statusEl.innerHTML = '<span class="status-badge unsaved">아직 저장되지 않았습니다</span>';
saveBtn.textContent = '출근 체크 저장';
saveBtn.classList.remove('saved');
}
}
async function saveCheckin() {
const date = document.getElementById('selectedDate').value;
if (!date) return alert('날짜를 선택해주세요.');
// 이미 저장된 경우 확인
if (isAlreadySaved) {
if (!confirm('이미 저장된 데이터가 있습니다.\n수정하시겠습니까?')) {
return;
}
}
// 연차가 아닌 작업자들만 체크인 데이터로 전송
const checkins = workers
.filter(w => checkinStatus[w.user_id]?.status !== 'vacation')
.map(w => ({
user_id: w.user_id,
is_present: checkinStatus[w.user_id]?.status === 'present'
}));
try {
const res = await axios.post('/attendance/checkins', { date, checkins });
if (res.data.success) {
alert(`${res.data.data.saved_count}명 출근 체크 저장 완료`);
isAlreadySaved = true;
updateSaveStatus();
} else {
alert('저장 실패: ' + (res.data.message || '알 수 없는 오류'));
}
} catch (e) {
console.error(e);
alert('저장 실패: ' + (e.response?.data?.message || e.message));
}
}
</script>
<script>initAuth();</script>
</body>
</html>

View File

@@ -0,0 +1,472 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>일일 출퇴근 입력 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<div class="page-header">
<div class="page-title-section">
<h1 class="page-title">일일 출퇴근 입력</h1>
<p class="page-description">오늘 출근한 작업자들의 출퇴근 기록을 입력합니다</p>
</div>
<div class="page-actions">
<input type="date" id="selectedDate" class="form-control" style="width: auto;">
<button class="btn btn-primary" onclick="loadAttendanceRecords()">
새로고침
</button>
</div>
</div>
<!-- 출퇴근 입력 폼 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">작업자 출퇴근 기록</h2>
<p class="text-muted">근태 구분을 선택하면 근무시간이 자동 입력됩니다. 연장근로는 별도로 입력 가능합니다. (조퇴: 2시간, 반반차: 6시간, 반차: 4시간, 정시: 8시간, 주말근무: 0시간)</p>
</div>
<div class="card-body">
<div id="attendanceList" class="data-table-container">
<!-- 출퇴근 기록 목록이 여기에 동적으로 렌더링됩니다 -->
</div>
<div style="text-align: center; margin-top: 2rem;">
<button class="btn btn-primary" onclick="saveAllAttendance()" style="padding: 1rem 3rem; font-size: 1.1rem;">
전체 저장
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/api-base.js?v=2026031401"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
// axios 기본 설정
(function() {
const checkApiConfig = setInterval(() => {
if (window.API_BASE_URL) {
clearInterval(checkApiConfig);
axios.defaults.baseURL = window.API_BASE_URL;
const token = localStorage.getItem('sso_token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('sso_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => Promise.reject(error)
);
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>
// 전역 변수
let workers = [];
let attendanceRecords = [];
// 근태 구분 옵션 (근무시간 자동 설정, 연장근로는 별도 입력)
const attendanceTypes = [
{ value: 'on_time', label: '정시', hours: 8 },
{ value: 'half_leave', label: '반차', hours: 4 },
{ value: 'quarter_leave', label: '반반차', hours: 6 },
{ value: 'early_leave', label: '조퇴', hours: 2 },
{ value: 'weekend_work', label: '주말근무', hours: 0 },
{ value: 'annual_leave', label: '연차', hours: 0 },
{ value: 'custom', label: '특이사항', hours: 0 }
];
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
initializePage();
});
function waitForAxiosConfig() {
return new Promise((resolve) => {
const check = setInterval(() => {
if (axios.defaults.baseURL) {
clearInterval(check);
resolve();
}
}, 50);
setTimeout(() => {
clearInterval(check);
resolve();
}, 5000);
});
}
async function initializePage() {
// 오늘 날짜 설정
const today = new Date().toISOString().split('T')[0];
document.getElementById('selectedDate').value = today;
try {
await loadWorkers();
await loadAttendanceRecords();
} catch (error) {
console.error('초기화 오류:', error);
alert('페이지 초기화 중 오류가 발생했습니다.');
}
}
async function loadWorkers() {
try {
const response = await axios.get('/workers?limit=100');
if (response.data.success) {
workers = response.data.data.filter(w => w.employment_status === 'employed');
}
} catch (error) {
console.error('작업자 목록 로드 오류:', error);
}
}
async function loadAttendanceRecords() {
const selectedDate = document.getElementById('selectedDate').value;
if (!selectedDate) {
alert('날짜를 선택해주세요.');
return;
}
try {
// 출퇴근 기록과 체크인 목록(휴가 정보 포함)을 동시에 가져오기
const [recordsResponse, checkinResponse] = await Promise.all([
axios.get(`/attendance/records?date=${selectedDate}`).catch(() => ({ data: { success: false, data: [] } })),
axios.get(`/attendance/checkin-list?date=${selectedDate}`).catch(() => ({ data: { success: false, data: [] } }))
]);
const existingRecords = recordsResponse.data.success ? recordsResponse.data.data : [];
const checkinList = checkinResponse.data.success ? checkinResponse.data.data : [];
// 체크인 목록을 기준으로 출퇴근 기록 생성 (연차 정보 포함)
attendanceRecords = checkinList.map(worker => {
const existingRecord = existingRecords.find(r => r.user_id === worker.user_id);
const isOnVacation = worker.vacation_status === 'approved';
// 기존 기록이 있으면 사용, 없으면 초기화
if (existingRecord) {
return existingRecord;
} else {
return {
user_id: worker.user_id,
worker_name: worker.worker_name,
attendance_date: selectedDate,
total_hours: isOnVacation ? 0 : 8,
overtime_hours: 0,
attendance_type: isOnVacation ? 'annual_leave' : 'on_time',
is_custom: false,
is_new: true,
is_on_vacation: isOnVacation,
vacation_type_name: worker.vacation_type_name
};
}
});
renderAttendanceList();
} catch (error) {
console.error('출퇴근 기록 로드 오류:', error);
alert('출퇴근 기록 조회 중 오류가 발생했습니다.');
}
}
function initializeAttendanceRecords() {
const selectedDate = document.getElementById('selectedDate').value;
attendanceRecords = workers.map(worker => ({
user_id: worker.user_id,
worker_name: worker.worker_name,
attendance_date: selectedDate,
total_hours: 8,
overtime_hours: 0,
attendance_type: 'on_time',
is_custom: false,
is_new: true
}));
renderAttendanceList();
}
function renderAttendanceList() {
const container = document.getElementById('attendanceList');
if (attendanceRecords.length === 0) {
container.innerHTML = `
<div class="empty-state">
<p>등록된 작업자가 없거나 출퇴근 기록이 없습니다.</p>
<button class="btn btn-primary" onclick="initializeAttendanceRecords()">
작업자 목록으로 초기화
</button>
</div>
`;
return;
}
const isMobile = window.innerWidth <= 768;
if (isMobile) {
// 모바일: 카드 뷰
const cardsHTML = `
<div class="mobile-attendance-cards">
${attendanceRecords.map((record, index) => {
const isCustom = record.is_custom || record.attendance_type === 'custom';
const isHoursReadonly = !isCustom;
const isOnVacation = record.is_on_vacation || false;
const vacationLabel = record.vacation_type_name ? ` (${record.vacation_type_name})` : '';
return `
<div class="mobile-attendance-card ${isOnVacation ? 'vacation' : ''}">
<div class="card-name">
${record.worker_name}
${isOnVacation ? `<span style="padding: 0.125rem 0.5rem; background: #dbeafe; color: #1e40af; border-radius: 0.25rem; font-size: 0.7rem; font-weight: 500;">연차${vacationLabel}</span>` : ''}
</div>
<div class="card-fields">
<div class="card-field">
<label>근태 구분</label>
<select onchange="updateAttendanceType(${index}, this.value)">
${attendanceTypes.map(type => `
<option value="${type.value}" ${record.attendance_type === type.value ? 'selected' : ''}>${type.label}</option>
`).join('')}
</select>
</div>
<div class="card-field">
<label>근무시간</label>
<input type="number" id="hours_${index}" value="${record.total_hours || 0}"
min="0" max="24" step="0.5" ${isHoursReadonly ? 'readonly' : ''}
onchange="updateTotalHours(${index}, this.value)"
style="background: ${isHoursReadonly ? '#f9fafb' : 'white'}; text-align: center;">
</div>
<div class="card-field">
<label>연장근로</label>
<input type="number" id="overtime_${index}" value="${record.overtime_hours || 0}"
min="0" max="12" step="0.5"
onchange="updateOvertimeHours(${index}, this.value)"
style="text-align: center;">
</div>
<div class="card-field">
<label>특이사항</label>
<div style="padding: 0.5rem; text-align: center;">
${isCustom ? '<span style="color: #dc2626; font-weight: 600;">✓</span>' : '<span style="color: #9ca3af;">-</span>'}
</div>
</div>
</div>
</div>
`;
}).join('')}
</div>
`;
container.innerHTML = cardsHTML;
} else {
// 데스크탑: 테이블 뷰
const tableHTML = `
<table class="data-table" style="font-size: 0.95rem;">
<thead style="background-color: #f8f9fa;">
<tr>
<th style="width: 120px;">작업자</th>
<th style="width: 180px;">근태 구분</th>
<th style="width: 100px;">근무시간</th>
<th style="width: 120px;">연장근로</th>
<th style="width: 100px;">특이사항</th>
</tr>
</thead>
<tbody>
${attendanceRecords.map((record, index) => {
const isCustom = record.is_custom || record.attendance_type === 'custom';
const isHoursReadonly = !isCustom;
const isOnVacation = record.is_on_vacation || false;
const vacationLabel = record.vacation_type_name ? ` (${record.vacation_type_name})` : '';
return `
<tr style="border-bottom: 1px solid #e5e7eb; ${isOnVacation ? 'background-color: #f0f9ff;' : ''}">
<td style="padding: 0.75rem; font-weight: 600;">
${record.worker_name}
${isOnVacation ? `<span style="margin-left: 0.5rem; display: inline-block; padding: 0.125rem 0.5rem; background-color: #dbeafe; color: #1e40af; border-radius: 0.25rem; font-size: 0.75rem; font-weight: 500;">연차${vacationLabel}</span>` : ''}
</td>
<td style="padding: 0.75rem;">
<select class="form-control"
onchange="updateAttendanceType(${index}, this.value)"
style="width: 160px; padding: 0.5rem; border: 1px solid #d1d5db; border-radius: 0.375rem;">
${attendanceTypes.map(type => `
<option value="${type.value}" ${record.attendance_type === type.value ? 'selected' : ''}>
${type.label}
</option>
`).join('')}
</select>
</td>
<td style="padding: 0.75rem;">
<input type="number" class="form-control"
id="hours_${index}"
value="${record.total_hours || 0}"
min="0" max="24" step="0.5"
${isHoursReadonly ? 'readonly' : ''}
onchange="updateTotalHours(${index}, this.value)"
style="width: 90px; padding: 0.5rem; border: 1px solid ${isHoursReadonly ? '#e5e7eb' : '#d1d5db'};
border-radius: 0.375rem; background-color: ${isHoursReadonly ? '#f9fafb' : 'white'}; text-align: center;">
</td>
<td style="padding: 0.75rem;">
<input type="number" class="form-control"
id="overtime_${index}"
value="${record.overtime_hours || 0}"
min="0" max="12" step="0.5"
onchange="updateOvertimeHours(${index}, this.value)"
style="width: 90px; padding: 0.5rem; border: 1px solid #d1d5db;
border-radius: 0.375rem; background-color: white; text-align: center;">
</td>
<td style="padding: 0.75rem; text-align: center;">
${isCustom ?
'<span style="color: #dc2626; font-weight: 600;">✓</span>' :
'<span style="color: #9ca3af;">-</span>'}
</td>
</tr>
`;
}).join('')}
</tbody>
</table>
`;
container.innerHTML = tableHTML;
}
}
// 화면 크기 변경 시 재렌더링
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (attendanceRecords.length > 0) renderAttendanceList();
}, 250);
});
function updateTotalHours(index, value) {
attendanceRecords[index].total_hours = parseFloat(value) || 0;
}
function updateOvertimeHours(index, value) {
attendanceRecords[index].overtime_hours = parseFloat(value) || 0;
}
function updateAttendanceType(index, value) {
const record = attendanceRecords[index];
record.attendance_type = value;
// 근태 구분에 따라 자동으로 근무시간 설정
const attendanceType = attendanceTypes.find(t => t.value === value);
if (value === 'custom') {
// 특이사항 선택 시 수동 입력 가능
record.is_custom = true;
// 기존 값 유지, 수동 입력 가능
} else if (attendanceType) {
// 다른 근태 구분 선택 시 근무시간만 자동 설정
record.is_custom = false;
record.total_hours = attendanceType.hours;
// 연장근로는 유지 (별도 입력 가능)
}
// UI 다시 렌더링
renderAttendanceList();
}
async function saveAllAttendance() {
const selectedDate = document.getElementById('selectedDate').value;
if (!selectedDate) {
alert('날짜를 선택해주세요.');
return;
}
if (attendanceRecords.length === 0) {
alert('저장할 출퇴근 기록이 없습니다.');
return;
}
// 모든 기록을 API 형식에 맞게 변환
const recordsToSave = attendanceRecords.map(record => ({
user_id: record.user_id,
attendance_date: selectedDate,
total_hours: record.total_hours || 0,
overtime_hours: record.overtime_hours || 0,
attendance_type: record.attendance_type || 'on_time',
is_custom: record.is_custom || false
}));
try {
// 각 기록을 순차적으로 저장
let successCount = 0;
let errorCount = 0;
for (const data of recordsToSave) {
try {
const response = await axios.post('/attendance/records', data);
if (response.data.success) {
successCount++;
}
} catch (error) {
console.error(`작업자 ${data.user_id} 저장 오류:`, error);
errorCount++;
}
}
if (errorCount === 0) {
alert(`${successCount}건의 출퇴근 기록이 모두 저장되었습니다.`);
await loadAttendanceRecords(); // 저장 후 새로고침
} else {
alert(`${successCount}건 저장 완료, ${errorCount}건 저장 실패\n자세한 내용은 콘솔을 확인해주세요.`);
}
} catch (error) {
console.error('전체 저장 오류:', error);
alert('저장 중 오류가 발생했습니다.');
}
}
</script>
<script>initAuth();</script>
</body>
</html>

View File

@@ -0,0 +1,171 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>월간 근무 비교 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>tailwind.config = { corePlugins: { preflight: false } }</script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<link rel="stylesheet" href="/css/monthly-comparison.css?v=2026040107">
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<!-- 페이지 헤더 -->
<div class="mc-header">
<div class="mc-header-row">
<button type="button" onclick="typeof goBackToList==='function'?goBackToList():history.back()" class="mc-back-btn"><i class="fas fa-arrow-left"></i></button>
<h1 id="pageTitle" style="display:flex;align-items:center;gap:8px;flex:1">월간 근무 비교</h1>
<button id="viewToggleBtn" class="mc-view-toggle hidden" onclick="toggleViewMode()">
<i class="fas fa-users-cog"></i>
</button>
</div>
</div>
<!-- 월 네비게이션 -->
<div class="mc-month-nav">
<button type="button" onclick="changeMonth(-1)"><i class="fas fa-chevron-left"></i></button>
<span id="monthLabel">2026년 3월</span>
<button type="button" onclick="changeMonth(1)"><i class="fas fa-chevron-right"></i></button>
<div class="mc-status-badge" id="statusBadge"></div>
</div>
<!-- ═══ 작업자 뷰 ═══ -->
<div id="workerView">
<div class="mc-summary-cards">
<div class="mc-card"><div class="mc-card-value" id="totalDays">-</div><div class="mc-card-label">총근무일</div></div>
<div class="mc-card"><div class="mc-card-value" id="totalHours">-</div><div class="mc-card-label">총시간</div></div>
<div class="mc-card"><div class="mc-card-value" id="overtimeHours">-</div><div class="mc-card-label">연장근로</div></div>
<div class="mc-card"><div class="mc-card-value" id="vacationDays">-</div><div class="mc-card-label">휴가</div></div>
</div>
<div class="mc-mismatch-alert hidden" id="mismatchAlert">
<i class="fas fa-exclamation-triangle text-amber-500"></i>
<span id="mismatchText"></span>
</div>
<div class="mc-daily-list" id="dailyList">
<div class="ds-skeleton"></div>
<div class="ds-skeleton"></div>
<div class="ds-skeleton"></div>
</div>
<div class="mc-bottom-actions hidden" id="bottomActions">
<button type="button" class="mc-confirm-btn" id="confirmBtn" onclick="confirmMonth()">
<i class="fas fa-check-circle mr-2"></i>확인 완료
</button>
<button type="button" class="mc-reject-btn" id="rejectBtn" onclick="openRejectModal()">
<i class="fas fa-times-circle mr-2"></i>문제 있음
</button>
</div>
<div class="mc-confirmed-status hidden" id="confirmedStatus">
<i class="fas fa-check-circle text-green-500"></i>
<span id="confirmedText"></span>
</div>
<!-- 하단 검토완료 버튼 제거됨 — 상단 헤더로 이동 -->
</div>
<!-- ═══ 관리자 뷰 ═══ -->
<div id="adminView" class="hidden">
<div class="mc-admin-summary" id="adminSummary">
<div class="mc-progress-bar"><div class="mc-progress-fill" id="progressFill"></div></div>
<div class="mc-progress-text" id="progressText"></div>
<div class="mc-status-counts" id="statusCounts"></div>
<button type="button" class="mc-review-send-btn hidden" id="reviewSendBtn" onclick="sendReviewAll()" style="margin-top:8px;width:100%;padding:10px;background:#2563eb;color:white;border:none;border-radius:8px;font-size:0.8rem;font-weight:600;cursor:pointer;">확인요청 발송</button>
</div>
<div class="mc-filter-tabs">
<button class="mc-tab active" data-filter="all" onclick="filterWorkers('all')">전체</button>
<button class="mc-tab" data-filter="confirmed" onclick="filterWorkers('confirmed')">확인</button>
<button class="mc-tab" data-filter="review_sent" onclick="filterWorkers('review_sent')">확인요청</button>
<button class="mc-tab" data-filter="pending" onclick="filterWorkers('pending')">미검토</button>
<button class="mc-tab" data-filter="change_request" onclick="filterWorkers('change_request')">수정요청</button>
<button class="mc-tab" data-filter="rejected" onclick="filterWorkers('rejected')">반려</button>
</div>
<div class="mc-worker-list" id="adminWorkerList">
<div class="ds-skeleton"></div>
</div>
<div class="mc-bottom-export" id="bottomExport">
<button type="button" class="mc-export-btn" id="exportBtn" onclick="downloadExcel()" disabled>
<i class="fas fa-file-excel mr-2"></i>엑셀 다운로드
</button>
<div class="mc-export-note" id="exportNote"></div>
</div>
</div>
<!-- 빈 상태 -->
<div class="mc-empty hidden" id="emptyState">
<i class="fas fa-calendar-xmark text-3xl text-gray-300"></i>
<p>해당 월의 데이터가 없습니다</p>
</div>
<!-- 권한 없음 -->
<div class="ds-empty hidden" id="noPermission">
<i class="fas fa-lock text-3xl text-gray-300"></i>
<p>접근 권한이 없습니다</p>
<a href="/pages/dashboard-new.html" class="ds-link">대시보드로 이동</a>
</div>
</div>
</div>
</div>
<!-- 반려 모달 -->
<div class="mc-modal-overlay hidden" id="rejectModal">
<div class="mc-modal">
<div class="mc-modal-header">
<span><i class="fas fa-times-circle text-red-500 mr-2"></i>문제 있음 (반려)</span>
<button type="button" onclick="closeRejectModal()"><i class="fas fa-times"></i></button>
</div>
<div class="mc-modal-body">
<p class="mc-modal-desc">반려 사유를 입력해주세요:</p>
<textarea id="rejectReason" class="mc-textarea" rows="3" placeholder="예: 3/2 근무시간이 실제와 다릅니다"></textarea>
<p class="mc-modal-note">
<i class="fas fa-info-circle text-blue-400 mr-1"></i>
반려 시 생산지원팀에 알림이 전달됩니다.
</p>
</div>
<div class="mc-modal-footer">
<button type="button" class="mc-modal-cancel" onclick="closeRejectModal()">취소</button>
<button type="button" class="mc-modal-submit" id="rejectSubmitBtn" onclick="submitReject()">반려 제출</button>
</div>
</div>
</div>
<!-- Toast -->
<div id="toastContainer" class="toast-container"></div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/api-base.js?v=2026031701"></script>
<script src="/js/monthly-comparison.js?v=2026040109"></script>
<script>initAuth();</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,113 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>월간 근무 확인 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>tailwind.config = { corePlugins: { preflight: false } }</script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<link rel="stylesheet" href="/css/my-monthly-confirm.css?v=2026040106">
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<!-- 월 네비게이션 -->
<div class="mmc-month-nav">
<button type="button" onclick="changeMonth(-1)"><i class="fas fa-chevron-left"></i></button>
<span id="monthLabel">2026년 3월</span>
<button type="button" onclick="changeMonth(1)"><i class="fas fa-chevron-right"></i></button>
<div class="mmc-status-badge" id="statusBadge"></div>
</div>
<!-- 사용자 정보 -->
<div class="mmc-user-info" id="userInfo">
<span id="userName">-</span>
<span id="userDept" class="mmc-user-dept">-</span>
</div>
<!-- 출근부 테이블 -->
<div class="mmc-table-wrap" id="tableWrap">
<div class="mmc-skeleton"></div>
<div class="mmc-skeleton"></div>
</div>
<!-- 요약 카드 -->
<div class="mmc-sum-cards" id="summaryCards"></div>
<!-- 연차 현황 -->
<div class="mmc-vacation-cards" id="vacationCards"></div>
<!-- 확인 상태 메시지 -->
<div class="mmc-confirmed-status hidden" id="confirmedStatus">
<i class="fas fa-check-circle text-green-500"></i>
<span id="confirmedText"></span>
</div>
<!-- 확인/반려 버튼 -->
<div class="mmc-bottom-actions" id="bottomActions">
<button type="button" class="mmc-confirm-btn" id="confirmBtn" onclick="confirmMonth()">
<i class="fas fa-check-circle mr-2"></i>확인 완료
</button>
<button type="button" class="mmc-reject-btn" id="rejectBtn" onclick="openRejectModal()">
<i class="fas fa-times-circle mr-2"></i>문제 있음
</button>
</div>
</div>
</div>
</div>
<!-- 반려 모달 -->
<div class="mmc-modal-overlay hidden" id="rejectModal">
<div class="mmc-modal">
<div class="mmc-modal-header">
<span><i class="fas fa-times-circle text-red-500 mr-2"></i>문제 있음 (반려)</span>
<button type="button" onclick="closeRejectModal()"><i class="fas fa-times"></i></button>
</div>
<div class="mmc-modal-body">
<p class="mmc-modal-desc">반려 사유를 입력해주세요:</p>
<textarea id="rejectReason" class="mmc-textarea" rows="3" placeholder="예: 3/2 근무시간이 실제와 다릅니다"></textarea>
<p class="mmc-modal-note">
<i class="fas fa-info-circle text-blue-400 mr-1"></i>
반려 시 생산지원팀에 알림이 전달됩니다.
</p>
</div>
<div class="mmc-modal-footer">
<button type="button" class="mmc-modal-cancel" onclick="closeRejectModal()">취소</button>
<button type="button" class="mmc-modal-submit" onclick="submitReject()">반려 제출</button>
</div>
</div>
</div>
<!-- Toast -->
<div id="toastContainer" class="toast-container"></div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/api-base.js?v=2026031701"></script>
<script src="/js/my-monthly-confirm.js?v=2026040106"></script>
<script>initAuth();</script>
</body>
</html>

View File

@@ -0,0 +1,575 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>내 연차 정보 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<link rel="stylesheet" href="/css/tbm-mobile.css?v=2026033108">
<style>
.page-wrapper {
padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px));
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="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<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>
</div>
</div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/api-base.js?v=2026031401"></script>
<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 = window.getSSOToken ? window.getSSOToken() : (localStorage.getItem('sso_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() {
// 현재 사용자 정보 가져오기 (쿠키 우선, localStorage 폴백)
currentUser = window.getSSOUser ? window.getSSOUser() : null;
if (!currentUser) {
const userStr = localStorage.getItem('sso_user');
if (userStr) { try { currentUser = JSON.parse(userStr); } catch(e) {} }
}
// 관리자 여부 확인
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 {
// 일반 사용자: 본인 user_id 사용
if (currentUser?.user_id) {
currentWorkerId = currentUser.user_id;
document.getElementById('infoGrid').style.display = 'grid';
await loadAllData();
} else {
// user_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.user_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.user_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}&user_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>
<script>initAuth();</script>
<script src="/static/js/shared-bottom-nav.js?v=2026040103"></script>
</body>
</html>

View File

@@ -0,0 +1,363 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>휴가 발생 입력 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<link rel="stylesheet" href="/css/vacation-allocation.css">
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<!-- 페이지 헤더 -->
<div class="page-header">
<h1 class="page-title">휴가 발생 입력</h1>
<p class="page-description">작업자별 휴가를 입력하고 특별 휴가를 관리합니다</p>
</div>
<!-- 탭 네비게이션 -->
<div class="tab-navigation">
<button class="tab-button active" data-tab="individual">개별 입력</button>
<button class="tab-button" data-tab="bulk">일괄 입력</button>
<button class="tab-button" data-tab="special">특별 휴가 관리</button>
</div>
<!-- 탭 1: 개별 입력 -->
<section id="tab-individual" class="tab-content active">
<div class="card">
<div class="card-header">
<h2 class="card-title">개별 작업자 휴가 입력</h2>
</div>
<div class="card-body">
<!-- 입력 폼 -->
<div class="form-section">
<div class="form-row">
<div class="form-group">
<label for="individualWorker">작업자 선택 <span class="required">*</span></label>
<select id="individualWorker" class="form-select" required>
<option value="">선택하세요</option>
<!-- JavaScript로 동적 생성 -->
</select>
</div>
<div class="form-group">
<label for="individualYear">연도 <span class="required">*</span></label>
<select id="individualYear" class="form-select" required>
<!-- JavaScript로 동적 생성 -->
</select>
</div>
<div class="form-group">
<label for="individualVacationType">휴가 유형 <span class="required">*</span></label>
<select id="individualVacationType" class="form-select" required>
<option value="">선택하세요</option>
<!-- JavaScript로 동적 생성 -->
</select>
</div>
</div>
<!-- 자동 계산 섹션 -->
<div class="auto-calculate-section">
<div class="section-header">
<h3>자동 계산 (연차만 해당)</h3>
<button id="autoCalculateBtn" class="btn btn-secondary btn-sm">
입사일 기준 자동 계산
</button>
</div>
<div id="autoCalculateResult" class="alert alert-info" style="display: none;">
<!-- 계산 결과 표시 -->
</div>
</div>
<!-- 수동 입력 -->
<div class="form-row">
<div class="form-group">
<label for="individualTotalDays">총 부여 일수 <span class="required">*</span></label>
<input type="number" id="individualTotalDays" class="form-input" min="0" step="0.5" required>
</div>
<div class="form-group">
<label for="individualUsedDays">사용 일수</label>
<input type="number" id="individualUsedDays" class="form-input" min="0" step="0.5" value="0">
</div>
<div class="form-group full-width">
<label for="individualNotes">비고</label>
<input type="text" id="individualNotes" class="form-input" placeholder="예: 2026년 연차">
</div>
</div>
<div class="form-actions">
<button id="individualSubmitBtn" class="btn btn-primary">
저장
</button>
<button id="individualResetBtn" class="btn btn-secondary">
초기화
</button>
</div>
</div>
<!-- 기존 데이터 테이블 -->
<div class="existing-data-section">
<h3>기존 입력 내역</h3>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>작업자</th>
<th>연도</th>
<th>휴가 유형</th>
<th>총 일수</th>
<th>사용 일수</th>
<th>잔여 일수</th>
<th>비고</th>
<th>작업</th>
</tr>
</thead>
<tbody id="individualTableBody">
<tr>
<td colspan="8" class="loading-state">
<p>작업자를 선택하세요</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</section>
<!-- 탭 2: 일괄 입력 -->
<section id="tab-bulk" class="tab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">근속년수별 연차 일괄 생성</h2>
</div>
<div class="card-body">
<div class="alert alert-warning">
<strong>주의:</strong> 이 기능은 연차(ANNUAL) 휴가 유형만 생성합니다. 각 작업자의 입사일을 기준으로 근속년수를 계산하여 자동으로 연차를 부여합니다.
</div>
<div class="form-section">
<div class="form-row">
<div class="form-group">
<label for="bulkYear">대상 연도 <span class="required">*</span></label>
<select id="bulkYear" class="form-select" required>
<!-- JavaScript로 동적 생성 -->
</select>
</div>
<div class="form-group">
<label for="bulkEmploymentStatus">재직 상태</label>
<select id="bulkEmploymentStatus" class="form-select">
<option value="employed">재직 중만</option>
<option value="all">전체</option>
</select>
</div>
</div>
<div class="form-actions">
<button id="bulkPreviewBtn" class="btn btn-secondary">
미리보기
</button>
<button id="bulkSubmitBtn" class="btn btn-primary" disabled>
일괄 생성
</button>
</div>
</div>
<!-- 미리보기 테이블 -->
<div id="bulkPreviewSection" class="preview-section" style="display: none;">
<h3>생성 예정 내역</h3>
<div class="table-responsive">
<table class="data-table">
<thead>
<tr>
<th>작업자</th>
<th>입사일</th>
<th>근속년수</th>
<th>부여 연차</th>
<th>계산 근거</th>
<th>상태</th>
</tr>
</thead>
<tbody id="bulkPreviewTableBody">
<!-- JavaScript로 동적 생성 -->
</tbody>
</table>
</div>
</div>
</div>
</div>
</section>
<!-- 탭 3: 특별 휴가 관리 -->
<section id="tab-special" class="tab-content">
<div class="card">
<div class="card-header">
<h2 class="card-title">특별 휴가 유형 관리</h2>
<button id="addSpecialTypeBtn" class="btn btn-primary btn-sm">
+ 새 휴가 유형 추가
</button>
</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="specialTypesTableBody">
<tr>
<td colspan="7" class="loading-state">
<div class="spinner"></div>
<p>데이터를 불러오는 중...</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
<!-- 알림 토스트 -->
<div class="toast-container" id="toastContainer"></div>
<!-- 모달: 휴가 유형 추가/수정 -->
<div id="vacationTypeModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3 id="modalTitle">휴가 유형 추가</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<form id="vacationTypeForm">
<input type="hidden" id="modalTypeId">
<div class="form-group">
<label for="modalTypeName">유형명 <span class="required">*</span></label>
<input type="text" id="modalTypeName" class="form-input" required>
</div>
<div class="form-group">
<label for="modalTypeCode">코드 <span class="required">*</span></label>
<input type="text" id="modalTypeCode" class="form-input" required>
<small>예: ANNUAL, SICK, MATERNITY (영문 대문자)</small>
</div>
<div class="form-row">
<div class="form-group">
<label for="modalPriority">우선순위 <span class="required">*</span></label>
<input type="number" id="modalPriority" class="form-input" min="1" required>
<small>낮을수록 먼저 차감</small>
</div>
<div class="form-group">
<label>
<input type="checkbox" id="modalIsSpecial">
특별 휴가
</label>
</div>
</div>
<div class="form-group">
<label for="modalDescription">설명</label>
<textarea id="modalDescription" class="form-input" rows="3"></textarea>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">저장</button>
<button type="button" class="btn btn-secondary modal-close">취소</button>
</div>
</form>
</div>
</div>
</div>
<!-- 모달: 휴가 수정 -->
<div id="editBalanceModal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3>휴가 수정</h3>
<button class="modal-close">&times;</button>
</div>
<div class="modal-body">
<form id="editBalanceForm">
<input type="hidden" id="editBalanceId">
<div class="form-group">
<label for="editTotalDays">총 일수 <span class="required">*</span></label>
<input type="number" id="editTotalDays" class="form-input" min="0" step="0.5" required>
</div>
<div class="form-group">
<label for="editUsedDays">사용 일수 <span class="required">*</span></label>
<input type="number" id="editUsedDays" class="form-input" min="0" step="0.5" required>
</div>
<div class="form-group">
<label for="editNotes">비고</label>
<input type="text" id="editNotes" class="form-input">
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary">저장</button>
<button type="button" class="btn btn-secondary modal-close">취소</button>
</div>
</form>
</div>
</div>
</div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/api-base.js?v=2026031401"></script>
<script type="module" src="/js/vacation-allocation.js" defer></script>
<script>initAuth();</script>
</body>
</html>

View File

@@ -0,0 +1,287 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>휴가 승인 관리 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<style>
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
border-bottom: 2px solid #e5e7eb;
}
.tab {
padding: 0.75rem 1.5rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-weight: 500;
color: #6b7280;
transition: all 0.2s;
}
.tab:hover {
color: #111827;
}
.tab.active {
color: #3b82f6;
border-bottom-color: #3b82f6;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* 모바일 최적화 */
@media (max-width: 768px) {
.tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; scrollbar-width: none; }
.tabs::-webkit-scrollbar { display: none; }
.tab { flex-shrink: 0; padding: 0.625rem 1rem; white-space: nowrap; font-size: 0.875rem; }
}
</style>
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<div class="page-header">
<div class="page-title-section">
<h1 class="page-title">휴가 승인 관리</h1>
<p class="page-description">휴가 신청을 승인하거나 거부합니다</p>
</div>
</div>
<!-- 탭 메뉴 -->
<div class="tabs">
<button class="tab active" onclick="switchTab('pending')">승인 대기 목록</button>
<button class="tab" onclick="switchTab('all')">전체 신청 내역</button>
</div>
<!-- 승인 대기 목록 탭 -->
<div id="pendingTab" class="tab-content active">
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">승인 대기 목록</h2>
<p class="text-muted">대기 중인 휴가 신청을 승인하거나 거부할 수 있습니다</p>
</div>
<div class="card-body">
<div id="pendingRequestsList" class="data-table-container">
<!-- 승인 대기 목록이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
<!-- 전체 신청 내역 탭 -->
<div id="allTab" class="tab-content">
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">전체 신청 내역</h2>
<div class="page-actions">
<input type="date" id="filterStartDate" class="form-control" style="width: auto;">
<span style="margin: 0 0.5rem;">~</span>
<input type="date" id="filterEndDate" class="form-control" style="width: auto;">
<button class="btn btn-secondary" onclick="filterAllRequests()">
조회
</button>
<button class="btn btn-secondary" onclick="resetFilter()">
전체
</button>
</div>
</div>
<div class="card-body">
<div id="allRequestsList" class="data-table-container">
<!-- 전체 신청 내역이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/api-base.js?v=2026031401"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="/js/vacation-common.js"></script>
<script>
// axios 기본 설정
(function() {
const checkApiConfig = setInterval(() => {
if (window.API_BASE_URL) {
clearInterval(checkApiConfig);
axios.defaults.baseURL = window.API_BASE_URL;
const token = localStorage.getItem('sso_token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('sso_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => Promise.reject(error)
);
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>
let allRequestsData = [];
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
initializePage();
});
async function initializePage() {
try {
const currentUser = getCurrentUser();
// 관리자 권한 체크
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
alert('관리자만 접근할 수 있습니다.');
window.location.href = '/pages/attendance/vacation-request.html';
return;
}
await loadPendingRequests();
await loadAllRequests();
// 휴가 업데이트 이벤트 리스너
window.addEventListener('vacation-updated', () => {
loadPendingRequests();
loadAllRequests();
});
// 날짜 필터 초기화 (최근 3개월)
const today = new Date();
const threeMonthsAgo = new Date();
threeMonthsAgo.setMonth(today.getMonth() - 3);
document.getElementById('filterStartDate').value = threeMonthsAgo.toISOString().split('T')[0];
document.getElementById('filterEndDate').value = today.toISOString().split('T')[0];
} catch (error) {
console.error('초기화 오류:', error);
alert('페이지 초기화 중 오류가 발생했습니다.');
}
}
function switchTab(tabName) {
// 모든 탭과 컨텐츠 비활성화
document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
// 선택한 탭 활성화
if (tabName === 'pending') {
document.querySelector('.tab:nth-child(1)').classList.add('active');
document.getElementById('pendingTab').classList.add('active');
} else if (tabName === 'all') {
document.querySelector('.tab:nth-child(2)').classList.add('active');
document.getElementById('allTab').classList.add('active');
}
}
async function loadPendingRequests() {
try {
const response = await axios.get('/vacation-requests/pending');
if (response.data.success) {
renderVacationRequests(response.data.data, 'pendingRequestsList', true, 'approval');
}
} catch (error) {
console.error('승인 대기 목록 로드 오류:', error);
document.getElementById('pendingRequestsList').innerHTML = `
<div class="empty-state">
<p>승인 대기 중인 신청이 없습니다.</p>
</div>
`;
}
}
async function loadAllRequests() {
try {
const response = await axios.get('/vacation-requests');
if (response.data.success) {
allRequestsData = response.data.data;
renderVacationRequests(allRequestsData, 'allRequestsList', false);
}
} catch (error) {
console.error('전체 신청 내역 로드 오류:', error);
document.getElementById('allRequestsList').innerHTML = `
<div class="empty-state">
<p>신청 내역이 없습니다.</p>
</div>
`;
}
}
function filterAllRequests() {
const startDate = document.getElementById('filterStartDate').value;
const endDate = document.getElementById('filterEndDate').value;
if (!startDate || !endDate) {
alert('시작일과 종료일을 모두 선택해주세요.');
return;
}
const filtered = allRequestsData.filter(req => {
return req.start_date >= startDate && req.start_date <= endDate;
});
renderVacationRequests(filtered, 'allRequestsList', false);
}
function resetFilter() {
renderVacationRequests(allRequestsData, 'allRequestsList', false);
}
</script>
<script>initAuth();</script>
</body>
</html>

View File

@@ -0,0 +1,307 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>휴가 직접 입력 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<div class="page-header">
<div class="page-title-section">
<h1 class="page-title">휴가 직접 입력</h1>
<p class="page-description">관리자 권한으로 작업자의 휴가 정보를 직접 입력합니다</p>
</div>
</div>
<!-- 휴가 직접 입력 폼 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">휴가 정보 입력</h2>
<p class="text-muted">승인 절차 없이 휴가 정보를 직접 입력합니다. 입력 즉시 승인 상태로 저장됩니다.</p>
</div>
<div class="card-body">
<form id="vacationInputForm" onsubmit="submitVacationInput(event)">
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;">
<div class="form-group">
<label for="inputWorker">작업자 *</label>
<select id="inputWorker" class="form-control" required onchange="updateVacationBalance()">
<option value="">선택하세요</option>
</select>
</div>
<div class="form-group">
<label for="inputVacationType">휴가 유형 *</label>
<select id="inputVacationType" class="form-control" required>
<option value="">선택하세요</option>
</select>
</div>
<div class="form-group">
<label for="inputStartDate">시작일 *</label>
<input type="date" id="inputStartDate" class="form-control" required>
</div>
<div class="form-group">
<label for="inputEndDate">종료일 *</label>
<input type="date" id="inputEndDate" class="form-control" required>
</div>
<div class="form-group">
<label for="inputDaysUsed">사용 일수 *</label>
<input type="number" id="inputDaysUsed" class="form-control" min="0.5" step="0.5" value="1.0" required>
</div>
<div class="form-group">
<label>작업자 휴가 잔여</label>
<div id="workerVacationBalance" style="padding: 0.75rem; background-color: #f9fafb; border-radius: 0.375rem; border: 1px solid #e5e7eb;">
<span class="text-muted">작업자를 선택하세요</span>
</div>
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label for="inputReason">사유</label>
<textarea id="inputReason" class="form-control" rows="3" placeholder="휴가 사유를 입력하세요 (선택)"></textarea>
</div>
</div>
<div style="text-align: center; margin-top: 2rem;">
<button type="submit" class="btn btn-primary" style="padding: 1rem 3rem;">
즉시 입력 (자동 승인)
</button>
</div>
</form>
</div>
</div>
</div>
<!-- 최근 입력 내역 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">최근 입력 내역</h2>
<div class="page-actions">
<button class="btn btn-secondary" onclick="loadRecentInputs()">
새로고침
</button>
</div>
</div>
<div class="card-body">
<div id="recentInputsList" class="data-table-container">
<!-- 최근 입력 내역이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/api-base.js?v=2026031401"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="/js/vacation-common.js"></script>
<script>
// axios 기본 설정
(function() {
const checkApiConfig = setInterval(() => {
if (window.API_BASE_URL) {
clearInterval(checkApiConfig);
axios.defaults.baseURL = window.API_BASE_URL;
const token = localStorage.getItem('sso_token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('sso_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => Promise.reject(error)
);
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>
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
initializePage();
});
async function initializePage() {
try {
const currentUser = getCurrentUser();
// 관리자 권한 체크
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
alert('관리자만 접근할 수 있습니다.');
window.location.href = '/pages/attendance/vacation-request.html';
return;
}
await loadWorkers();
await loadVacationTypes();
await loadRecentInputs();
// 휴가 업데이트 이벤트 리스너
window.addEventListener('vacation-updated', loadRecentInputs);
} catch (error) {
console.error('초기화 오류:', error);
alert('페이지 초기화 중 오류가 발생했습니다.');
}
}
async function updateVacationBalance() {
const workerId = document.getElementById('inputWorker').value;
const balanceDiv = document.getElementById('workerVacationBalance');
if (!workerId) {
balanceDiv.innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
return;
}
try {
const response = await axios.get(`/attendance/vacation-balance/${workerId}`);
if (response.data.success) {
const balance = response.data.data;
if (!balance || Object.keys(balance).length === 0) {
balanceDiv.innerHTML = '<span class="text-muted">휴가 잔여 정보가 없습니다</span>';
return;
}
const balanceHTML = Object.keys(balance).map(key => {
const info = balance[key];
return `
<div style="display: inline-block; margin-right: 1rem;">
<span style="color: #6b7280; font-size: 0.875rem;">${key}:</span>
<strong style="color: #111827; font-size: 1rem; margin-left: 0.25rem;">${info.remaining || 0}일</strong>
<span style="color: #9ca3af; font-size: 0.75rem; margin-left: 0.25rem;">(전체: ${info.total || 0}일)</span>
</div>
`;
}).join('');
balanceDiv.innerHTML = balanceHTML;
}
} catch (error) {
console.error('휴가 잔여 조회 오류:', error);
balanceDiv.innerHTML = '<span class="text-muted" style="color: #dc2626;">조회 실패</span>';
}
}
async function submitVacationInput(event) {
event.preventDefault();
const data = {
user_id: parseInt(document.getElementById('inputWorker').value),
vacation_type_id: parseInt(document.getElementById('inputVacationType').value),
start_date: document.getElementById('inputStartDate').value,
end_date: document.getElementById('inputEndDate').value,
days_used: parseFloat(document.getElementById('inputDaysUsed').value),
reason: document.getElementById('inputReason').value || null,
auto_approve: true // 자동 승인 플래그
};
if (!confirm(`${document.getElementById('inputWorker').selectedOptions[0].text}의 휴가를 즉시 입력하시겠습니까?\n\n입력 즉시 승인 상태로 저장됩니다.`)) {
return;
}
try {
// 휴가 신청 생성
const response = await axios.post('/vacation-requests', data);
if (response.data.success) {
const requestId = response.data.data.request_id;
// 즉시 승인 처리
try {
const approveResponse = await axios.patch(`/vacation-requests/${requestId}/approve`);
if (approveResponse.data.success) {
alert('휴가 정보가 입력되고 자동 승인되었습니다.');
document.getElementById('vacationInputForm').reset();
document.getElementById('workerVacationBalance').innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
window.dispatchEvent(new Event('vacation-updated'));
}
} catch (approveError) {
console.error('자동 승인 오류:', approveError);
alert('휴가 정보는 입력되었으나 자동 승인에 실패했습니다. 승인 관리 페이지에서 수동으로 승인해주세요.');
}
}
} catch (error) {
console.error('휴가 입력 오류:', error);
alert(error.response?.data?.message || '휴가 입력 중 오류가 발생했습니다.');
}
}
async function loadRecentInputs() {
try {
const response = await axios.get('/vacation-requests');
if (response.data.success) {
// 최근 30일 이내 승인된 항목만 표시
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const thirtyDaysAgoStr = thirtyDaysAgo.toISOString().split('T')[0];
const recentApproved = response.data.data.filter(req =>
req.status === 'approved' &&
req.created_at >= thirtyDaysAgoStr
).slice(0, 20); // 최근 20개만
renderVacationRequests(recentApproved, 'recentInputsList', false);
}
} catch (error) {
console.error('최근 입력 내역 로드 오류:', error);
document.getElementById('recentInputsList').innerHTML = `
<div class="empty-state">
<p>최근 입력 내역이 없습니다.</p>
</div>
`;
}
}
</script>
<script>initAuth();</script>
</body>
</html>

View File

@@ -0,0 +1,484 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>휴가 관리 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<style>
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1.5rem;
border-bottom: 2px solid #e5e7eb;
}
.tab {
padding: 0.75rem 1.5rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-weight: 500;
color: #6b7280;
transition: all 0.2s;
}
.tab:hover {
color: #111827;
}
.tab.active {
color: #3b82f6;
border-bottom-color: #3b82f6;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* 모바일 최적화 */
@media (max-width: 768px) {
.tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; flex-wrap: nowrap; scrollbar-width: none; }
.tabs::-webkit-scrollbar { display: none; }
.tab { flex-shrink: 0; padding: 0.625rem 1rem; white-space: nowrap; font-size: 0.875rem; }
.page-actions { flex-direction: column; width: 100%; gap: 0.5rem; }
.page-actions input[type="date"] { width: 100%; }
.page-actions .btn { width: 100%; text-align: center; }
}
</style>
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<div class="page-header">
<div class="page-title-section">
<h1 class="page-title">휴가 관리</h1>
<p class="page-description">휴가 신청을 승인하고 작업자 휴가 정보를 관리합니다</p>
</div>
</div>
<!-- 탭 메뉴 -->
<div class="tabs">
<button class="tab active" onclick="switchTab('approval')">승인 대기 목록</button>
<button class="tab" onclick="switchTab('input')">직접 입력</button>
<button class="tab" onclick="switchTab('all')">전체 신청 내역</button>
</div>
<!-- 승인 대기 목록 탭 -->
<div id="approvalTab" class="tab-content active">
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">승인 대기 목록</h2>
<p class="text-muted">대기 중인 휴가 신청을 승인하거나 거부할 수 있습니다</p>
</div>
<div class="card-body">
<div id="pendingRequestsList" class="data-table-container">
<!-- 승인 대기 목록이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
<!-- 직접 입력 탭 -->
<div id="inputTab" class="tab-content">
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">휴가 정보 직접 입력</h2>
<p class="text-muted">승인 절차 없이 휴가 정보를 직접 입력합니다. 입력 즉시 승인 상태로 저장됩니다.</p>
</div>
<div class="card-body">
<form id="vacationInputForm" onsubmit="submitVacationInput(event)">
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;">
<div class="form-group">
<label for="inputWorker">작업자 *</label>
<select id="inputWorker" class="form-control" required onchange="updateVacationBalance()">
<option value="">선택하세요</option>
</select>
</div>
<div class="form-group">
<label for="inputVacationType">휴가 유형 *</label>
<select id="inputVacationType" class="form-control" required>
<option value="">선택하세요</option>
</select>
</div>
<div class="form-group">
<label for="inputStartDate">시작일 *</label>
<input type="date" id="inputStartDate" class="form-control" required>
</div>
<div class="form-group">
<label for="inputEndDate">종료일 *</label>
<input type="date" id="inputEndDate" class="form-control" required>
</div>
<div class="form-group">
<label for="inputDaysUsed">사용 일수 *</label>
<input type="number" id="inputDaysUsed" class="form-control" min="0.5" step="0.5" value="1.0" required>
</div>
<div class="form-group">
<label>작업자 휴가 잔여</label>
<div id="workerVacationBalance" style="padding: 0.75rem; background-color: #f9fafb; border-radius: 0.375rem; border: 1px solid #e5e7eb;">
<span class="text-muted">작업자를 선택하세요</span>
</div>
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label for="inputReason">사유</label>
<textarea id="inputReason" class="form-control" rows="3" placeholder="휴가 사유를 입력하세요 (선택)"></textarea>
</div>
</div>
<div style="text-align: center; margin-top: 2rem;">
<button type="submit" class="btn btn-primary" style="padding: 1rem 3rem;">
즉시 입력 (자동 승인)
</button>
</div>
</form>
</div>
</div>
<!-- 최근 입력 내역 -->
<div class="card" style="margin-top: 2rem;">
<div class="card-header">
<h2 class="card-title">최근 입력 내역</h2>
<div class="page-actions">
<button class="btn btn-secondary" onclick="loadRecentInputs()">
새로고침
</button>
</div>
</div>
<div class="card-body">
<div id="recentInputsList" class="data-table-container">
<!-- 최근 입력 내역이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
<!-- 전체 신청 내역 탭 -->
<div id="allTab" class="tab-content">
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">전체 신청 내역</h2>
<div class="page-actions">
<input type="date" id="filterStartDate" class="form-control" style="width: auto;">
<span style="margin: 0 0.5rem;">~</span>
<input type="date" id="filterEndDate" class="form-control" style="width: auto;">
<button class="btn btn-secondary" onclick="filterAllRequests()">
조회
</button>
<button class="btn btn-secondary" onclick="resetFilter()">
전체
</button>
</div>
</div>
<div class="card-body">
<div id="allRequestsList" class="data-table-container">
<!-- 전체 신청 내역이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/api-base.js?v=2026031401"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="/js/vacation-common.js"></script>
<script>
// axios 기본 설정
(function() {
const checkApiConfig = setInterval(() => {
if (window.API_BASE_URL) {
clearInterval(checkApiConfig);
axios.defaults.baseURL = window.API_BASE_URL;
const token = localStorage.getItem('sso_token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('sso_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => Promise.reject(error)
);
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>
let allRequestsData = [];
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
initializePage();
});
async function initializePage() {
try {
const currentUser = getCurrentUser();
// 관리자 권한 체크
if (currentUser.access_level !== 'system' && currentUser.access_level !== 'admin') {
alert('관리자만 접근할 수 있습니다.');
window.location.href = '/pages/attendance/vacation-request.html';
return;
}
await loadWorkers();
await loadVacationTypes();
await loadPendingRequests();
await loadAllRequests();
// 휴가 업데이트 이벤트 리스너
window.addEventListener('vacation-updated', () => {
loadPendingRequests();
loadAllRequests();
});
// 날짜 필터 초기화 (최근 3개월)
const today = new Date();
const threeMonthsAgo = new Date();
threeMonthsAgo.setMonth(today.getMonth() - 3);
document.getElementById('filterStartDate').value = threeMonthsAgo.toISOString().split('T')[0];
document.getElementById('filterEndDate').value = today.toISOString().split('T')[0];
} catch (error) {
console.error('초기화 오류:', error);
alert('페이지 초기화 중 오류가 발생했습니다.');
}
}
function switchTab(tabName) {
// 모든 탭과 컨텐츠 비활성화
document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
// 선택한 탭 활성화
if (tabName === 'approval') {
document.querySelector('.tab:nth-child(1)').classList.add('active');
document.getElementById('approvalTab').classList.add('active');
} else if (tabName === 'input') {
document.querySelector('.tab:nth-child(2)').classList.add('active');
document.getElementById('inputTab').classList.add('active');
} else if (tabName === 'all') {
document.querySelector('.tab:nth-child(3)').classList.add('active');
document.getElementById('allTab').classList.add('active');
}
}
async function loadPendingRequests() {
try {
const response = await axios.get('/vacation-requests/pending');
if (response.data.success) {
renderVacationRequests(response.data.data, 'pendingRequestsList', true, 'approval');
}
} catch (error) {
console.error('승인 대기 목록 로드 오류:', error);
document.getElementById('pendingRequestsList').innerHTML = `
<div class="empty-state">
<p>승인 대기 중인 신청이 없습니다.</p>
</div>
`;
}
}
async function loadAllRequests() {
try {
const response = await axios.get('/vacation-requests');
if (response.data.success) {
allRequestsData = response.data.data;
renderVacationRequests(allRequestsData, 'allRequestsList', false);
}
} catch (error) {
console.error('전체 신청 내역 로드 오류:', error);
document.getElementById('allRequestsList').innerHTML = `
<div class="empty-state">
<p>신청 내역이 없습니다.</p>
</div>
`;
}
}
async function updateVacationBalance() {
const workerId = document.getElementById('inputWorker').value;
const balanceDiv = document.getElementById('workerVacationBalance');
if (!workerId) {
balanceDiv.innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
return;
}
try {
const response = await axios.get(`/attendance/vacation-balance/${workerId}`);
if (response.data.success) {
const balance = response.data.data;
if (!balance || Object.keys(balance).length === 0) {
balanceDiv.innerHTML = '<span class="text-muted">휴가 잔여 정보가 없습니다</span>';
return;
}
const balanceHTML = Object.keys(balance).map(key => {
const info = balance[key];
return `
<div style="display: inline-block; margin-right: 1rem;">
<span style="color: #6b7280; font-size: 0.875rem;">${key}:</span>
<strong style="color: #111827; font-size: 1rem; margin-left: 0.25rem;">${info.remaining || 0}일</strong>
<span style="color: #9ca3af; font-size: 0.75rem; margin-left: 0.25rem;">(전체: ${info.total || 0}일)</span>
</div>
`;
}).join('');
balanceDiv.innerHTML = balanceHTML;
}
} catch (error) {
console.error('휴가 잔여 조회 오류:', error);
balanceDiv.innerHTML = '<span class="text-muted" style="color: #dc2626;">조회 실패</span>';
}
}
async function submitVacationInput(event) {
event.preventDefault();
const data = {
user_id: parseInt(document.getElementById('inputWorker').value),
vacation_type_id: parseInt(document.getElementById('inputVacationType').value),
start_date: document.getElementById('inputStartDate').value,
end_date: document.getElementById('inputEndDate').value,
days_used: parseFloat(document.getElementById('inputDaysUsed').value),
reason: document.getElementById('inputReason').value || null
};
if (!confirm(`${document.getElementById('inputWorker').selectedOptions[0].text}의 휴가를 즉시 입력하시겠습니까?\n\n입력 즉시 승인 상태로 저장됩니다.`)) {
return;
}
try {
// 휴가 신청 생성
const response = await axios.post('/vacation-requests', data);
if (response.data.success) {
const requestId = response.data.data.request_id;
// 즉시 승인 처리
try {
const approveResponse = await axios.patch(`/vacation-requests/${requestId}/approve`);
if (approveResponse.data.success) {
alert('휴가 정보가 입력되고 자동 승인되었습니다.');
document.getElementById('vacationInputForm').reset();
document.getElementById('workerVacationBalance').innerHTML = '<span class="text-muted">작업자를 선택하세요</span>';
window.dispatchEvent(new Event('vacation-updated'));
loadRecentInputs();
}
} catch (approveError) {
console.error('자동 승인 오류:', approveError);
alert('휴가 정보는 입력되었으나 자동 승인에 실패했습니다. 승인 관리 탭에서 수동으로 승인해주세요.');
}
}
} catch (error) {
console.error('휴가 입력 오류:', error);
alert(error.response?.data?.message || '휴가 입력 중 오류가 발생했습니다.');
}
}
async function loadRecentInputs() {
try {
const response = await axios.get('/vacation-requests');
if (response.data.success) {
// 최근 30일 이내 승인된 항목만 표시
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
const thirtyDaysAgoStr = thirtyDaysAgo.toISOString().split('T')[0];
const recentApproved = response.data.data.filter(req =>
req.status === 'approved' &&
req.created_at >= thirtyDaysAgoStr
).slice(0, 20); // 최근 20개만
renderVacationRequests(recentApproved, 'recentInputsList', false);
}
} catch (error) {
console.error('최근 입력 내역 로드 오류:', error);
document.getElementById('recentInputsList').innerHTML = `
<div class="empty-state">
<p>최근 입력 내역이 없습니다.</p>
</div>
`;
}
}
function filterAllRequests() {
const startDate = document.getElementById('filterStartDate').value;
const endDate = document.getElementById('filterEndDate').value;
if (!startDate || !endDate) {
alert('시작일과 종료일을 모두 선택해주세요.');
return;
}
const filtered = allRequestsData.filter(req => {
return req.start_date >= startDate && req.start_date <= endDate;
});
renderVacationRequests(filtered, 'allRequestsList', false);
}
function resetFilter() {
renderVacationRequests(allRequestsData, 'allRequestsList', false);
}
</script>
<script>initAuth();</script>
</body>
</html>

View File

@@ -0,0 +1,285 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>휴가 신청 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<div class="page-header">
<div class="page-title-section">
<h1 class="page-title">휴가 신청</h1>
<p class="page-description">휴가를 신청하고 신청 내역을 확인합니다</p>
</div>
</div>
<!-- 휴가 잔여 현황 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">휴가 잔여 현황</h2>
</div>
<div class="card-body">
<div id="vacationBalance" style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem;">
<!-- 휴가 잔여 정보가 여기에 표시됩니다 -->
</div>
</div>
</div>
</div>
<!-- 휴가 신청 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">휴가 신청</h2>
</div>
<div class="card-body">
<form id="vacationRequestForm" onsubmit="submitVacationRequest(event)">
<div style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;">
<div class="form-group">
<label for="vacationType">휴가 유형 *</label>
<select id="vacationType" class="form-control" required>
<option value="">선택하세요</option>
</select>
</div>
<div class="form-group">
<label for="daysUsed">사용 일수 *</label>
<input type="number" id="daysUsed" class="form-control" min="0.5" step="0.5" value="1.0" required>
</div>
<div class="form-group">
<label for="startDate">시작일 *</label>
<input type="date" id="startDate" class="form-control" required>
</div>
<div class="form-group">
<label for="endDate">종료일 *</label>
<input type="date" id="endDate" class="form-control" required>
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label for="reason">사유</label>
<textarea id="reason" class="form-control" rows="3" placeholder="휴가 사유를 입력하세요 (선택)"></textarea>
</div>
</div>
<div style="text-align: center; margin-top: 2rem;">
<button type="submit" class="btn btn-primary" style="padding: 1rem 3rem;">
신청하기
</button>
</div>
</form>
</div>
</div>
</div>
<!-- 내 신청 내역 -->
<div class="content-section">
<div class="card">
<div class="card-header">
<h2 class="card-title">내 신청 내역</h2>
</div>
<div class="card-body">
<div id="myRequestsList" class="data-table-container">
<!-- 내 신청 내역이 여기에 동적으로 렌더링됩니다 -->
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/api-base.js?v=2026031401"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script src="/js/vacation-common.js"></script>
<script>
// axios 기본 설정
(function() {
const checkApiConfig = setInterval(() => {
if (window.API_BASE_URL) {
clearInterval(checkApiConfig);
axios.defaults.baseURL = window.API_BASE_URL;
const token = localStorage.getItem('sso_token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('sso_token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
error => Promise.reject(error)
);
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>
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
initializePage();
});
async function initializePage() {
try {
const currentUser = getCurrentUser();
if (!currentUser || !currentUser.user_id) {
alert('작업자 정보가 없습니다. 관리자에게 문의하세요.');
return;
}
await loadVacationTypes();
await loadVacationBalance();
await loadMyRequests();
// 휴가 업데이트 이벤트 리스너
window.addEventListener('vacation-updated', () => {
loadVacationBalance();
loadMyRequests();
});
} catch (error) {
console.error('초기화 오류:', error);
alert('페이지 초기화 중 오류가 발생했습니다.');
}
}
async function loadVacationBalance() {
const currentUser = getCurrentUser();
try {
const response = await axios.get(`/attendance/vacation-balance/${currentUser.user_id}`);
if (response.data.success) {
renderVacationBalance(response.data.data);
}
} catch (error) {
console.error('휴가 잔여 조회 오류:', error);
document.getElementById('vacationBalance').innerHTML = `
<p class="text-muted">휴가 잔여 정보를 불러올 수 없습니다.</p>
`;
}
}
function renderVacationBalance(balance) {
const container = document.getElementById('vacationBalance');
if (!balance || Object.keys(balance).length === 0) {
container.innerHTML = '<p class="text-muted">휴가 잔여 정보가 없습니다.</p>';
return;
}
const balanceHTML = Object.keys(balance).map(key => {
const info = balance[key];
return `
<div style="padding: 1.5rem; background-color: #f9fafb; border-radius: 0.5rem; border: 1px solid #e5e7eb;">
<div style="font-size: 0.875rem; color: #6b7280; margin-bottom: 0.5rem;">${key}</div>
<div style="font-size: 1.5rem; font-weight: 700; color: #111827;">
${info.remaining || 0}
</div>
<div style="font-size: 0.75rem; color: #9ca3af; margin-top: 0.25rem;">
사용: ${info.used || 0}일 / 전체: ${info.total || 0}
</div>
</div>
`;
}).join('');
container.innerHTML = balanceHTML;
}
async function submitVacationRequest(event) {
event.preventDefault();
const currentUser = getCurrentUser();
const data = {
user_id: currentUser.user_id,
vacation_type_id: parseInt(document.getElementById('vacationType').value),
start_date: document.getElementById('startDate').value,
end_date: document.getElementById('endDate').value,
days_used: parseFloat(document.getElementById('daysUsed').value),
reason: document.getElementById('reason').value || null
};
try {
const response = await axios.post('/vacation-requests', data);
if (response.data.success) {
alert('휴가 신청이 완료되었습니다.');
document.getElementById('vacationRequestForm').reset();
window.dispatchEvent(new Event('vacation-updated'));
}
} catch (error) {
console.error('휴가 신청 오류:', error);
alert(error.response?.data?.message || '휴가 신청 중 오류가 발생했습니다.');
}
}
async function loadMyRequests() {
const currentUser = getCurrentUser();
try {
const response = await axios.get('/vacation-requests');
if (response.data.success) {
// 내 신청만 필터링
const myRequests = response.data.data.filter(req =>
req.requested_by === currentUser.user_id || req.user_id === currentUser.user_id
);
renderVacationRequests(myRequests, 'myRequestsList', true, 'delete');
}
} catch (error) {
console.error('내 신청 내역 로드 오류:', error);
document.getElementById('myRequestsList').innerHTML = `
<div class="empty-state">
<p>신청 내역이 없습니다.</p>
</div>
`;
}
}
</script>
<script>initAuth();</script>
</body>
</html>

View File

@@ -0,0 +1,839 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>근무 현황 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<style>
.page-wrapper {
padding: 1.5rem;
max-width: 1400px;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.page-title {
font-size: 1.25rem;
font-weight: 600;
margin: 0;
}
.controls {
display: flex;
gap: 0.5rem;
align-items: center;
}
.controls input[type="date"] {
padding: 0.4rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
font-size: 0.875rem;
}
.btn {
padding: 0.4rem 0.75rem;
border: none;
border-radius: 0.25rem;
cursor: pointer;
font-size: 0.8rem;
}
.btn-primary { background: #3b82f6; color: white; }
.btn-outline { background: white; border: 1px solid #d1d5db; }
.btn-success { background: #10b981; color: white; }
/* 요약 */
.summary-row {
display: flex;
gap: 1rem;
padding: 0.5rem 0;
margin-bottom: 0.5rem;
font-size: 0.8rem;
color: #6b7280;
border-bottom: 1px solid #e5e7eb;
}
.summary-row span { display: flex; align-items: center; gap: 0.25rem; }
.summary-row .dot {
width: 8px; height: 8px; border-radius: 50%;
}
.dot-normal { background: #10b981; }
.dot-annual { background: #3b82f6; }
.dot-half { background: #22c55e; }
.dot-quarter { background: #eab308; }
.dot-early { background: #ef4444; }
.dot-overtime { background: #f97316; }
/* 테이블 */
.data-table {
width: 100%;
border-collapse: collapse;
font-size: 0.85rem;
background: white;
border: 1px solid #e5e7eb;
}
.data-table th, .data-table td {
padding: 0.5rem 0.75rem;
text-align: left;
border-bottom: 1px solid #e5e7eb;
}
.data-table th {
background: #f9fafb;
font-weight: 500;
color: #374151;
font-size: 0.8rem;
}
.data-table tr:hover {
background: #f9fafb;
}
.data-table tr.saved {
background: #f0fdf4;
}
.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;
}
.saved-tag {
font-size: 0.65rem;
color: #10b981;
background: #dcfce7;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
margin-left: 0.5rem;
}
.type-select {
padding: 0.25rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
font-size: 0.8rem;
min-width: 100px;
}
.overtime-input {
width: 50px;
padding: 0.25rem;
border: 1px solid #d1d5db;
border-radius: 0.25rem;
text-align: center;
font-size: 0.8rem;
}
.hours-cell {
text-align: center;
min-width: 60px;
}
.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 {
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.25rem;
}
.save-status {
font-size: 0.8rem;
color: #6b7280;
}
.save-status.saved { color: #10b981; }
.save-status.unsaved { color: #f59e0b; }
.btn-save {
padding: 0.5rem 1.5rem;
font-size: 0.875rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 0.25rem;
cursor: pointer;
}
.btn-save:hover { background: #2563eb; }
.btn-save:disabled { background: #9ca3af; cursor: not-allowed; }
.warning-box {
background: #fef3c7;
border: 1px solid #fcd34d;
color: #92400e;
padding: 0.5rem 0.75rem;
border-radius: 0.25rem;
margin-bottom: 0.75rem;
font-size: 0.8rem;
}
.warning-box a { color: #92400e; font-weight: 500; }
/* 모바일 최적화 */
@media (max-width: 768px) {
.summary-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.375rem; font-size: 0.7rem; }
.summary-row span { flex-direction: column; text-align: center; gap: 0.125rem; }
.controls { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; }
.controls input[type="date"] { grid-column: 1 / -1; }
.save-bar { position: sticky; bottom: 0; z-index: 20; background: white; box-shadow: 0 -2px 8px rgba(0,0,0,0.08); margin: 0 -1rem; padding: 0.75rem 1rem; }
.btn-save { width: 100%; padding: 0.75rem; font-size: 1rem; }
}
</style>
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white">
<i class="fas fa-bars text-xl"></i>
</button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<div class="page-header">
<h1 class="page-title">근무 현황</h1>
<div class="controls">
<input type="date" id="selectedDate">
<button class="btn btn-primary" onclick="loadWorkStatus()">조회</button>
<button class="btn btn-outline" onclick="setAllNormal()">전체 정시근무</button>
</div>
</div>
<div id="noCheckinWarning" class="warning-box" style="display:none;">
출근 체크가 완료되지 않았습니다. 먼저 <a href="/pages/attendance/checkin.html">출근 체크</a>를 진행해주세요.
</div>
<div class="summary-row">
<span><span class="dot dot-normal"></span> 정시 <strong id="normalCount">0</strong></span>
<span><span class="dot dot-annual"></span> 연차 <strong id="annualCount">0</strong></span>
<span><span class="dot dot-half"></span> 반차 <strong id="halfCount">0</strong></span>
<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">
<thead>
<tr>
<th style="width:30px">#</th>
<th>이름</th>
<th>출근</th>
<th>근태구분</th>
<th class="hours-cell">기본</th>
<th class="hours-cell">연장</th>
<th class="hours-cell">합계</th>
</tr>
</thead>
<tbody id="workerTableBody">
</tbody>
</table>
<div class="save-bar">
<span id="saveStatus" class="save-status"></span>
<button id="saveBtn" class="btn-save" onclick="saveWorkStatus()">저장</button>
</div>
</div>
</div>
</div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/api-base.js?v=2026031401"></script>
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
<script>
(function() {
const checkApiConfig = setInterval(() => {
if (window.API_BASE_URL) {
clearInterval(checkApiConfig);
axios.defaults.baseURL = window.API_BASE_URL;
const token = localStorage.getItem('sso_token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
}
}, 50);
})();
let workers = [];
let workStatus = {};
let hasCheckinData = false;
let isAlreadySaved = false;
let isSaving = false;
let earlyLeaveTypeId = null;
const attendanceTypes = [
{ value: 'normal', label: '정시근무', hours: 8, isLeave: false },
{ value: 'annual', label: '연차', hours: 0, isLeave: true },
{ value: 'half', label: '반차', hours: 4, isLeave: true },
{ value: 'quarter', label: '반반차', hours: 6, isLeave: true },
{ value: 'early', label: '조퇴', hours: 2, isLeave: true },
{ value: 'overtime', label: '연장근로', hours: 8, isLeave: false }
];
document.addEventListener('DOMContentLoaded', async () => {
await waitForAxiosConfig();
document.getElementById('selectedDate').value = new Date().toISOString().split('T')[0];
loadWorkStatus();
});
function waitForAxiosConfig() {
return new Promise((resolve) => {
const check = setInterval(() => {
if (axios.defaults.baseURL) {
clearInterval(check);
resolve();
}
}, 50);
setTimeout(() => { clearInterval(check); resolve(); }, 5000);
});
}
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('날짜를 선택해주세요.');
try {
// EARLY_LEAVE 유형 ID 조회 (최초 1회)
if (!earlyLeaveTypeId) {
try {
const vtRes = await axios.get('/attendance/vacation-types');
const earlyType = (vtRes.data.data || []).find(t => t.type_code === 'EARLY_LEAVE');
earlyLeaveTypeId = earlyType?.id || null;
} catch(e) {}
}
const [workersRes, recordsRes] = await Promise.all([
axios.get('/workers?limit=100'),
axios.get(`/attendance/daily-records?date=${selectedDate}`).catch(() => ({ data: { data: [] } }))
]);
workers = (workersRes.data.data || []).filter(w => w.status === 'active' && (!w.employment_status || w.employment_status === 'employed'));
const records = recordsRes.data.data || [];
hasCheckinData = records.length > 0;
isAlreadySaved = records.some(r => r.attendance_type_id || r.total_work_hours > 0);
document.getElementById('noCheckinWarning').style.display = hasCheckinData ? 'none' : 'block';
workStatus = {};
workers.forEach(w => {
const record = records.find(r => r.user_id === w.user_id);
// 입사일 이전인지 확인
const joinDate = w.join_date ? w.join_date.split('T')[0] : null;
const isBeforeJoin = joinDate && selectedDate < joinDate;
if (isBeforeJoin) {
// 입사 전 날짜
workStatus[w.user_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;
// 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.user_id] = {
isPresent: record.is_present === 1 || typeInfo?.isLeave,
type: type,
hours: typeInfo !== undefined ? typeInfo.hours : 8,
overtimeHours: overtimeHours,
isSaved: record.attendance_type_id != null || record.total_work_hours > 0 || record.vacation_type_id != null,
hasLeaveInfo: typeInfo?.isLeave || false
};
} else {
// 출근 체크 기록이 없는 경우 - 결근 상태
workStatus[w.user_id] = {
isPresent: false,
type: 'normal',
hours: 8,
overtimeHours: 0,
isSaved: false,
hasLeaveInfo: false
};
}
});
render();
updateSummary();
updateSaveStatus();
} catch (e) {
console.error(e);
alert('데이터 로드 실패');
}
}
function render() {
const isMobile = window.innerWidth <= 768;
if (isMobile) {
renderMobile();
} else {
renderDesktop();
}
}
function renderMobile() {
const tbody = document.getElementById('workerTableBody');
if (workers.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#6b7280;padding:2rem;">작업자가 없습니다</td></tr>';
return;
}
// 모바일에서는 테이블을 숨기고 카드 뷰 사용
const table = tbody.closest('table');
table.style.display = 'none';
// 기존 모바일 컨테이너 제거
let mobileContainer = document.getElementById('mobileWorkCards');
if (!mobileContainer) {
mobileContainer = document.createElement('div');
mobileContainer.id = 'mobileWorkCards';
table.parentNode.insertBefore(mobileContainer, table.nextSibling);
}
mobileContainer.className = 'mobile-work-cards';
mobileContainer.innerHTML = workers.map((w, idx) => {
const s = workStatus[w.user_id];
if (s.isNotHired) {
return `
<div class="mobile-work-card not-hired">
<div class="wc-left">
<span class="wc-name">${w.worker_name} <span class="not-hired-tag">미입사</span></span>
<span class="wc-status" style="color:#9ca3af;">입사일: ${formatDisplayDate(s.joinDate)}</span>
</div>
<div class="wc-right"><span class="wc-hours">-</span></div>
</div>
`;
}
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;
let rowClass = '';
if (s.isSaved) rowClass = isLeaveType ? 'leave' : 'saved';
else if (!s.isPresent) rowClass = isLeaveType ? 'leave' : 'absent-no-leave';
let statusText = '', statusClass = '';
if (isLeaveType) { statusText = typeInfo.label; statusClass = 'color:#a16207;'; }
else if (s.isPresent) { statusText = '출근'; statusClass = 'color:#10b981;'; }
else { statusText = '⚠️ 결근'; statusClass = 'color:#dc2626;font-weight:600;'; }
let tag = '';
if (s.isSaved) tag = '<span class="saved-tag">저장됨</span>';
else if (isLeaveType) tag = '<span class="leave-tag">연차</span>';
return `
<div class="mobile-work-card ${rowClass}">
<div class="wc-left">
<span class="wc-name">${w.worker_name} ${tag}</span>
<span class="wc-status" style="${statusClass}">${statusText}</span>
</div>
<div class="wc-right">
<select onchange="updateType(${w.user_id}, this.value)">
${attendanceTypes.map(t => `
<option value="${t.value}" ${s.type === t.value ? 'selected' : ''}>${t.label}</option>
`).join('')}
</select>
${showOvertimeInput ? `
<input type="number" class="overtime-input" value="${s.overtimeHours}" min="0" max="8" step="0.5"
onchange="updateOvertime(${w.user_id}, this.value)" style="width:60px;text-align:center;font-size:14px;">
` : ''}
<span class="wc-hours">${totalHours}h</span>
</div>
</div>
`;
}).join('');
}
function renderDesktop() {
const tbody = document.getElementById('workerTableBody');
const table = tbody.closest('table');
table.style.display = '';
// 모바일 컨테이너 숨기기
const mobileContainer = document.getElementById('mobileWorkCards');
if (mobileContainer) mobileContainer.remove();
if (workers.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:#6b7280;padding:2rem;">작업자가 없습니다</td></tr>';
return;
}
tbody.innerHTML = workers.map((w, idx) => {
const s = workStatus[w.user_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;
let rowClass = '';
if (s.isSaved) rowClass = isLeaveType ? 'leave' : 'saved';
else if (!s.isPresent) rowClass = isLeaveType ? 'leave' : 'absent-no-leave';
let statusText = '', 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>
${tag}
</td>
<td class="${statusClass}">${statusText}</td>
<td>
<select class="type-select" onchange="updateType(${w.user_id}, this.value)">
${attendanceTypes.map(t => `
<option value="${t.value}" ${s.type === t.value ? 'selected' : ''}>${t.label}</option>
`).join('')}
</select>
</td>
<td class="hours-cell">${baseHours}h</td>
<td class="hours-cell">
${showOvertimeInput ? `
<input type="number" class="overtime-input" value="${s.overtimeHours}" min="0" max="8" step="0.5"
onchange="updateOvertime(${w.user_id}, this.value)">
` : '-'}
</td>
<td class="hours-cell"><strong>${totalHours}h</strong></td>
</tr>
`;
}).join('');
}
// 화면 크기 변경 시 재렌더링
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
if (workers.length > 0) render();
}, 250);
});
function updateType(workerId, value) {
const typeInfo = attendanceTypes.find(t => t.value === value);
workStatus[workerId].type = value;
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;
} else {
workStatus[workerId].overtimeHours = 0;
}
render();
updateSummary();
}
function updateOvertime(workerId, value) {
workStatus[workerId].overtimeHours = parseFloat(value) || 0;
render();
updateSummary();
}
function setAllNormal() {
workers.forEach(w => {
workStatus[w.user_id].type = 'normal';
workStatus[w.user_id].hours = 8;
workStatus[w.user_id].overtimeHours = 0;
});
render();
updateSummary();
}
function updateSummary() {
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': if (s.isPresent) normal++; break;
case 'annual': annual++; break;
case 'half': half++; break;
case 'quarter': quarter++; break;
case 'early': early++; break;
case 'overtime': overtime++; break;
}
});
document.getElementById('normalCount').textContent = normal;
document.getElementById('annualCount').textContent = annual;
document.getElementById('halfCount').textContent = half;
document.getElementById('quarterCount').textContent = quarter;
document.getElementById('earlyCount').textContent = early;
document.getElementById('overtimeCount').textContent = overtime;
document.getElementById('absentCount').textContent = absent;
}
function updateSaveStatus() {
const statusEl = document.getElementById('saveStatus');
const saveBtn = document.getElementById('saveBtn');
if (isAlreadySaved) {
statusEl.innerHTML = '이 날짜의 근무 현황이 저장되어 있습니다';
statusEl.className = 'save-status saved';
saveBtn.textContent = '수정 저장';
} else {
statusEl.innerHTML = '아직 저장되지 않았습니다';
statusEl.className = 'save-status unsaved';
saveBtn.textContent = '저장';
}
}
async function saveWorkStatus() {
const date = document.getElementById('selectedDate').value;
if (!date) return alert('날짜를 선택해주세요.');
if (isSaving) return;
const saveBtn = document.getElementById('saveBtn');
// work_attendance_types: 1=NORMAL, 2=LATE, 3=EARLY_LEAVE, 4=ABSENT, 5=VACATION
const typeIdMap = {
'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, EARLY_LEAVE=동적
const vacationTypeIdMap = {
'annual': 1,
'half': 2,
'quarter': 3,
'early': earlyLeaveTypeId,
};
// 조퇴가 있는데 vacation_type_id가 없으면 저장 차단
const hasEarlyWithoutType = workers.some(w => {
const s = workStatus[w.user_id];
return s && s.type === 'early' && !earlyLeaveTypeId;
});
if (hasEarlyWithoutType) {
alert('조퇴 휴가 유형이 등록되지 않았습니다. 관리자에게 문의해주세요.');
isSaving = false;
saveBtn.disabled = false;
saveBtn.textContent = '저장';
return;
}
// 미입사자 제외하고 저장할 데이터 생성
const recordsToSave = workers
.filter(w => !workStatus[w.user_id]?.isNotHired)
.map(w => {
const s = workStatus[w.user_id];
const totalHours = s.type === 'overtime' ? s.hours + s.overtimeHours : s.hours;
return {
record_date: date,
user_id: w.user_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;
saveBtn.textContent = '저장 중...';
try {
let ok = 0, fail = 0;
for (const r of recordsToSave) {
try {
await axios.post('/attendance/records', r);
ok++;
} catch (e) {
console.error('저장 실패:', e);
fail++;
}
}
if (fail === 0) {
alert(`${ok}명 저장 완료`);
isAlreadySaved = true;
workers.forEach(w => {
if (workStatus[w.user_id]) {
workStatus[w.user_id].isSaved = true;
}
});
render();
updateSaveStatus();
} else if (ok > 0) {
alert(`${ok}명 성공, ${fail}명 실패`);
} else {
alert('저장에 실패했습니다');
}
} catch (e) {
console.error(e);
alert('저장 중 오류가 발생했습니다');
} finally {
isSaving = false;
saveBtn.disabled = false;
updateSaveStatus();
}
}
</script>
<script>initAuth();</script>
</body>
</html>

View File

@@ -0,0 +1,55 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>대시보드 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<link rel="stylesheet" href="/css/production-dashboard.css?v=2026040102">
<link rel="stylesheet" href="/css/tbm-mobile.css?v=2026033108">
<style>.pd-container { padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px)); }</style>
</head>
<body class="bg-gray-50">
<span id="headerUserName" class="hidden">-</span>
<span id="headerUserAvatar" class="hidden">-</span>
<nav id="sideNav"></nav>
<div id="mobileOverlay" class="mobile-overlay hidden" onclick="toggleSideNav()"></div>
<main class="pd-main">
<section class="pd-profile-card" id="profileCard"></section>
<section class="pd-section" id="deptPagesSection">
<h2 class="pd-section-title">내 메뉴</h2>
<div class="pd-grid" id="deptPagesGrid"></div>
</section>
<section class="pd-section hidden" id="personalPagesSection">
<h2 class="pd-section-title">추가 메뉴</h2>
<div class="pd-grid" id="personalPagesGrid"></div>
</section>
<section class="pd-section hidden" id="adminPagesSection">
<h2 class="pd-section-title">관리 도구</h2>
<div class="pd-grid" id="adminPagesGrid"></div>
</section>
</main>
<!-- 연차 상세 모달 -->
<div class="pd-detail-modal" id="vacDetailModal" onclick="if(event.target===this)closeVacDetail()">
<div class="pd-detail-sheet">
<div class="pd-detail-header">
<span class="pd-detail-title"><i class="fas fa-umbrella-beach"></i> 연차 상세</span>
<button class="pd-detail-close" onclick="closeVacDetail()"><i class="fas fa-times"></i></button>
</div>
<div id="vacDetailContent"></div>
</div>
</div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/production-dashboard.js?v=2026040103"></script>
<script src="/static/js/shared-bottom-nav.js?v=2026040103"></script>
</body>
</html>

View File

@@ -0,0 +1,336 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>작업장 현황 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<link rel="stylesheet" href="/css/modern-dashboard.css?v=2026031401">
<link rel="stylesheet" href="/css/mobile.css?v=2026031601">
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white">
<i class="fas fa-bars text-xl"></i>
</button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
</div>
</div>
</header>
<!-- Mobile overlay -->
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<!-- Sidebar Nav -->
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<!-- 모바일 대시보드 (작업장 리스트 뷰) -->
<section id="mobileDashboardView" class="mobile-dashboard-view" style="display:none;">
<div class="md-date-header">
<span class="md-date-label">금일 현황</span>
<time class="md-date-value" id="mDateValue"></time>
</div>
<div class="md-category-tabs" id="mCategoryTabs"></div>
<div class="md-workplace-list" id="mWorkplaceList"></div>
</section>
<!-- 작업장 현황 -->
<section class="workplace-status-section">
<div class="card">
<div class="card-header">
<div class="flex justify-between items-center">
<h2 class="card-title">작업장 현황</h2>
<div class="flex items-center" style="gap: 12px;">
<select id="categorySelect" class="form-select w-full sm:w-auto">
<option value="">공장을 선택하세요</option>
</select>
<button class="btn btn-primary btn-sm" id="refreshMapBtn">새로고침</button>
</div>
</div>
</div>
<div class="card-body">
<div id="workplaceMapContainer" style="position: relative; display: none;">
<canvas id="workplaceMapCanvas" style="width: 100%; max-width: 100%; cursor: pointer; border: 2px solid #d1d5db; border-radius: 0.5rem;"></canvas>
<div id="mapLegend" style="position: absolute; top: 16px; right: 16px; background: white; padding: 16px; border-radius: 0.5rem; box-shadow: 0 4px 6px rgba(0,0,0,0.1);">
<h4 style="font-size: 0.875rem; font-weight: 700; margin-bottom: 12px;">범례</h4>
<div style="display: flex; flex-direction: column; gap: 8px;">
<div style="display: flex; align-items: center; gap: 8px;">
<div style="width: 16px; height: 16px; background: rgba(59, 130, 246, 0.3); border: 2px solid rgb(59, 130, 246); border-radius: 4px;"></div>
<span style="font-size: 0.875rem;">작업 중 (내부 작업자)</span>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<div style="width: 16px; height: 16px; background: rgba(168, 85, 247, 0.3); border: 2px solid rgb(168, 85, 247); border-radius: 4px;"></div>
<span style="font-size: 0.875rem;">방문 예정 (외부 인원)</span>
</div>
<div style="display: flex; align-items: center; gap: 8px;">
<div style="width: 16px; height: 16px; background: rgba(34, 197, 94, 0.3); border: 2px solid rgb(34, 197, 94); border-radius: 4px;"></div>
<span style="font-size: 0.875rem;">작업 + 방문</span>
</div>
</div>
</div>
</div>
<div id="mapPlaceholder" style="display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 400px; color: #6b7280;">
<div style="font-size: 48px; margin-bottom: 16px;">🏭</div>
<h3 style="margin-bottom: 8px;">공장을 선택하세요</h3>
<p style="font-size: 0.875rem;">위에서 공장을 선택하면 작업장 현황을 확인할 수 있습니다.</p>
</div>
</div>
</div>
</section>
<!-- 임시 이동된 설비 현황 -->
<section class="moved-equipment-section" style="margin-top: 24px;">
<div class="card">
<div class="card-header">
<div class="flex justify-between items-center">
<h2 class="card-title">🚚 임시 이동된 설비</h2>
<button class="btn btn-outline btn-sm" onclick="loadMovedEquipments()">새로고침</button>
</div>
</div>
<div class="card-body">
<div id="movedEquipmentList" class="moved-equipment-grid"></div>
<div id="noMovedEquipment" style="display: none; text-align: center; padding: 40px; color: #6b7280;">
<div style="font-size: 48px; margin-bottom: 12px;"></div>
<p>임시 이동된 설비가 없습니다.</p>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
<!-- 토스트 -->
<div class="toast-container" id="toastContainer"></div>
<!-- 작업장 상세 정보 모달 -->
<div id="workplaceDetailModal" class="workplace-modal-overlay">
<div class="workplace-modal-container">
<div class="workplace-modal-header">
<div class="workplace-modal-title-section">
<h2 id="modalWorkplaceName" class="workplace-modal-title"></h2>
<p id="modalWorkplaceDesc" class="workplace-modal-subtitle"></p>
</div>
<button class="workplace-modal-close" onclick="closeWorkplaceModal()">&times;</button>
</div>
<div class="workplace-modal-body">
<div class="workplace-modal-tabs">
<button class="workplace-tab active" data-tab="overview" onclick="switchWorkplaceTab('overview')">
<span class="tab-icon">📊</span><span class="tab-text">현황 개요</span>
</button>
<button class="workplace-tab" data-tab="workers" onclick="switchWorkplaceTab('workers')">
<span class="tab-icon">👷</span><span class="tab-text">작업자</span>
<span id="workerCountBadge" class="tab-badge">0</span>
</button>
<button class="workplace-tab" data-tab="visitors" onclick="switchWorkplaceTab('visitors')">
<span class="tab-icon">🚪</span><span class="tab-text">방문자</span>
<span id="visitorCountBadge" class="tab-badge">0</span>
</button>
<button class="workplace-tab" data-tab="detail-map" onclick="switchWorkplaceTab('detail-map')">
<span class="tab-icon">🗺️</span><span class="tab-text">상세 지도</span>
</button>
<button class="workplace-tab" data-tab="moved-eq" onclick="switchWorkplaceTab('moved-eq')">
<span class="tab-icon">🚚</span><span class="tab-text">이동 설비</span>
<span id="movedEqCountBadge" class="tab-badge" style="display:none;">0</span>
</button>
</div>
<div class="workplace-tab-contents">
<div id="tab-overview" class="workplace-tab-content active">
<div class="workplace-summary-cards">
<div class="summary-card workers">
<div class="summary-icon">👷</div>
<div class="summary-info"><span class="summary-value" id="summaryWorkerCount">0</span><span class="summary-label">작업자</span></div>
</div>
<div class="summary-card visitors">
<div class="summary-icon">🚪</div>
<div class="summary-info"><span class="summary-value" id="summaryVisitorCount">0</span><span class="summary-label">방문자</span></div>
</div>
<div class="summary-card tasks">
<div class="summary-icon">📋</div>
<div class="summary-info"><span class="summary-value" id="summaryTaskCount">0</span><span class="summary-label">작업 수</span></div>
</div>
</div>
<div class="workplace-section">
<h4 class="section-title"><span class="section-icon">🔧</span> 진행 중인 작업</h4>
<div id="currentTasksList" class="current-tasks-list"><p class="empty-message">진행 중인 작업이 없습니다.</p></div>
</div>
<div class="workplace-section">
<h4 class="section-title"><span class="section-icon">⚙️</span> 설비 현황</h4>
<div id="equipmentSummary" class="equipment-summary"><p class="empty-message">설비 정보를 불러오는 중...</p></div>
</div>
</div>
<div id="tab-workers" class="workplace-tab-content"><div id="internalWorkersList" class="workers-list"></div></div>
<div id="tab-visitors" class="workplace-tab-content"><div id="externalVisitorsList" class="visitors-list"></div></div>
<div id="tab-detail-map" class="workplace-tab-content">
<div id="detailMapContainer" class="detail-map-container">
<div class="detail-map-placeholder"><span class="placeholder-icon">🗺️</span><p>상세 지도를 불러오는 중...</p></div>
</div>
<div id="detailMapLegend" class="detail-map-legend"></div>
</div>
<div id="tab-moved-eq" class="workplace-tab-content">
<div class="moved-eq-tab-content">
<div class="workplace-section">
<h4 class="section-title"><span class="section-icon">📥</span> 이 작업장으로 이동해 온 설비</h4>
<div id="movedInEquipmentList" class="moved-eq-list"><p class="empty-message">없음</p></div>
</div>
<div class="workplace-section">
<h4 class="section-title"><span class="section-icon">📤</span> 다른 곳으로 이동한 설비</h4>
<div id="movedOutEquipmentList" class="moved-eq-list"><p class="empty-message">없음</p></div>
</div>
</div>
</div>
</div>
</div>
<div class="workplace-modal-footer">
<button class="btn btn-outline" onclick="openPatrolPage()"><span>🔍</span> 순회점검</button>
<button class="btn btn-primary" onclick="closeWorkplaceModal()">닫기</button>
</div>
<!-- 설비 상세 슬라이드 패널 -->
<div id="equipmentSlidePanel" class="equipment-slide-panel">
<div class="slide-panel-header">
<button class="slide-panel-back" onclick="closeEquipmentPanel()">&larr;</button>
<div class="slide-panel-title-section">
<h3 id="panelEquipmentTitle" class="slide-panel-title"></h3>
<span id="panelEquipmentStatus" class="slide-panel-status"></span>
</div>
</div>
<div class="slide-panel-body">
<div class="panel-section"><div class="panel-info-grid" id="panelEquipmentInfo"></div></div>
<div class="panel-section">
<div class="panel-section-header"><h4>설비 사진</h4><button class="btn-icon-sm" onclick="openPanelPhotoUpload()">+</button></div>
<div class="panel-photo-grid" id="panelPhotoGrid"><div class="panel-empty">등록된 사진이 없습니다</div></div>
</div>
<div class="panel-actions">
<button class="panel-action-btn move" onclick="openPanelMoveModal()"><span></span> 임시이동</button>
<button class="panel-action-btn repair" onclick="openPanelRepairModal()"><span>🔧</span> 수리신청</button>
<button class="panel-action-btn export" onclick="openPanelExportModal()"><span>🚚</span> 외부반출</button>
</div>
<div class="panel-section"><h4 class="panel-section-title">수리 이력</h4><div id="panelRepairHistory" class="panel-history-list"><div class="panel-empty">수리 이력이 없습니다</div></div></div>
<div class="panel-section"><h4 class="panel-section-title">외부반출 이력</h4><div id="panelExternalHistory" class="panel-history-list"><div class="panel-empty">외부반출 이력이 없습니다</div></div></div>
</div>
</div>
</div>
</div>
<!-- 설비 사진 업로드 모달 -->
<div id="panelPhotoModal" class="mini-modal-overlay" style="display:none;">
<div class="mini-modal">
<div class="mini-modal-header"><h4>사진 추가</h4><button onclick="closePanelPhotoModal()">&times;</button></div>
<div class="mini-modal-body">
<input type="file" id="panelPhotoInput" accept="image/*" onchange="previewPanelPhoto(event)">
<div id="panelPhotoPreview" class="mini-photo-preview"></div>
<input type="text" id="panelPhotoDesc" class="form-control" placeholder="설명 (선택)">
</div>
<div class="mini-modal-footer">
<button class="btn btn-secondary btn-sm" onclick="closePanelPhotoModal()">취소</button>
<button class="btn btn-primary btn-sm" onclick="uploadPanelPhoto()">업로드</button>
</div>
</div>
</div>
<!-- 설비 임시이동 모달 -->
<div id="panelMoveModal" class="mini-modal-overlay" style="display:none;">
<div class="mini-modal" style="max-width:700px;">
<div class="mini-modal-header"><h4 id="panelMoveTitle">설비 임시 이동</h4><button onclick="closePanelMoveModal()">&times;</button></div>
<div class="mini-modal-body" style="padding:0;">
<div id="moveStep1" class="move-step-content">
<div class="move-step-header"><span class="step-badge">1</span><span>공장 선택</span></div>
<div class="move-factory-grid" id="moveFactoryGrid"></div>
</div>
<div id="moveStep2" class="move-step-content" style="display:none;">
<div class="move-step-header"><button class="btn-step-back" onclick="moveBackToStep1()"></button><span class="step-badge">2</span><span id="moveStep2Title">작업장 선택</span></div>
<p class="move-help-text">지도에서 이동할 작업장을 클릭하세요</p>
<div class="move-layout-map" id="moveLayoutMapContainer"></div>
</div>
<div id="moveStep3" class="move-step-content" style="display:none;">
<div class="move-step-header"><button class="btn-step-back" onclick="moveBackToStep2()"></button><span class="step-badge">3</span><span id="moveStep3Title">위치 선택</span></div>
<p class="move-help-text">지도에서 설비를 배치할 위치를 클릭하세요</p>
<div class="move-detail-map" id="moveDetailMapContainer"></div>
<div class="form-group" style="padding:12px;"><input type="text" id="panelMoveReason" class="form-control" placeholder="이동 사유 (선택)"></div>
</div>
</div>
<div class="mini-modal-footer">
<button class="btn btn-secondary btn-sm" onclick="closePanelMoveModal()">취소</button>
<button class="btn btn-primary btn-sm" id="panelMoveConfirmBtn" onclick="confirmPanelMove()" disabled>이동 확인</button>
</div>
</div>
</div>
<!-- 설비 수리신청 모달 -->
<div id="panelRepairModal" class="mini-modal-overlay" style="display:none;">
<div class="mini-modal">
<div class="mini-modal-header"><h4>수리 신청</h4><button onclick="closePanelRepairModal()">&times;</button></div>
<div class="mini-modal-body">
<div class="form-group"><label>수리 유형</label><select id="panelRepairItem" class="form-control" onchange="onRepairTypeChange()"><option value="">선택하세요</option></select></div>
<div class="form-group" id="newRepairTypeGroup" style="display:none;"><label>새 유형 이름</label><input type="text" id="newRepairTypeName" class="form-control" placeholder="새로운 수리 유형 입력"></div>
<div class="form-group"><label>상세 내용</label><textarea id="panelRepairDesc" class="form-control" rows="3" placeholder="수리 필요 내용"></textarea></div>
<div class="form-group"><label>사진 첨부</label><input type="file" id="panelRepairPhotoInput" accept="image/*" multiple></div>
</div>
<div class="mini-modal-footer">
<button class="btn btn-secondary btn-sm" onclick="closePanelRepairModal()">취소</button>
<button class="btn btn-primary btn-sm" onclick="submitPanelRepair()">신청</button>
</div>
</div>
</div>
<!-- 설비 외부반출 모달 -->
<div id="panelExportModal" class="mini-modal-overlay" style="display:none;">
<div class="mini-modal">
<div class="mini-modal-header"><h4>외부 반출</h4><button onclick="closePanelExportModal()">&times;</button></div>
<div class="mini-modal-body">
<div class="form-group"><label class="checkbox-inline"><input type="checkbox" id="panelIsRepairExport"> 수리 외주</label></div>
<div class="form-group"><label>반출일</label><input type="date" id="panelExportDate" class="form-control"></div>
<div class="form-group"><label>반입 예정일</label><input type="date" id="panelExpectedReturn" class="form-control"></div>
<div class="form-group"><label>반출처</label><input type="text" id="panelExportDest" class="form-control" placeholder="업체명"></div>
<div class="form-group"><label>반출 사유</label><textarea id="panelExportReason" class="form-control" rows="2"></textarea></div>
</div>
<div class="mini-modal-footer">
<button class="btn btn-secondary btn-sm" onclick="closePanelExportModal()">취소</button>
<button class="btn btn-primary btn-sm" onclick="submitPanelExport()">반출</button>
</div>
</div>
</div>
<!-- 설비 반입 모달 -->
<div id="panelReturnModal" class="mini-modal-overlay" style="display:none;">
<div class="mini-modal" style="max-width:350px;">
<div class="mini-modal-header"><h4>설비 반입</h4><button onclick="closePanelReturnModal()">&times;</button></div>
<div class="mini-modal-body">
<input type="hidden" id="panelReturnLogId">
<div class="form-group"><label>반입일</label><input type="date" id="panelReturnDate" class="form-control"></div>
<div class="form-group"><label>반입 후 상태</label><select id="panelReturnStatus" class="form-control"><option value="active">정상 가동</option><option value="maintenance">점검 필요</option><option value="repair_needed">추가 수리 필요</option></select></div>
</div>
<div class="mini-modal-footer">
<button class="btn btn-secondary btn-sm" onclick="closePanelReturnModal()">취소</button>
<button class="btn btn-primary btn-sm" onclick="submitPanelReturn()">반입</button>
</div>
</div>
</div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/api-base.js?v=2026031401"></script>
<script type="module" src="/js/modern-dashboard.js?v=2026031401"></script>
<script type="module" src="/js/group-leader-dashboard.js?v=2026031401"></script>
<script src="/js/workplace-status.js?v=2026031401"></script>
<script src="/js/mobile-dashboard.js?v=2026031401"></script>
<script>initAuth();</script>
</body>
</html>

View File

@@ -0,0 +1,218 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>일일순회점검 - TK 공장관리</title>
<link rel="stylesheet" href="/css/daily-patrol.css?v=2026031401">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<!-- 페이지 헤더 -->
<div class="page-header">
<div class="page-title-section">
<h1 class="page-title">일일순회점검</h1>
<p class="page-description">작업장을 순회하며 안전 및 정리정돈 상태를 점검합니다</p>
</div>
</div>
<!-- 점검 시작 영역 -->
<div class="patrol-start-section">
<!-- 오늘 점검 현황 요약 -->
<div id="todayStatusSummary" class="today-status-summary">
<!-- JS에서 렌더링 -->
</div>
<button type="button" class="btn btn-primary btn-lg" id="startPatrolBtn" onclick="showFactorySelection()">
<span class="btn-icon"></span> 순회점검 시작
</button>
</div>
<!-- 공장 선택 영역 (점검 시작 후 표시) -->
<div id="factorySelectionArea" class="factory-selection-area" style="display: none;">
<div class="factory-selection-header">
<h3>공장을 선택하세요</h3>
<p class="factory-selection-subtitle" id="patrolSessionInfo"><!-- JS에서 렌더링 --></p>
</div>
<div id="factoryCardsContainer" class="factory-cards-container">
<!-- JS에서 공장 카드 렌더링 -->
</div>
</div>
<!-- 점검 진행 영역 (세션 시작 후 표시) -->
<div id="patrolArea" class="patrol-area" style="display: none;">
<!-- 세션 정보 -->
<div id="sessionInfo" class="session-info-bar">
<!-- JS에서 렌더링 -->
</div>
<!-- 지도 및 체크리스트 영역 -->
<div class="patrol-content">
<!-- 작업장 지도 (좌측) -->
<div class="patrol-map-section">
<div class="map-header">
<h3>작업장 지도</h3>
<div class="map-legend">
<span class="legend-item completed"><span class="dot"></span> 점검완료</span>
<span class="legend-item in-progress"><span class="dot"></span> 점검중</span>
<span class="legend-item pending"><span class="dot"></span> 미점검</span>
</div>
</div>
<div id="patrolMapContainer" class="patrol-map-container">
<!-- 지도 이미지 및 작업장 마커 -->
</div>
<!-- 작업장 목록 (지도 대신 사용 가능) -->
<div id="workplaceListContainer" class="workplace-list-container">
<!-- 작업장 목록 -->
</div>
</div>
<!-- 체크리스트 영역 (우측) -->
<div class="patrol-checklist-section">
<div id="checklistHeader" class="checklist-header">
<h3>체크리스트</h3>
<p class="checklist-subtitle">작업장을 선택하면 체크리스트가 표시됩니다</p>
</div>
<div id="checklistContent" class="checklist-content">
<!-- 체크리스트 항목들 -->
<div class="checklist-placeholder">
<p>좌측 지도에서 점검할 작업장을 선택해주세요</p>
</div>
</div>
<div id="checklistActions" class="checklist-actions" style="display: none;">
<button type="button" class="btn btn-secondary" onclick="saveChecklistDraft()">임시저장</button>
<button type="button" class="btn btn-primary" onclick="saveChecklist()">저장 후 다음</button>
</div>
</div>
</div>
<!-- 물품 현황 영역 -->
<div id="itemsSection" class="items-section" style="display: none;">
<div class="items-header">
<h3><span id="selectedWorkplaceName">작업장</span> 물품 현황</h3>
<button type="button" class="btn btn-sm btn-outline" onclick="toggleItemEditMode()">
<span id="itemEditModeText">편집모드</span>
</button>
</div>
<div id="itemsMapContainer" class="items-map-container">
<!-- 작업장 상세 지도 및 물품 마커 -->
</div>
<div id="itemsLegend" class="items-legend">
<!-- 물품 유형 범례 -->
</div>
</div>
<!-- 순회점검 완료 버튼 -->
<div class="patrol-complete-section">
<div class="form-group">
<label for="patrolNotes">특이사항</label>
<textarea id="patrolNotes" class="form-control" rows="2" placeholder="순회 중 발견한 특이사항을 기록하세요..."></textarea>
</div>
<button type="button" class="btn btn-success btn-lg" onclick="completePatrol()">
순회점검 완료
</button>
</div>
</div>
</div>
</div>
</div>
<!-- 물품 추가/수정 모달 -->
<div id="itemModal" class="modal-overlay" style="display: none;">
<div class="modal-container" style="max-width: 500px;">
<div class="modal-header">
<h2 id="itemModalTitle">물품 추가</h2>
<button class="btn-close" onclick="closeItemModal()">&times;</button>
</div>
<div class="modal-body">
<form id="itemForm">
<input type="hidden" id="itemId">
<div class="form-group">
<label for="itemType">물품 유형 *</label>
<select id="itemType" class="form-control" required>
<!-- JS에서 옵션 추가 -->
</select>
</div>
<div class="form-group">
<label for="itemName">물품명/설명</label>
<input type="text" id="itemName" class="form-control" placeholder="예: A사 용기 10개">
</div>
<div class="form-group">
<label for="itemQuantity">수량</label>
<input type="number" id="itemQuantity" class="form-control" value="1" min="1">
</div>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick="closeItemModal()">취소</button>
<button type="button" class="btn btn-danger" id="deleteItemBtn" onclick="deleteItem()" style="display: none;">삭제</button>
<button type="button" class="btn btn-primary" onclick="saveItem()">저장</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=2026031401
</script>
<script>
(function() {
const checkApiConfig = setInterval(() => {
if (window.API_BASE_URL) {
clearInterval(checkApiConfig);
axios.defaults.baseURL = window.API_BASE_URL;
const token = localStorage.getItem('sso_token');
if (token) {
axios.defaults.headers.common['Authorization'] = `Bearer ${token}`;
}
axios.interceptors.request.use(
config => {
const token = localStorage.getItem('sso_token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
},
error => Promise.reject(error)
);
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/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/api-base.js?v=2026031401"></script>
<script src="/js/daily-patrol.js?v=2026031401"></script>
<script>initAuth();</script>
</body>
</html>

View File

@@ -0,0 +1,313 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>구역 상세 - TK 공장관리</title>
<link rel="stylesheet" href="/css/zone-detail.css?v=2026031401">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<!-- 페이지 헤더 -->
<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>
</div>
</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=2026031401
</script>
<script>
(function() {
const checkApiConfig = setInterval(() => {
if (window.API_BASE_URL) {
clearInterval(checkApiConfig);
axios.defaults.baseURL = window.API_BASE_URL;
const token = localStorage.getItem('sso_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/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/api-base.js?v=2026031401"></script>
<script src="/js/zone-detail.js?v=2026031401"></script>
<script>initAuth();</script>
</body>
</html>

View File

@@ -0,0 +1,329 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>내 프로필 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<style>
.profile-page {
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
}
.profile-header {
text-align: center;
margin-bottom: 40px;
}
.profile-avatar {
width: 120px;
height: 120px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 3rem;
color: white;
margin: 0 auto 20px;
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
}
.profile-name {
font-size: 2rem;
font-weight: 700;
color: #333;
margin-bottom: 8px;
}
.profile-role {
font-size: 1.2rem;
color: #666;
font-weight: 500;
}
.profile-cards {
display: grid;
gap: 24px;
}
.profile-card {
background: white;
border-radius: 12px;
box-shadow: 0 4px 16px rgba(0,0,0,0.08);
padding: 28px;
}
.card-title {
font-size: 1.3rem;
font-weight: 600;
color: #333;
margin-bottom: 20px;
display: flex;
align-items: center;
gap: 10px;
}
.info-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
}
.info-item {
display: flex;
flex-direction: column;
gap: 6px;
}
.info-label {
font-size: 0.85rem;
color: #666;
font-weight: 500;
}
.info-value {
font-size: 1.05rem;
color: #333;
font-weight: 600;
}
.action-buttons {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 24px;
}
.action-btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 8px;
text-decoration: none;
font-family: inherit;
}
.btn-primary {
background: #1976d2;
color: white;
}
.btn-primary:hover {
background: #1565c0;
transform: translateY(-1px);
}
.btn-secondary {
background: #f5f5f5;
color: #333;
border: 1px solid #e0e0e0;
}
.btn-secondary:hover {
background: #e0e0e0;
}
.btn-warning {
background: #ff9800;
color: white;
}
.btn-warning:hover {
background: #f57c00;
transform: translateY(-1px);
}
/* 통계 카드 */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 16px;
margin-top: 16px;
}
.stat-box {
background: #f5f5f5;
border-radius: 8px;
padding: 20px;
text-align: center;
}
.stat-number {
font-size: 2rem;
font-weight: 700;
color: #1976d2;
display: block;
margin-bottom: 4px;
}
.stat-label {
font-size: 0.9rem;
color: #666;
}
@media (max-width: 768px) {
.profile-page {
padding: 20px 16px;
}
.profile-avatar {
width: 100px;
height: 100px;
font-size: 2.5rem;
}
.profile-name {
font-size: 1.6rem;
}
.info-grid {
grid-template-columns: 1fr;
}
.action-buttons {
flex-direction: column;
}
.action-btn {
width: 100%;
justify-content: center;
}
}
</style>
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<div class="profile-page">
<div class="profile-header">
<div class="profile-avatar" id="profileAvatar"></div>
<h1 class="profile-name" id="profileName">사용자</h1>
<p class="profile-role" id="profileRole">역할</p>
</div>
<div class="profile-cards">
<!-- 기본 정보 -->
<div class="profile-card">
<h2 class="card-title">기본 정보</h2>
<div class="info-grid">
<div class="info-item">
<span class="info-label">사용자 ID</span>
<span class="info-value" id="userId">-</span>
</div>
<div class="info-item">
<span class="info-label">사용자명</span>
<span class="info-value" id="username">-</span>
</div>
<div class="info-item">
<span class="info-label">이름</span>
<span class="info-value" id="fullName">-</span>
</div>
<div class="info-item">
<span class="info-label">권한 레벨</span>
<span class="info-value" id="accessLevel">-</span>
</div>
<div class="info-item">
<span class="info-label">작업자 ID</span>
<span class="info-value" id="workerId">-</span>
</div>
<div class="info-item">
<span class="info-label">가입일</span>
<span class="info-value" id="createdAt">-</span>
</div>
</div>
</div>
<!-- 활동 정보 -->
<div class="profile-card">
<h2 class="card-title">활동 정보</h2>
<div class="info-grid">
<div class="info-item">
<span class="info-label">마지막 로그인</span>
<span class="info-value" id="lastLogin">-</span>
</div>
<div class="info-item">
<span class="info-label">이메일</span>
<span class="info-value" id="email">-</span>
</div>
</div>
<!-- 간단한 통계 (준비중) -->
<div class="stats-grid" style="margin-top: 24px; opacity: 0.5;">
<div class="stat-box">
<span class="stat-number">-</span>
<span class="stat-label">작업 보고서</span>
</div>
<div class="stat-box">
<span class="stat-number">-</span>
<span class="stat-label">이번 달 활동</span>
</div>
<div class="stat-box">
<span class="stat-number">-</span>
<span class="stat-label">팀 기여도</span>
</div>
</div>
</div>
<!-- 빠른 작업 -->
<div class="profile-card">
<h2 class="card-title">빠른 작업</h2>
<div class="action-buttons">
<a href="/pages/profile/password.html" class="action-btn btn-warning">
비밀번호 변경
</a>
<button class="action-btn btn-secondary" disabled>
프로필 수정 (준비중)
</button>
<button class="action-btn btn-secondary" disabled>
설정 (준비중)
</button>
<a href="javascript:history.back()" class="action-btn btn-secondary">
<span></span>
<span>돌아가기</span>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/api-base.js?v=2026031401"></script>
<script type="module" src="/js/my-profile.js"></script>
<script>initAuth();</script>
</body>
</html>

View File

@@ -0,0 +1,398 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>비밀번호 변경 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<style>
/* 페이지 전용 스타일 */
.password-page {
max-width: 600px;
margin: 0 auto;
padding: 40px 20px;
}
.page-title {
text-align: center;
margin-bottom: 40px;
}
.page-title h1 {
font-size: 2rem;
color: #333;
margin-bottom: 12px;
}
.page-title p {
color: #666;
font-size: 1.1rem;
}
/* 카드 스타일 */
.password-card {
background: white;
border-radius: 16px;
box-shadow: 0 4px 20px rgba(0,0,0,0.08);
overflow: hidden;
}
.card-header {
background: linear-gradient(135deg, #ff9800 0%, #f57c00 100%);
color: white;
padding: 24px 32px;
}
.card-header h2 {
margin: 0;
font-size: 1.5rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 12px;
}
.card-body {
padding: 32px;
}
/* 알림 박스 */
.info-box {
background: #e3f2fd;
border: 1px solid #90caf9;
border-radius: 8px;
padding: 16px;
margin-bottom: 28px;
}
.info-box h4 {
margin: 0 0 12px 0;
color: #1565c0;
font-size: 1rem;
display: flex;
align-items: center;
gap: 8px;
}
.info-box ul {
margin: 0;
padding-left: 24px;
color: #0d47a1;
}
.info-box li {
margin-bottom: 6px;
}
/* 폼 스타일 */
.password-form {
display: flex;
flex-direction: column;
gap: 24px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.form-group label {
font-weight: 600;
color: #333;
font-size: 0.95rem;
display: flex;
align-items: center;
gap: 8px;
}
.input-wrapper {
position: relative;
}
.form-control {
width: 100%;
padding: 14px 48px 14px 16px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 1rem;
transition: all 0.3s ease;
font-family: inherit;
}
.form-control:focus {
outline: none;
border-color: #ff9800;
box-shadow: 0 0 0 3px rgba(255, 152, 0, 0.1);
}
.password-toggle {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
cursor: pointer;
padding: 8px;
font-size: 1.2rem;
opacity: 0.6;
transition: opacity 0.2s;
}
.password-toggle:hover {
opacity: 1;
}
/* 버튼 */
.form-actions {
display: flex;
gap: 12px;
margin-top: 12px;
}
.btn {
padding: 14px 28px;
border: none;
border-radius: 8px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-family: inherit;
}
.btn-primary {
background: #ff9800;
color: white;
flex: 1;
}
.btn-primary:hover {
background: #f57c00;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(255, 152, 0, 0.3);
}
.btn-primary:disabled {
background: #ccc;
cursor: not-allowed;
transform: none;
}
.btn-secondary {
background: #f5f5f5;
color: #666;
border: 1px solid #e0e0e0;
}
.btn-secondary:hover {
background: #e0e0e0;
}
/* 메시지 */
.message-box {
padding: 16px;
border-radius: 8px;
margin-bottom: 20px;
animation: slideDown 0.3s ease-out;
}
.message-box.error {
background: #ffebee;
border: 1px solid #ffcdd2;
color: #c62828;
}
.message-box.success {
background: #e8f5e9;
border: 1px solid #c8e6c9;
color: #2e7d32;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* 하단 링크 */
.back-link {
text-align: center;
margin-top: 32px;
padding-top: 32px;
border-top: 1px solid #e0e0e0;
}
.back-link a {
color: #1976d2;
text-decoration: none;
font-size: 0.95rem;
display: inline-flex;
align-items: center;
gap: 6px;
transition: color 0.2s;
}
.back-link a:hover {
color: #1565c0;
text-decoration: underline;
}
/* 반응형 */
@media (max-width: 768px) {
.password-page {
padding: 20px 16px;
}
.card-body {
padding: 24px;
}
.form-actions {
flex-direction: column;
}
.btn {
width: 100%;
}
}
</style>
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<div class="password-page">
<div class="page-title">
<h1>비밀번호 변경</h1>
<p>계정 보안을 위해 정기적으로 비밀번호를 변경해주세요</p>
</div>
<div class="password-card">
<div class="card-header">
<h2>새 비밀번호 설정</h2>
</div>
<div class="card-body">
<!-- 메시지 영역 -->
<div id="message-area"></div>
<!-- 안내 정보 -->
<div class="info-box">
<h4>비밀번호 요구사항</h4>
<ul>
<li>최소 6자 이상 입력해주세요</li>
<li>영문 대/소문자, 숫자, 특수문자를 조합하면 더 안전합니다</li>
<li>개인정보나 쉬운 단어는 피해주세요</li>
<li>이전 비밀번호와 다르게 설정해주세요</li>
</ul>
</div>
<!-- 비밀번호 변경 폼 -->
<form id="changePasswordForm" class="password-form">
<div class="form-group">
<label for="currentPassword">현재 비밀번호</label>
<div class="input-wrapper">
<input
type="password"
id="currentPassword"
class="form-control"
placeholder="현재 비밀번호를 입력하세요"
required
autocomplete="current-password"
/>
<button type="button" class="password-toggle" data-target="currentPassword">보기</button>
</div>
</div>
<div class="form-group">
<label for="newPassword">새 비밀번호</label>
<div class="input-wrapper">
<input
type="password"
id="newPassword"
class="form-control"
placeholder="새 비밀번호를 입력하세요"
required
autocomplete="new-password"
/>
<button type="button" class="password-toggle" data-target="newPassword">보기</button>
</div>
<div id="passwordStrength"></div>
</div>
<div class="form-group">
<label for="confirmPassword">새 비밀번호 확인</label>
<div class="input-wrapper">
<input
type="password"
id="confirmPassword"
class="form-control"
placeholder="새 비밀번호를 다시 입력하세요"
required
autocomplete="new-password"
/>
<button type="button" class="password-toggle" data-target="confirmPassword">보기</button>
</div>
</div>
<div class="form-actions">
<button type="submit" class="btn btn-primary" id="submitBtn">
비밀번호 변경
</button>
<button type="button" class="btn btn-secondary" id="resetBtn">
초기화
</button>
</div>
</form>
<div class="back-link">
<a href="javascript:history.back()">
<span></span>
<span>이전 페이지로 돌아가기</span>
</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script>initAuth();</script>
<script src="/js/change-password.js?v=2026040101"></script>
</body>
</html>

View File

@@ -0,0 +1,105 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>소모품 신청 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040105">
<link rel="stylesheet" href="/css/purchase-mobile.css?v=2026040106">
<script src="https://cdn.jsdelivr.net/npm/heic2any@0.0.4/dist/heic2any.min.js"></script>
</head>
<body class="bg-gray-50">
<!-- 헤더 -->
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="px-4 flex justify-between items-center h-12">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="text-orange-200 hover:text-white"><i class="fas fa-bars text-lg"></i></button>
<h1 class="text-base font-semibold">소모품 신청</h1>
</div>
<div class="flex items-center gap-2">
<span id="headerUserName" class="text-sm text-orange-200"></span>
</div>
</div>
</header>
<!-- 사이드 네비 (TBM 패턴) -->
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-12 left-0 bottom-0 overflow-y-auto"></nav>
<div class="pm-content">
<!-- 상태 탭 -->
<div class="pm-tabs" id="statusTabs">
<button class="pm-tab active" data-status="" onclick="filterByStatus(this)">전체</button>
<button class="pm-tab" data-status="pending" onclick="filterByStatus(this)">대기</button>
<button class="pm-tab" data-status="grouped" onclick="filterByStatus(this)">구매진행중</button>
<button class="pm-tab" data-status="purchased" onclick="filterByStatus(this)">구매완료</button>
<button class="pm-tab" data-status="received" onclick="filterByStatus(this)">입고완료</button>
</div>
<!-- 카드 리스트 -->
<div class="pm-cards" id="requestCards"></div>
<div class="pm-loading hidden" id="loadingMore">더 불러오는 중...</div>
</div>
<!-- FAB -->
<button class="pm-fab" onclick="openRequestSheet()"><i class="fas fa-plus"></i></button>
<!-- 신청 바텀시트 -->
<div class="pm-overlay" id="requestOverlay" onclick="closeRequestSheet()"></div>
<div class="pm-sheet" id="requestSheet">
<div class="pm-sheet-handle"></div>
<div class="pm-sheet-header">
<span class="pm-sheet-title">소모품 신청</span>
<button class="pm-sheet-close" onclick="closeRequestSheet()"><i class="fas fa-times"></i></button>
</div>
<div class="pm-sheet-body">
<!-- 검색 -->
<div class="pm-search-wrap">
<input type="text" id="searchInput" class="pm-search-input" placeholder="품목 검색 (이름, 초성, 별칭)">
<div class="pm-search-spinner" id="searchSpinner"><i class="fas fa-spinner text-orange-500"></i></div>
</div>
<div class="pm-search-results" id="searchResults"></div>
<!-- 장바구니 -->
<div id="cartWrap" class="hidden mb-3">
<div class="pm-cart-header">
<span class="pm-cart-title"><i class="fas fa-shopping-cart mr-1"></i>신청 목록</span>
<span class="pm-cart-count" id="cartCount">0건</span>
</div>
<div id="cartList"></div>
</div>
<!-- 사진 (공통) -->
<div class="pm-field">
<label class="pm-label">참고 사진</label>
<label class="pm-photo-btn">
<i class="fas fa-camera"></i>
<span id="photoLabel">사진 촬영/선택</span>
<input type="file" id="reqPhotoInput" accept="image/*,.heic,.heif" capture="environment" class="hidden" onchange="onMobilePhotoSelected(this)">
</label>
<img id="reqPhotoPreview" class="pm-photo-preview hidden">
</div>
<button id="submitBtn" class="pm-submit" onclick="submitRequest()" disabled>신청하기</button>
</div>
</div>
<!-- 상세 바텀시트 -->
<div class="pm-overlay" id="detailOverlay" onclick="closeDetailSheet()"></div>
<div class="pm-sheet" id="detailSheet">
<div class="pm-sheet-handle"></div>
<div class="pm-sheet-header">
<span class="pm-sheet-title">신청 상세</span>
<button class="pm-sheet-close" onclick="closeDetailSheet()"><i class="fas fa-times"></i></button>
</div>
<div class="pm-sheet-body" id="detailContent"></div>
</div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/static/js/purchase-request-mobile.js?v=2026040104"></script>
<script src="/static/js/shared-bottom-nav.js?v=2026040103"></script>
</body>
</html>

View File

@@ -0,0 +1,319 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>소모품 신청 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<script src="https://cdn.jsdelivr.net/npm/heic2any@0.0.4/dist/heic2any.min.js"></script>
<style>
.item-dropdown { position: absolute; top: 100%; left: 0; right: 0; max-height: 280px; overflow-y: auto; background: white; border: 1px solid #e5e7eb; border-top: none; border-radius: 0 0 8px 8px; box-shadow: 0 4px 12px rgba(0,0,0,0.1); z-index: 20; display: none; }
.item-dropdown.open { display: block; }
.item-dropdown-item { padding: 8px 12px; cursor: pointer; font-size: 14px; display: flex; align-items: center; gap: 8px; }
.item-dropdown-item:hover, .item-dropdown-item.active { background: #fff7ed; }
.item-dropdown-item .cat-tag { font-size: 11px; padding: 1px 6px; border-radius: 4px; white-space: nowrap; }
.item-dropdown-custom { padding: 10px 12px; cursor: pointer; font-size: 14px; color: #ea580c; border-top: 1px solid #f3f4f6; display: flex; align-items: center; gap: 6px; }
.item-dropdown-custom:hover { background: #fff7ed; }
.photo-preview-container { position: relative; display: inline-block; }
.photo-preview-container .remove-btn { position: absolute; top: -6px; right: -6px; width: 20px; height: 20px; border-radius: 50%; background: #ef4444; color: white; border: none; cursor: pointer; font-size: 11px; display: flex; align-items: center; justify-content: center; }
</style>
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<div class="page-header">
<div class="page-title-section">
<h1 class="page-title">소모품 신청</h1>
<p class="page-description">소모품을 신청하고 처리 현황을 확인합니다</p>
</div>
</div>
<!-- 구매신청 폼 -->
<div class="bg-white rounded-xl shadow-sm p-5 mb-6">
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-plus-circle text-orange-500 mr-2"></i>신규 소모품 신청</h2>
<div class="grid grid-cols-1 sm:grid-cols-4 gap-4 items-start">
<div class="sm:col-span-2 relative">
<label class="block text-xs font-medium text-gray-600 mb-1">소모품 <span class="text-red-400">*</span></label>
<input type="text" id="prItemSearch" class="w-full px-3 py-2 border rounded-lg text-sm focus:ring-2 focus:ring-orange-300" placeholder="소모품 검색 또는 직접 입력" autocomplete="off">
<input type="hidden" id="prItemId" value="">
<input type="hidden" id="prCustomItemName" value="">
<div id="prItemDropdown" class="item-dropdown"></div>
<div id="prItemPreview" class="mt-2 hidden flex items-center gap-3 p-2 bg-gray-50 rounded-lg">
<img id="prItemPhoto" class="w-12 h-12 rounded object-cover hidden">
<div>
<div id="prItemInfo" class="text-sm text-gray-700"></div>
<div id="prItemPrice" class="text-xs text-gray-500"></div>
</div>
</div>
<!-- 직접 입력 시 분류 선택 -->
<div id="prCustomCategoryWrap" class="mt-2 hidden">
<label class="block text-xs font-medium text-gray-600 mb-1">분류 <span class="text-red-400">*</span></label>
<select id="prCustomCategory" class="w-full px-3 py-2 border rounded-lg text-sm">
<option value="consumable">소모품</option>
<option value="safety">안전용품</option>
<option value="repair">수선비</option>
<option value="equipment">설비</option>
</select>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">수량 <span class="text-red-400">*</span></label>
<input type="number" id="prQuantity" class="w-full px-3 py-2 border rounded-lg text-sm" min="1" value="1">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">메모</label>
<input type="text" id="prNotes" class="w-full px-3 py-2 border rounded-lg text-sm" placeholder="선택 사항">
</div>
</div>
<!-- 사진 첨부 -->
<div class="mt-3">
<label class="block text-xs font-medium text-gray-600 mb-1">사진 첨부 (선택)</label>
<div class="flex items-center gap-3">
<label class="px-3 py-2 border rounded-lg text-sm text-gray-600 cursor-pointer hover:bg-gray-50 inline-flex items-center gap-1">
<i class="fas fa-camera text-orange-400"></i> 사진 선택
<input type="file" id="prPhotoInput" accept="image/*,.heic,.heif" class="hidden" onchange="onPhotoSelected(this)">
</label>
<div id="prPhotoPreview" class="hidden photo-preview-container">
<img id="prPhotoPreviewImg" class="w-16 h-16 rounded object-cover">
<button class="remove-btn" onclick="removePhoto()" title="삭제">&times;</button>
</div>
<span id="prPhotoStatus" class="text-xs text-gray-400"></span>
</div>
</div>
<div class="mt-4 flex justify-end">
<button onclick="submitPurchaseRequest()" class="px-5 py-2 bg-orange-600 text-white rounded-lg text-sm hover:bg-orange-700">
<i class="fas fa-paper-plane mr-1"></i>소모품 신청
</button>
</div>
</div>
<!-- 필터 -->
<div class="flex gap-3 mb-4 flex-wrap items-center">
<select id="prFilterStatus" class="px-3 py-2 border rounded-lg text-sm" onchange="loadRequests()">
<option value="">전체 상태</option>
<option value="pending">대기</option>
<option value="grouped">구매진행중</option>
<option value="purchased">구매완료</option>
<option value="received">입고완료</option>
<option value="cancelled">취소</option>
<option value="returned">반품</option>
<option value="hold">보류</option>
</select>
<select id="prFilterCategory" class="px-3 py-2 border rounded-lg text-sm" onchange="loadRequests()">
<option value="">전체 분류</option>
<option value="consumable">소모품</option>
<option value="safety">안전용품</option>
<option value="repair">수선비</option>
<option value="equipment">설비</option>
</select>
<button onclick="loadRequests()" class="px-3 py-2 border rounded-lg text-sm text-gray-600 hover:bg-gray-100">
<i class="fas fa-sync-alt"></i>
</button>
<!-- 그룹 액션 (admin only) -->
<div id="batchActions" class="hidden ml-auto flex items-center gap-2">
<span id="selectedCount" class="text-sm text-gray-500">0건 선택</span>
<button onclick="openBatchCreateModal()" class="px-3 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">
<i class="fas fa-layer-group mr-1"></i>그룹 생성
</button>
</div>
<button id="batchViewBtn" class="hidden ml-2 px-3 py-2 border border-blue-300 text-blue-600 rounded-lg text-sm hover:bg-blue-50" onclick="toggleBatchView()">
<i class="fas fa-layer-group mr-1"></i>그룹 보기
</button>
</div>
<!-- 그룹 목록 (토글) -->
<div id="batchView" class="hidden mb-4">
<div class="bg-white rounded-xl shadow-sm p-4">
<div class="flex justify-between items-center mb-3">
<h3 class="font-semibold text-gray-700"><i class="fas fa-layer-group mr-1 text-blue-500"></i>구매 그룹</h3>
<button onclick="loadBatches()" class="text-xs text-gray-400 hover:text-gray-600"><i class="fas fa-sync-alt"></i></button>
</div>
<div id="batchList" class="space-y-2 text-sm"></div>
</div>
</div>
<!-- 신청 목록 -->
<div class="bg-white rounded-xl shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 text-xs uppercase">
<tr>
<th class="px-2 py-3 text-center" id="thCheckbox" style="display:none"><input type="checkbox" id="selectAllCb" onchange="toggleSelectAll(this)"></th>
<th class="px-4 py-3 text-left">품목</th>
<th class="px-4 py-3 text-left">분류</th>
<th class="px-4 py-3 text-right">수량</th>
<th class="px-4 py-3 text-left">신청자</th>
<th class="px-4 py-3 text-left">신청일</th>
<th class="px-4 py-3 text-center">상태</th>
<th class="px-4 py-3 text-center">액션</th>
</tr>
</thead>
<tbody id="prRequestList" class="divide-y">
<tr><td colspan="7" class="px-4 py-8 text-center text-gray-400">데이터를 불러오는 중...</td></tr>
</tbody>
</table>
</div>
</div>
<!-- 구매 처리 모달 -->
<div id="purchaseModal" class="hidden fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onclick="if(event.target===this)closePurchaseModal()">
<div class="bg-white rounded-xl shadow-xl max-w-lg w-full p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">구매 처리</h3>
<button onclick="closePurchaseModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<div id="purchaseModalInfo" class="bg-gray-50 rounded-lg p-3 mb-4 text-sm"></div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">업체</label>
<select id="pmVendor" class="w-full px-3 py-2 border rounded-lg text-sm">
<option value="">업체 선택</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">구매일 <span class="text-red-400">*</span></label>
<input type="date" id="pmDate" class="w-full px-3 py-2 border rounded-lg text-sm">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">실구매 단가 <span class="text-red-400">*</span></label>
<input type="number" id="pmUnitPrice" class="w-full px-3 py-2 border rounded-lg text-sm" min="0" oninput="showPriceDiff()">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">수량</label>
<input type="number" id="pmQuantity" class="w-full px-3 py-2 border rounded-lg text-sm" min="1" value="1">
</div>
<div class="col-span-2" id="pmPriceDiffArea">
</div>
<!-- 마스터 등록 체크박스 (미등록 품목일 때만 표시) -->
<div class="col-span-2 hidden" id="pmMasterRegisterWrap">
<label class="flex items-center gap-2 cursor-pointer p-2 bg-orange-50 rounded-lg">
<input type="checkbox" id="pmRegisterToMaster" checked class="h-4 w-4 rounded text-orange-600">
<span class="text-sm text-gray-700">소모품 마스터에 등록</span>
<span class="text-xs text-gray-400">(구매 처리 시 자동 등록)</span>
</label>
</div>
<div class="col-span-2">
<label class="block text-xs font-medium text-gray-600 mb-1">메모</label>
<input type="text" id="pmNotes" class="w-full px-3 py-2 border rounded-lg text-sm">
</div>
</div>
<div class="flex justify-end mt-4 gap-2">
<button type="button" onclick="closePurchaseModal()" class="px-4 py-2 border rounded-lg text-sm hover:bg-gray-50">취소</button>
<button type="button" onclick="submitPurchase()" class="px-4 py-2 bg-orange-600 text-white rounded-lg text-sm hover:bg-orange-700">구매 완료</button>
</div>
</div>
</div>
<!-- 보류 모달 -->
<div id="holdModal" class="hidden fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onclick="if(event.target===this)closeHoldModal()">
<div class="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">보류 처리</h3>
<button onclick="closeHoldModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">보류 사유</label>
<textarea id="holdReason" rows="3" class="w-full px-3 py-2 border rounded-lg text-sm" placeholder="보류 사유를 입력하세요"></textarea>
</div>
<div class="flex justify-end mt-4 gap-2">
<button type="button" onclick="closeHoldModal()" class="px-4 py-2 border rounded-lg text-sm hover:bg-gray-50">취소</button>
<button type="button" onclick="submitHold()" class="px-4 py-2 bg-gray-600 text-white rounded-lg text-sm hover:bg-gray-700">보류</button>
</div>
</div>
</div>
<!-- 그룹 생성 모달 -->
<div id="batchCreateModal" class="hidden fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onclick="if(event.target===this)closeBatchCreateModal()">
<div class="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-blue-700"><i class="fas fa-layer-group mr-2"></i>구매 그룹 <20><><EFBFBD></h3>
<button onclick="closeBatchCreateModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<div id="batchSelectedInfo" class="mb-3 text-sm text-gray-600"></div>
<div class="mb-3">
<label class="block text-xs font-medium text-gray-600 mb-1">그룹명</label>
<input type="text" id="bcName" class="w-full px-3 py-2 border rounded-lg text-sm" placeholder="예: 4월 소모품 1차">
</div>
<div class="grid grid-cols-2 gap-3 mb-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">분류</label>
<select id="bcCategory" class="w-full px-3 py-2 border rounded-lg text-sm">
<option value="">자동 분류</option>
<option value="consumable">소모품</option>
<option value="safety">안전<EFBFBD><EFBFBD><EFBFBD></option>
<option value="repair">수선비</option>
<option value="equipment">설비</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">업체</label>
<select id="bcVendor" class="w-full px-3 py-2 border rounded-lg text-sm"></select>
</div>
</div>
<div class="mb-3">
<label class="block text-xs font-medium text-gray-600 mb-1">메모</label>
<input type="text" id="bcNotes" class="w-full px-3 py-2 border rounded-lg text-sm" placeholder="메모">
</div>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeBatchCreateModal()" class="px-4 py-2 border rounded-lg text-sm hover:bg-gray-50">취소</button>
<button type="button" onclick="submitBatchCreate()" class="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700">생성</button>
</div>
</div>
</div>
<!-- 입고 처리 <20><>-->
<div id="receiveModal" class="hidden fixed inset-0 bg-black/40 z-50 flex items-center justify-center p-4" onclick="if(event.target===this)closeReceiveModal()">
<div class="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-teal-700"><i class="fas fa-box-open mr-2"></i>입고 처리</h3>
<button onclick="closeReceiveModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<div id="receiveModalInfo" class="mb-4 p-3 bg-gray-50 rounded-lg"></div>
<div class="mb-3">
<label class="block text-xs font-medium text-gray-600 mb-1">보관 위치</label>
<input type="text" id="rcLocation" class="w-full px-3 py-2 border rounded-lg text-sm" placeholder="예: 1층 자재창고 A-3선반">
</div>
<div class="mb-3">
<label class="block text-xs font-medium text-gray-600 mb-1">입고 사진 (선택)</label>
<input type="file" id="rcPhotoInput" accept="image/*,.heic,.heif" onchange="onReceivePhotoSelected(this)" class="text-sm">
<div id="rcPhotoPreview" class="hidden mt-2">
<img id="rcPhotoPreviewImg" class="w-24 h-24 rounded object-cover">
</div>
</div>
<div class="flex justify-end mt-4 gap-2">
<button type="button" onclick="closeReceiveModal()" class="px-4 py-2 border rounded-lg text-sm hover:bg-gray-50">취소</button>
<button type="button" onclick="submitReceive()" class="px-4 py-2 bg-teal-600 text-white rounded-lg text-sm hover:bg-teal-700">입고 확인</button>
</div>
</div>
</div>
</div>
</div>
</div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/static/js/purchase-request.js?v=2026040104"></script>
</body>
</html>

View File

@@ -0,0 +1 @@
# Placeholder file to create work directory

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,171 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>입력 현황 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<link rel="stylesheet" href="/css/daily-status.css?v=2026033001">
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<!-- Header -->
<div class="ds-header">
<h1><i class="fas fa-chart-bar mr-2"></i>일별 입력 현황</h1>
</div>
<!-- Date Navigation -->
<div class="ds-date-nav">
<button type="button" class="ds-date-btn" id="prevDate" onclick="changeDate(-1)">
<i class="fas fa-chevron-left"></i>
</button>
<div class="ds-date-display" id="dateDisplay" onclick="openDatePicker()">
<span id="dateText">2026-03-30</span>
<span id="dayText" class="ds-day-label">월요일</span>
</div>
<button type="button" class="ds-date-btn" id="nextDate" onclick="changeDate(1)">
<i class="fas fa-chevron-right"></i>
</button>
<input type="date" id="datePicker" class="hidden" onchange="onDatePicked(this.value)">
</div>
<!-- Summary Cards -->
<div class="ds-summary">
<div class="ds-card ds-card-total" onclick="setFilter('all')">
<div class="ds-card-num" id="totalCount">-</div>
<div class="ds-card-label">전체 작업자</div>
</div>
<div class="ds-card ds-card-done" onclick="setFilter('complete')">
<div class="ds-card-num" id="doneCount">-</div>
<div class="ds-card-label">완료</div>
<div class="ds-card-pct" id="donePct">-</div>
</div>
<div class="ds-card ds-card-missing" onclick="setFilter('both_missing')">
<div class="ds-card-num" id="missingCount">-</div>
<div class="ds-card-label">미입력</div>
<div class="ds-card-pct" id="missingPct">-</div>
</div>
</div>
<!-- Filter Tabs -->
<div class="ds-tabs">
<button type="button" class="ds-tab active" data-filter="all" onclick="setFilter('all')">전체 <span class="ds-tab-badge" id="filterAll">0</span></button>
<button type="button" class="ds-tab" data-filter="complete" onclick="setFilter('complete')">완료 <span class="ds-tab-badge" id="filterComplete">0</span></button>
<button type="button" class="ds-tab" data-filter="both_missing" onclick="setFilter('both_missing')">미입력 <span class="ds-tab-badge" id="filterMissing">0</span></button>
<button type="button" class="ds-tab" data-filter="partial" onclick="setFilter('partial')">부분 <span class="ds-tab-badge" id="filterPartial">0</span></button>
</div>
<!-- Worker List -->
<div class="ds-list" id="workerList">
<div class="ds-skeleton"></div>
<div class="ds-skeleton"></div>
<div class="ds-skeleton"></div>
</div>
<!-- Empty State -->
<div class="ds-empty hidden" id="emptyState">
<i class="fas fa-clipboard-check text-3xl text-gray-300"></i>
<p>해당 조건의 작업자가 없습니다</p>
</div>
<!-- No Permission -->
<div class="ds-no-perm hidden" id="noPermission">
<i class="fas fa-lock text-3xl text-gray-300"></i>
<p>접근 권한이 없습니다</p>
<a href="/pages/dashboard.html" class="ds-link">대시보드로 이동</a>
</div>
<!-- Bottom Action -->
<div class="ds-bottom-action" id="bottomAction">
<button type="button" class="ds-proxy-btn" id="proxyBtn" onclick="goProxyInput()" disabled>
<i class="fas fa-user-edit mr-2"></i>미입력자 대리입력 (<span id="proxyCount">0</span>명)
</button>
</div>
<!-- Bottom Nav -->
<nav class="m-bottom-nav">
<a href="/pages/dashboard.html" class="m-nav-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
<span class="m-nav-label"></span>
</a>
<a href="/pages/work/tbm-mobile.html" class="m-nav-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 11l3 3L22 4"/>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
</svg>
<span class="m-nav-label">TBM</span>
</a>
<a href="/pages/work/daily-status.html" class="m-nav-item active">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="3" width="18" height="18" rx="2"/>
<path d="M3 9h18"/>
<path d="M9 21V9"/>
</svg>
<span class="m-nav-label">현황</span>
</a>
<a href="/pages/work/report-create.html" class="m-nav-item">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/>
<polyline points="14 2 14 8 20 8"/>
</svg>
<span class="m-nav-label">작업보고</span>
</a>
</nav>
<!-- Worker Detail Bottom Sheet -->
<div class="ds-sheet-overlay hidden" id="sheetOverlay" onclick="closeSheet()"></div>
<div class="ds-sheet hidden" id="detailSheet">
<div class="ds-sheet-handle" onclick="closeSheet()"></div>
<div class="ds-sheet-header">
<span id="sheetWorkerName">-</span>
<span id="sheetWorkerInfo" class="ds-sheet-sub">-</span>
</div>
<div class="ds-sheet-body" id="sheetBody">
<div class="ds-sheet-loading"><i class="fas fa-spinner fa-spin"></i> 로딩 중...</div>
</div>
<div class="ds-sheet-actions">
<button type="button" class="ds-sheet-btn" id="sheetProxyBtn" onclick="goProxyInputSingle()">
<i class="fas fa-user-edit mr-1"></i>이 작업자 대리입력
</button>
</div>
</div>
<!-- Toast -->
<div id="toastContainer" class="toast-container"></div>
</div>
</div>
</div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/api-base.js?v=2026031701"></script>
<script src="/js/daily-status.js?v=2026033001"></script>
</body>
</html>

View File

@@ -0,0 +1,200 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>회의록 상세 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<style>
.attendee-tag { display: inline-flex; align-items: center; gap: 4px; background: #eff6ff; color: #2563eb; padding: 2px 8px; border-radius: 9999px; font-size: 0.75rem; }
.attendee-tag .remove-btn { cursor: pointer; color: #93c5fd; }
.attendee-tag .remove-btn:hover { color: #dc2626; }
.user-search-results { position: absolute; top: 100%; left: 0; right: 0; background: white; border: 1px solid #e2e8f0; border-radius: 0.5rem; max-height: 200px; overflow-y: auto; z-index: 50; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
.user-search-item { padding: 0.5rem 0.75rem; cursor: pointer; font-size: 0.875rem; }
.user-search-item:hover { background: #f1f5f9; }
</style>
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white">
<i class="fas fa-bars text-xl"></i>
</button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<!-- 상단 -->
<div class="flex items-center gap-3 mb-5">
<a href="/pages/work/meetings.html" class="text-gray-400 hover:text-gray-600"><i class="fas fa-arrow-left"></i></a>
<h2 id="pageTitle" class="text-xl font-bold text-gray-800">새 회의록</h2>
<span id="statusBadge" class="badge badge-gray hidden">초안</span>
</div>
<!-- 기본 정보 -->
<div class="bg-white rounded-xl shadow-sm p-5 mb-4">
<h3 class="font-semibold text-gray-800 mb-3">기본 정보</h3>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">날짜 *</label>
<input type="date" id="meetingDate" class="input-field w-full rounded-lg px-3 py-2 text-sm" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">시간</label>
<input type="time" id="meetingTime" class="input-field w-full rounded-lg px-3 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">장소</label>
<input type="text" id="meetingLocation" class="input-field w-full rounded-lg px-3 py-2 text-sm" placeholder="회의 장소">
</div>
<div class="sm:col-span-2 lg:col-span-3">
<label class="block text-sm font-medium text-gray-700 mb-1">제목 *</label>
<input type="text" id="meetingTitle" class="input-field w-full rounded-lg px-3 py-2 text-sm" placeholder="회의 제목" required>
</div>
<div class="sm:col-span-2 lg:col-span-3">
<label class="block text-sm font-medium text-gray-700 mb-1">요약</label>
<textarea id="meetingSummary" class="input-field w-full rounded-lg px-3 py-2 text-sm" rows="3" placeholder="회의 요약"></textarea>
</div>
</div>
</div>
<!-- 참석자 -->
<div class="bg-white rounded-xl shadow-sm p-5 mb-4">
<h3 class="font-semibold text-gray-800 mb-3">참석자</h3>
<div class="relative mb-3">
<input type="text" id="attendeeSearch" class="input-field w-full rounded-lg px-3 py-2 text-sm" placeholder="이름 또는 아이디로 검색" autocomplete="off">
<div id="attendeeResults" class="user-search-results hidden"></div>
</div>
<div id="attendeeTags" class="flex flex-wrap gap-2"></div>
</div>
<!-- 안건 목록 -->
<div class="bg-white rounded-xl shadow-sm p-5 mb-4">
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold text-gray-800">안건</h3>
<button id="btnAddItem" class="hidden text-sm text-orange-600 hover:text-orange-700 font-medium" onclick="openItemModal()">
<i class="fas fa-plus mr-1"></i>안건 추가
</button>
</div>
<div id="agendaList" class="space-y-3"></div>
<div id="agendaEmpty" class="text-center py-6 text-gray-400 text-sm">
<i class="fas fa-clipboard-list text-2xl mb-2"></i>
<p>안건이 없습니다.</p>
</div>
</div>
<!-- 하단 버튼 -->
<div class="flex flex-wrap gap-2 justify-end mb-8" id="bottomActions">
<button id="btnSave" class="hidden bg-orange-600 text-white px-5 py-2 rounded-lg text-sm hover:bg-orange-700" onclick="saveMeeting()">
<i class="fas fa-save mr-1"></i>저장
</button>
<button id="btnPublish" class="hidden bg-green-600 text-white px-5 py-2 rounded-lg text-sm hover:bg-green-700" onclick="publishMeeting()">
<i class="fas fa-paper-plane mr-1"></i>발행
</button>
<button id="btnUnpublish" class="hidden bg-gray-500 text-white px-5 py-2 rounded-lg text-sm hover:bg-gray-600" onclick="unpublishMeeting()">
<i class="fas fa-undo mr-1"></i>발행 취소
</button>
<button id="btnDelete" class="hidden bg-red-500 text-white px-5 py-2 rounded-lg text-sm hover:bg-red-600" onclick="deleteMeeting()">
<i class="fas fa-trash mr-1"></i>삭제
</button>
</div>
</div>
</div>
</div>
<!-- 안건 모달 -->
<div id="itemModal" class="modal-overlay hidden">
<div class="modal-content p-6">
<div class="flex justify-between items-center mb-4">
<h3 id="itemModalTitle" class="text-lg font-bold">안건 추가</h3>
<button onclick="closeItemModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times text-xl"></i></button>
</div>
<input type="hidden" id="itemId">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">유형</label>
<select id="itemType" class="input-field w-full rounded-lg px-3 py-2 text-sm">
<option value="schedule_update">공정현황</option>
<option value="issue">이슈</option>
<option value="decision">결정사항</option>
<option value="action_item">조치사항</option>
<option value="other">기타</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">관련 프로젝트</label>
<select id="itemProject" class="input-field w-full rounded-lg px-3 py-2 text-sm" onchange="loadItemMilestones()">
<option value="">선택안함</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">관련 마일스톤</label>
<select id="itemMilestone" class="input-field w-full rounded-lg px-3 py-2 text-sm">
<option value="">선택안함</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">상태</label>
<select id="itemStatus" class="input-field w-full rounded-lg px-3 py-2 text-sm">
<option value="open">미처리</option>
<option value="in_progress">진행중</option>
<option value="completed">완료</option>
<option value="cancelled">취소</option>
</select>
</div>
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">내용 *</label>
<textarea id="itemContent" class="input-field w-full rounded-lg px-3 py-2 text-sm" rows="3" required></textarea>
</div>
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">결정사항</label>
<textarea id="itemDecision" class="input-field w-full rounded-lg px-3 py-2 text-sm" rows="2"></textarea>
</div>
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">필요조치</label>
<textarea id="itemAction" class="input-field w-full rounded-lg px-3 py-2 text-sm" rows="2"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">담당자</label>
<select id="itemResponsible" class="input-field w-full rounded-lg px-3 py-2 text-sm">
<option value="">선택안함</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">기한</label>
<input type="date" id="itemDueDate" class="input-field w-full rounded-lg px-3 py-2 text-sm">
</div>
</div>
<div class="flex justify-end gap-2 mt-4">
<button type="button" onclick="closeItemModal()" class="px-4 py-2 text-sm border rounded-lg hover:bg-gray-50">취소</button>
<button type="button" onclick="saveItem()" class="px-4 py-2 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700">저장</button>
</div>
</div>
</div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/meeting-detail.js?v=2026031701"></script>
</body>
</html>

View File

@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>생산회의록 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white">
<i class="fas fa-bars text-xl"></i>
</button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<div class="mb-5">
<h2 class="text-xl font-bold text-gray-800">생산회의록</h2>
<p class="text-sm text-gray-500 mt-0.5">생산회의 기록을 관리합니다</p>
</div>
<!-- 필터 + 버튼 -->
<div class="bg-white rounded-xl shadow-sm p-4 mb-4 flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
<select id="yearFilter" class="input-field rounded-lg px-3 py-1.5 text-sm w-24"></select>
<select id="monthFilter" class="input-field rounded-lg px-3 py-1.5 text-sm w-20">
<option value="">전체</option>
<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>
</div>
<input type="text" id="searchInput" class="input-field rounded-lg px-3 py-1.5 text-sm w-48" placeholder="제목/내용 검색">
<button id="btnNewMeeting" class="hidden ml-auto bg-orange-600 text-white px-4 py-1.5 rounded-lg text-sm hover:bg-orange-700">
<i class="fas fa-plus mr-1"></i>새 회의록
</button>
</div>
<!-- 미완료 조치사항 요약 -->
<div id="actionSummary" class="hidden bg-amber-50 border border-amber-200 rounded-xl p-4 mb-4">
<div class="flex items-center gap-2 mb-2">
<i class="fas fa-exclamation-triangle text-amber-600"></i>
<span class="font-semibold text-amber-800 text-sm">미완료 조치사항</span>
<span id="actionCount" class="badge badge-amber">0</span>
</div>
<div id="actionList" class="space-y-1 text-sm max-h-40 overflow-y-auto"></div>
</div>
<!-- 회의록 목록 -->
<div id="meetingList" class="space-y-3"></div>
<div id="emptyState" class="hidden text-center py-12 text-gray-400">
<i class="fas fa-clipboard text-4xl mb-3"></i>
<p>회의록이 없습니다.</p>
</div>
</div>
</div>
</div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/meetings.js?v=2026031701"></script>
</body>
</html>

View File

@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>대리입력 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<link rel="stylesheet" href="/css/proxy-input.css?v=2026033201">
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<!-- ═══ STEP 1: 작업자 선택 ═══ -->
<div id="step1">
<div class="pi-title-row">
<h2 class="pi-title">대리입력</h2>
<div class="pi-date-group">
<input type="date" id="dateInput" class="pi-date-input" onchange="loadWorkers()">
<button class="pi-refresh-btn" onclick="loadWorkers()"><i class="fas fa-sync-alt"></i></button>
</div>
</div>
<div class="pi-status-bar" id="statusBar">
<span>전체 <strong id="totalNum">0</strong></span>
<span>완료 <strong id="doneNum" class="text-green-600">0</strong></span>
<span>미입력 <strong id="missingNum" class="text-red-500">0</strong></span>
<span>휴가 <strong id="vacNum" class="text-blue-500">0</strong></span>
</div>
<div class="pi-select-all">
<label><input type="checkbox" id="selectAll" onchange="toggleSelectAll(this.checked)"> 전체 선택</label>
</div>
<div class="pi-worker-list" id="workerList">
<div class="pi-skeleton"></div>
<div class="pi-skeleton"></div>
<div class="pi-skeleton"></div>
</div>
<div class="pi-bottom-bar" id="editBar">
<button class="pi-edit-btn" id="editBtn" onclick="openEditMode()" disabled>
<i class="fas fa-pen mr-2"></i><span id="editBtnText">작업자를 선택하세요</span>
</button>
</div>
</div>
<!-- ═══ STEP 2: 일괄 편집 ═══ -->
<div id="step2" class="hidden">
<div class="pi-title-row">
<button class="pi-back-btn" onclick="closeEditMode()"><i class="fas fa-arrow-left"></i></button>
<h2 class="pi-title" id="editTitle">일괄 편집</h2>
</div>
<div class="pi-bulk-form">
<div class="pi-edit-row">
<select id="bulkProject" class="pi-select" required></select>
<select id="bulkWorkType" class="pi-select" required></select>
</div>
<div class="pi-edit-row">
<label class="pi-field"><span>시간</span><input type="number" id="bulkHours" value="8" step="0.5" min="0" max="24" class="pi-input"></label>
<label class="pi-field"><span>부적합 시간</span><input type="number" id="bulkDefect" value="0" step="0.5" min="0" max="24" class="pi-input" onchange="onDefectChange()"></label>
</div>
<div id="defectCategoryRow" class="hidden">
<div class="pi-edit-row">
<select id="bulkDefectCategory" class="pi-select" onchange="onDefectCategoryChange()">
<option value="">부적합 대분류 *</option>
</select>
<select id="bulkDefectItem" class="pi-select">
<option value="">소분류 *</option>
</select>
</div>
</div>
<input type="text" id="bulkNote" placeholder="비고 (선택)" class="pi-note-input">
</div>
<div class="pi-target-section">
<div class="pi-target-label">적용 대상</div>
<div class="pi-target-list" id="targetWorkers"></div>
</div>
<div class="pi-bottom-bar">
<button class="pi-save-btn" id="saveBtn" onclick="saveAll()">
<i class="fas fa-save mr-2"></i><span id="saveBtnText">전체 저장</span>
</button>
</div>
</div>
<!-- Toast -->
<div id="toastContainer" class="toast-container"></div>
</div>
</div>
</div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/api-base.js?v=2026031701"></script>
<script src="/js/proxy-input.js?v=2026033202"></script>
<script>initAuth();</script>
</body>
</html>

View File

@@ -0,0 +1,209 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>작업보고서 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<link rel="stylesheet" href="/css/daily-work-report-mobile.css?v=2026031401">
<link rel="stylesheet" href="/css/tbm-mobile.css?v=2026033108">
<style>
/* 데스크탑이면 리다이렉트 */
@media (min-width: 769px) {
body::before {
content: 'redirect';
display: none;
}
}
</style>
<script>
// 데스크탑 접속 시 리다이렉트
if (window.innerWidth > 768) {
window.location.replace('/pages/work/report-create.html');
}
</script>
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<!-- Sticky 헤더 -->
<div class="m-header">
<h1 class="m-header-title">작업보고서</h1>
<div class="m-header-action">
<button class="m-btn-add" id="btnAddManual" onclick="MobileReport.addManualCard()" style="display:none;">+ 수동추가</button>
</div>
</div>
<!-- 탭바 -->
<div class="m-tab-bar">
<button class="m-tab-btn active" data-tab="tbm" onclick="MobileReport.switchTab('tbm')">
TBM 작업 <span class="m-tab-count" id="tbmCount">0</span>
</button>
<button class="m-tab-btn" data-tab="manual" onclick="MobileReport.switchTab('manual')">
수동 입력
</button>
<button class="m-tab-btn" data-tab="completed" onclick="MobileReport.switchTab('completed')">
완료
</button>
</div>
<!-- 메시지 -->
<div class="m-message" id="mMessage"></div>
<!-- TBM 탭 -->
<div class="m-tab-content active" id="tabTbm">
<div id="tbmCardList">
<div class="m-loading"></div>
</div>
</div>
<!-- 수동 입력 탭 -->
<div class="m-tab-content" id="tabManual">
<div id="manualCardList">
<div class="m-empty">
<div class="m-empty-icon">📝</div>
<div>수동으로 작업보고서를 추가하세요</div>
<button class="m-btn-add" style="margin-top:0.75rem;" onclick="MobileReport.addManualCard()">+ 수동추가</button>
</div>
</div>
</div>
<!-- 완료 탭 -->
<div class="m-tab-content" id="tabCompleted">
<div class="m-completed-header">
<input type="date" class="m-date-input" id="completedDate" onchange="MobileReport.loadCompletedReports()">
</div>
<div id="completedCardList">
<div class="m-empty">
<div class="m-empty-icon">📋</div>
<div>날짜를 선택하세요</div>
</div>
</div>
</div>
<!-- 시간 선택 오버레이 -->
<div class="m-time-overlay" id="mTimeOverlay" onclick="MobileReport.closeTimePicker()">
<div class="m-time-popup" onclick="event.stopPropagation()">
<div class="m-time-header">
<h3 class="m-time-title" id="mTimeTitle">작업시간 선택</h3>
<button class="m-time-close" onclick="MobileReport.closeTimePicker()">&times;</button>
</div>
<div class="m-quick-time-grid">
<button class="m-time-btn" onclick="MobileReport.setTime(0.5)">30분</button>
<button class="m-time-btn" onclick="MobileReport.setTime(1)">1시간</button>
<button class="m-time-btn" onclick="MobileReport.setTime(2)">2시간</button>
<button class="m-time-btn" onclick="MobileReport.setTime(4)">4시간</button>
<button class="m-time-btn" onclick="MobileReport.setTime(8)">8시간</button>
</div>
<div class="m-time-adjust">
<button class="m-time-adjust-btn" onclick="MobileReport.adjustTime(-0.5)">-</button>
<span class="m-time-current" id="mTimeCurrent">0시간</span>
<button class="m-time-adjust-btn" onclick="MobileReport.adjustTime(0.5)">+</button>
</div>
<button class="m-time-confirm" onclick="MobileReport.confirmTime()">확인</button>
</div>
</div>
<!-- 부적합 바텀시트 -->
<div class="m-overlay" id="defectOverlay" onclick="MobileReport.hideDefectSheet()"></div>
<div class="m-bottom-sheet" id="defectSheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<h3 class="m-sheet-title">부적합 입력</h3>
<button class="m-sheet-close" onclick="MobileReport.hideDefectSheet()">&times;</button>
</div>
<div class="m-sheet-body" id="defectSheetBody">
<!-- 동적 렌더링 -->
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn primary" onclick="MobileReport.saveDefects()">저장</button>
</div>
</div>
<!-- 작업장소 바텀시트 -->
<div class="m-overlay" id="wpOverlay" onclick="MobileReport.hideWorkplaceSheet()"></div>
<div class="m-bottom-sheet" id="wpSheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<h3 class="m-sheet-title" id="wpSheetTitle">작업장소 선택</h3>
<button class="m-sheet-close" onclick="MobileReport.hideWorkplaceSheet()">&times;</button>
</div>
<div class="m-sheet-body" id="wpSheetBody">
<!-- 동적 렌더링 -->
</div>
</div>
<!-- 수정 바텀시트 -->
<div class="m-overlay" id="editOverlay" onclick="MobileReport.hideEditSheet()"></div>
<div class="m-bottom-sheet" id="editSheet">
<div class="m-sheet-handle"></div>
<div class="m-sheet-header">
<h3 class="m-sheet-title">보고서 수정</h3>
<button class="m-sheet-close" onclick="MobileReport.hideEditSheet()">&times;</button>
</div>
<div class="m-sheet-body m-edit-form" id="editSheetBody">
<!-- 동적 렌더링 -->
</div>
<div class="m-sheet-footer">
<button class="m-submit-btn primary" onclick="MobileReport.saveEditedReport()">저장</button>
</div>
</div>
<!-- 결과 모달 -->
<div class="m-result-overlay" id="mResultOverlay">
<div class="m-result-box">
<div class="m-result-icon" id="mResultIcon"></div>
<div class="m-result-title" id="mResultTitle"></div>
<div class="m-result-message" id="mResultMessage"></div>
<div class="m-result-details" id="mResultDetails" style="display:none;"></div>
<button class="m-result-close" onclick="MobileReport.closeResult()">확인</button>
</div>
</div>
<!-- 토스트 -->
<div class="m-toast" id="mToast"></div>
</div>
</div>
</div>
<!-- 공통 모듈 -->
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/api-base.js?v=2026031401"></script>
<script src="/js/common/utils.js?v=2026031401"></script>
<script src="/js/common/base-state.js?v=2026031401"></script>
<!-- 작업보고서 모듈 (재사용) -->
<script src="/js/daily-work-report/state.js?v=2026031401"></script>
<script src="/js/daily-work-report/utils.js?v=2026031401"></script>
<script src="/js/daily-work-report/api.js?v=2026031401"></script>
<!-- 모바일 전용 UI 로직 -->
<script src="/js/daily-work-report-mobile.js?v=2026031401"></script>
<script>initAuth();</script>
<script src="/static/js/shared-bottom-nav.js?v=2026040103"></script>
</body>
</html>

View File

@@ -0,0 +1,163 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>작업보고서 작성 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<link rel="stylesheet" href="/css/daily-work-report.css?v=2026031401">
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white">
<i class="fas fa-bars text-xl"></i>
</button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
</div>
</div>
</header>
<!-- Mobile overlay -->
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<!-- Sidebar Nav -->
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<!-- 탭 메뉴 -->
<div class="tab-menu" style="margin-bottom: 1.5rem;">
<button class="tab-btn active" id="tbmReportTab" onclick="switchTab('tbm')">
작업보고서 작성
</button>
<button class="tab-btn" id="completedReportTab" onclick="switchTab('completed')">
작성 완료 보고서
</button>
</div>
<!-- 메시지 영역 -->
<div id="message-container"></div>
<!-- TBM 작업보고 섹션 -->
<div id="tbmReportSection" class="step-section active">
<div id="tbmWorkList"></div>
</div>
<!-- 작성 완료 보고서 섹션 -->
<div id="completedReportSection" class="step-section" style="display: none;">
<div class="form-group" style="max-width: 300px; margin-bottom: 1.25rem;">
<label for="completedReportDate" class="form-label">조회 날짜</label>
<input type="date" id="completedReportDate" class="form-input" onchange="loadCompletedReports()">
</div>
<div id="completedReportsList"></div>
</div>
</div>
</div>
</div>
<!-- 저장 결과 모달 -->
<div id="saveResultModal" class="modal-overlay" style="display: none;">
<div class="modal-container result-modal">
<div class="modal-header">
<h2 id="resultModalTitle">저장 결과</h2>
<button class="modal-close-btn" onclick="closeSaveResultModal()">×</button>
</div>
<div class="modal-body">
<div id="resultModalContent" class="result-content"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" onclick="closeSaveResultModal()">확인</button>
</div>
</div>
</div>
<!-- 작업장소 선택 모달 (지도 기반) -->
<div id="workplaceModal" class="modal-overlay" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); z-index: 1002; align-items: center; justify-content: center; overflow-y: auto; padding: 2rem 0;">
<div class="modal-container" style="background: white; border-radius: 8px; max-width: 1000px; width: 90%; max-height: none; margin: auto; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1); display: flex; flex-direction: column;">
<div class="modal-header" style="padding: 1.5rem; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between; align-items: center;">
<h2 style="font-size: 1.25rem; font-weight: 600; color: #111827; margin: 0;">작업장소 선택</h2>
<button class="modal-close" onclick="closeWorkplaceModal()" style="background: none; border: none; font-size: 1.5rem; cursor: pointer; color: #6b7280; padding: 0; width: 2rem; height: 2rem; display: flex; align-items: center; justify-content: center; border-radius: 4px;">&times;</button>
</div>
<div class="modal-body" style="padding: 1.5rem; flex: 1; overflow-y: visible;">
<div id="categorySelectionArea">
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; color: #374151;">공장 선택</h3>
<div id="workplaceCategoryList" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem;"></div>
</div>
<div id="workplaceSelectionArea" style="display: none; margin-top: 1.5rem;">
<h3 style="font-size: 1rem; font-weight: 600; margin-bottom: 0.75rem; color: #374151;">
<span id="selectedCategoryTitle">작업장 선택</span>
</h3>
<div id="layoutMapArea" style="display: none; margin-bottom: 1.5rem; padding: 1rem; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 0.5rem;">
<div style="font-size: 0.875rem; color: #6b7280; margin-bottom: 0.75rem;">지도에서 작업장을 클릭하여 선택하세요</div>
<div style="text-align: center; position: relative; display: inline-block; max-width: 100%;">
<canvas id="workplaceMapCanvas" style="max-width: 100%; border-radius: 0.5rem; cursor: pointer; box-shadow: 0 2px 4px rgba(0,0,0,0.1);"></canvas>
</div>
</div>
<div style="margin-bottom: 1rem;">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem;">
<span style="font-size: 0.875rem; color: #6b7280;">리스트에서 선택</span>
</div>
<div id="workplaceListArea" style="display: flex; flex-direction: column; gap: 0.5rem; max-height: 200px; overflow-y: auto; padding: 0.75rem; border: 1px solid #e5e7eb; border-radius: 0.5rem; background: white;"></div>
</div>
<div style="display: flex; gap: 0.75rem; justify-content: flex-end; margin-top: 1rem; padding-top: 1rem; border-top: 1px solid #e5e7eb;">
<button type="button" class="btn btn-secondary" onclick="closeWorkplaceModal()">취소</button>
<button type="button" class="btn btn-primary" id="confirmWorkplaceBtn" onclick="confirmWorkplaceSelection()" disabled>선택 완료</button>
</div>
</div>
</div>
</div>
</div>
<!-- 시간 선택 팝오버 -->
<div id="timePickerOverlay" class="time-picker-overlay" style="display: none;" onclick="closeTimePicker()">
<div class="time-picker-popup" onclick="event.stopPropagation()">
<div class="time-picker-header">
<h3 id="timePickerTitle">작업시간 선택</h3>
<button class="time-picker-close" onclick="closeTimePicker()">&times;</button>
</div>
<div class="quick-time-grid">
<button type="button" class="time-btn" onclick="setTimeValue(0.5)"><span class="time-value">30분</span></button>
<button type="button" class="time-btn" onclick="setTimeValue(1)"><span class="time-value">1시간</span></button>
<button type="button" class="time-btn" onclick="setTimeValue(2)"><span class="time-value">2시간</span></button>
<button type="button" class="time-btn" onclick="setTimeValue(4)"><span class="time-value">4시간</span></button>
<button type="button" class="time-btn" onclick="setTimeValue(8)"><span class="time-value">8시간</span></button>
</div>
<div class="time-adjust-area">
<span class="current-time-label">현재:</span>
<strong id="currentTimeDisplay" class="current-time-value">0시간</strong>
<div class="adjust-buttons">
<button type="button" class="adjust-btn" onclick="adjustTime(-0.5)">-30분</button>
<button type="button" class="adjust-btn" onclick="adjustTime(0.5)">+30분</button>
</div>
</div>
<button type="button" class="confirm-btn" onclick="confirmTimeSelection()">확인</button>
</div>
</div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/api-base.js?v=2026031401"></script>
<script src="/js/common/utils.js?v=2026031401"></script>
<script src="/js/common/base-state.js?v=2026031401"></script>
<script src="/js/daily-work-report/state.js?v=2026031401"></script>
<script src="/js/daily-work-report/utils.js?v=2026031401"></script>
<script src="/js/daily-work-report/api.js?v=2026031401"></script>
<script defer src="/js/daily-work-report.js?v=2026031401"></script>
<script>initAuth();</script>
</body>
</html>

View File

@@ -0,0 +1,413 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>공정표 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<style>
/* Gantt container */
.gantt-wrapper { position: relative; overflow: auto; border: 1px solid #e2e8f0; border-radius: 0.5rem; background: #fff; }
.gantt-container { position: relative; min-width: 100%; }
/* Left column sticky */
.gantt-label { position: sticky; left: 0; z-index: 20; background: #fff; border-right: 2px solid #e2e8f0; min-width: 250px; max-width: 250px; }
.gantt-header .gantt-label { background: #f1f5f9; z-index: 25; }
/* Row styles */
.gantt-row { display: flex; border-bottom: 1px solid #f1f5f9; min-height: 32px; align-items: stretch; }
.gantt-row:hover { background: #fafbfc; }
.gantt-row.project-row { background: #f8fafc; font-weight: 600; }
.gantt-row.project-row .gantt-label { background: #f8fafc; }
.gantt-row.phase-row .gantt-label { padding-left: 1.25rem; color: #6b7280; font-size: 0.8rem; }
.gantt-row.task-row .gantt-label { padding-left: 2.25rem; font-size: 0.8rem; }
.gantt-row.milestone-row .gantt-label { padding-left: 1.25rem; font-size: 0.8rem; color: #7c3aed; }
.gantt-row.nc-row .gantt-label { padding-left: 1.25rem; font-size: 0.8rem; color: #dc2626; }
/* Header */
.gantt-header { display: flex; border-bottom: 2px solid #e2e8f0; background: #f1f5f9; position: sticky; top: 0; z-index: 22; }
.gantt-month-header { display: flex; border-bottom: 1px solid #e2e8f0; background: #f8fafc; position: sticky; top: 0; z-index: 22; }
/* Timeline cells */
.gantt-timeline { display: flex; flex: 1; position: relative; }
.gantt-day { flex: 0 0 var(--day-width); border-right: 1px solid #f1f5f9; display: flex; align-items: center; justify-content: center; font-size: 0.65rem; color: #9ca3af; }
.gantt-day.weekend { background: #fafafa; }
.gantt-day.month-label { font-weight: 600; color: #475569; font-size: 0.75rem; justify-content: flex-start; padding-left: 4px; border-right: 1px solid #cbd5e1; }
/* Bars */
.gantt-bar { position: absolute; height: 20px; top: 6px; border-radius: 3px; cursor: pointer; transition: opacity 0.15s; min-width: 4px; z-index: 5; }
.gantt-bar:hover { opacity: 0.85; filter: brightness(1.1); }
.gantt-bar-progress { height: 100%; border-radius: 3px; opacity: 0.4; }
.gantt-bar-label { position: absolute; left: 4px; top: 1px; font-size: 0.65rem; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: calc(100% - 8px); line-height: 18px; }
/* Today marker */
.today-marker { position: absolute; top: 0; bottom: 0; width: 2px; background: #ef4444; z-index: 10; pointer-events: none; }
/* Milestone diamond */
.milestone-marker { position: absolute; top: 6px; width: 14px; height: 14px; background: #7c3aed; transform: rotate(45deg); z-index: 5; cursor: pointer; border: 1px solid #6d28d9; }
.milestone-marker:hover { filter: brightness(1.2); }
/* NC badge */
.nc-badge { display: inline-flex; align-items: center; justify-content: center; background: #fef2f2; color: #dc2626; border-radius: 9999px; padding: 0 0.5rem; font-size: 0.7rem; font-weight: 600; height: 20px; cursor: pointer; position: absolute; top: 6px; z-index: 5; }
/* Collapse toggle */
.collapse-toggle { cursor: pointer; user-select: none; }
.collapse-toggle .arrow { display: inline-block; transition: transform 0.2s; font-size: 0.6rem; margin-right: 4px; }
.collapse-toggle.collapsed .arrow { transform: rotate(-90deg); }
/* Zoom controls */
.zoom-btn.active { background: #ea580c; color: #fff; }
/* Label content */
.label-content { display: flex; align-items: center; height: 100%; padding: 0 0.75rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
</style>
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white">
<i class="fas fa-bars text-xl"></i>
</button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-full mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<div class="mb-4">
<h2 class="text-xl font-bold text-gray-800">공정표</h2>
<p class="text-sm text-gray-500 mt-0.5">프로젝트별 공정 일정을 Gantt 차트로 관리합니다</p>
</div>
<!-- 툴바 -->
<div class="bg-white rounded-xl shadow-sm p-4 mb-4 flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-gray-600">연도:</label>
<select id="yearSelect" class="input-field rounded-lg px-3 py-1.5 text-sm w-24"></select>
</div>
<div class="flex items-center gap-1">
<button class="zoom-btn px-3 py-1.5 rounded-lg text-sm border" data-zoom="month">월간</button>
<button class="zoom-btn px-3 py-1.5 rounded-lg text-sm border active" data-zoom="quarter">분기</button>
<button class="zoom-btn px-3 py-1.5 rounded-lg text-sm border" data-zoom="year">연간</button>
</div>
<div class="flex items-center gap-2 ml-auto">
<button id="btnGenTemplate" class="hidden bg-indigo-600 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-indigo-700" onclick="openTemplateModal()">
<i class="fas fa-wand-magic-sparkles mr-1"></i>표준공정 생성
</button>
<button id="btnAddEntry" class="hidden bg-orange-600 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-orange-700">
<i class="fas fa-plus mr-1"></i>항목 추가
</button>
<button id="btnBatchAdd" class="hidden bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-blue-700">
<i class="fas fa-layer-group mr-1"></i>일괄 생성
</button>
<button id="btnAddMilestone" class="hidden bg-purple-600 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-purple-700">
<i class="fas fa-diamond mr-1"></i>마일스톤
</button>
</div>
</div>
<!-- Gantt Chart -->
<div class="gantt-wrapper" id="ganttWrapper" style="max-height: calc(100vh - 220px);">
<div class="gantt-container" id="ganttContainer">
<!-- Rendered by JS -->
</div>
</div>
</div>
</div>
</div>
<!-- 항목 추가/수정 모달 -->
<div id="entryModal" class="modal-overlay hidden">
<div class="modal-content p-6">
<div class="flex justify-between items-center mb-4">
<h3 id="entryModalTitle" class="text-lg font-bold">공정표 항목 추가</h3>
<button onclick="closeEntryModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times text-xl"></i></button>
</div>
<form id="entryForm" onsubmit="return false;">
<input type="hidden" id="entryId">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">프로젝트 *</label>
<select id="entryProject" class="input-field w-full rounded-lg px-3 py-2 text-sm" required></select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">공정 단계 *</label>
<select id="entryPhase" class="input-field w-full rounded-lg px-3 py-2 text-sm" required></select>
</div>
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">작업명 *</label>
<div class="flex gap-2">
<select id="entryTemplate" class="input-field flex-1 rounded-lg px-3 py-2 text-sm">
<option value="">직접 입력</option>
</select>
<input type="text" id="entryTaskName" class="input-field flex-1 rounded-lg px-3 py-2 text-sm" placeholder="작업명 입력">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">시작일 *</label>
<input type="date" id="entryStartDate" class="input-field w-full rounded-lg px-3 py-2 text-sm" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">종료일 *</label>
<input type="date" id="entryEndDate" class="input-field w-full rounded-lg px-3 py-2 text-sm" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">담당자</label>
<input type="text" id="entryAssignee" class="input-field w-full rounded-lg px-3 py-2 text-sm" placeholder="담당자">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">진행률 (%)</label>
<input type="number" id="entryProgress" class="input-field w-full rounded-lg px-3 py-2 text-sm" min="0" max="100" value="0">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">상태</label>
<select id="entryStatus" class="input-field w-full rounded-lg px-3 py-2 text-sm">
<option value="planned">계획</option>
<option value="in_progress">진행중</option>
<option value="completed">완료</option>
<option value="delayed">지연</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">선행작업 (의존관계)</label>
<select id="entryDependencies" class="input-field w-full rounded-lg px-3 py-2 text-sm" multiple style="min-height: 60px;"></select>
</div>
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">메모</label>
<textarea id="entryNotes" class="input-field w-full rounded-lg px-3 py-2 text-sm" rows="2"></textarea>
</div>
</div>
<div class="flex justify-end gap-2 mt-4">
<button type="button" onclick="closeEntryModal()" class="px-4 py-2 text-sm border rounded-lg hover:bg-gray-50">취소</button>
<button type="button" onclick="saveEntry()" class="px-4 py-2 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700">저장</button>
</div>
</form>
</div>
</div>
<!-- 일괄 생성 모달 -->
<div id="batchModal" class="modal-overlay hidden">
<div class="modal-content p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-bold">템플릿 기반 일괄 생성</h3>
<button onclick="closeBatchModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times text-xl"></i></button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">프로젝트 *</label>
<select id="batchProject" class="input-field w-full rounded-lg px-3 py-2 text-sm"></select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">공정 단계 *</label>
<select id="batchPhase" class="input-field w-full rounded-lg px-3 py-2 text-sm" onchange="loadBatchTemplates()"></select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">기준 시작일 *</label>
<input type="date" id="batchStartDate" class="input-field w-full rounded-lg px-3 py-2 text-sm" onchange="recalcBatchDates()">
</div>
</div>
<div id="batchTemplateList" class="space-y-2 mb-4 max-h-60 overflow-y-auto">
<!-- 템플릿 목록 동적 생성 -->
</div>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeBatchModal()" class="px-4 py-2 text-sm border rounded-lg hover:bg-gray-50">취소</button>
<button type="button" onclick="saveBatchEntries()" class="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">일괄 생성</button>
</div>
</div>
</div>
<!-- 마일스톤 모달 -->
<div id="milestoneModal" class="modal-overlay hidden">
<div class="modal-content p-6">
<div class="flex justify-between items-center mb-4">
<h3 id="milestoneModalTitle" class="text-lg font-bold">마일스톤 추가</h3>
<button onclick="closeMilestoneModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times text-xl"></i></button>
</div>
<form id="milestoneForm" onsubmit="return false;">
<input type="hidden" id="milestoneId">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">프로젝트 *</label>
<select id="milestoneProject" class="input-field w-full rounded-lg px-3 py-2 text-sm" required></select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">마일스톤명 *</label>
<input type="text" id="milestoneName" class="input-field w-full rounded-lg px-3 py-2 text-sm" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">날짜 *</label>
<input type="date" id="milestoneDate" class="input-field w-full rounded-lg px-3 py-2 text-sm" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">유형</label>
<select id="milestoneType" class="input-field w-full rounded-lg px-3 py-2 text-sm">
<option value="deadline">납기</option>
<option value="review">검토</option>
<option value="inspection">검사</option>
<option value="delivery">출하</option>
<option value="meeting">회의</option>
<option value="other">기타</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">상태</label>
<select id="milestoneStatus" class="input-field w-full rounded-lg px-3 py-2 text-sm">
<option value="upcoming">예정</option>
<option value="completed">완료</option>
<option value="missed">미달성</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">연결 작업</label>
<select id="milestoneEntry" class="input-field w-full rounded-lg px-3 py-2 text-sm">
<option value="">없음</option>
</select>
</div>
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">메모</label>
<textarea id="milestoneNotes" class="input-field w-full rounded-lg px-3 py-2 text-sm" rows="2"></textarea>
</div>
</div>
<div class="flex justify-end gap-2 mt-4">
<button type="button" onclick="closeMilestoneModal()" class="px-4 py-2 text-sm border rounded-lg hover:bg-gray-50">취소</button>
<button type="button" onclick="saveMilestone()" class="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">저장</button>
</div>
</form>
</div>
</div>
<!-- 부적합 팝업 -->
<div id="ncPopup" class="modal-overlay hidden">
<div class="modal-content p-6" style="max-width: 600px;">
<div class="flex justify-between items-center mb-4">
<h3 id="ncPopupTitle" class="text-lg font-bold">부적합 현황</h3>
<button onclick="document.getElementById('ncPopup').classList.add('hidden')" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times text-xl"></i></button>
</div>
<div id="ncPopupContent" class="space-y-2 max-h-80 overflow-y-auto"></div>
</div>
</div>
<!-- 바 상세 팝업 -->
<div id="barDetailPopup" class="modal-overlay hidden">
<div class="modal-content p-6" style="max-width: 500px;">
<div class="flex justify-between items-center mb-4">
<h3 id="barDetailTitle" class="text-lg font-bold">작업 상세</h3>
<button onclick="document.getElementById('barDetailPopup').classList.add('hidden')" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times text-xl"></i></button>
</div>
<div id="barDetailContent"></div>
<div id="barDetailActions" class="flex justify-end gap-2 mt-4"></div>
</div>
</div>
<!-- 표준공정 생성 모달 -->
<div id="templateModal" class="modal-overlay hidden">
<div class="modal-content p-6" style="max-width: 400px;">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-bold"><i class="fas fa-wand-magic-sparkles text-indigo-500 mr-2"></i>표준공정 생성</h3>
<button onclick="closeTemplateModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">프로젝트</label>
<select id="tmplProjectSelect" class="input-field w-full rounded-lg px-3 py-2 text-sm"></select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">제품유형</label>
<select id="tmplProductType" class="input-field w-full rounded-lg px-3 py-2 text-sm">
<option value="">선택</option>
</select>
</div>
<p class="text-xs text-gray-500"><i class="fas fa-info-circle mr-1"></i>선택한 제품유형의 표준공정이 자동으로 생성됩니다</p>
</div>
<div class="flex justify-end gap-2 mt-5">
<button onclick="closeTemplateModal()" class="px-4 py-2 border rounded-lg text-sm text-gray-600 hover:bg-gray-50">취소</button>
<button onclick="generateTemplate()" class="px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm hover:bg-indigo-700">
<i class="fas fa-wand-magic-sparkles mr-1"></i>생성
</button>
</div>
</div>
</div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/schedule.js?v=2026031701"></script>
<script>
// 표준공정 생성 모달
async function openTemplateModal() {
const modal = document.getElementById('templateModal');
const projSel = document.getElementById('tmplProjectSelect');
const typeSel = document.getElementById('tmplProductType');
// 프로젝트 목록 로드
try {
const r = await apiFetch('/api/schedule/product-types');
typeSel.innerHTML = '<option value="">선택</option>';
(r.data || []).forEach(pt => {
typeSel.innerHTML += `<option value="${pt.code}">${pt.code} - ${escapeHtml(pt.name)}</option>`;
});
} catch(e) { console.warn('제품유형 로드 실패:', e); }
// 프로젝트 목록 (기존 gantt에서 사용 중인 projects 변수 활용)
if (typeof allProjects !== 'undefined' && allProjects.length) {
projSel.innerHTML = allProjects.map(p =>
`<option value="${p.project_id}">${escapeHtml(p.job_no)} - ${escapeHtml(p.project_name)}</option>`
).join('');
} else {
try {
const r = await apiFetch('/api/projects/active');
const projs = r.data || [];
projSel.innerHTML = projs.map(p =>
`<option value="${p.project_id}">${escapeHtml(p.job_no)} - ${escapeHtml(p.project_name)}</option>`
).join('');
} catch(e) { projSel.innerHTML = '<option>프로젝트 로드 실패</option>'; }
}
modal.classList.remove('hidden');
}
function closeTemplateModal() {
document.getElementById('templateModal').classList.add('hidden');
}
async function generateTemplate() {
const projectId = document.getElementById('tmplProjectSelect').value;
const productTypeCode = document.getElementById('tmplProductType').value;
if (!projectId || !productTypeCode) {
showToast('프로젝트와 제품유형을 선택해주세요', 'error');
return;
}
try {
const r = await apiFetch('/api/schedule/generate-from-template', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_id: parseInt(projectId), product_type_code: productTypeCode })
});
showToast(r.message || `${r.data.created}개 표준공정이 생성되었습니다`);
closeTemplateModal();
if (typeof loadGanttData === 'function') loadGanttData();
} catch(e) {
showToast(e.message || '표준공정 생성 실패', 'error');
}
}
</script>
</body>
</html>

View File

@@ -0,0 +1,858 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>TBM 등록 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<style>
* { box-sizing: border-box; }
button, .worker-card, .list-item, .list-item-skip, .pill-btn, .pill-btn-add,
.nav-btn, .select-all-btn, [onclick] {
touch-action: manipulation;
}
@media (min-width: 480px) {
.tbm-create-wrap { max-width: 480px; margin: 0 auto; min-height: 100vh; }
}
/* Fixed header */
.wizard-header {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
height: 52px;
background: linear-gradient(135deg, #2563eb, #1d4ed8);
color: white;
display: flex;
align-items: center;
padding: 0 1rem;
gap: 0.75rem;
}
@media (min-width: 480px) {
.wizard-header { max-width: 480px; margin: 0 auto; }
}
.wizard-header .back-btn {
width: 36px;
height: 36px;
border: none;
background: rgba(255,255,255,0.15);
color: white;
border-radius: 50%;
font-size: 1.25rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
-webkit-tap-highlight-color: transparent;
flex-shrink: 0;
}
.wizard-header .back-btn:active { background: rgba(255,255,255,0.25); }
.wizard-header h1 {
margin: 0;
font-size: 1.0625rem;
font-weight: 700;
}
/* Step indicator */
.step-indicator {
position: sticky;
top: 52px;
z-index: 90;
display: flex;
justify-content: center;
align-items: center;
padding: 0.625rem 0.5rem;
gap: 0;
background: white;
border-bottom: 1px solid #e5e7eb;
}
.step {
display: flex;
align-items: center;
gap: 0.125rem;
font-size: 0.5625rem;
color: #9ca3af;
}
.step .step-dot {
width: 20px;
height: 20px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.625rem;
font-weight: 700;
background: #e5e7eb;
color: #9ca3af;
flex-shrink: 0;
}
.step.active .step-dot { background: #2563eb; color: white; }
.step.active { color: #2563eb; font-weight: 600; }
.step.completed .step-dot { background: #10b981; color: white; }
.step.completed { color: #10b981; }
.step-line {
width: 10px;
height: 2px;
background: #e5e7eb;
margin: 0 0.0625rem;
flex-shrink: 0;
}
.step.completed + .step-line { background: #10b981; }
/* Step content area */
.step-content {
padding: 52px 0 76px 0; /* header + bottom nav */
min-height: 100vh;
}
/* Sections */
.wizard-section {
margin: 0.75rem;
background: white;
border-radius: 0.75rem;
padding: 1.25rem;
box-shadow: 0 1px 3px rgba(0,0,0,0.06);
}
.section-title {
font-size: 0.9375rem;
font-weight: 700;
color: #1f2937;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
}
.section-title .sn {
width: 22px;
height: 22px;
border-radius: 50%;
background: #2563eb;
color: white;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
font-weight: 700;
flex-shrink: 0;
}
/* Info row */
.info-row {
display: flex;
align-items: center;
padding: 0.75rem 0;
border-bottom: 1px solid #f3f4f6;
}
.info-row:last-child { border-bottom: none; }
.info-label {
font-size: 0.8125rem;
color: #6b7280;
width: 70px;
flex-shrink: 0;
}
.info-value {
font-size: 0.9375rem;
font-weight: 600;
color: #1f2937;
flex: 1;
}
/* Worker grid */
.worker-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 0.5rem;
}
.worker-card {
padding: 0.75rem;
border: 2px solid #e5e7eb;
border-radius: 0.75rem;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: all 0.12s;
min-height: 44px;
display: flex;
align-items: center;
gap: 0.5rem;
}
.worker-card:active { transform: scale(0.97); }
.worker-card.selected {
border-color: #2563eb;
background: #eff6ff;
}
.worker-card .worker-check {
width: 20px;
height: 20px;
border-radius: 4px;
border: 2px solid #d1d5db;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
flex-shrink: 0;
color: transparent;
}
.worker-card.selected .worker-check {
border-color: #2563eb;
background: #2563eb;
color: white;
}
.worker-card .worker-info {
flex: 1;
min-width: 0;
}
.worker-card .worker-name {
font-size: 0.8125rem;
font-weight: 600;
color: #1f2937;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.worker-card .worker-type {
font-size: 0.6875rem;
color: #6b7280;
}
.select-all-bar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.625rem 0;
margin-bottom: 0.5rem;
border-bottom: 1px solid #f3f4f6;
}
.select-all-bar .count {
font-size: 0.8125rem;
color: #2563eb;
font-weight: 600;
}
.select-all-btn {
padding: 0.375rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
background: white;
font-size: 0.75rem;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.select-all-btn:active { background: #f3f4f6; }
/* Project / list items */
.list-item {
padding: 0.875rem 1rem;
border: 1.5px solid #e5e7eb;
border-radius: 0.75rem;
margin-bottom: 0.5rem;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: all 0.12s;
}
.list-item:active { transform: scale(0.98); }
.list-item.selected {
border-color: #2563eb;
background: #eff6ff;
}
.list-item .item-title {
font-size: 0.875rem;
font-weight: 600;
color: #1f2937;
}
.list-item .item-desc {
font-size: 0.75rem;
color: #6b7280;
margin-top: 0.125rem;
}
.list-item-skip {
padding: 0.875rem 1rem;
border: 1.5px dashed #d1d5db;
border-radius: 0.75rem;
text-align: center;
font-size: 0.8125rem;
color: #6b7280;
cursor: pointer;
margin-bottom: 0.5rem;
-webkit-tap-highlight-color: transparent;
}
.list-item-skip:active { background: #f9fafb; }
.list-item-skip.selected {
border-color: #6b7280;
border-style: solid;
background: #f9fafb;
color: #374151;
font-weight: 600;
}
/* Pill buttons */
.pill-grid {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.pill-btn {
padding: 0.5rem 0.875rem;
border: 1.5px solid #d1d5db;
border-radius: 2rem;
background: white;
font-size: 0.8125rem;
cursor: pointer;
transition: all 0.12s;
-webkit-tap-highlight-color: transparent;
white-space: nowrap;
}
.pill-btn:active { transform: scale(0.97); }
.pill-btn.selected {
border-color: #2563eb;
background: #eff6ff;
color: #1d4ed8;
font-weight: 600;
}
.sub-section {
margin-top: 1rem;
padding-top: 0.875rem;
border-top: 1px solid #f3f4f6;
}
.sub-section-title {
font-size: 0.8125rem;
font-weight: 600;
color: #6b7280;
margin-bottom: 0.5rem;
}
/* Map button */
.map-open-btn {
display: flex;
align-items: center;
width: 100%;
padding: 0.875rem 1rem;
background: linear-gradient(135deg, #0d9488, #0f766e);
color: white;
border: none;
border-radius: 0.75rem;
cursor: pointer;
margin-bottom: 0.75rem;
-webkit-tap-highlight-color: transparent;
gap: 0.75rem;
}
.map-open-btn:active { opacity: 0.85; transform: scale(0.98); }
.map-open-icon { font-size: 1.5rem; flex-shrink: 0; }
.map-open-text { font-size: 0.9375rem; font-weight: 700; flex: 1; text-align: left; }
.map-open-arrow { font-size: 1.125rem; opacity: 0.7; flex-shrink: 0; }
.location-info {
padding: 0.75rem;
background: #f0fdf4;
border: 1px solid #bbf7d0;
border-radius: 0.5rem;
font-size: 0.8125rem;
color: #166534;
margin-bottom: 0.75rem;
line-height: 1.5;
}
.location-info.empty {
background: #f9fafb;
border-color: #e5e7eb;
color: #9ca3af;
}
/* Summary card (Step 6) */
.summary-card {
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
padding: 1rem;
margin-bottom: 0.75rem;
background: #f9fafb;
}
.summary-row {
display: flex;
padding: 0.375rem 0;
}
.summary-label {
font-size: 0.75rem;
color: #6b7280;
width: 70px;
flex-shrink: 0;
}
.summary-value {
font-size: 0.8125rem;
font-weight: 600;
color: #1f2937;
flex: 1;
}
/* Worker accordion (Step 6) */
.worker-accordion {
border: 1.5px solid #e5e7eb;
border-radius: 0.75rem;
margin-bottom: 0.5rem;
overflow: hidden;
}
.worker-accordion.overridden {
border-color: #f97316;
}
.worker-accordion-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
background: #f9fafb;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
user-select: none;
}
.worker-accordion-header:active { background: #f3f4f6; }
.worker-accordion .acc-left {
display: flex;
align-items: center;
gap: 0.5rem;
}
.worker-accordion .acc-name {
font-size: 0.875rem;
font-weight: 600;
color: #1f2937;
}
.worker-accordion .acc-badge {
font-size: 0.625rem;
padding: 0.125rem 0.375rem;
border-radius: 9999px;
font-weight: 600;
}
.badge-default {
background: #dbeafe;
color: #1e40af;
}
.badge-override {
background: #ffedd5;
color: #c2410c;
}
.worker-accordion .acc-arrow {
font-size: 0.75rem;
color: #9ca3af;
transition: transform 0.2s;
}
.worker-accordion.open .acc-arrow { transform: rotate(180deg); }
.worker-accordion-body {
display: none;
border-top: 1px solid #e5e7eb;
padding: 0.75rem 1rem;
}
.worker-accordion.open .worker-accordion-body { display: block; }
.override-row {
margin-bottom: 0.75rem;
}
.override-label {
font-size: 0.75rem;
color: #6b7280;
margin-bottom: 0.25rem;
}
.override-select {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.8125rem;
background: white;
-webkit-appearance: none;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='%236b7280' viewBox='0 0 16 16'%3E%3Cpath d='M8 11L3 6h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.75rem center;
padding-right: 2rem;
}
.override-select:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37,99,235,0.1);
}
.reset-btn {
width: 100%;
padding: 0.5rem;
border: 1px dashed #d1d5db;
border-radius: 0.5rem;
background: white;
font-size: 0.75rem;
color: #6b7280;
cursor: pointer;
margin-top: 0.25rem;
-webkit-tap-highlight-color: transparent;
}
.reset-btn:active { background: #f3f4f6; }
/* Empty state */
.empty-state {
text-align: center;
padding: 2rem 1rem;
color: #9ca3af;
font-size: 0.875rem;
}
/* Fixed bottom nav */
.wizard-nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 100;
height: 68px;
background: white;
border-top: 1px solid #e5e7eb;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 1rem;
padding-bottom: env(safe-area-inset-bottom);
}
@media (min-width: 480px) {
.wizard-nav { max-width: 480px; margin: 0 auto; }
}
.nav-btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 0.75rem;
font-size: 0.9375rem;
font-weight: 700;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
touch-action: manipulation;
transition: all 0.15s;
}
.nav-btn:active { transform: scale(0.97); }
.nav-btn-prev {
background: #f3f4f6;
color: #374151;
}
.nav-btn-next {
background: #2563eb;
color: white;
}
.nav-btn-next:disabled {
background: #d1d5db;
cursor: not-allowed;
}
.nav-btn-next:not(:disabled):active { background: #1d4ed8; }
.nav-btn-save {
background: #10b981;
color: white;
}
.nav-btn-save:disabled {
background: #d1d5db;
cursor: not-allowed;
}
.nav-btn-save:not(:disabled):active { background: #059669; }
/* Landscape map overlay */
.landscape-overlay {
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.95);
display: flex;
align-items: center;
justify-content: center;
}
.landscape-inner {
display: flex;
flex-direction: column;
background: #111;
overflow: hidden;
}
.landscape-inner.rotated {
width: 100vh;
height: 100vw;
transform: translate(-50%, -50%) rotate(90deg);
position: absolute;
top: 50%;
left: 50%;
}
.landscape-inner.no-rotate {
width: 100vw;
height: 100vh;
}
.landscape-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
background: linear-gradient(135deg, #2563eb, #1d4ed8);
color: white;
flex-shrink: 0;
min-height: 44px;
}
.landscape-header h3 {
margin: 0;
font-size: 1rem;
font-weight: 600;
}
.landscape-header .ls-selected {
font-size: 0.8125rem;
font-weight: 400;
opacity: 0.9;
margin-left: 0.5rem;
}
.landscape-close-btn {
width: 36px;
height: 36px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.2);
color: white;
font-size: 1.5rem;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
-webkit-tap-highlight-color: transparent;
}
.landscape-canvas-wrap {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: #111;
padding: 8px;
}
.landscape-canvas-wrap canvas {
touch-action: none;
}
/* Loading overlay */
.loading-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(255,255,255,0.8);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #e5e7eb;
border-top-color: #2563eb;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
.loading-text {
font-size: 0.875rem;
color: #6b7280;
}
/* Toast */
.toast-container {
position: fixed;
top: 60px;
left: 50%;
transform: translateX(-50%);
z-index: 10001;
display: flex;
flex-direction: column;
align-items: center;
pointer-events: none;
}
@keyframes slideIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes slideOut {
from { opacity: 1; transform: translateY(0); }
to { opacity: 0; transform: translateY(-10px); }
}
/* Bulk edit */
.bulk-bar { display:flex; align-items:center; justify-content:space-between; padding:0.625rem 0; border-bottom:1px solid #f3f4f6; margin-bottom:0.5rem; }
.bulk-bar .bulk-left { display:flex; align-items:center; gap:0.5rem; }
.bulk-bar .bulk-count { font-size:0.8125rem; color:#2563eb; font-weight:600; }
.bulk-edit-btn { padding:0.375rem 0.75rem; background:#2563eb; color:#fff; border:none; border-radius:0.5rem; font-size:0.75rem; font-weight:600; cursor:pointer; -webkit-tap-highlight-color:transparent; }
.bulk-edit-btn:disabled { background:#d1d5db; cursor:not-allowed; }
.bulk-edit-btn:not(:disabled):active { background:#1d4ed8; }
.bulk-form { background:#f0f9ff; border:1.5px solid #93c5fd; border-radius:0.75rem; padding:1rem; margin-bottom:0.75rem; }
.bulk-form .override-row { margin-bottom:0.75rem; }
.bulk-form .bulk-apply-btn { width:100%; padding:0.625rem; background:#2563eb; color:#fff; border:none; border-radius:0.5rem; font-size:0.875rem; font-weight:700; margin-top:0.5rem; cursor:pointer; -webkit-tap-highlight-color:transparent; }
.bulk-form .bulk-apply-btn:active { background:#1d4ed8; }
.bulk-form .bulk-cancel-btn { width:100%; padding:0.5rem; background:none; border:1px solid #d1d5db; border-radius:0.5rem; font-size:0.75rem; color:#6b7280; margin-top:0.375rem; cursor:pointer; -webkit-tap-highlight-color:transparent; }
.bulk-form .bulk-cancel-btn:active { background:#f3f4f6; }
.acc-check { width:20px; height:20px; accent-color:#2563eb; flex-shrink:0; margin:0; }
/* Inline add: dashed pill for "+" */
.pill-btn-add {
padding: 0.5rem 0.875rem;
border: 1.5px dashed #93c5fd;
border-radius: 2rem;
background: white;
font-size: 0.8125rem;
color: #2563eb;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
white-space: nowrap;
transition: all 0.12s;
}
.pill-btn-add:active { background: #eff6ff; transform: scale(0.97); }
/* Inline add: dashed list-item for new task */
.list-item-add {
padding: 0.875rem 1rem;
border: 1.5px dashed #93c5fd;
border-radius: 0.75rem;
margin-bottom: 0.5rem;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
text-align: center;
font-size: 0.8125rem;
color: #2563eb;
transition: all 0.12s;
}
.list-item-add:active { background: #eff6ff; transform: scale(0.98); }
/* Inline add form */
.inline-add-form {
background: #f0f9ff;
border: 1.5px solid #93c5fd;
border-radius: 0.75rem;
padding: 0.75rem;
margin-top: 0.5rem;
margin-bottom: 0.5rem;
animation: fadeSlideIn 150ms ease-out;
}
.inline-add-form input[type="text"] {
width: 100%;
padding: 0.625rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.875rem;
background: white;
margin-bottom: 0.5rem;
}
.inline-add-form input[type="text"]:focus {
outline: none;
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37,99,235,0.1);
}
.inline-add-form .inline-add-btns {
display: flex;
gap: 0.5rem;
}
.inline-add-form .inline-add-btns button {
flex: 1;
padding: 0.5rem;
border-radius: 0.5rem;
font-size: 0.8125rem;
font-weight: 600;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
}
.inline-add-form .btn-cancel {
background: white;
border: 1px solid #d1d5db;
color: #6b7280;
}
.inline-add-form .btn-cancel:active { background: #f3f4f6; }
.inline-add-form .btn-save {
background: #2563eb;
border: none;
color: white;
}
.inline-add-form .btn-save:active { background: #1d4ed8; }
.inline-add-form .btn-save:disabled {
background: #93c5fd;
cursor: not-allowed;
}
@keyframes fadeSlideIn {
from { opacity: 0; transform: translateY(-6px); }
to { opacity: 1; transform: translateY(0); }
}
</style>
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white">
<i class="fas fa-bars text-xl"></i>
</button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<div class="tbm-create-wrap">
<!-- Fixed Header -->
<div class="wizard-header">
<button type="button" class="back-btn" onclick="goBack()">&#8592;</button>
<h1>TBM 시작</h1>
</div>
<!-- Step Indicator -->
<div class="step-indicator" id="stepIndicator">
<div class="step active"><span class="step-dot">1</span><span>작업자</span></div>
<div class="step-line"></div>
<div class="step"><span class="step-dot">2</span><span>프로젝트+공정</span></div>
<div class="step-line"></div>
<div class="step"><span class="step-dot">3</span><span>확인</span></div>
</div>
<!-- Step Content -->
<div class="step-content" id="stepContainer">
<!-- Dynamically rendered by tbm-create.js -->
</div>
<!-- Fixed Bottom Nav -->
<div class="wizard-nav" id="wizardNav">
<button type="button" class="nav-btn nav-btn-prev" id="prevBtn" style="visibility:hidden;">&#8592; 이전</button>
<button type="button" class="nav-btn nav-btn-next" id="nextBtn">다음 &#8594;</button>
</div>
<!-- Landscape Map Overlay removed - workplace selection moved to detail edit stage -->
<!-- Loading Overlay -->
<div id="loadingOverlay" class="loading-overlay">
<div class="loading-spinner"></div>
<div class="loading-text" id="loadingText">데이터를 불러오는 중...</div>
</div>
<!-- Toast Container -->
<div id="toastContainer" class="toast-container"></div>
</div>
</div>
</div>
</div>
<!-- Scripts -->
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/api-base.js?v=2026040101"></script>
<!-- 공통 모듈 -->
<script src="/js/common/utils.js?v=2026040101"></script>
<script src="/js/common/base-state.js?v=2026040101"></script>
<script src="/js/tbm/state.js?v=2026040101"></script>
<script src="/js/tbm/utils.js?v=2026040101"></script>
<script src="/js/tbm/api.js?v=2026040101"></script>
<script src="/js/tbm-create.js?v=2026040101"></script>
<script>initAuth();</script>
</body>
</html>

View File

@@ -0,0 +1,279 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>TBM - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<link rel="stylesheet" href="/css/tbm-mobile.css?v=2026040101">
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white"><i class="fas fa-bars text-xl"></i></button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<!-- Loading Overlay -->
<div id="loadingOverlay" class="m-loading-overlay">
<div class="m-loading-spinner"></div>
<div class="m-loading-text" id="loadingText">불러오는 중...</div>
</div>
<!-- Header -->
<div class="m-header">
<div class="m-header-top">
<div>
<h1>TBM</h1>
<div class="m-date" id="headerDate"></div>
</div>
<button type="button" class="m-new-btn" onclick="location.href='/pages/work/tbm-create.html'">
+ 새 TBM
</button>
</div>
</div>
<!-- Tabs -->
<div class="m-tabs">
<button type="button" class="m-tab active" data-tab="today" onclick="switchTab('today')">
당일 <span class="tab-count" id="todayCount">0</span>
</button>
<button type="button" class="m-tab" data-tab="all" onclick="switchTab('all')">
전체 <span class="tab-count" id="allCount">0</span>
</button>
</div>
<!-- Content -->
<div class="m-content" id="tbmContent">
<div class="m-skeleton"></div>
<div class="m-skeleton"></div>
<div class="m-skeleton"></div>
<div class="m-skeleton"></div>
</div>
<script src="/static/js/shared-bottom-nav.js?v=2026040103"></script>
<!-- Toast -->
<div id="toastContainer" class="toast-container"></div>
<!-- TBM 완료 바텀시트 -->
<div id="completeOverlay" style="display:none; position:fixed; inset:0; background:rgba(0,0,0,0.5); z-index:9000;" onclick="closeCompleteSheet()"></div>
<div id="completeSheet" style="display:none; position:fixed; bottom:0; left:0; right:0; z-index:9001; background:white; border-radius:1rem 1rem 0 0; max-height:85vh; overflow-y:auto; padding-bottom:env(safe-area-inset-bottom); box-shadow:0 -4px 24px rgba(0,0,0,0.15);">
<div style="padding:1rem 1rem 0;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:0.75rem;">
<h3 style="margin:0; font-size:1rem; font-weight:700;">TBM 완료</h3>
<button type="button" onclick="closeCompleteSheet()" style="background:none; border:none; font-size:1.25rem; color:#6b7280; cursor:pointer; padding:0.25rem;"></button>
</div>
<p style="margin:0 0 0.75rem; font-size:0.8125rem; color:#6b7280;">각 작업자의 근태를 선택하세요</p>
</div>
<div id="completeWorkerList" style="padding:0 1rem;"></div>
<div style="padding:0.75rem 1rem 1rem;">
<button type="button" id="completeSheetBtn" onclick="submitCompleteSheet()" style="width:100%; padding:0.75rem; background:#2563eb; color:white; border:none; border-radius:0.5rem; font-size:0.9375rem; font-weight:700; cursor:pointer;">완료 처리</button>
</div>
</div>
<!-- 세부 편집 바텀시트 -->
<div id="detailEditOverlay" class="detail-edit-overlay" onclick="closeDetailEditSheet()"></div>
<div id="detailEditSheet" class="detail-edit-sheet">
<div class="de-header">
<div class="de-header-row">
<h3>세부 내역 입력</h3>
<button type="button" class="de-close" onclick="closeDetailEditSheet()"></button>
</div>
</div>
<div class="de-select-all-row">
<input type="checkbox" class="de-worker-check" id="deSelectAll" onchange="toggleSelectAll()">
<span>전체 선택</span>
<span id="deSelectedCount" style="margin-left:auto; font-weight:600; color:#2563eb;"></span>
</div>
<div class="de-group-bar" id="deGroupBar">
<span id="deGroupLabel">0명 선택</span>
<button type="button" class="de-group-btn" onclick="openPicker('task')">작업 설정</button>
<button type="button" class="de-group-btn" onclick="openPicker('workplace')">장소 설정</button>
</div>
<div class="de-worker-list" id="deWorkerList"></div>
<div class="de-save-area">
<div style="display:flex; gap:0.5rem;">
<button type="button" class="de-save-btn" id="deSaveBtn" onclick="saveDetailEdit()" style="flex:2;">저장</button>
<button type="button" class="de-save-btn" onclick="completeFromDetailSheet()" style="flex:1; background:#10b981;">완료</button>
<button type="button" class="de-save-btn" onclick="handoverFromDetailSheet()" style="flex:0.7; background:#f59e0b;">인계</button>
<button type="button" class="de-save-btn" onclick="deleteFromDetailSheet()" style="flex:0.7; background:#ef4444;">삭제</button>
</div>
</div>
</div>
<!-- 작업/장소 선택 피커 -->
<div id="pickerOverlay" class="picker-overlay" onclick="closePicker()"></div>
<div id="pickerSheet" class="picker-sheet">
<div class="picker-header">
<h4 id="pickerTitle">선택</h4>
<button type="button" class="picker-close" onclick="closePicker()"></button>
</div>
<div class="picker-list" id="pickerList"></div>
<div class="picker-add-row" id="pickerAddRow">
<input type="text" class="picker-add-input" id="pickerAddInput" placeholder="새 항목 추가...">
<button type="button" class="picker-add-btn" id="pickerAddBtn" onclick="addNewItem()">추가</button>
</div>
</div>
<!-- 분할 바텀시트 -->
<div id="splitOverlay" class="split-overlay" onclick="closeSplitSheet()"></div>
<div id="splitSheet" class="split-sheet">
<div class="split-header">
<div style="display:flex; justify-content:space-between; align-items:center;">
<h4 id="splitTitle">작업 분할</h4>
<button type="button" onclick="closeSplitSheet()" style="background:none; border:none; font-size:1.125rem; color:#6b7280; cursor:pointer;"></button>
</div>
<p id="splitSubtitle">작업자의 근무 시간을 분할합니다</p>
</div>
<div class="split-body">
<div class="split-field">
<label>현재 TBM 작업 시간</label>
<input type="number" id="splitHours" class="split-input" step="0.5" min="0.5" max="7.5" placeholder="예: 5">
<div id="splitRemainder" style="font-size:0.75rem; color:#6b7280; margin-top:0.25rem;"></div>
</div>
<div class="split-field">
<label>나머지 시간 배정</label>
<div class="split-radio-group">
<div class="split-radio-item active" id="splitOptKeep" onclick="setSplitOption('keep')">현재 TBM 유지</div>
<div class="split-radio-item" id="splitOptSend" onclick="setSplitOption('send')">다른 반장에게</div>
</div>
</div>
<div class="split-field">
<label>프로젝트 (변경 시 선택)</label>
<select id="splitProjectId" class="split-input" style="padding:0.625rem;">
<option value="">현재 프로젝트 유지</option>
</select>
</div>
<div class="split-field">
<label>공정 (변경 시 선택)</label>
<select id="splitWorkTypeId" class="split-input" style="padding:0.625rem;">
<option value="">현재 공정 유지</option>
</select>
</div>
<div id="splitSessionPicker" style="display:none;">
<div class="split-field">
<label>이동할 TBM 선택</label>
<div class="split-session-list" id="splitSessionList"></div>
</div>
</div>
</div>
<div class="split-footer">
<button type="button" class="split-btn" id="splitSaveBtn" onclick="saveSplit()">분할 저장</button>
</div>
</div>
<!-- 빼오기 바텀시트 -->
<div id="pullOverlay" class="pull-overlay" onclick="closePullSheet()"></div>
<div id="pullSheet" class="pull-sheet">
<div class="pull-header">
<div style="display:flex; justify-content:space-between; align-items:center;">
<h4 id="pullTitle">팀원 목록</h4>
<button type="button" onclick="closePullSheet()" style="background:none; border:none; font-size:1.125rem; color:#6b7280; cursor:pointer;"></button>
</div>
<p id="pullSubtitle"></p>
</div>
<div id="pullMemberList"></div>
</div>
<!-- 빼오기 시간 입력 모달 -->
<div id="pullHoursOverlay" class="split-overlay" style="z-index:9300;" onclick="closePullHoursModal()"></div>
<div id="pullHoursSheet" class="split-sheet" style="z-index:9301; max-height:60vh;">
<div class="split-header">
<div style="display:flex; justify-content:space-between; align-items:center;">
<h4 id="pullHoursTitle">빼오기</h4>
<button type="button" onclick="closePullHoursModal()" style="background:none; border:none; font-size:1.125rem; color:#6b7280; cursor:pointer;"></button>
</div>
<p id="pullHoursSubtitle">이동할 시간을 입력하세요</p>
</div>
<div class="split-body">
<div class="split-field">
<label>빼올 시간</label>
<input type="number" id="pullHoursInput" class="split-input" step="0.5" min="0.5" placeholder="예: 3">
</div>
<div class="split-field">
<label>내 TBM 프로젝트 (선택)</label>
<select id="pullProjectId" class="split-input" style="padding:0.625rem;">
<option value="">내 TBM 프로젝트 사용</option>
</select>
</div>
<div class="split-field">
<label>내 TBM 공정 (선택)</label>
<select id="pullWorkTypeId" class="split-input" style="padding:0.625rem;">
<option value="">내 TBM 공정 사용</option>
</select>
</div>
</div>
<div class="split-footer">
<button type="button" class="split-btn" id="pullHoursSaveBtn" onclick="confirmPull()">빼오기 실행</button>
</div>
</div>
<!-- 인계 바텀시트 -->
<div id="handoverOverlay" class="split-overlay" onclick="closeHandoverSheet()"></div>
<div id="handoverSheet" class="split-sheet">
<div class="split-header">
<div style="display:flex; justify-content:space-between; align-items:center;">
<h4>작업 인계</h4>
<button type="button" onclick="closeHandoverSheet()" style="background:none; border:none; font-size:1.125rem; color:#6b7280; cursor:pointer;"></button>
</div>
<p id="handoverSubtitle">인계할 반장을 선택하세요</p>
</div>
<div class="split-body">
<div class="split-field">
<label>인계 대상 반장</label>
<select id="handoverLeaderId" class="split-input" style="padding:0.625rem;">
<option value="">반장 선택...</option>
</select>
</div>
<div class="split-field">
<label>인계할 작업자</label>
<div id="handoverWorkerList" style="max-height:200px; overflow-y:auto;"></div>
</div>
<div class="split-field">
<label>비고</label>
<input type="text" id="handoverNotes" class="split-input" placeholder="인계 사유 (선택)">
</div>
</div>
<div class="split-footer">
<button type="button" class="split-btn" onclick="submitHandover()">인계 요청</button>
</div>
</div>
</div>
</div>
</div>
<!-- 공통 모듈 -->
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/api-base.js?v=2026040101"></script>
<script src="/js/common/utils.js?v=2026040101"></script>
<script src="/js/common/base-state.js?v=2026040101"></script>
<script src="/js/tbm/state.js?v=2026040101"></script>
<script src="/js/tbm/utils.js?v=2026040101"></script>
<script src="/js/tbm/api.js?v=2026040101"></script>
<script src="/js/tbm-mobile.js?v=2026033102"></script>
<script>initAuth();</script>
</body>
</html>

View File

@@ -0,0 +1,587 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>TBM 관리 - TK 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<link rel="stylesheet" href="/css/tbm.css?v=2026031602">
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white">
<i class="fas fa-bars text-xl"></i>
</button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
</div>
</div>
</header>
<!-- Mobile overlay -->
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<!-- Sidebar Nav -->
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<div class="tbm-container">
<!-- 페이지 헤더 -->
<div class="tbm-page-header">
<div class="tbm-title-section">
<h1 class="tbm-page-title">
<span class="tbm-page-title-icon">&#128736;</span>
TBM (Tool Box Meeting)
</h1>
<p class="tbm-page-description">아침 안전 회의 및 팀 구성 관리</p>
</div>
</div>
<!-- TBM 탭 메뉴 -->
<div class="tbm-tab-menu">
<button class="tbm-tab-btn active" data-tab="tbm-input" onclick="switchTbmTab('tbm-input')">
<span class="tbm-tab-icon">&#128221;</span>
TBM 입력
</button>
<button class="tbm-tab-btn" data-tab="tbm-manage" onclick="switchTbmTab('tbm-manage')">
<span class="tbm-tab-icon">&#128202;</span>
TBM 관리
</button>
</div>
<!-- TBM 입력 탭 -->
<div id="tbm-input-tab" class="tbm-tab-content active">
<div class="tbm-section">
<div class="tbm-section-header">
<h2 class="tbm-section-title">
<span>&#128197;</span>
오늘의 TBM
</h2>
<div class="tbm-section-actions">
<button class="tbm-btn tbm-btn-primary" onclick="openNewTbmModal()">
<span class="tbm-btn-icon">+</span>
새 TBM 시작
</button>
</div>
</div>
<div class="tbm-stats-bar">
<span class="tbm-stat-item">
<span class="tbm-stat-label">오늘 등록</span>
<span class="tbm-stat-value highlight" id="todayTotalSessions">0</span>
<span class="tbm-stat-label"></span>
</span>
<span class="tbm-stat-item">
<span class="tbm-stat-label">완료</span>
<span class="tbm-stat-value success" id="todayCompletedSessions">0</span>
<span class="tbm-stat-label"></span>
</span>
<span class="tbm-stat-item">
<span class="tbm-stat-label">진행중</span>
<span class="tbm-stat-value warning" id="todayActiveSessions">0</span>
<span class="tbm-stat-label"></span>
</span>
</div>
<div class="tbm-card-grid" id="todayTbmGrid"></div>
<div class="tbm-empty-state" id="todayEmptyState" style="display: none;">
<div class="tbm-empty-icon">&#128203;</div>
<h3 class="tbm-empty-title">오늘 등록된 TBM이 없습니다</h3>
<p class="tbm-empty-description">"새 TBM 시작" 버튼을 눌러 오늘의 TBM을 시작해보세요.</p>
<button class="tbm-btn tbm-btn-primary" onclick="openNewTbmModal()">
<span class="tbm-btn-icon">+</span>
첫 TBM 시작하기
</button>
</div>
</div>
</div>
<!-- TBM 관리 탭 -->
<div id="tbm-manage-tab" class="tbm-tab-content">
<div class="tbm-section">
<div class="tbm-section-header">
<h2 class="tbm-section-title">
<span>&#128218;</span>
TBM 기록
</h2>
<div class="tbm-section-actions">
<button class="tbm-btn tbm-btn-secondary" onclick="loadMoreTbmDays()" id="loadMoreBtn">
더 보기
</button>
</div>
</div>
<div class="tbm-stats-bar">
<span class="tbm-stat-item">
<span class="tbm-stat-label"></span>
<span class="tbm-stat-value" id="totalSessions">0</span>
<span class="tbm-stat-label"></span>
</span>
<span class="tbm-stat-item">
<span class="tbm-stat-label">완료</span>
<span class="tbm-stat-value success" id="completedSessions">0</span>
<span class="tbm-stat-label"></span>
</span>
<span class="tbm-stat-item" id="viewModeIndicator" style="display: none;">
<span class="tbm-stat-value" id="viewModeText">내 TBM만</span>
</span>
</div>
<div class="tbm-section-body" id="tbmDateGroupsContainer"></div>
<div class="tbm-empty-state" id="emptyState" style="display: none;">
<div class="tbm-empty-icon">&#128218;</div>
<h3 class="tbm-empty-title">등록된 TBM 세션이 없습니다</h3>
<p class="tbm-empty-description">TBM 입력 탭에서 새로운 TBM을 시작해보세요.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- TBM 생성 모달 -->
<div id="tbmModal" class="tbm-modal-overlay" style="display: none;">
<div class="tbm-modal" style="max-width: 800px;">
<div class="tbm-modal-header">
<h2 class="tbm-modal-title" id="modalTitle"><span>&#128221;</span> 새 TBM 시작</h2>
<button class="tbm-modal-close" onclick="closeTbmModal()">×</button>
</div>
<div class="tbm-modal-body">
<form id="tbmForm" onsubmit="event.preventDefault(); saveTbmSession();">
<input type="hidden" id="sessionId">
<div class="tbm-form-section">
<h3 class="tbm-form-section-title"><span>&#128197;</span> 기본 정보</h3>
<div class="tbm-form-row">
<div class="tbm-form-group">
<label class="tbm-form-label">TBM 날짜<span class="tbm-form-required">*</span></label>
<div class="tbm-form-input-readonly" id="sessionDateDisplay">-</div>
<input type="hidden" id="sessionDate">
</div>
<div class="tbm-form-group">
<label class="tbm-form-label">입력자<span class="tbm-form-required">*</span></label>
<div class="tbm-form-input-readonly" id="leaderName">-</div>
<input type="hidden" id="leaderId">
</div>
</div>
<div class="tbm-form-row">
<div class="tbm-form-group">
<label class="tbm-form-label">프로젝트</label>
<select id="newTbmProjectId" class="tbm-form-input"><option value="">선택 안함</option></select>
</div>
<div class="tbm-form-group">
<label class="tbm-form-label">공정<span class="tbm-form-required">*</span></label>
<select id="newTbmWorkTypeId" class="tbm-form-input" required><option value="">공정 선택...</option></select>
</div>
</div>
</div>
<div class="tbm-form-section">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 class="tbm-form-section-title" style="margin: 0; border: 0; padding: 0;">
<span>&#128101;</span> 작업자 선택
<span id="newTbmWorkerCount" style="color: #3b82f6; font-size: 0.875rem;">(0명)</span>
</h3>
<div style="display: flex; gap: 0.5rem;">
<button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="selectAllNewTbmWorkers()">전체 선택</button>
<button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="deselectAllNewTbmWorkers()">전체 해제</button>
</div>
</div>
<div id="newTbmWorkerGrid" class="tbm-worker-select-grid"></div>
<div class="tbm-alert tbm-alert-info" style="margin-top: 1rem;">
<span class="tbm-alert-icon">&#128161;</span>
<div class="tbm-alert-content">
<div class="tbm-alert-text">저장 후 카드를 클릭하면 작업자별 <strong>작업/작업장</strong>을 입력할 수 있습니다.</div>
</div>
</div>
</div>
<!-- 편집 모드: 작업자별 작업 목록 (openTeamCompositionModal에서 사용) -->
<div id="workerTaskListSection" class="tbm-form-section" style="display: none;">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 class="tbm-form-section-title" style="margin: 0; border: 0; padding: 0;">
<span>&#128101;</span> 작업자별 작업 배정
</h3>
<button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="openBulkSettingModal()">⚙ 일괄 설정</button>
</div>
<div id="workerListEmpty" style="display: none; justify-content: center; align-items: center; padding: 2rem; color: #94a3b8; font-size: 0.875rem;">
배정된 작업자가 없습니다.
</div>
<div id="workerTaskList" style="display: flex; flex-direction: column; gap: 1rem;"></div>
</div>
</form>
</div>
<div class="tbm-modal-footer">
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeTbmModal()">취소</button>
<button type="button" class="tbm-btn tbm-btn-primary" onclick="saveTbmSession()"><span class="tbm-btn-icon">&#10003;</span> 저장하기</button>
</div>
</div>
</div>
<!-- 일괄 설정 모달 -->
<div id="bulkSettingModal" class="tbm-modal-overlay" style="display: none; z-index: 1101;">
<div class="tbm-modal" style="max-width: 700px;">
<div class="tbm-modal-header">
<h2 class="tbm-modal-title"><span>&#9881;</span> 일괄 설정</h2>
<button class="tbm-modal-close" onclick="closeBulkSettingModal()">×</button>
</div>
<div class="tbm-modal-body">
<div class="tbm-alert tbm-alert-info">
<span class="tbm-alert-icon">&#128161;</span>
<div class="tbm-alert-content">
<div class="tbm-alert-title">일괄 설정</div>
<div class="tbm-alert-text">선택한 작업자들의 <strong>첫 번째 작업 라인</strong>에 동일한 정보가 적용됩니다.</div>
</div>
</div>
<div class="tbm-form-section">
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.75rem;">
<label class="tbm-form-label">적용할 작업자 선택<span class="tbm-form-required">*</span></label>
<div style="display: flex; gap: 0.25rem;">
<button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="selectAllForBulk()">전체</button>
<button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="deselectAllForBulk()">해제</button>
</div>
</div>
<div id="bulkWorkerSelection" class="tbm-worker-select-grid" style="max-height: 180px;"></div>
</div>
<div class="tbm-form-section" style="border-top: 1px solid #e2e8f0; padding-top: 1.5rem;">
<h3 class="tbm-form-section-title" style="border: 0; padding: 0; margin-bottom: 1rem;"><span>&#128736;</span> 적용할 작업 정보</h3>
<div class="tbm-form-group">
<label class="tbm-form-label">프로젝트</label>
<button type="button" id="bulkProjectBtn" onclick="openBulkItemSelect('project')" class="tbm-select-btn">프로젝트 선택 <span class="tbm-select-arrow">&#9660;</span></button>
<input type="hidden" id="bulkProjectId">
</div>
<div class="tbm-form-row">
<div class="tbm-form-group">
<label class="tbm-form-label">공정<span class="tbm-form-required">*</span></label>
<button type="button" id="bulkWorkTypeBtn" onclick="openBulkItemSelect('workType')" class="tbm-select-btn">공정 선택 <span class="tbm-select-arrow">&#9660;</span></button>
<input type="hidden" id="bulkWorkTypeId">
</div>
<div class="tbm-form-group">
<label class="tbm-form-label">작업<span class="tbm-form-required">*</span></label>
<button type="button" id="bulkTaskBtn" onclick="openBulkItemSelect('task')" class="tbm-select-btn" disabled>작업 선택 <span class="tbm-select-arrow">&#9660;</span></button>
<input type="hidden" id="bulkTaskId">
</div>
</div>
<div class="tbm-form-group">
<label class="tbm-form-label">작업장<span class="tbm-form-required">*</span></label>
<button type="button" id="bulkWorkplaceBtn" onclick="openBulkWorkplaceSelect()" class="tbm-select-btn">작업장 선택 <span class="tbm-select-arrow">&#9660;</span></button>
<input type="hidden" id="bulkWorkplaceCategoryId">
<input type="hidden" id="bulkWorkplaceId">
</div>
</div>
</div>
<div class="tbm-modal-footer">
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeBulkSettingModal()">취소</button>
<button type="button" class="tbm-btn tbm-btn-primary" onclick="applyBulkSettings()"><span class="tbm-btn-icon">&#10003;</span> 선택한 작업자에 적용</button>
</div>
</div>
</div>
<!-- 작업자 선택 모달 -->
<div id="workerSelectionModal" class="tbm-modal-overlay" style="display: none; z-index: 1101;">
<div class="tbm-modal" style="max-width: 800px;">
<div class="tbm-modal-header">
<h2 class="tbm-modal-title"><span>&#128101;</span> 작업자 선택</h2>
<button class="tbm-modal-close" onclick="closeWorkerSelectionModal()">×</button>
</div>
<div class="tbm-modal-body">
<div style="margin-bottom: 1rem; display: flex; gap: 0.5rem;">
<button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="selectAllWorkersInModal()">전체 선택</button>
<button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="deselectAllWorkersInModal()">전체 해제</button>
</div>
<div id="workerCardGrid" class="tbm-worker-select-grid"></div>
</div>
<div class="tbm-modal-footer">
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeWorkerSelectionModal()">취소</button>
<button type="button" class="tbm-btn tbm-btn-primary" onclick="confirmWorkerSelection()"><span class="tbm-btn-icon">&#10003;</span> 선택 완료</button>
</div>
</div>
</div>
<!-- 항목 선택 모달 -->
<div id="itemSelectModal" class="tbm-modal-overlay" style="display: none; z-index: 1102;">
<div class="tbm-modal" style="max-width: 600px;">
<div class="tbm-modal-header">
<h2 class="tbm-modal-title" id="itemSelectModalTitle">항목 선택</h2>
<button class="tbm-modal-close" onclick="closeItemSelectModal()">×</button>
</div>
<div class="tbm-modal-body">
<div id="itemSelectList" class="tbm-item-list"></div>
</div>
<div class="tbm-modal-footer">
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeItemSelectModal()">취소</button>
</div>
</div>
</div>
<!-- 작업장 선택 모달 -->
<div id="workplaceSelectModal" class="tbm-modal-overlay" style="display: none; z-index: 1102;">
<div class="tbm-modal" style="max-width: 1000px;">
<div class="tbm-modal-header">
<h2 class="tbm-modal-title"><span>&#127981;</span> 작업장 선택</h2>
<button class="tbm-modal-close" onclick="closeWorkplaceSelectModal()">×</button>
</div>
<div class="tbm-modal-body">
<div class="tbm-form-section">
<h3 class="tbm-form-section-title">
<span style="background: #3b82f6; color: white; width: 24px; height: 24px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 0.8rem;">1</span>
공장 선택
</h3>
<div id="categoryList" style="display: flex; flex-wrap: wrap; gap: 0.5rem;"></div>
</div>
<div id="workplaceSelectionArea" style="display: none;">
<div class="tbm-form-section">
<h3 class="tbm-form-section-title">
<span style="background: #3b82f6; color: white; width: 24px; height: 24px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 0.8rem;">2</span>
작업장 선택
</h3>
<div id="layoutMapArea" style="display: none; padding: 1rem; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px;">
<div style="font-size: 0.875rem; color: #64748b; margin-bottom: 0.75rem;">지도에서 작업장을 클릭하여 선택하세요</div>
<div class="tbm-workplace-map-container">
<canvas id="workplaceMapCanvas"></canvas>
</div>
<button type="button" class="landscape-trigger-btn" id="landscapeTriggerBtn" onclick="openLandscapeMap()" style="display:none;">&#128250; 전체화면 지도</button>
</div>
<div style="margin-top: 0.75rem;">
<button type="button" class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="toggleWorkplaceList()" id="toggleListBtn" style="display: none;">리스트로 선택</button>
<div id="workplaceListSection">
<div id="workplaceList" class="tbm-item-list">
<div style="color: #94a3b8; text-align: center; padding: 2rem;">공장을 먼저 선택해주세요</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="tbm-modal-footer">
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeWorkplaceSelectModal()">취소</button>
<button type="button" class="tbm-btn tbm-btn-primary" onclick="confirmWorkplaceSelection()" id="confirmWorkplaceBtn" disabled><span class="tbm-btn-icon">&#10003;</span> 선택 완료</button>
</div>
</div>
</div>
<!-- 팀 구성 모달 -->
<div id="teamModal" class="tbm-modal-overlay" style="display: none;">
<div class="tbm-modal" style="max-width: 900px;">
<div class="tbm-modal-header">
<h2 class="tbm-modal-title"><span>&#128101;</span> 팀 구성</h2>
<button class="tbm-modal-close" onclick="closeTeamModal()">×</button>
</div>
<div class="tbm-modal-body">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem;">
<h3 class="tbm-form-section-title" style="margin: 0; border: 0; padding: 0;">작업자 선택</h3>
<div style="display: flex; gap: 0.5rem;">
<button class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="selectAllWorkers()">전체 선택</button>
<button class="tbm-btn tbm-btn-secondary tbm-btn-sm" onclick="deselectAllWorkers()">전체 해제</button>
</div>
</div>
<div id="workerSelectionGrid" class="tbm-worker-select-grid"></div>
<div style="margin-top: 1.5rem;">
<h3 class="tbm-form-section-title">선택된 팀원 <span id="selectedCount" style="color: #3b82f6;">0</span></h3>
<div id="selectedWorkersList" style="display: flex; flex-wrap: wrap; gap: 0.5rem; min-height: 50px; padding: 0.75rem; background: #f8fafc; border-radius: 10px; border: 1px solid #e2e8f0;">
<p style="margin: 0; color: #94a3b8; font-size: 0.875rem;">작업자를 선택해주세요</p>
</div>
</div>
</div>
<div class="tbm-modal-footer">
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeTeamModal()">취소</button>
<button type="button" class="tbm-btn tbm-btn-primary" onclick="saveTeamComposition()"><span class="tbm-btn-icon">&#10003;</span> 팀 구성 완료</button>
</div>
</div>
</div>
<!-- 안전 체크리스트 모달 -->
<div id="safetyModal" class="tbm-modal-overlay" style="display: none;">
<div class="tbm-modal" style="max-width: 700px;">
<div class="tbm-modal-header">
<h2 class="tbm-modal-title"><span>&#128737;</span> 안전 체크리스트</h2>
<button class="tbm-modal-close" onclick="closeSafetyModal()">×</button>
</div>
<div class="tbm-modal-body">
<div id="safetyChecklistContainer" class="tbm-safety-list"></div>
</div>
<div class="tbm-modal-footer">
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeSafetyModal()">취소</button>
<button type="button" class="tbm-btn tbm-btn-success" onclick="saveSafetyChecklist()"><span class="tbm-btn-icon">&#10003;</span> 안전 체크 완료</button>
</div>
</div>
</div>
<!-- TBM 완료 모달 -->
<div id="completeModal" class="tbm-modal-overlay" style="display: none;">
<div class="tbm-modal" style="max-width: 500px;">
<div class="tbm-modal-header">
<h2 class="tbm-modal-title"><span>&#10003;</span> TBM 완료</h2>
<button class="tbm-modal-close" onclick="closeCompleteModal()">×</button>
</div>
<div class="tbm-modal-body">
<div class="tbm-alert tbm-alert-warning">
<span class="tbm-alert-icon">&#9888;</span>
<div class="tbm-alert-content">
<div class="tbm-alert-title">TBM 완료 확인</div>
<div class="tbm-alert-text">완료 후에는 수정할 수 없습니다.</div>
</div>
</div>
<div class="tbm-form-group" style="margin-top: 1.5rem;">
<label class="tbm-form-label">종료 시간</label>
<input type="time" id="endTime" class="tbm-form-input">
</div>
<div class="tbm-form-group" style="margin-top: 1rem;">
<label class="tbm-form-label">작업자 근태</label>
<div id="completeAttendanceList" style="max-height: 300px; overflow-y: auto; border: 1px solid #e5e7eb; border-radius: 0.5rem; padding: 0.5rem;">
<div style="text-align:center; color:#9ca3af; padding:1rem;">로딩 중...</div>
</div>
</div>
</div>
<div class="tbm-modal-footer">
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeCompleteModal()">취소</button>
<button type="button" class="tbm-btn tbm-btn-success" id="completeModalBtn" onclick="completeTbmSession()"><span class="tbm-btn-icon">&#10003;</span> 완료</button>
</div>
</div>
</div>
<!-- 작업 인계 모달 -->
<div id="handoverModal" class="tbm-modal-overlay" style="display: none;">
<div class="tbm-modal" style="max-width: 600px;">
<div class="tbm-modal-header">
<h2 class="tbm-modal-title"><span>&#128073;</span> 작업 인계</h2>
<button class="tbm-modal-close" onclick="closeHandoverModal()">×</button>
</div>
<div class="tbm-modal-body">
<form id="handoverForm">
<input type="hidden" id="handoverSessionId">
<div class="tbm-form-group">
<label class="tbm-form-label">인계 사유<span class="tbm-form-required">*</span></label>
<select id="handoverReason" class="tbm-form-input" required>
<option value="">사유 선택...</option>
<option value="half_day">반차</option>
<option value="early_leave">조퇴</option>
<option value="emergency">긴급 상황</option>
<option value="other">기타</option>
</select>
</div>
<div class="tbm-form-group">
<label class="tbm-form-label">인수자 (다음 팀장)<span class="tbm-form-required">*</span></label>
<select id="toLeaderId" class="tbm-form-input" required><option value="">인수자 선택...</option></select>
</div>
<div class="tbm-form-row">
<div class="tbm-form-group">
<label class="tbm-form-label">인계 날짜<span class="tbm-form-required">*</span></label>
<input type="date" id="handoverDate" class="tbm-form-input" required>
</div>
<div class="tbm-form-group">
<label class="tbm-form-label">인계 시간</label>
<input type="time" id="handoverTime" class="tbm-form-input">
</div>
</div>
<div class="tbm-form-group">
<label class="tbm-form-label">인계 내용</label>
<textarea id="handoverNotes" class="tbm-form-input" rows="4" placeholder="인수자에게 전달할 내용을 입력하세요" style="resize: vertical;"></textarea>
</div>
<div class="tbm-form-group">
<label class="tbm-form-label" style="margin-bottom: 0.75rem;">인계할 팀원 선택</label>
<div id="handoverTeamList" style="max-height: 200px; overflow-y: auto; border: 1px solid #e2e8f0; border-radius: 10px; padding: 0.75rem; background: #f8fafc;"></div>
</div>
</form>
</div>
<div class="tbm-modal-footer">
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeHandoverModal()">취소</button>
<button type="button" class="tbm-btn tbm-btn-primary" onclick="saveHandover()"><span class="tbm-btn-icon">&#128073;</span> 인계 요청</button>
</div>
</div>
</div>
<!-- TBM 상세보기 모달 -->
<div id="detailModal" class="tbm-modal-overlay" style="display: none;">
<div class="tbm-modal" style="max-width: 900px;">
<div class="tbm-modal-header">
<h2 class="tbm-modal-title"><span>&#128203;</span> TBM 상세 정보</h2>
<button class="tbm-modal-close" onclick="closeDetailModal()">×</button>
</div>
<div class="tbm-modal-body">
<div class="tbm-form-section">
<h3 class="tbm-form-section-title"><span>&#128197;</span> 기본 정보</h3>
<div id="detailBasicInfo" style="display: grid; grid-template-columns: repeat(2, 1fr); gap: 1rem;"></div>
</div>
<div class="tbm-form-section">
<h3 class="tbm-form-section-title"><span>&#128101;</span> 팀 구성</h3>
<div id="detailTeamMembers" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 0.75rem;"></div>
</div>
<div class="tbm-form-section">
<h3 class="tbm-form-section-title"><span>&#128737;</span> 안전 체크리스트</h3>
<div id="detailSafetyChecks"></div>
</div>
</div>
<div class="tbm-modal-footer" id="detailModalFooter">
<button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeDetailModal()">닫기</button>
</div>
</div>
</div>
<!-- 가로모드 전체화면 지도 -->
<div id="landscapeOverlay" class="landscape-overlay" style="display:none;">
<div id="landscapeInner" class="landscape-inner">
<div class="landscape-header">
<h3>&#127981; 작업장 선택</h3>
<button type="button" class="landscape-close-btn" onclick="closeLandscapeMap()">×</button>
</div>
<div class="landscape-canvas-wrap">
<canvas id="landscapeCanvas"></canvas>
</div>
</div>
</div>
<!-- 분할 모달 -->
<div id="splitModal" class="tbm-modal-overlay" style="display:none;">
<div class="tbm-modal-container" style="max-width:500px;">
<div class="tbm-modal-header"><h2>작업 분할</h2><button type="button" class="tbm-modal-close" onclick="closeSplitModal()">×</button></div>
<div class="tbm-modal-body">
<p style="font-size:0.8125rem; color:#6b7280; margin-bottom:0.75rem;">작업자의 배정 시간을 분할합니다.</p>
<div id="splitMemberList" style="display:flex; flex-direction:column; gap:0.5rem;"></div>
</div>
<div class="tbm-modal-footer"><button type="button" class="tbm-btn tbm-btn-secondary" onclick="closeSplitModal()">닫기</button></div>
</div>
</div>
<!-- 빼오기 모달 -->
<div id="pullModal" class="tbm-modal-overlay" style="display:none;">
<div class="tbm-modal-container" style="max-width:500px;">
<div class="tbm-modal-header"><h2>빼오기</h2><button type="button" class="tbm-modal-close" onclick="closePullModal()">×</button></div>
<div class="tbm-modal-body">
<p style="font-size:0.8125rem; color:#6b7280; margin-bottom:0.75rem;">다른 반장의 TBM에서 작업자를 빼옵니다.</p>
<div id="pullSessionList"></div>
</div>
<div class="tbm-modal-footer"><button type="button" class="tbm-btn tbm-btn-secondary" onclick="closePullModal()">닫기</button></div>
</div>
</div>
<!-- 토스트 -->
<div class="toast-container" id="toastContainer"></div>
<script src="/js/sso-relay.js?v=20260401"></script>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/api-base.js?v=2026031602"></script>
<script src="/js/common/utils.js?v=2026031602"></script>
<script src="/js/common/base-state.js?v=2026031602"></script>
<script src="/js/tbm/state.js?v=2026031602"></script>
<script src="/js/tbm/utils.js?v=2026031602"></script>
<script src="/js/tbm/api.js?v=2026031602"></script>
<script defer src="/js/tbm.js?v=2026031604"></script>
<script>initAuth();</script>
</body>
</html>