feat: 작업보고서 시스템 완성
- 일일 공수 입력 기능 - 부적합 사항 등록 (이미지 선택사항) - 날짜별 부적합 조회 (시간순 나열) - 목록 관리 (인라인 편집, 작업시간 확인 버튼) - 보고서 생성 (총 공수/부적합 시간 분리) - JWT 인증 및 권한 관리 - Docker 기반 배포 환경 구성
This commit is contained in:
417
frontend/chart.html
Normal file
417
frontend/chart.html
Normal file
@@ -0,0 +1,417 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>M-Project - 차트</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">
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<style>
|
||||
:root {
|
||||
--sky-50: #f0f9ff;
|
||||
--sky-100: #e0f2fe;
|
||||
--sky-200: #bae6fd;
|
||||
--sky-300: #7dd3fc;
|
||||
--sky-400: #38bdf8;
|
||||
--sky-500: #0ea5e9;
|
||||
--gray-100: #f3f4f6;
|
||||
--gray-200: #e5e7eb;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(to bottom, #ffffff, #f0f9ff);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
backdrop-filter: blur(10px);
|
||||
-webkit-backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
background: white;
|
||||
border-left: 4px solid var(--sky-400);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.stat-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.gallery-item {
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.gallery-item:hover {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.modal {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal.show {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 헤더 -->
|
||||
<header class="glass sticky top-0 z-50 shadow-sm">
|
||||
<div class="container mx-auto px-4 py-3 flex justify-between items-center">
|
||||
<h1 class="text-xl font-bold text-gray-800">
|
||||
<i class="fas fa-chart-bar text-sky-500 mr-2"></i>차트
|
||||
</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="index.html" class="text-sky-600 hover:text-sky-700">
|
||||
<i class="fas fa-camera text-xl"></i>
|
||||
</a>
|
||||
<span class="text-gray-600 text-sm" id="userName"></span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="container mx-auto px-4 py-6 max-w-6xl">
|
||||
<!-- 통계 카드 -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<div class="stat-card p-4 rounded-xl shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">전체 사진</p>
|
||||
<p class="text-2xl font-bold text-gray-800" id="totalCount">0</p>
|
||||
</div>
|
||||
<i class="fas fa-images text-3xl text-sky-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card p-4 rounded-xl shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">오늘</p>
|
||||
<p class="text-2xl font-bold text-gray-800" id="todayCount">0</p>
|
||||
</div>
|
||||
<i class="fas fa-calendar-day text-3xl text-sky-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card p-4 rounded-xl shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">이번 주</p>
|
||||
<p class="text-2xl font-bold text-gray-800" id="weekCount">0</p>
|
||||
</div>
|
||||
<i class="fas fa-calendar-week text-3xl text-sky-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="stat-card p-4 rounded-xl shadow-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">가장 많은</p>
|
||||
<p class="text-lg font-bold text-gray-800" id="topCategory">-</p>
|
||||
</div>
|
||||
<i class="fas fa-trophy text-3xl text-sky-400"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 차트 영역 -->
|
||||
<div class="grid md:grid-cols-2 gap-6 mb-8">
|
||||
<!-- 카테고리별 차트 -->
|
||||
<div class="glass p-6 rounded-xl shadow-lg">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">카테고리별 분포</h3>
|
||||
<canvas id="categoryChart" width="400" height="300"></canvas>
|
||||
</div>
|
||||
|
||||
<!-- 일별 추이 차트 -->
|
||||
<div class="glass p-6 rounded-xl shadow-lg">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">최근 7일 추이</h3>
|
||||
<canvas id="trendChart" width="400" height="300"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 갤러리 -->
|
||||
<div class="glass p-6 rounded-xl shadow-lg">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-semibold text-gray-800">전체 갤러리</h3>
|
||||
<select id="categoryFilter" class="px-4 py-2 rounded-lg border border-gray-300 focus:border-sky-400 focus:outline-none">
|
||||
<option value="all">전체 카테고리</option>
|
||||
<option value="food">음식</option>
|
||||
<option value="place">장소</option>
|
||||
<option value="people">사람</option>
|
||||
<option value="object">물건</option>
|
||||
<option value="event">행사</option>
|
||||
<option value="etc">기타</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div id="gallery" class="grid grid-cols-3 md:grid-cols-6 gap-3">
|
||||
<!-- 갤러리 아이템들이 여기에 표시됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- 이미지 모달 -->
|
||||
<div id="imageModal" class="modal" onclick="closeModal()">
|
||||
<div class="bg-white rounded-xl p-4 max-w-2xl mx-4" onclick="event.stopPropagation()">
|
||||
<img id="modalImage" class="w-full rounded-lg mb-4">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<p class="font-semibold text-lg" id="modalDescription"></p>
|
||||
<p class="text-sm text-gray-600 mt-1" id="modalInfo"></p>
|
||||
</div>
|
||||
<button onclick="closeModal()" class="text-gray-500 hover:text-gray-700">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let allData = [];
|
||||
let currentUser = null;
|
||||
|
||||
// 초기화
|
||||
window.onload = () => {
|
||||
// 로그인 체크
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
currentUser = params.get('user');
|
||||
|
||||
if (!currentUser) {
|
||||
// 로컬스토리지에서 최근 사용자 확인
|
||||
const recentUser = localStorage.getItem('m-project-recent-user');
|
||||
if (recentUser) {
|
||||
currentUser = recentUser;
|
||||
} else {
|
||||
window.location.href = 'index.html';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('userName').textContent = currentUser;
|
||||
loadData();
|
||||
};
|
||||
|
||||
// 데이터 로드
|
||||
function loadData() {
|
||||
const savedData = JSON.parse(localStorage.getItem('m-project-data') || '[]');
|
||||
allData = savedData.filter(item => item.user === currentUser);
|
||||
|
||||
updateStats();
|
||||
createCharts();
|
||||
displayGallery();
|
||||
}
|
||||
|
||||
// 통계 업데이트
|
||||
function updateStats() {
|
||||
const now = new Date();
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
||||
const weekAgo = new Date(today.getTime() - 7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
document.getElementById('totalCount').textContent = allData.length;
|
||||
|
||||
// 오늘 카운트
|
||||
const todayCount = allData.filter(item => {
|
||||
const itemDate = new Date(item.timestamp);
|
||||
return itemDate >= today;
|
||||
}).length;
|
||||
document.getElementById('todayCount').textContent = todayCount;
|
||||
|
||||
// 이번 주 카운트
|
||||
const weekCount = allData.filter(item => {
|
||||
const itemDate = new Date(item.timestamp);
|
||||
return itemDate >= weekAgo;
|
||||
}).length;
|
||||
document.getElementById('weekCount').textContent = weekCount;
|
||||
|
||||
// 가장 많은 카테고리
|
||||
const categoryCounts = {};
|
||||
allData.forEach(item => {
|
||||
categoryCounts[item.category] = (categoryCounts[item.category] || 0) + 1;
|
||||
});
|
||||
|
||||
const categoryNames = {
|
||||
food: '음식',
|
||||
place: '장소',
|
||||
people: '사람',
|
||||
object: '물건',
|
||||
event: '행사',
|
||||
etc: '기타'
|
||||
};
|
||||
|
||||
if (Object.keys(categoryCounts).length > 0) {
|
||||
const topCat = Object.entries(categoryCounts)
|
||||
.sort((a, b) => b[1] - a[1])[0][0];
|
||||
document.getElementById('topCategory').textContent = categoryNames[topCat];
|
||||
}
|
||||
}
|
||||
|
||||
// 차트 생성
|
||||
function createCharts() {
|
||||
// 카테고리별 차트
|
||||
const categoryCounts = {};
|
||||
allData.forEach(item => {
|
||||
categoryCounts[item.category] = (categoryCounts[item.category] || 0) + 1;
|
||||
});
|
||||
|
||||
const categoryNames = {
|
||||
food: '음식',
|
||||
place: '장소',
|
||||
people: '사람',
|
||||
object: '물건',
|
||||
event: '행사',
|
||||
etc: '기타'
|
||||
};
|
||||
|
||||
const categoryCtx = document.getElementById('categoryChart').getContext('2d');
|
||||
new Chart(categoryCtx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: Object.keys(categoryCounts).map(key => categoryNames[key]),
|
||||
datasets: [{
|
||||
data: Object.values(categoryCounts),
|
||||
backgroundColor: [
|
||||
'#0ea5e9',
|
||||
'#38bdf8',
|
||||
'#7dd3fc',
|
||||
'#bae6fd',
|
||||
'#e0f2fe',
|
||||
'#f0f9ff'
|
||||
]
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'bottom'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 일별 추이 차트
|
||||
const today = new Date();
|
||||
const dates = [];
|
||||
const counts = [];
|
||||
|
||||
for (let i = 6; i >= 0; i--) {
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - i);
|
||||
date.setHours(0, 0, 0, 0);
|
||||
|
||||
const nextDate = new Date(date);
|
||||
nextDate.setDate(nextDate.getDate() + 1);
|
||||
|
||||
const count = allData.filter(item => {
|
||||
const itemDate = new Date(item.timestamp);
|
||||
return itemDate >= date && itemDate < nextDate;
|
||||
}).length;
|
||||
|
||||
dates.push(date.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' }));
|
||||
counts.push(count);
|
||||
}
|
||||
|
||||
const trendCtx = document.getElementById('trendChart').getContext('2d');
|
||||
new Chart(trendCtx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: dates,
|
||||
datasets: [{
|
||||
label: '업로드 수',
|
||||
data: counts,
|
||||
borderColor: '#0ea5e9',
|
||||
backgroundColor: 'rgba(14, 165, 233, 0.1)',
|
||||
tension: 0.4
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
ticks: {
|
||||
stepSize: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 갤러리 표시
|
||||
function displayGallery(filter = 'all') {
|
||||
const gallery = document.getElementById('gallery');
|
||||
gallery.innerHTML = '';
|
||||
|
||||
const filteredData = filter === 'all'
|
||||
? allData
|
||||
: allData.filter(item => item.category === filter);
|
||||
|
||||
// 최신순으로 정렬
|
||||
filteredData.sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
|
||||
|
||||
filteredData.forEach(item => {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'gallery-item';
|
||||
div.innerHTML = `<img src="${item.image}" class="w-full h-24 object-cover rounded-lg shadow-sm">`;
|
||||
div.onclick = () => showModal(item);
|
||||
gallery.appendChild(div);
|
||||
});
|
||||
|
||||
if (filteredData.length === 0) {
|
||||
gallery.innerHTML = '<p class="col-span-full text-center text-gray-500 py-8">표시할 이미지가 없습니다.</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// 필터 변경
|
||||
document.getElementById('categoryFilter').addEventListener('change', (e) => {
|
||||
displayGallery(e.target.value);
|
||||
});
|
||||
|
||||
// 모달 표시
|
||||
function showModal(item) {
|
||||
const categoryNames = {
|
||||
food: '음식',
|
||||
place: '장소',
|
||||
people: '사람',
|
||||
object: '물건',
|
||||
event: '행사',
|
||||
etc: '기타'
|
||||
};
|
||||
|
||||
document.getElementById('modalImage').src = item.image;
|
||||
document.getElementById('modalDescription').textContent = item.description;
|
||||
document.getElementById('modalInfo').textContent =
|
||||
`${categoryNames[item.category]} • ${new Date(item.timestamp).toLocaleString('ko-KR')}`;
|
||||
document.getElementById('imageModal').classList.add('show');
|
||||
}
|
||||
|
||||
// 모달 닫기
|
||||
function closeModal() {
|
||||
document.getElementById('imageModal').classList.remove('show');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
458
frontend/daily-work.html
Normal file
458
frontend/daily-work.html
Normal file
@@ -0,0 +1,458 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>일일 공수 입력</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">
|
||||
<style>
|
||||
:root {
|
||||
--primary: #3b82f6;
|
||||
--primary-dark: #2563eb;
|
||||
--success: #10b981;
|
||||
--gray-50: #f9fafb;
|
||||
--gray-100: #f3f4f6;
|
||||
--gray-200: #e5e7eb;
|
||||
--gray-300: #d1d5db;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--gray-50);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-dark);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: var(--success);
|
||||
color: white;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-success:hover {
|
||||
background-color: #059669;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.input-field {
|
||||
border: 1px solid var(--gray-300);
|
||||
background: white;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
border-color: var(--primary);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.work-card {
|
||||
background: white;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.work-card:hover {
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
background: linear-gradient(135deg, #3b82f6, #2563eb);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
color: #4b5563;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background-color: #f3f4f6;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="min-h-screen bg-gray-50">
|
||||
<!-- 헤더 -->
|
||||
<header class="bg-white shadow-sm sticky top-0 z-50">
|
||||
<div class="container mx-auto px-4 py-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-xl font-bold text-gray-800">
|
||||
<i class="fas fa-clipboard-list mr-2"></i>작업보고서 시스템
|
||||
</h1>
|
||||
<button onclick="AuthAPI.logout()" class="px-3 py-1 bg-red-500 text-white rounded hover:bg-red-600 transition-colors text-sm">
|
||||
<i class="fas fa-sign-out-alt mr-1"></i>로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 네비게이션 -->
|
||||
<nav class="bg-white border-b">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex gap-2 py-2 overflow-x-auto">
|
||||
<a href="daily-work.html" class="nav-link active">
|
||||
<i class="fas fa-calendar-check mr-2"></i>일일 공수
|
||||
</a>
|
||||
<a href="index.html" class="nav-link">
|
||||
<i class="fas fa-camera-retro mr-2"></i>부적합 등록
|
||||
</a>
|
||||
<a href="issue-view.html" class="nav-link">
|
||||
<i class="fas fa-search mr-2"></i>부적합 조회
|
||||
</a>
|
||||
<a href="index.html#list" class="nav-link">
|
||||
<i class="fas fa-list mr-2"></i>목록 관리
|
||||
</a>
|
||||
<a href="index.html#summary" class="nav-link">
|
||||
<i class="fas fa-chart-bar mr-2"></i>보고서
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 메인 컨텐츠 -->
|
||||
<main class="container mx-auto px-4 py-6 max-w-2xl">
|
||||
<!-- 입력 카드 -->
|
||||
<div class="work-card p-6 mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-6">
|
||||
<i class="fas fa-edit text-blue-500 mr-2"></i>공수 입력
|
||||
</h2>
|
||||
|
||||
<form id="dailyWorkForm" class="space-y-6">
|
||||
<!-- 날짜 선택 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
<i class="fas fa-calendar mr-1"></i>날짜
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
id="workDate"
|
||||
class="input-field w-full px-4 py-3 rounded-lg text-lg"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 인원 입력 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
<i class="fas fa-users mr-1"></i>작업 인원
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="workerCount"
|
||||
min="1"
|
||||
class="input-field w-full px-4 py-3 rounded-lg text-lg"
|
||||
placeholder="예: 5"
|
||||
required
|
||||
>
|
||||
<p class="text-sm text-gray-500 mt-1">기본 근무시간: 8시간/인</p>
|
||||
</div>
|
||||
|
||||
<!-- 잔업 섹션 -->
|
||||
<div class="border-t pt-4">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<label class="text-sm font-medium text-gray-700">
|
||||
<i class="fas fa-clock mr-1"></i>잔업 여부
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
id="overtimeToggle"
|
||||
class="px-4 py-2 rounded-lg border border-gray-300 hover:bg-gray-50 transition-colors"
|
||||
onclick="toggleOvertime()"
|
||||
>
|
||||
<i class="fas fa-plus mr-2"></i>잔업 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 잔업 입력 영역 -->
|
||||
<div id="overtimeSection" class="hidden space-y-3 bg-gray-50 p-4 rounded-lg">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">잔업 인원</label>
|
||||
<input
|
||||
type="number"
|
||||
id="overtimeWorkers"
|
||||
min="0"
|
||||
class="input-field w-full px-3 py-2 rounded"
|
||||
placeholder="명"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">잔업 시간</label>
|
||||
<input
|
||||
type="number"
|
||||
id="overtimeHours"
|
||||
min="0"
|
||||
step="0.5"
|
||||
class="input-field w-full px-3 py-2 rounded"
|
||||
placeholder="시간"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 총 공수 표시 -->
|
||||
<div class="bg-blue-50 rounded-lg p-4">
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-gray-700 font-medium">예상 총 공수</span>
|
||||
<span class="text-2xl font-bold text-blue-600" id="totalHours">0시간</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 저장 버튼 -->
|
||||
<button type="submit" class="btn-primary w-full py-3 rounded-lg font-medium text-lg">
|
||||
<i class="fas fa-save mr-2"></i>저장하기
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- 최근 입력 내역 -->
|
||||
<div class="work-card p-6">
|
||||
<h3 class="text-lg font-semibold text-gray-800 mb-4">
|
||||
<i class="fas fa-history text-gray-500 mr-2"></i>최근 입력 내역
|
||||
</h3>
|
||||
<div id="recentEntries" class="space-y-3">
|
||||
<!-- 최근 입력 내역이 여기에 표시됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script>
|
||||
let currentUser = null;
|
||||
let dailyWorks = [];
|
||||
|
||||
// 페이지 로드 시 인증 체크
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
const user = TokenManager.getUser();
|
||||
if (!user) {
|
||||
window.location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
currentUser = user;
|
||||
|
||||
// 오늘 날짜로 초기화
|
||||
document.getElementById('workDate').valueAsDate = new Date();
|
||||
|
||||
// 최근 내역 로드
|
||||
await loadRecentEntries();
|
||||
});
|
||||
|
||||
// 잔업 토글
|
||||
function toggleOvertime() {
|
||||
const section = document.getElementById('overtimeSection');
|
||||
const button = document.getElementById('overtimeToggle');
|
||||
|
||||
if (section.classList.contains('hidden')) {
|
||||
section.classList.remove('hidden');
|
||||
button.innerHTML = '<i class="fas fa-minus mr-2"></i>잔업 취소';
|
||||
button.classList.add('bg-gray-100');
|
||||
} else {
|
||||
section.classList.add('hidden');
|
||||
button.innerHTML = '<i class="fas fa-plus mr-2"></i>잔업 추가';
|
||||
button.classList.remove('bg-gray-100');
|
||||
// 잔업 입력값 초기화
|
||||
document.getElementById('overtimeWorkers').value = '';
|
||||
document.getElementById('overtimeHours').value = '';
|
||||
}
|
||||
calculateTotal();
|
||||
}
|
||||
|
||||
// 총 공수 계산
|
||||
function calculateTotal() {
|
||||
const workerCount = parseInt(document.getElementById('workerCount').value) || 0;
|
||||
const regularHours = workerCount * 8; // 기본 8시간
|
||||
|
||||
let overtimeTotal = 0;
|
||||
if (!document.getElementById('overtimeSection').classList.contains('hidden')) {
|
||||
const overtimeWorkers = parseInt(document.getElementById('overtimeWorkers').value) || 0;
|
||||
const overtimeHours = parseFloat(document.getElementById('overtimeHours').value) || 0;
|
||||
overtimeTotal = overtimeWorkers * overtimeHours;
|
||||
}
|
||||
|
||||
const total = regularHours + overtimeTotal;
|
||||
document.getElementById('totalHours').textContent = `${total}시간`;
|
||||
}
|
||||
|
||||
// 입력값 변경 시 총 공수 재계산
|
||||
document.getElementById('workerCount').addEventListener('input', calculateTotal);
|
||||
document.getElementById('overtimeWorkers').addEventListener('input', calculateTotal);
|
||||
document.getElementById('overtimeHours').addEventListener('input', calculateTotal);
|
||||
|
||||
// 폼 제출
|
||||
document.getElementById('dailyWorkForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
const workDate = document.getElementById('workDate').value;
|
||||
const workerCount = parseInt(document.getElementById('workerCount').value);
|
||||
|
||||
let overtimeWorkers = 0;
|
||||
let overtimeHours = 0;
|
||||
|
||||
if (!document.getElementById('overtimeSection').classList.contains('hidden')) {
|
||||
overtimeWorkers = parseInt(document.getElementById('overtimeWorkers').value) || 0;
|
||||
overtimeHours = parseFloat(document.getElementById('overtimeHours').value) || 0;
|
||||
}
|
||||
|
||||
try {
|
||||
// API 호출
|
||||
await DailyWorkAPI.create({
|
||||
date: new Date(workDate).toISOString(),
|
||||
worker_count: workerCount,
|
||||
overtime_workers: overtimeWorkers,
|
||||
overtime_hours: overtimeHours
|
||||
});
|
||||
|
||||
// 성공 메시지
|
||||
showSuccessMessage();
|
||||
|
||||
// 폼 초기화
|
||||
resetForm();
|
||||
|
||||
// 최근 내역 갱신
|
||||
await loadRecentEntries();
|
||||
} catch (error) {
|
||||
alert(error.message || '저장에 실패했습니다.');
|
||||
}
|
||||
});
|
||||
|
||||
// 최근 데이터 로드
|
||||
async function loadRecentEntries() {
|
||||
try {
|
||||
// 최근 7일간 데이터 가져오기
|
||||
const endDate = new Date();
|
||||
const startDate = new Date();
|
||||
startDate.setDate(startDate.getDate() - 7);
|
||||
|
||||
dailyWorks = await DailyWorkAPI.getAll({
|
||||
start_date: startDate.toISOString().split('T')[0],
|
||||
end_date: endDate.toISOString().split('T')[0],
|
||||
limit: 7
|
||||
});
|
||||
|
||||
displayRecentEntries();
|
||||
} catch (error) {
|
||||
console.error('데이터 로드 실패:', error);
|
||||
dailyWorks = [];
|
||||
displayRecentEntries();
|
||||
}
|
||||
}
|
||||
|
||||
// 성공 메시지
|
||||
function showSuccessMessage() {
|
||||
const button = document.querySelector('button[type="submit"]');
|
||||
const originalHTML = button.innerHTML;
|
||||
button.innerHTML = '<i class="fas fa-check-circle mr-2"></i>저장 완료!';
|
||||
button.classList.remove('btn-primary');
|
||||
button.classList.add('btn-success');
|
||||
|
||||
setTimeout(() => {
|
||||
button.innerHTML = originalHTML;
|
||||
button.classList.remove('btn-success');
|
||||
button.classList.add('btn-primary');
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// 폼 초기화
|
||||
function resetForm() {
|
||||
document.getElementById('workerCount').value = '';
|
||||
document.getElementById('overtimeWorkers').value = '';
|
||||
document.getElementById('overtimeHours').value = '';
|
||||
document.getElementById('overtimeSection').classList.add('hidden');
|
||||
document.getElementById('overtimeToggle').innerHTML = '<i class="fas fa-plus mr-2"></i>잔업 추가';
|
||||
document.getElementById('overtimeToggle').classList.remove('bg-gray-100');
|
||||
document.getElementById('totalHours').textContent = '0시간';
|
||||
|
||||
// 날짜는 오늘로 유지
|
||||
document.getElementById('workDate').valueAsDate = new Date();
|
||||
}
|
||||
|
||||
// 최근 입력 내역 표시
|
||||
function displayRecentEntries() {
|
||||
const container = document.getElementById('recentEntries');
|
||||
|
||||
if (dailyWorks.length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-500 text-center py-4">입력된 내역이 없습니다.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = dailyWorks.map(item => {
|
||||
const date = new Date(item.date);
|
||||
const dateStr = date.toLocaleDateString('ko-KR', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
weekday: 'short'
|
||||
});
|
||||
|
||||
return `
|
||||
<div class="flex justify-between items-center p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
|
||||
<div>
|
||||
<p class="font-medium text-gray-800">${dateStr}</p>
|
||||
<p class="text-sm text-gray-600">
|
||||
인원: ${item.worker_count}명
|
||||
${item.overtime_total > 0 ? `• 잔업: ${item.overtime_workers}명 × ${item.overtime_hours}시간` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<p class="text-lg font-bold text-blue-600">${item.total_hours}시간</p>
|
||||
${currentUser && currentUser.role === 'admin' ? `
|
||||
<button
|
||||
onclick="deleteDailyWork(${item.id})"
|
||||
class="mt-1 px-2 py-1 bg-red-500 text-white rounded hover:bg-red-600 transition-colors text-xs"
|
||||
>
|
||||
<i class="fas fa-trash mr-1"></i>삭제
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// 일일 공수 삭제 (관리자만)
|
||||
async function deleteDailyWork(workId) {
|
||||
if (!currentUser || currentUser.role !== 'admin') {
|
||||
alert('관리자만 삭제할 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('정말로 이 일일 공수 기록을 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await DailyWorkAPI.delete(workId);
|
||||
|
||||
// 성공 시 목록 다시 로드
|
||||
await loadRecentEntries();
|
||||
|
||||
alert('삭제되었습니다.');
|
||||
} catch (error) {
|
||||
alert(error.message || '삭제에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
909
frontend/index.html
Normal file
909
frontend/index.html
Normal file
@@ -0,0 +1,909 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>작업보고서 시스템</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">
|
||||
<style>
|
||||
:root {
|
||||
--primary: #3b82f6;
|
||||
--primary-dark: #2563eb;
|
||||
--success: #10b981;
|
||||
--warning: #f59e0b;
|
||||
--danger: #ef4444;
|
||||
--gray-50: #f9fafb;
|
||||
--gray-100: #f3f4f6;
|
||||
--gray-200: #e5e7eb;
|
||||
--gray-300: #d1d5db;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: var(--gray-50);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: var(--primary-dark);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
|
||||
.btn-primary:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.input-field {
|
||||
border: 1px solid var(--gray-300);
|
||||
background: white;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
border-color: var(--primary);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.75rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.status-new {
|
||||
background-color: #dbeafe;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.status-progress {
|
||||
background-color: #fef3c7;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.status-complete {
|
||||
background-color: #d1fae5;
|
||||
color: #065f46;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: #6b7280;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
background-color: var(--gray-100);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.nav-link.active {
|
||||
background-color: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 로그인 화면 -->
|
||||
<div id="loginScreen" class="min-h-screen flex items-center justify-center p-4 bg-gray-50">
|
||||
<div class="bg-white rounded-xl shadow-lg p-8 w-full max-w-sm">
|
||||
<div class="text-center mb-6">
|
||||
<i class="fas fa-clipboard-check text-5xl text-blue-500 mb-4"></i>
|
||||
<h1 class="text-2xl font-bold text-gray-800">작업보고서 시스템</h1>
|
||||
<p class="text-gray-600 text-sm mt-2">부적합 사항 관리 및 공수 계산</p>
|
||||
</div>
|
||||
|
||||
<form id="loginForm" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">아이디</label>
|
||||
<input
|
||||
type="text"
|
||||
id="userId"
|
||||
class="input-field w-full px-4 py-2 rounded-lg"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">비밀번호</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
class="input-field w-full px-4 py-2 rounded-lg"
|
||||
required
|
||||
>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary w-full py-2 rounded-lg font-medium">
|
||||
로그인
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 메인 화면 -->
|
||||
<div id="mainScreen" class="hidden min-h-screen bg-gray-50">
|
||||
<!-- 헤더 -->
|
||||
<header class="bg-white shadow-sm sticky top-0 z-50">
|
||||
<div class="container mx-auto px-4 py-3">
|
||||
<div class="flex justify-between items-center">
|
||||
<h1 class="text-xl font-bold text-gray-800">
|
||||
<i class="fas fa-clipboard-check text-blue-500 mr-2"></i>작업보고서
|
||||
</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
<span class="text-sm text-gray-600" id="userDisplay"></span>
|
||||
<button onclick="logout()" class="text-gray-500 hover:text-gray-700">
|
||||
<i class="fas fa-sign-out-alt"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- 네비게이션 -->
|
||||
<nav class="bg-white border-b">
|
||||
<div class="container mx-auto px-4">
|
||||
<div class="flex gap-2 py-2 overflow-x-auto">
|
||||
<a href="daily-work.html" class="nav-link">
|
||||
<i class="fas fa-calendar-check mr-2"></i>일일 공수
|
||||
</a>
|
||||
<button class="nav-link active" onclick="showSection('report')">
|
||||
<i class="fas fa-camera-retro mr-2"></i>부적합 등록
|
||||
</button>
|
||||
<a href="issue-view.html" class="nav-link">
|
||||
<i class="fas fa-search mr-2"></i>부적합 조회
|
||||
</a>
|
||||
<button class="nav-link" onclick="showSection('list')">
|
||||
<i class="fas fa-list mr-2"></i>목록 관리
|
||||
</button>
|
||||
<button class="nav-link" onclick="showSection('summary')">
|
||||
<i class="fas fa-chart-bar mr-2"></i>보고서
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 부적합 등록 섹션 (모바일 최적화) -->
|
||||
<section id="reportSection" class="container mx-auto px-4 py-6 max-w-lg">
|
||||
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-500 mr-2"></i>부적합 사항 등록
|
||||
</h2>
|
||||
|
||||
<form id="reportForm" class="space-y-4">
|
||||
<!-- 사진 업로드 (선택사항) -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||
사진 <span class="text-gray-500 text-xs">(선택사항)</span>
|
||||
</label>
|
||||
<div
|
||||
id="photoUpload"
|
||||
class="border-2 border-dashed border-gray-300 rounded-lg p-8 text-center cursor-pointer hover:border-blue-400 transition-colors"
|
||||
onclick="document.getElementById('photoInput').click()"
|
||||
>
|
||||
<div id="photoPreview" class="hidden mb-4">
|
||||
<img id="previewImg" class="max-h-48 mx-auto rounded-lg">
|
||||
<button type="button" onclick="removePhoto(event)" class="mt-2 px-2 py-1 bg-red-500 text-white rounded text-xs hover:bg-red-600">
|
||||
<i class="fas fa-times mr-1"></i>사진 제거
|
||||
</button>
|
||||
</div>
|
||||
<div id="photoPlaceholder">
|
||||
<i class="fas fa-camera text-4xl text-gray-400 mb-2"></i>
|
||||
<p class="text-gray-600">사진 촬영 또는 선택</p>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="photoInput" accept="image/*" capture="camera" class="hidden">
|
||||
</div>
|
||||
|
||||
<!-- 카테고리 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">카테고리</label>
|
||||
<select id="category" class="input-field w-full px-4 py-2 rounded-lg" required>
|
||||
<option value="">선택하세요</option>
|
||||
<option value="material_missing">자재누락</option>
|
||||
<option value="dimension_defect">치수불량</option>
|
||||
<option value="incoming_defect">입고자재 불량</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 간단 설명 -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-2">간단 설명</label>
|
||||
<textarea
|
||||
id="description"
|
||||
rows="3"
|
||||
class="input-field w-full px-4 py-2 rounded-lg resize-none"
|
||||
placeholder="발견된 문제를 간단히 설명하세요"
|
||||
required
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-primary w-full py-3 rounded-lg font-medium text-lg">
|
||||
<i class="fas fa-check mr-2"></i>등록하기
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 목록 관리 섹션 -->
|
||||
<section id="listSection" class="hidden container mx-auto px-4 py-6">
|
||||
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800 mb-4">부적합 사항 목록</h2>
|
||||
<div id="issueList" class="space-y-4">
|
||||
<!-- 목록이 여기에 표시됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 보고서 섹션 -->
|
||||
<section id="summarySection" class="hidden container mx-auto px-4 py-6">
|
||||
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-800">작업 보고서</h2>
|
||||
<button onclick="printReport()" class="btn-primary px-4 py-2 rounded-lg text-sm">
|
||||
<i class="fas fa-print mr-2"></i>인쇄
|
||||
</button>
|
||||
</div>
|
||||
<div id="reportContent">
|
||||
<!-- 보고서 내용이 여기에 표시됩니다 -->
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script>
|
||||
let currentUser = null;
|
||||
let currentPhoto = null;
|
||||
let issues = [];
|
||||
|
||||
// 페이지 로드 시 인증 체크
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
const user = TokenManager.getUser();
|
||||
if (user) {
|
||||
currentUser = user;
|
||||
document.getElementById('userDisplay').textContent = user.full_name || user.username;
|
||||
document.getElementById('loginScreen').classList.add('hidden');
|
||||
document.getElementById('mainScreen').classList.remove('hidden');
|
||||
|
||||
// 일반 사용자는 보고서 메뉴 숨김
|
||||
if (user.role === 'user') {
|
||||
const reportBtn = document.querySelector('button[onclick="showSection(\'summary\')"]');
|
||||
if (reportBtn) reportBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
loadIssues();
|
||||
}
|
||||
});
|
||||
|
||||
// 로그인
|
||||
document.getElementById('loginForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
const userId = document.getElementById('userId').value;
|
||||
const password = document.getElementById('password').value;
|
||||
|
||||
try {
|
||||
const data = await AuthAPI.login(userId, password);
|
||||
currentUser = data.user;
|
||||
document.getElementById('userDisplay').textContent = currentUser.full_name || currentUser.username;
|
||||
document.getElementById('loginScreen').classList.add('hidden');
|
||||
document.getElementById('mainScreen').classList.remove('hidden');
|
||||
|
||||
// 일반 사용자는 보고서 메뉴 숨김
|
||||
if (currentUser.role === 'user') {
|
||||
const reportBtn = document.querySelector('button[onclick="showSection(\'summary\')"]');
|
||||
if (reportBtn) reportBtn.style.display = 'none';
|
||||
}
|
||||
|
||||
loadIssues();
|
||||
} catch (error) {
|
||||
alert(error.message || '로그인에 실패했습니다.');
|
||||
}
|
||||
});
|
||||
|
||||
// 로그아웃
|
||||
function logout() {
|
||||
AuthAPI.logout();
|
||||
}
|
||||
|
||||
// 섹션 전환
|
||||
function showSection(section) {
|
||||
// 모든 섹션 숨기기
|
||||
document.querySelectorAll('section').forEach(s => s.classList.add('hidden'));
|
||||
// 선택된 섹션 표시
|
||||
document.getElementById(section + 'Section').classList.remove('hidden');
|
||||
|
||||
// 네비게이션 활성화 상태 변경
|
||||
document.querySelectorAll('.nav-link').forEach(link => link.classList.remove('active'));
|
||||
event.target.closest('.nav-link').classList.add('active');
|
||||
|
||||
// 섹션별 초기화
|
||||
if (section === 'list') {
|
||||
displayIssueList();
|
||||
} else if (section === 'summary') {
|
||||
generateReport();
|
||||
}
|
||||
}
|
||||
|
||||
// 사진 업로드
|
||||
document.getElementById('photoInput').addEventListener('change', (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
currentPhoto = e.target.result;
|
||||
document.getElementById('previewImg').src = currentPhoto;
|
||||
document.getElementById('photoPreview').classList.remove('hidden');
|
||||
document.getElementById('photoPlaceholder').classList.add('hidden');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
});
|
||||
|
||||
// 사진 제거
|
||||
function removePhoto(event) {
|
||||
event.stopPropagation();
|
||||
currentPhoto = null;
|
||||
document.getElementById('photoInput').value = '';
|
||||
document.getElementById('photoPreview').classList.add('hidden');
|
||||
document.getElementById('photoPlaceholder').classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 목록에서 사진 변경
|
||||
const tempPhotoChanges = new Map();
|
||||
|
||||
function handlePhotoChange(issueId, input) {
|
||||
const file = input.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
// 임시로 저장
|
||||
tempPhotoChanges.set(issueId, e.target.result);
|
||||
|
||||
// 미리보기 업데이트
|
||||
const photoElement = document.getElementById(`photo-${issueId}`);
|
||||
if (photoElement.tagName === 'IMG') {
|
||||
photoElement.src = e.target.result;
|
||||
} else {
|
||||
// div를 img로 교체
|
||||
const img = document.createElement('img');
|
||||
img.id = `photo-${issueId}`;
|
||||
img.src = e.target.result;
|
||||
img.className = 'w-32 h-32 object-cover rounded-lg shadow-sm';
|
||||
photoElement.parentNode.replaceChild(img, photoElement);
|
||||
}
|
||||
|
||||
// 수정 상태로 표시
|
||||
markAsModified(issueId);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}
|
||||
|
||||
// 부적합 사항 등록
|
||||
document.getElementById('reportForm').addEventListener('submit', async (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
try {
|
||||
const issueData = {
|
||||
photo: currentPhoto,
|
||||
category: document.getElementById('category').value,
|
||||
description: document.getElementById('description').value
|
||||
};
|
||||
|
||||
await IssuesAPI.create(issueData);
|
||||
|
||||
// 성공 메시지
|
||||
alert('부적합 사항이 등록되었습니다.');
|
||||
|
||||
// 폼 초기화
|
||||
document.getElementById('reportForm').reset();
|
||||
currentPhoto = null;
|
||||
document.getElementById('photoPreview').classList.add('hidden');
|
||||
document.getElementById('photoPlaceholder').classList.remove('hidden');
|
||||
|
||||
// 목록 다시 로드
|
||||
await loadIssues();
|
||||
if (document.getElementById('listSection').classList.contains('hidden') === false) {
|
||||
displayIssueList();
|
||||
}
|
||||
} catch (error) {
|
||||
alert(error.message || '등록에 실패했습니다.');
|
||||
}
|
||||
});
|
||||
|
||||
// 데이터 로드
|
||||
async function loadIssues() {
|
||||
try {
|
||||
issues = await IssuesAPI.getAll();
|
||||
} catch (error) {
|
||||
console.error('이슈 로드 실패:', error);
|
||||
issues = [];
|
||||
}
|
||||
}
|
||||
|
||||
// LocalStorage 관련 함수는 더 이상 사용하지 않음
|
||||
function saveIssues() {
|
||||
// Deprecated - API 사용
|
||||
}
|
||||
|
||||
// 목록 표시
|
||||
function displayIssueList() {
|
||||
const container = document.getElementById('issueList');
|
||||
container.innerHTML = '';
|
||||
|
||||
if (issues.length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-500 text-center py-8">등록된 부적합 사항이 없습니다.</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
issues.forEach(issue => {
|
||||
const categoryNames = {
|
||||
material_missing: '자재누락',
|
||||
dimension_defect: '치수불량',
|
||||
incoming_defect: '입고자재 불량'
|
||||
};
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'border rounded-lg p-6 bg-gray-50';
|
||||
div.id = `issue-card-${issue.id}`;
|
||||
div.innerHTML = `
|
||||
<div class="space-y-4">
|
||||
<!-- 사진과 기본 정보 -->
|
||||
<div class="flex gap-4">
|
||||
<div class="relative">
|
||||
${issue.photo_path ?
|
||||
`<img id="photo-${issue.id}" src="${issue.photo_path}" class="w-32 h-32 object-cover rounded-lg shadow-sm">` :
|
||||
`<div id="photo-${issue.id}" class="w-32 h-32 bg-gray-200 rounded-lg flex items-center justify-center">
|
||||
<i class="fas fa-image text-gray-400 text-3xl"></i>
|
||||
</div>`
|
||||
}
|
||||
<button
|
||||
type="button"
|
||||
onclick="document.getElementById('photoEdit-${issue.id}').click()"
|
||||
class="absolute bottom-1 right-1 p-1 bg-blue-500 text-white rounded-full hover:bg-blue-600 text-xs"
|
||||
title="사진 변경"
|
||||
>
|
||||
<i class="fas fa-camera"></i>
|
||||
</button>
|
||||
<input type="file" id="photoEdit-${issue.id}" accept="image/*" class="hidden" onchange="handlePhotoChange(${issue.id}, this)">
|
||||
</div>
|
||||
|
||||
<div class="flex-1 space-y-3">
|
||||
<!-- 카테고리 선택 -->
|
||||
<div>
|
||||
<label class="text-sm text-gray-600">카테고리</label>
|
||||
<select
|
||||
id="category-${issue.id}"
|
||||
class="input-field w-full px-3 py-2 rounded-lg"
|
||||
onchange="markAsModified(${issue.id})"
|
||||
>
|
||||
<option value="material_missing" ${issue.category === 'material_missing' ? 'selected' : ''}>자재누락</option>
|
||||
<option value="dimension_defect" ${issue.category === 'dimension_defect' ? 'selected' : ''}>치수불량</option>
|
||||
<option value="incoming_defect" ${issue.category === 'incoming_defect' ? 'selected' : ''}>입고자재 불량</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- 설명 -->
|
||||
<div>
|
||||
<label class="text-sm text-gray-600">설명</label>
|
||||
<textarea
|
||||
id="description-${issue.id}"
|
||||
class="input-field w-full px-3 py-2 rounded-lg resize-none"
|
||||
rows="2"
|
||||
oninput="markAsModified(${issue.id})"
|
||||
>${issue.description}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 작업 시간 입력 및 버튼 -->
|
||||
<div class="border-t pt-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- 작업 시간 입력 영역 -->
|
||||
<div class="flex items-center gap-2 p-2 bg-blue-50 rounded-lg">
|
||||
<label class="text-sm font-medium text-gray-700">
|
||||
<i class="fas fa-wrench text-blue-500 mr-1"></i>해결 시간:
|
||||
</label>
|
||||
<input
|
||||
id="workHours-${issue.id}"
|
||||
type="number"
|
||||
step="0.5"
|
||||
min="0"
|
||||
value="${issue.work_hours || ''}"
|
||||
class="input-field px-3 py-1 w-20 rounded bg-white"
|
||||
oninput="onWorkHoursChange(${issue.id})"
|
||||
placeholder="0"
|
||||
>
|
||||
<span class="text-gray-600">시간</span>
|
||||
|
||||
<!-- 시간 입력 확인 버튼 -->
|
||||
<button
|
||||
id="workHours-confirm-${issue.id}"
|
||||
onclick="confirmWorkHours(${issue.id})"
|
||||
class="px-3 py-1 rounded transition-colors text-sm font-medium ${
|
||||
issue.work_hours
|
||||
? 'bg-green-100 text-green-700 cursor-default'
|
||||
: 'bg-blue-500 text-white hover:bg-blue-600'
|
||||
}"
|
||||
${issue.work_hours ? 'disabled' : ''}
|
||||
>
|
||||
${issue.work_hours
|
||||
? '<i class="fas fa-check-circle mr-1"></i>완료'
|
||||
: '<i class="fas fa-clock mr-1"></i>확인'
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 전체 저장/취소 버튼 (수정시 표시) -->
|
||||
<div id="save-buttons-${issue.id}" class="hidden flex gap-2 ml-4 border-l pl-4">
|
||||
<button
|
||||
onclick="saveIssueChanges(${issue.id})"
|
||||
class="px-3 py-1 bg-green-500 text-white rounded hover:bg-green-600 transition-colors text-sm"
|
||||
>
|
||||
<i class="fas fa-save mr-1"></i>전체 저장
|
||||
</button>
|
||||
<button
|
||||
onclick="cancelIssueChanges(${issue.id})"
|
||||
class="px-3 py-1 bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors text-sm"
|
||||
>
|
||||
<i class="fas fa-undo mr-1"></i>되돌리기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
${currentUser && currentUser.role === 'admin' ? `
|
||||
<button
|
||||
onclick="deleteIssue(${issue.id})"
|
||||
class="ml-auto px-3 py-1 bg-red-500 text-white rounded hover:bg-red-600 transition-colors text-sm"
|
||||
>
|
||||
<i class="fas fa-trash mr-1"></i>삭제
|
||||
</button>
|
||||
` : ''}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4 text-sm text-gray-500">
|
||||
<span><i class="fas fa-user mr-1"></i>${issue.reporter.full_name || issue.reporter.username}</span>
|
||||
<span><i class="fas fa-calendar mr-1"></i>${new Date(issue.report_date).toLocaleDateString()}</span>
|
||||
<span class="px-2 py-1 rounded-full text-xs ${
|
||||
issue.status === 'complete' ? 'bg-green-100 text-green-700' :
|
||||
issue.status === 'progress' ? 'bg-yellow-100 text-yellow-700' :
|
||||
'bg-gray-100 text-gray-700'
|
||||
}">
|
||||
${issue.status === 'complete' ? '완료' : issue.status === 'progress' ? '진행중' : '신규'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
container.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
// 수정 상태 표시
|
||||
const modifiedIssues = new Set();
|
||||
|
||||
function markAsModified(issueId) {
|
||||
modifiedIssues.add(issueId);
|
||||
const saveButtons = document.getElementById(`save-buttons-${issueId}`);
|
||||
if (saveButtons) {
|
||||
saveButtons.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// 카드 배경색 변경으로 수정 중임을 표시
|
||||
const card = document.getElementById(`issue-card-${issueId}`);
|
||||
if (card) {
|
||||
card.classList.add('bg-yellow-50', 'border-yellow-300');
|
||||
}
|
||||
}
|
||||
|
||||
// 작업 시간 변경 시
|
||||
function onWorkHoursChange(issueId) {
|
||||
const input = document.getElementById(`workHours-${issueId}`);
|
||||
const confirmBtn = document.getElementById(`workHours-confirm-${issueId}`);
|
||||
|
||||
if (input && confirmBtn) {
|
||||
const hasValue = input.value && parseFloat(input.value) > 0;
|
||||
const issue = issues.find(i => i.id === issueId);
|
||||
const isChanged = parseFloat(input.value) !== (issue?.work_hours || 0);
|
||||
|
||||
// 버튼 활성화/비활성화
|
||||
confirmBtn.disabled = !hasValue || !isChanged;
|
||||
|
||||
// 버튼 스타일 업데이트
|
||||
if (!hasValue) {
|
||||
confirmBtn.className = 'px-3 py-1 rounded transition-colors text-sm font-medium bg-gray-300 text-gray-500 cursor-not-allowed';
|
||||
confirmBtn.innerHTML = '<i class="fas fa-clock mr-1"></i>확인';
|
||||
} else if (issue?.work_hours && !isChanged) {
|
||||
confirmBtn.className = 'px-3 py-1 rounded transition-colors text-sm font-medium bg-green-100 text-green-700 cursor-default';
|
||||
confirmBtn.innerHTML = '<i class="fas fa-check-circle mr-1"></i>완료';
|
||||
} else {
|
||||
confirmBtn.className = 'px-3 py-1 rounded transition-colors text-sm font-medium bg-blue-500 text-white hover:bg-blue-600';
|
||||
confirmBtn.innerHTML = '<i class="fas fa-clock mr-1"></i>확인';
|
||||
}
|
||||
|
||||
// 다른 필드도 수정된 경우를 위해 markAsModified 호출
|
||||
if (isChanged) {
|
||||
markAsModified(issueId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 작업 시간만 빠르게 저장
|
||||
async function confirmWorkHours(issueId) {
|
||||
const workHours = parseFloat(document.getElementById(`workHours-${issueId}`).value);
|
||||
|
||||
if (!workHours || workHours <= 0) {
|
||||
alert('작업 시간을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// 작업 시간만 업데이트
|
||||
await IssuesAPI.update(issueId, {
|
||||
work_hours: workHours
|
||||
});
|
||||
|
||||
// 성공 시 데이터 다시 로드
|
||||
await loadIssues();
|
||||
displayIssueList();
|
||||
|
||||
// 성공 메시지
|
||||
showToastMessage(`${workHours}시간 저장 완료!`, 'success');
|
||||
|
||||
} catch (error) {
|
||||
alert(error.message || '저장에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 변경사항 저장
|
||||
async function saveIssueChanges(issueId) {
|
||||
try {
|
||||
const category = document.getElementById(`category-${issueId}`).value;
|
||||
const description = document.getElementById(`description-${issueId}`).value.trim();
|
||||
const workHours = parseFloat(document.getElementById(`workHours-${issueId}`).value) || 0;
|
||||
|
||||
// 유효성 검사
|
||||
if (!description) {
|
||||
alert('설명은 필수 입력 항목입니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 업데이트 데이터 준비
|
||||
const updateData = {
|
||||
category: category,
|
||||
description: description,
|
||||
work_hours: workHours
|
||||
};
|
||||
|
||||
// 사진이 변경되었으면 추가
|
||||
if (tempPhotoChanges.has(issueId)) {
|
||||
updateData.photo = tempPhotoChanges.get(issueId);
|
||||
}
|
||||
|
||||
// API 호출
|
||||
await IssuesAPI.update(issueId, updateData);
|
||||
|
||||
// 성공 시 상태 초기화
|
||||
modifiedIssues.delete(issueId);
|
||||
tempPhotoChanges.delete(issueId);
|
||||
|
||||
// 데이터 다시 로드
|
||||
await loadIssues();
|
||||
displayIssueList();
|
||||
|
||||
// 성공 메시지 (작은 토스트 메시지)
|
||||
showToastMessage('저장되었습니다!', 'success');
|
||||
|
||||
} catch (error) {
|
||||
alert(error.message || '저장에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 변경사항 취소
|
||||
async function cancelIssueChanges(issueId) {
|
||||
// 수정 상태 초기화
|
||||
modifiedIssues.delete(issueId);
|
||||
tempPhotoChanges.delete(issueId);
|
||||
|
||||
// 원래 데이터로 복원
|
||||
await loadIssues();
|
||||
displayIssueList();
|
||||
}
|
||||
|
||||
// 토스트 메시지 표시
|
||||
function showToastMessage(message, type = 'success') {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `fixed bottom-4 right-4 px-4 py-2 rounded-lg text-white z-50 ${
|
||||
type === 'success' ? 'bg-green-500' : 'bg-red-500'
|
||||
}`;
|
||||
toast.innerHTML = `<i class="fas fa-check-circle mr-2"></i>${message}`;
|
||||
document.body.appendChild(toast);
|
||||
|
||||
setTimeout(() => {
|
||||
toast.remove();
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
// 이슈 삭제 함수 (관리자만)
|
||||
async function deleteIssue(issueId) {
|
||||
if (!currentUser || currentUser.role !== 'admin') {
|
||||
alert('관리자만 삭제할 수 있습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm('정말로 이 부적합 사항을 삭제하시겠습니까?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await IssuesAPI.delete(issueId);
|
||||
|
||||
// 성공 시 목록 다시 로드
|
||||
await loadIssues();
|
||||
displayIssueList();
|
||||
|
||||
alert('삭제되었습니다.');
|
||||
} catch (error) {
|
||||
alert(error.message || '삭제에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
|
||||
// 보고서 생성
|
||||
async function generateReport() {
|
||||
const container = document.getElementById('reportContent');
|
||||
|
||||
// 날짜 범위 계산
|
||||
const dates = issues.map(i => new Date(i.report_date));
|
||||
const startDate = dates.length > 0 ? new Date(Math.min(...dates)) : new Date();
|
||||
const endDate = new Date();
|
||||
|
||||
// 일일 공수 데이터 가져오기
|
||||
let dailyWorkTotal = 0;
|
||||
try {
|
||||
const dailyWorks = await DailyWorkAPI.getAll({
|
||||
start_date: startDate.toISOString().split('T')[0],
|
||||
end_date: endDate.toISOString().split('T')[0]
|
||||
});
|
||||
dailyWorkTotal = dailyWorks.reduce((sum, work) => sum + work.total_hours, 0);
|
||||
} catch (error) {
|
||||
console.error('일일 공수 데이터 로드 실패:', error);
|
||||
}
|
||||
|
||||
// 부적합 사항 해결 시간 계산
|
||||
const issueHours = issues.reduce((sum, issue) => sum + issue.work_hours, 0);
|
||||
const categoryCount = {};
|
||||
|
||||
issues.forEach(issue => {
|
||||
categoryCount[issue.category] = (categoryCount[issue.category] || 0) + 1;
|
||||
});
|
||||
|
||||
// 부적합 시간 비율 계산
|
||||
const issuePercentage = dailyWorkTotal > 0 ? ((issueHours / dailyWorkTotal) * 100).toFixed(1) : 0;
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="space-y-6">
|
||||
<!-- 요약 페이지 -->
|
||||
<div class="border-b pb-6">
|
||||
<h3 class="text-2xl font-bold text-center mb-6">작업 보고서</h3>
|
||||
|
||||
<div class="grid md:grid-cols-3 gap-4">
|
||||
<div class="bg-gray-50 rounded-lg p-4">
|
||||
<h4 class="font-semibold text-gray-700 mb-2">작업 기간</h4>
|
||||
<p class="text-lg">${startDate.toLocaleDateString()} ~ ${endDate.toLocaleDateString()}</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-blue-50 rounded-lg p-4">
|
||||
<h4 class="font-semibold text-blue-700 mb-2">총 작업 공수</h4>
|
||||
<p class="text-3xl font-bold text-blue-600">${dailyWorkTotal}시간</p>
|
||||
<p class="text-xs text-gray-600 mt-1">일일 공수 합계</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-red-50 rounded-lg p-4">
|
||||
<h4 class="font-semibold text-red-700 mb-2">부적합 처리 시간</h4>
|
||||
<p class="text-3xl font-bold text-red-600">${issueHours}시간</p>
|
||||
<p class="text-xs text-gray-600 mt-1">전체 공수의 ${issuePercentage}%</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 효율성 지표 -->
|
||||
<div class="mt-6">
|
||||
<h4 class="font-semibold text-gray-700 mb-3">작업 효율성</h4>
|
||||
<div class="bg-gradient-to-r from-green-50 to-blue-50 rounded-lg p-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">정상 작업 시간</p>
|
||||
<p class="text-2xl font-bold text-green-600">${dailyWorkTotal - issueHours}시간</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-sm text-gray-600">작업 효율</p>
|
||||
<p class="text-3xl font-bold ${100 - issuePercentage >= 90 ? 'text-green-600' : 100 - issuePercentage >= 80 ? 'text-yellow-600' : 'text-red-600'}">
|
||||
${(100 - issuePercentage).toFixed(1)}%
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-600">부적합 처리</p>
|
||||
<p class="text-2xl font-bold text-red-600">${issueHours}시간</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 카테고리별 분석 -->
|
||||
<div class="mt-6">
|
||||
<h4 class="font-semibold text-gray-700 mb-3">부적합 카테고리별 분석</h4>
|
||||
<div class="grid md:grid-cols-3 gap-4">
|
||||
<div class="bg-blue-50 rounded-lg p-4 text-center">
|
||||
<p class="text-2xl font-bold text-blue-600">${categoryCount.material_missing || 0}</p>
|
||||
<p class="text-sm text-gray-600">자재누락</p>
|
||||
</div>
|
||||
<div class="bg-orange-50 rounded-lg p-4 text-center">
|
||||
<p class="text-2xl font-bold text-orange-600">${categoryCount.dimension_defect || 0}</p>
|
||||
<p class="text-sm text-gray-600">치수불량</p>
|
||||
</div>
|
||||
<div class="bg-purple-50 rounded-lg p-4 text-center">
|
||||
<p class="text-2xl font-bold text-purple-600">${categoryCount.incoming_defect || 0}</p>
|
||||
<p class="text-sm text-gray-600">입고자재 불량</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 상세 내역 -->
|
||||
<div>
|
||||
<h4 class="font-semibold text-gray-700 mb-4">부적합 사항 상세</h4>
|
||||
${issues.map(issue => {
|
||||
const categoryNames = {
|
||||
material_missing: '자재누락',
|
||||
dimension_defect: '치수불량',
|
||||
incoming_defect: '입고자재 불량'
|
||||
};
|
||||
return `
|
||||
<div class="border rounded-lg p-4 mb-4 break-inside-avoid">
|
||||
<div class="flex gap-4">
|
||||
${issue.photo_path ? `<img src="${issue.photo_path}" class="w-32 h-32 object-cover rounded-lg">` : ''}
|
||||
<div class="flex-1">
|
||||
<h5 class="font-semibold text-gray-800 mb-2">${categoryNames[issue.category] || issue.category}</h5>
|
||||
<p class="text-gray-600 mb-2">${issue.description}</p>
|
||||
<div class="grid grid-cols-2 gap-2 text-sm">
|
||||
<p><span class="font-medium">상태:</span> ${
|
||||
issue.status === 'new' ? '신규' :
|
||||
issue.status === 'progress' ? '진행중' : '완료'
|
||||
}</p>
|
||||
<p><span class="font-medium">작업시간:</span> ${issue.work_hours}시간</p>
|
||||
<p><span class="font-medium">보고자:</span> ${issue.reporter.full_name || issue.reporter.username}</p>
|
||||
<p><span class="font-medium">보고일:</span> ${new Date(issue.report_date).toLocaleDateString()}</p>
|
||||
</div>
|
||||
${issue.detail_notes ? `
|
||||
<div class="mt-2 p-2 bg-gray-50 rounded">
|
||||
<p class="text-sm text-gray-600">${issue.detail_notes}</p>
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// 인쇄
|
||||
function printReport() {
|
||||
window.print();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
315
frontend/issue-view.html
Normal file
315
frontend/issue-view.html
Normal file
@@ -0,0 +1,315 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>부적합 사항 조회 - 작업보고서</title>
|
||||
|
||||
<!-- Tailwind CSS -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
|
||||
<!-- Custom Styles -->
|
||||
<style>
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 50%, #f0f9ff 100%);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.glass-effect {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.input-field {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.input-field:focus {
|
||||
outline: none;
|
||||
border-color: #60a5fa;
|
||||
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.1);
|
||||
}
|
||||
|
||||
.line-clamp-2 {
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Navigation -->
|
||||
<nav class="glass-effect border-b border-gray-200 sticky top-0 z-50">
|
||||
<div class="container mx-auto px-4 py-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-xl font-semibold text-gray-800">
|
||||
<i class="fas fa-clipboard-list mr-2"></i>작업보고서 시스템
|
||||
</h1>
|
||||
<div class="flex items-center gap-4">
|
||||
<a href="/daily-work.html" class="text-gray-600 hover:text-gray-800 transition-colors">
|
||||
<i class="fas fa-calendar-day mr-1"></i>일일 공수
|
||||
</a>
|
||||
<a href="/index.html" class="text-gray-600 hover:text-gray-800 transition-colors">
|
||||
<i class="fas fa-exclamation-triangle mr-1"></i>부적합 등록
|
||||
</a>
|
||||
<a href="/issue-view.html" class="text-blue-600 font-medium">
|
||||
<i class="fas fa-search mr-1"></i>부적합 조회
|
||||
</a>
|
||||
<a href="/index.html#list" class="text-gray-600 hover:text-gray-800 transition-colors">
|
||||
<i class="fas fa-list mr-1"></i>목록 관리
|
||||
</a>
|
||||
<a href="/index.html#summary" class="text-gray-600 hover:text-gray-800 transition-colors">
|
||||
<i class="fas fa-chart-bar mr-1"></i>보고서
|
||||
</a>
|
||||
<button onclick="AuthAPI.logout()" class="px-3 py-1 bg-red-500 text-white rounded hover:bg-red-600 transition-colors text-sm">
|
||||
<i class="fas fa-sign-out-alt mr-1"></i>로그아웃
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<main class="container mx-auto px-4 py-8">
|
||||
<!-- 날짜 선택 섹션 (간소화) -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-4 mb-6">
|
||||
<div class="flex flex-wrap gap-3 items-center">
|
||||
<h2 class="text-lg font-semibold text-gray-800">
|
||||
<i class="fas fa-list-alt text-blue-500 mr-2"></i>부적합 사항 목록
|
||||
</h2>
|
||||
|
||||
<div class="flex-1"></div>
|
||||
|
||||
<button onclick="setDateRange('today')" class="px-3 py-1.5 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors text-sm">
|
||||
오늘
|
||||
</button>
|
||||
<button onclick="setDateRange('week')" class="px-3 py-1.5 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors text-sm">
|
||||
이번 주
|
||||
</button>
|
||||
<button onclick="setDateRange('month')" class="px-3 py-1.5 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors text-sm">
|
||||
이번 달
|
||||
</button>
|
||||
<button onclick="setDateRange('all')" class="px-3 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors text-sm">
|
||||
전체
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 결과 섹션 -->
|
||||
<div class="bg-white rounded-xl shadow-sm p-6">
|
||||
<div id="issueResults" class="space-y-3">
|
||||
<!-- 결과가 여기에 표시됩니다 -->
|
||||
<div class="text-gray-500 text-center py-8">
|
||||
<i class="fas fa-spinner fa-spin text-3xl mb-3"></i>
|
||||
<p>데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Scripts -->
|
||||
<script src="/static/js/api.js"></script>
|
||||
<script>
|
||||
let currentUser = null;
|
||||
let issues = [];
|
||||
let currentRange = 'week'; // 기본값: 이번 주
|
||||
|
||||
// 페이지 로드 시
|
||||
window.addEventListener('DOMContentLoaded', async () => {
|
||||
const user = TokenManager.getUser();
|
||||
if (!user) {
|
||||
window.location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
currentUser = user;
|
||||
|
||||
// 기본값: 이번 주 데이터 로드
|
||||
setDateRange('week');
|
||||
});
|
||||
|
||||
// 날짜 범위 설정 및 자동 조회
|
||||
async function setDateRange(range) {
|
||||
currentRange = range;
|
||||
|
||||
// 버튼 스타일 업데이트
|
||||
document.querySelectorAll('button[onclick^="setDateRange"]').forEach(btn => {
|
||||
if (btn.textContent.includes('전체') && range === 'all') {
|
||||
btn.className = 'px-3 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors text-sm';
|
||||
} else if (btn.textContent.includes('오늘') && range === 'today') {
|
||||
btn.className = 'px-3 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors text-sm';
|
||||
} else if (btn.textContent.includes('이번 주') && range === 'week') {
|
||||
btn.className = 'px-3 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors text-sm';
|
||||
} else if (btn.textContent.includes('이번 달') && range === 'month') {
|
||||
btn.className = 'px-3 py-1.5 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors text-sm';
|
||||
} else {
|
||||
btn.className = 'px-3 py-1.5 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 transition-colors text-sm';
|
||||
}
|
||||
});
|
||||
|
||||
await loadIssues(range);
|
||||
}
|
||||
|
||||
// 부적합 사항 로드
|
||||
async function loadIssues(range) {
|
||||
const container = document.getElementById('issueResults');
|
||||
container.innerHTML = `
|
||||
<div class="text-gray-500 text-center py-8">
|
||||
<i class="fas fa-spinner fa-spin text-3xl mb-3"></i>
|
||||
<p>데이터를 불러오는 중...</p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
try {
|
||||
// 모든 이슈 가져오기
|
||||
const allIssues = await IssuesAPI.getAll();
|
||||
|
||||
// 날짜 필터링
|
||||
const today = new Date();
|
||||
today.setHours(23, 59, 59, 999);
|
||||
let startDate = new Date();
|
||||
|
||||
switch(range) {
|
||||
case 'today':
|
||||
startDate = new Date();
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
break;
|
||||
case 'week':
|
||||
startDate.setDate(today.getDate() - 7);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
break;
|
||||
case 'month':
|
||||
startDate.setMonth(today.getMonth() - 1);
|
||||
startDate.setHours(0, 0, 0, 0);
|
||||
break;
|
||||
case 'all':
|
||||
startDate = new Date(2020, 0, 1); // 충분히 과거 날짜
|
||||
break;
|
||||
}
|
||||
|
||||
// 필터링 및 정렬 (최신순)
|
||||
issues = allIssues
|
||||
.filter(issue => {
|
||||
const issueDate = new Date(issue.report_date);
|
||||
return issueDate >= startDate && issueDate <= today;
|
||||
})
|
||||
.sort((a, b) => new Date(b.report_date) - new Date(a.report_date));
|
||||
|
||||
// 결과 표시
|
||||
displayResults();
|
||||
|
||||
} catch (error) {
|
||||
console.error('조회 실패:', error);
|
||||
container.innerHTML = `
|
||||
<div class="text-red-500 text-center py-8">
|
||||
<i class="fas fa-exclamation-circle text-3xl mb-3"></i>
|
||||
<p>데이터를 불러오는데 실패했습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// 결과 표시 (시간순 나열)
|
||||
function displayResults() {
|
||||
const container = document.getElementById('issueResults');
|
||||
|
||||
if (issues.length === 0) {
|
||||
container.innerHTML = `
|
||||
<div class="text-gray-500 text-center py-8">
|
||||
<i class="fas fa-inbox text-4xl mb-3"></i>
|
||||
<p>등록된 부적합 사항이 없습니다.</p>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
|
||||
const categoryNames = {
|
||||
material_missing: '자재누락',
|
||||
dimension_defect: '치수불량',
|
||||
incoming_defect: '입고자재 불량'
|
||||
};
|
||||
|
||||
const categoryColors = {
|
||||
material_missing: 'bg-yellow-100 text-yellow-700 border-yellow-300',
|
||||
dimension_defect: 'bg-orange-100 text-orange-700 border-orange-300',
|
||||
incoming_defect: 'bg-red-100 text-red-700 border-red-300'
|
||||
};
|
||||
|
||||
// 시간순으로 나열
|
||||
container.innerHTML = issues.map(issue => {
|
||||
const date = new Date(issue.report_date);
|
||||
const dateStr = date.toLocaleDateString('ko-KR', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
weekday: 'short'
|
||||
});
|
||||
const timeStr = date.toLocaleTimeString('ko-KR', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
|
||||
return `
|
||||
<div class="flex gap-3 p-3 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors border-l-4 ${categoryColors[issue.category].split(' ')[2] || 'border-gray-300'}">
|
||||
<!-- 사진 -->
|
||||
${issue.photo_path ?
|
||||
`<img src="${issue.photo_path}" class="w-20 h-20 object-cover rounded shadow-sm flex-shrink-0">` :
|
||||
`<div class="w-20 h-20 bg-gray-200 rounded flex items-center justify-center flex-shrink-0">
|
||||
<i class="fas fa-image text-gray-400"></i>
|
||||
</div>`
|
||||
}
|
||||
|
||||
<!-- 내용 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-start justify-between mb-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="px-2 py-0.5 rounded text-xs font-medium ${categoryColors[issue.category].split(' ').slice(0, 2).join(' ')}">
|
||||
${categoryNames[issue.category]}
|
||||
</span>
|
||||
${issue.work_hours ? `
|
||||
<span class="text-xs text-green-600 font-medium">
|
||||
✓ ${issue.work_hours}시간
|
||||
</span>
|
||||
` : ''}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
${dateStr} ${timeStr}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-800 line-clamp-2">${issue.description}</p>
|
||||
|
||||
<div class="mt-1 text-xs text-gray-500">
|
||||
<i class="fas fa-user mr-1"></i>
|
||||
${issue.reporter.full_name || issue.reporter.username}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
// 상단에 요약 추가
|
||||
const summary = `
|
||||
<div class="mb-4 p-3 bg-blue-50 rounded-lg text-sm">
|
||||
<span class="font-medium text-blue-900">총 ${issues.length}건</span>
|
||||
<span class="text-blue-700 ml-3">
|
||||
자재누락: ${issues.filter(i => i.category === 'material_missing').length}건 |
|
||||
치수불량: ${issues.filter(i => i.category === 'dimension_defect').length}건 |
|
||||
입고자재 불량: ${issues.filter(i => i.category === 'incoming_defect').length}건
|
||||
</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
container.innerHTML = summary + container.innerHTML;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
208
frontend/static/js/api.js
Normal file
208
frontend/static/js/api.js
Normal file
@@ -0,0 +1,208 @@
|
||||
// API 기본 설정
|
||||
const API_BASE_URL = '/api';
|
||||
|
||||
// 토큰 관리
|
||||
const TokenManager = {
|
||||
getToken: () => localStorage.getItem('access_token'),
|
||||
setToken: (token) => localStorage.setItem('access_token', token),
|
||||
removeToken: () => localStorage.removeItem('access_token'),
|
||||
|
||||
getUser: () => {
|
||||
const userStr = localStorage.getItem('current_user');
|
||||
return userStr ? JSON.parse(userStr) : null;
|
||||
},
|
||||
setUser: (user) => localStorage.setItem('current_user', JSON.stringify(user)),
|
||||
removeUser: () => localStorage.removeItem('current_user')
|
||||
};
|
||||
|
||||
// API 요청 헬퍼
|
||||
async function apiRequest(endpoint, options = {}) {
|
||||
const token = TokenManager.getToken();
|
||||
|
||||
const defaultHeaders = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
if (token) {
|
||||
defaultHeaders['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const config = {
|
||||
...options,
|
||||
headers: {
|
||||
...defaultHeaders,
|
||||
...options.headers
|
||||
}
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, config);
|
||||
|
||||
if (response.status === 401) {
|
||||
// 인증 실패 시 로그인 페이지로
|
||||
TokenManager.removeToken();
|
||||
TokenManager.removeUser();
|
||||
window.location.href = '/index.html';
|
||||
return;
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || 'API 요청 실패');
|
||||
}
|
||||
|
||||
return await response.json();
|
||||
} catch (error) {
|
||||
console.error('API 요청 에러:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Auth API
|
||||
const AuthAPI = {
|
||||
login: async (username, password) => {
|
||||
const response = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ username, password })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new Error(error.detail || '로그인 실패');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
TokenManager.setToken(data.access_token);
|
||||
TokenManager.setUser(data.user);
|
||||
return data;
|
||||
},
|
||||
|
||||
logout: () => {
|
||||
TokenManager.removeToken();
|
||||
TokenManager.removeUser();
|
||||
window.location.href = '/index.html';
|
||||
},
|
||||
|
||||
getMe: () => apiRequest('/auth/me'),
|
||||
|
||||
createUser: (userData) => apiRequest('/auth/users', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(userData)
|
||||
}),
|
||||
|
||||
getUsers: () => apiRequest('/auth/users'),
|
||||
|
||||
updateUser: (userId, userData) => apiRequest(`/auth/users/${userId}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(userData)
|
||||
}),
|
||||
|
||||
deleteUser: (userId) => apiRequest(`/auth/users/${userId}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
};
|
||||
|
||||
// Issues API
|
||||
const IssuesAPI = {
|
||||
create: (issueData) => apiRequest('/issues/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(issueData)
|
||||
}),
|
||||
|
||||
getAll: (params = {}) => {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
return apiRequest(`/issues/${queryString ? '?' + queryString : ''}`);
|
||||
},
|
||||
|
||||
get: (id) => apiRequest(`/issues/${id}`),
|
||||
|
||||
update: (id, issueData) => apiRequest(`/issues/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(issueData)
|
||||
}),
|
||||
|
||||
delete: (id) => apiRequest(`/issues/${id}`, {
|
||||
method: 'DELETE'
|
||||
}),
|
||||
|
||||
getStats: () => apiRequest('/issues/stats/summary')
|
||||
};
|
||||
|
||||
// Daily Work API
|
||||
const DailyWorkAPI = {
|
||||
create: (workData) => apiRequest('/daily-work/', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(workData)
|
||||
}),
|
||||
|
||||
getAll: (params = {}) => {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
return apiRequest(`/daily-work/${queryString ? '?' + queryString : ''}`);
|
||||
},
|
||||
|
||||
get: (id) => apiRequest(`/daily-work/${id}`),
|
||||
|
||||
update: (id, workData) => apiRequest(`/daily-work/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(workData)
|
||||
}),
|
||||
|
||||
delete: (id) => apiRequest(`/daily-work/${id}`, {
|
||||
method: 'DELETE'
|
||||
}),
|
||||
|
||||
getStats: (params = {}) => {
|
||||
const queryString = new URLSearchParams(params).toString();
|
||||
return apiRequest(`/daily-work/stats/summary${queryString ? '?' + queryString : ''}`);
|
||||
}
|
||||
};
|
||||
|
||||
// Reports API
|
||||
const ReportsAPI = {
|
||||
getSummary: (startDate, endDate) => apiRequest('/reports/summary', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
})
|
||||
}),
|
||||
|
||||
getIssues: (startDate, endDate) => {
|
||||
const params = new URLSearchParams({
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
}).toString();
|
||||
return apiRequest(`/reports/issues?${params}`);
|
||||
},
|
||||
|
||||
getDailyWorks: (startDate, endDate) => {
|
||||
const params = new URLSearchParams({
|
||||
start_date: startDate,
|
||||
end_date: endDate
|
||||
}).toString();
|
||||
return apiRequest(`/reports/daily-works?${params}`);
|
||||
}
|
||||
};
|
||||
|
||||
// 권한 체크
|
||||
function checkAuth() {
|
||||
const user = TokenManager.getUser();
|
||||
if (!user) {
|
||||
window.location.href = '/index.html';
|
||||
return null;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
function checkAdminAuth() {
|
||||
const user = checkAuth();
|
||||
if (user && user.role !== 'admin') {
|
||||
alert('관리자 권한이 필요합니다.');
|
||||
window.location.href = '/index.html';
|
||||
return null;
|
||||
}
|
||||
return user;
|
||||
}
|
||||
Reference in New Issue
Block a user