feat: 작업보고서 시스템 완성

- 일일 공수 입력 기능
- 부적합 사항 등록 (이미지 선택사항)
- 날짜별 부적합 조회 (시간순 나열)
- 목록 관리 (인라인 편집, 작업시간 확인 버튼)
- 보고서 생성 (총 공수/부적합 시간 분리)
- JWT 인증 및 권한 관리
- Docker 기반 배포 환경 구성
This commit is contained in:
hyungi
2025-09-17 10:41:25 +09:00
commit 1339e5dded
36 changed files with 5233 additions and 0 deletions

417
frontend/chart.html Normal file
View 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
View 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
View 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
View 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
View 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;
}