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:
1
system1-factory/web/public/pages/admin/.gitkeep
Normal file
1
system1-factory/web/public/pages/admin/.gitkeep
Normal file
@@ -0,0 +1 @@
|
||||
# Placeholder file to create admin directory
|
||||
503
system1-factory/web/public/pages/admin/attendance-report.html
Normal file
503
system1-factory/web/public/pages/admin/attendance-report.html
Normal 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>
|
||||
4
system1-factory/web/public/pages/admin/departments.html
Normal file
4
system1-factory/web/public/pages/admin/departments.html
Normal 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>
|
||||
359
system1-factory/web/public/pages/admin/equipment-detail.html
Normal file
359
system1-factory/web/public/pages/admin/equipment-detail.html
Normal 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">←</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">⇄</span>
|
||||
<span>임시이동</span>
|
||||
</button>
|
||||
<button class="btn btn-action btn-repair" onclick="openRepairModal()">
|
||||
<span class="btn-icon">🔧</span>
|
||||
<span>수리신청</span>
|
||||
</button>
|
||||
<button class="btn btn-action btn-export" onclick="openExportModal()">
|
||||
<span class="btn-icon">🚚</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()">×</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()">×</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()">×</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()">×</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()">×</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">×</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>
|
||||
235
system1-factory/web/public/pages/admin/equipments.html
Normal file
235
system1-factory/web/public/pages/admin/equipments.html
Normal 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()">×</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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
4
system1-factory/web/public/pages/admin/projects.html
Normal file
4
system1-factory/web/public/pages/admin/projects.html
Normal 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>
|
||||
142
system1-factory/web/public/pages/admin/purchase-analysis.html
Normal file
142
system1-factory/web/public/pages/admin/purchase-analysis.html
Normal 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>
|
||||
892
system1-factory/web/public/pages/admin/repair-management.html
Normal file
892
system1-factory/web/public/pages/admin/repair-management.html
Normal 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')">×</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')">×</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')">×</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>
|
||||
4
system1-factory/web/public/pages/admin/tasks.html
Normal file
4
system1-factory/web/public/pages/admin/tasks.html
Normal 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>
|
||||
4
system1-factory/web/public/pages/admin/workplaces.html
Normal file
4
system1-factory/web/public/pages/admin/workplaces.html
Normal 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>
|
||||
Reference in New Issue
Block a user