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>