- 일일 공수 입력 기능 - 부적합 사항 등록 (이미지 선택사항) - 날짜별 부적합 조회 (시간순 나열) - 목록 관리 (인라인 편집, 작업시간 확인 버튼) - 보고서 생성 (총 공수/부적합 시간 분리) - JWT 인증 및 권한 관리 - Docker 기반 배포 환경 구성
909 lines
43 KiB
HTML
909 lines
43 KiB
HTML
<!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> |