Files
tk-factory-services/user-management/web/index.html
Hyungi Ahn 234a6252c0 feat: 모바일 신고 시스템 구축 + tkqc 연동 + tkuser 이슈유형 관리
- tkreport 모바일 신고 페이지 (5단계 위자드: 유형→위치→프로젝트→항목→사진)
- 프로젝트 DB 연동 (아코디언 UI: TBM등록/활성프로젝트/모름)
- 클라이언트 이미지 리사이징 (1280px, JPEG 80%)
- nginx client_max_body_size 50m, /api/projects/ 프록시 추가
- 부적합 신고 → tkqc 자동 연동 (사진 base64 전달, SSO 토큰 유지)
- work_issue_reports에 project_id 컬럼 추가
- imageUploadService 경로 수정 (public/uploads → uploads, Docker 볼륨 일치)
- tkuser 이슈유형 탭, 휴가관리, nginx 프록시 업데이트
- tkqc 대시보드/수신함/관리함/폐기함 UI 업데이트
- system1 랜딩페이지 업데이트

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-12 15:52:45 +09:00

3284 lines
203 KiB
HTML

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>통합 관리 - TK Factory Services</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>
@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: #f1f5f9; min-height: 100vh; }
.input-field { background: white; border: 1px solid #e2e8f0; transition: all 0.2s; }
.input-field:focus { outline: none; border-color: #64748b; box-shadow: 0 0 0 3px rgba(100,116,139,0.1); }
.tab-btn { transition: all 0.2s; }
.tab-btn.active { background: #334155; color: white; }
.tab-btn:not(.active) { color: #64748b; }
.tab-btn:not(.active):hover { background: #e2e8f0; }
.system-section { border-left: 4px solid; }
.system-section.system1 { border-color: #3b82f6; }
.system-section.system3 { border-color: #8b5cf6; }
.group-header { cursor: pointer; user-select: none; }
.group-header:hover { background: #f8fafc; }
.perm-item { transition: all 0.15s; }
.perm-item.checked { background: #f0f9ff; border-color: #93c5fd; }
.toast-message { transition: all 0.3s ease; }
.fade-in { opacity: 0; transform: translateY(10px); transition: opacity 0.4s, transform 0.4s; }
.fade-in.visible { opacity: 1; transform: translateY(0); }
</style>
</head>
<body>
<!-- Header -->
<header class="bg-slate-800 text-white sticky top-0 z-50">
<div id="headerInner" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<i class="fas fa-cogs text-xl text-slate-300"></i>
<h1 class="text-lg font-semibold">TK 통합 관리</h1>
</div>
<div class="flex items-center gap-4">
<div class="text-right hidden sm:block">
<div id="headerUserName" class="text-sm font-medium truncate max-w-[200px]">-</div>
<div id="headerUserRole" class="text-xs text-slate-400">-</div>
</div>
<div id="headerUserAvatar" class="w-8 h-8 bg-slate-600 rounded-full flex items-center justify-center text-sm font-semibold flex-shrink-0">-</div>
<button onclick="doLogout()" class="text-slate-400 hover:text-white flex-shrink-0" title="로그아웃"><i class="fas fa-sign-out-alt"></i></button>
</div>
</div>
</div>
</header>
<!-- Tab Navigation -->
<nav id="tabNav" class="bg-white border-b shadow-sm sticky top-14 z-40 hidden">
<div id="tabNavInner" class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex gap-1 py-2 overflow-x-auto">
<button class="tab-btn active px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap" onclick="switchTab('users')">
<i class="fas fa-users mr-2"></i>사용자
</button>
<button class="tab-btn px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap" onclick="switchTab('projects')">
<i class="fas fa-folder-open mr-2"></i>프로젝트
</button>
<button class="tab-btn px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap" onclick="switchTab('workplaces')">
<i class="fas fa-building mr-2"></i>작업장
</button>
<button class="tab-btn px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap" onclick="switchTab('departments')">
<i class="fas fa-sitemap mr-2"></i>부서
</button>
<button class="tab-btn px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap" onclick="switchTab('issueTypes')">
<i class="fas fa-exclamation-triangle mr-2"></i>이슈 유형
</button>
<button class="tab-btn px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap" onclick="switchTab('tasks')">
<i class="fas fa-tasks mr-2"></i>작업
</button>
<button class="tab-btn px-4 py-2 rounded-lg text-sm font-medium whitespace-nowrap" onclick="switchTab('vacations')">
<i class="fas fa-umbrella-beach mr-2"></i>휴가
</button>
</div>
</div>
</nav>
<!-- Main Content -->
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6 fade-in">
<!-- ============ 사용자 탭 ============ -->
<div id="tab-users">
<!-- 사용자 추가 + 목록 -->
<div id="adminSection" class="hidden">
<div class="grid lg:grid-cols-5 gap-6">
<!-- 사용자 추가 (좁게) -->
<div class="lg:col-span-2 bg-white rounded-xl shadow-sm p-5">
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-user-plus text-slate-400 mr-2"></i>사용자 추가</h2>
<form id="addUserForm" class="space-y-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">사용자 ID</label>
<input type="text" id="newUsername" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="예: 홍길동" required>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">이름</label>
<input type="text" id="newFullName" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="실명" required>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">비밀번호</label>
<input type="password" id="newPassword" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="초기 비밀번호" required>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">부서</label>
<select id="newDepartment" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="">선택</option>
<option value="production">생산</option>
<option value="quality">품질</option>
<option value="purchasing">구매</option>
<option value="design">설계</option>
<option value="sales">영업</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">권한</label>
<select id="newRole" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="user">사용자</option>
<option value="admin">관리자</option>
</select>
</div>
</div>
<button type="submit" class="w-full px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium">
<i class="fas fa-plus mr-1"></i>추가
</button>
</form>
</div>
<!-- 사용자 목록 (넓게) -->
<div class="lg:col-span-3 bg-white rounded-xl shadow-sm p-5">
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-users text-slate-400 mr-2"></i>사용자 목록</h2>
<div id="userList" class="space-y-2 max-h-[420px] overflow-y-auto">
<div class="text-gray-400 text-center py-8"><i class="fas fa-spinner fa-spin text-2xl"></i><p class="mt-2 text-sm">로딩 중...</p></div>
</div>
</div>
</div>
<!-- 페이지 권한 관리 -->
<div class="mt-6 bg-white rounded-xl shadow-sm p-5">
<div class="flex items-center justify-between mb-5">
<h2 class="text-base font-semibold text-gray-800"><i class="fas fa-shield-alt text-slate-400 mr-2"></i>페이지 접근 권한</h2>
<select id="permissionUserSelect" class="input-field px-3 py-1.5 rounded-lg text-sm min-w-[200px]">
<option value="">사용자 선택</option>
</select>
</div>
<div id="permissionPanel" class="hidden">
<!-- System 1 - 공장관리 -->
<div class="system-section system1 rounded-lg mb-5 bg-white">
<div class="flex items-center justify-between px-4 py-3 bg-blue-50 rounded-t-lg border border-blue-100">
<div class="flex items-center gap-2">
<i class="fas fa-industry text-blue-500"></i>
<span class="font-semibold text-sm text-blue-900">공장관리</span>
<span class="text-xs text-blue-500 bg-blue-100 px-2 py-0.5 rounded-full">System 1</span>
</div>
<div class="flex gap-2">
<button onclick="toggleSystemAll('s1', true)" class="text-xs text-blue-600 hover:underline">전체 허용</button>
<span class="text-gray-300">|</span>
<button onclick="toggleSystemAll('s1', false)" class="text-xs text-blue-600 hover:underline">전체 해제</button>
</div>
</div>
<div id="s1-perms" class="p-4 border border-t-0 border-blue-100 rounded-b-lg space-y-4"></div>
</div>
<!-- System 3 - 부적합관리 -->
<div class="system-section system3 rounded-lg mb-5 bg-white">
<div class="flex items-center justify-between px-4 py-3 bg-purple-50 rounded-t-lg border border-purple-100">
<div class="flex items-center gap-2">
<i class="fas fa-shield-halved text-purple-500"></i>
<span class="font-semibold text-sm text-purple-900">부적합관리</span>
<span class="text-xs text-purple-500 bg-purple-100 px-2 py-0.5 rounded-full">System 3</span>
</div>
<div class="flex gap-2">
<button onclick="toggleSystemAll('s3', true)" class="text-xs text-purple-600 hover:underline">전체 허용</button>
<span class="text-gray-300">|</span>
<button onclick="toggleSystemAll('s3', false)" class="text-xs text-purple-600 hover:underline">전체 해제</button>
</div>
</div>
<div id="s3-perms" class="p-4 border border-t-0 border-purple-100 rounded-b-lg space-y-4"></div>
</div>
<!-- 저장 버튼 -->
<div class="flex items-center gap-3 pt-2">
<button id="savePermissionsBtn" class="px-6 py-2.5 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium">
<i class="fas fa-save mr-2"></i>권한 저장
</button>
<span id="permissionSaveStatus" class="text-sm"></span>
</div>
</div>
<div id="permissionEmpty" class="text-center text-gray-400 py-8 text-sm">
<i class="fas fa-hand-pointer text-2xl mb-2"></i>
<p>사용자를 선택하면 권한을 설정할 수 있습니다</p>
</div>
</div>
</div>
<!-- 비밀번호 변경 (일반 사용자) -->
<div id="passwordChangeSection" class="hidden">
<div class="bg-white rounded-xl shadow-sm p-6 max-w-md mx-auto">
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-key text-slate-400 mr-2"></i>비밀번호 변경</h2>
<form id="changePasswordForm" class="space-y-4">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">현재 비밀번호</label>
<input type="password" id="currentPassword" class="input-field w-full px-3 py-2 rounded-lg text-sm" required>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">새 비밀번호</label>
<input type="password" id="newPasswordChange" class="input-field w-full px-3 py-2 rounded-lg text-sm" required minlength="6" placeholder="최소 6자">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">새 비밀번호 확인</label>
<input type="password" id="confirmPassword" class="input-field w-full px-3 py-2 rounded-lg text-sm" required>
</div>
<button type="submit" class="w-full px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium">
<i class="fas fa-save mr-1"></i>비밀번호 변경
</button>
</form>
</div>
</div>
</div>
<!-- ============ 준비 중 탭 (placeholder) ============ -->
<div id="tab-projects" class="hidden">
<div class="grid lg:grid-cols-5 gap-6">
<!-- 프로젝트 추가 (좁게) -->
<div class="lg:col-span-2 bg-white rounded-xl shadow-sm p-5">
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-folder-plus text-slate-400 mr-2"></i>프로젝트 추가</h2>
<form id="addProjectForm" class="space-y-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Job No <span class="text-red-400">*</span></label>
<input type="text" id="newJobNo" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="예: JOB-2026-001" required>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">프로젝트명 <span class="text-red-400">*</span></label>
<input type="text" id="newProjectName" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="프로젝트 이름" required>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">계약일</label>
<input type="date" id="newContractDate" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">납기일</label>
<input type="date" id="newDueDate" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">현장</label>
<input type="text" id="newSite" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="설치 현장">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">PM</label>
<input type="text" id="newPm" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="담당 PM">
</div>
<button type="submit" class="w-full px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium">
<i class="fas fa-plus mr-1"></i>추가
</button>
</form>
</div>
<!-- 프로젝트 목록 (넓게) -->
<div class="lg:col-span-3 bg-white rounded-xl shadow-sm p-5">
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-folder-open text-slate-400 mr-2"></i>프로젝트 목록</h2>
<div id="projectList" class="space-y-2 max-h-[520px] overflow-y-auto">
<div class="text-gray-400 text-center py-8"><i class="fas fa-spinner fa-spin text-2xl"></i><p class="mt-2 text-sm">로딩 중...</p></div>
</div>
</div>
</div>
</div>
<!-- ============ 작업자 탭 ============ -->
<!-- ============ 부서 탭 ============ -->
<div id="tab-departments" class="hidden">
<div class="grid lg:grid-cols-5 gap-6">
<div class="lg:col-span-2 bg-white rounded-xl shadow-sm p-5">
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-sitemap text-slate-400 mr-2"></i>부서 등록</h2>
<form id="addDepartmentForm" class="space-y-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">부서명 <span class="text-red-400">*</span></label>
<input type="text" id="newDeptName" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="부서 이름" required>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">상위부서</label>
<select id="newDeptParent" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="">없음 (최상위)</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">설명</label>
<input type="text" id="newDeptDescription" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="부서 설명">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">표시순서</label>
<input type="number" id="newDeptOrder" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" value="0" min="0">
</div>
<button type="submit" class="w-full px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium">
<i class="fas fa-plus mr-1"></i>추가
</button>
</form>
</div>
<div class="lg:col-span-3 bg-white rounded-xl shadow-sm p-5">
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-sitemap text-slate-400 mr-2"></i>부서 목록</h2>
<div id="departmentList" class="space-y-2 max-h-[520px] overflow-y-auto">
<div class="text-gray-400 text-center py-8"><i class="fas fa-spinner fa-spin text-2xl"></i><p class="mt-2 text-sm">로딩 중...</p></div>
</div>
</div>
</div>
</div>
<!-- ============ 이슈 유형 탭 ============ -->
<div id="tab-issueTypes" class="hidden">
<div class="grid lg:grid-cols-5 gap-6">
<!-- 좌측: 등록 폼 -->
<div class="lg:col-span-2 space-y-4">
<!-- 카테고리 등록 -->
<div class="bg-white rounded-xl shadow-sm p-5">
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-layer-group text-slate-400 mr-2"></i>카테고리 등록</h2>
<form id="addIssueCategoryForm" class="space-y-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">유형 <span class="text-red-400">*</span></label>
<div class="flex gap-3">
<label class="flex items-center gap-1.5 text-sm cursor-pointer">
<input type="radio" name="newIssueCatType" value="nonconformity" checked class="accent-slate-700"> 부적합
</label>
<label class="flex items-center gap-1.5 text-sm cursor-pointer">
<input type="radio" name="newIssueCatType" value="safety" class="accent-slate-700"> 안전
</label>
<label class="flex items-center gap-1.5 text-sm cursor-pointer">
<input type="radio" name="newIssueCatType" value="facility" class="accent-slate-700"> 시설설비
</label>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">카테고리명 <span class="text-red-400">*</span></label>
<input type="text" id="newIssueCatName" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="카테고리 이름" required>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">설명</label>
<input type="text" id="newIssueCatDesc" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="카테고리 설명">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">표시순서</label>
<input type="number" id="newIssueCatOrder" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" value="0" min="0">
</div>
<button type="submit" class="w-full px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium">
<i class="fas fa-plus mr-1"></i>카테고리 추가
</button>
</form>
</div>
<!-- 아이템 등록 -->
<div class="bg-white rounded-xl shadow-sm p-5">
<h2 class="text-base font-semibold text-gray-800 mb-4"><i class="fas fa-list-ul text-slate-400 mr-2"></i>아이템 등록</h2>
<form id="addIssueItemForm" class="space-y-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">카테고리 <span class="text-red-400">*</span></label>
<select id="newIssueItemCategory" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" required>
<option value="">선택</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">아이템명 <span class="text-red-400">*</span></label>
<input type="text" id="newIssueItemName" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="아이템 이름" required>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">설명</label>
<input type="text" id="newIssueItemDesc" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="아이템 설명">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">심각도</label>
<select id="newIssueItemSeverity" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="low">낮음</option>
<option value="medium" selected>보통</option>
<option value="high">높음</option>
<option value="critical">심각</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">표시순서</label>
<input type="number" id="newIssueItemOrder" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" value="0" min="0">
</div>
</div>
<button type="submit" class="w-full px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium">
<i class="fas fa-plus mr-1"></i>아이템 추가
</button>
</form>
</div>
</div>
<!-- 우측: 카테고리/아이템 목록 -->
<div class="lg:col-span-3 bg-white rounded-xl shadow-sm p-5">
<div class="flex items-center justify-between mb-4">
<h2 class="text-base font-semibold text-gray-800"><i class="fas fa-exclamation-triangle text-slate-400 mr-2"></i>이슈 유형 목록</h2>
<div class="flex gap-1 bg-gray-100 rounded-lg p-0.5">
<button id="issueTypeToggleNonconformity" onclick="switchIssueType('nonconformity')" class="px-3 py-1 rounded-md text-xs font-medium bg-slate-700 text-white">부적합</button>
<button id="issueTypeToggleSafety" onclick="switchIssueType('safety')" class="px-3 py-1 rounded-md text-xs font-medium text-gray-500 hover:bg-gray-200">안전</button>
<button id="issueTypeToggleFacility" onclick="switchIssueType('facility')" class="px-3 py-1 rounded-md text-xs font-medium text-gray-500 hover:bg-gray-200">시설설비</button>
</div>
</div>
<div id="issueCategoryList" class="space-y-2 max-h-[600px] overflow-y-auto">
<div class="text-gray-400 text-center py-8"><i class="fas fa-spinner fa-spin text-2xl"></i><p class="mt-2 text-sm">로딩 중...</p></div>
</div>
</div>
</div>
</div>
<!-- ============ 작업 탭 ============ -->
<div id="tab-tasks" class="hidden">
<div class="flex gap-6" style="height: calc(100vh - 9rem);">
<!-- 공정 사이드바 -->
<div class="w-64 flex-shrink-0 bg-white rounded-xl shadow-sm p-4 flex flex-col overflow-hidden">
<div class="flex items-center justify-between mb-3">
<div class="text-xs font-semibold text-gray-400 uppercase tracking-wider">공정</div>
<button onclick="openWorkTypeModal()" class="text-xs text-slate-500 hover:text-slate-700 px-1.5 py-0.5 rounded hover:bg-gray-100" title="공정 추가"><i class="fas fa-plus"></i></button>
</div>
<div id="workTypeSidebar" class="space-y-1 flex-1 overflow-y-auto">
<div class="text-gray-400 text-center py-8 text-sm"><i class="fas fa-spinner fa-spin"></i></div>
</div>
</div>
<!-- 작업 목록 메인 -->
<div class="flex-1 overflow-hidden min-w-0 flex flex-col">
<div class="bg-white rounded-xl shadow-sm p-5 flex flex-col flex-1 overflow-hidden">
<div class="flex items-center justify-between mb-4 flex-wrap gap-2">
<h2 class="text-base font-semibold text-gray-800">
<i class="fas fa-tasks text-slate-400 mr-2"></i>작업 목록
<span id="taskFilterLabel" class="text-sm font-normal text-gray-500 ml-1">- 전체</span>
</h2>
<div class="flex items-center gap-2">
<span id="taskStats" class="text-xs text-gray-400"></span>
<button onclick="openTaskModal()" class="px-3 py-1.5 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-xs font-medium">
<i class="fas fa-plus mr-1"></i>작업 추가
</button>
</div>
</div>
<div id="taskList" class="space-y-2 flex-1 overflow-y-auto">
<div class="text-gray-400 text-center py-8"><i class="fas fa-spinner fa-spin text-2xl"></i><p class="mt-2 text-sm">로딩 중...</p></div>
</div>
</div>
</div>
</div>
</div>
<!-- 공정 추가/편집 모달 -->
<div id="workTypeModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl max-w-sm w-full p-6">
<div class="flex items-center justify-between mb-4">
<h3 id="workTypeModalTitle" class="text-base font-semibold text-gray-900">공정 추가</h3>
<button onclick="closeWorkTypeModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<form id="workTypeForm" class="space-y-3">
<input type="hidden" id="wtEditId">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">공정명 <span class="text-red-400">*</span></label>
<input type="text" id="wtName" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="예: 용접" required>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">카테고리</label>
<input type="text" id="wtCategory" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="예: 제작">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">설명</label>
<textarea id="wtDesc" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" rows="2" placeholder="선택사항"></textarea>
</div>
<div class="flex gap-3 pt-2">
<button type="button" onclick="closeWorkTypeModal()" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm">취소</button>
<button type="submit" class="flex-1 px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium"><i class="fas fa-save mr-1"></i>저장</button>
</div>
</form>
</div>
</div>
<!-- 작업 추가/편집 모달 -->
<div id="taskModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl max-w-md w-full p-6">
<div class="flex items-center justify-between mb-4">
<h3 id="taskModalTitle" class="text-base font-semibold text-gray-900">작업 추가</h3>
<button onclick="closeTaskModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<form id="taskForm" class="space-y-3">
<input type="hidden" id="taskEditId">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">작업명 <span class="text-red-400">*</span></label>
<input type="text" id="taskName" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="예: 서스 용접" required>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">소속 공정</label>
<select id="taskWorkType" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="">미지정</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">설명</label>
<textarea id="taskDesc" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" rows="3" placeholder="선택사항"></textarea>
</div>
<div class="flex items-center gap-2">
<input type="checkbox" id="taskActive" checked class="rounded">
<label for="taskActive" class="text-sm text-gray-600">활성화 <span class="text-xs text-gray-400">(비활성 시 TBM 입력에서 숨김)</span></label>
</div>
<div class="flex gap-3 pt-2">
<button type="button" onclick="closeTaskModal()" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm">취소</button>
<button type="submit" class="flex-1 px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium"><i class="fas fa-save mr-1"></i>저장</button>
</div>
</form>
</div>
</div>
<!-- ============ 휴가 탭 ============ -->
<div id="tab-vacations" class="hidden">
<div class="flex gap-6" style="height: calc(100vh - 9rem);">
<!-- 휴가 유형 사이드바 -->
<div class="w-72 flex-shrink-0 bg-white rounded-xl shadow-sm p-4 flex flex-col overflow-hidden">
<div class="flex items-center justify-between mb-3">
<div class="text-xs font-semibold text-gray-400 uppercase tracking-wider">휴가 유형</div>
<button onclick="openVacTypeModal()" class="text-xs text-slate-500 hover:text-slate-700 px-1.5 py-0.5 rounded hover:bg-gray-100" title="유형 추가"><i class="fas fa-plus"></i></button>
</div>
<div id="vacTypeSidebar" class="space-y-1 flex-1 overflow-y-auto">
<div class="text-gray-400 text-center py-8 text-sm"><i class="fas fa-spinner fa-spin"></i></div>
</div>
</div>
<!-- 연차 배정 메인 -->
<div class="flex-1 overflow-hidden min-w-0 flex flex-col">
<div class="bg-white rounded-xl shadow-sm p-5 flex flex-col flex-1 overflow-hidden">
<div class="flex items-center justify-between mb-4 flex-wrap gap-2">
<h2 class="text-base font-semibold text-gray-800"><i class="fas fa-calendar-check text-slate-400 mr-2"></i>연차 배정</h2>
<div class="flex items-center gap-2">
<select id="vacYear" class="input-field px-3 py-1.5 rounded-lg text-sm" onchange="loadVacBalances()">
</select>
<button onclick="autoCalcVacation()" class="px-3 py-1.5 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 text-xs font-medium" title="입사일 기반 연차 자동 계산">
<i class="fas fa-calculator mr-1"></i>자동 계산
</button>
<button onclick="openVacBalanceModal()" class="px-3 py-1.5 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-xs font-medium">
<i class="fas fa-plus mr-1"></i>개별 배정
</button>
</div>
</div>
<div id="vacBalanceTable" class="flex-1 overflow-auto">
<div class="text-gray-400 text-center py-8"><i class="fas fa-spinner fa-spin text-2xl"></i><p class="mt-2 text-sm">로딩 중...</p></div>
</div>
</div>
</div>
</div>
</div>
<!-- 휴가 유형 모달 -->
<div id="vacTypeModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl max-w-sm w-full p-6">
<div class="flex items-center justify-between mb-4">
<h3 id="vacTypeModalTitle" class="text-base font-semibold text-gray-900">휴가 유형 추가</h3>
<button onclick="closeVacTypeModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<form id="vacTypeForm" class="space-y-3">
<input type="hidden" id="vtEditId">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">유형 코드 <span class="text-red-400">*</span></label>
<input type="text" id="vtCode" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="예: ANNUAL" required>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">유형명 <span class="text-red-400">*</span></label>
<input type="text" id="vtName" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="예: 연차" required>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">차감 일수</label>
<input type="number" id="vtDeductDays" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" value="1.0" step="0.1" min="0">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">우선순위</label>
<input type="number" id="vtPriority" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" value="99" min="0">
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">설명</label>
<textarea id="vtDescription" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" rows="2" placeholder="선택사항"></textarea>
</div>
<div class="flex items-center gap-3">
<label class="flex items-center gap-1.5 text-sm text-gray-600"><input type="checkbox" id="vtSpecial" class="rounded">특별휴가</label>
</div>
<div class="flex gap-3 pt-2">
<button type="button" onclick="closeVacTypeModal()" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm">취소</button>
<button type="submit" class="flex-1 px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium"><i class="fas fa-save mr-1"></i>저장</button>
</div>
</form>
</div>
</div>
<!-- 개별 배정 모달 -->
<div id="vacBalanceModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl max-w-sm w-full p-6">
<div class="flex items-center justify-between mb-4">
<h3 id="vacBalModalTitle" class="text-base font-semibold text-gray-900">연차 배정</h3>
<button onclick="closeVacBalanceModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<form id="vacBalanceForm" class="space-y-3">
<input type="hidden" id="vbEditId">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">작업자 <span class="text-red-400">*</span></label>
<select id="vbWorker" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" required>
<option value="">선택</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">휴가 유형 <span class="text-red-400">*</span></label>
<select id="vbType" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" required>
<option value="">선택</option>
</select>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">배정 일수</label>
<input type="number" id="vbTotalDays" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" value="0" step="0.5" min="0">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">사용 일수</label>
<input type="number" id="vbUsedDays" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" value="0" step="0.5" min="0">
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">비고</label>
<input type="text" id="vbNotes" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="메모">
</div>
<div class="flex gap-3 pt-2">
<button type="button" onclick="closeVacBalanceModal()" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm">취소</button>
<button type="submit" class="flex-1 px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium"><i class="fas fa-save mr-1"></i>저장</button>
</div>
</form>
</div>
</div>
<!-- ============ 작업장 탭 ============ -->
<div id="tab-workplaces" class="hidden">
<div class="flex gap-6" style="height: calc(100vh - 9rem);">
<!-- 사이드바 -->
<div class="w-72 flex-shrink-0 bg-white rounded-xl shadow-sm p-4 flex flex-col overflow-hidden">
<div id="wpSidebarContent" class="flex flex-col flex-1 overflow-hidden">
<div class="text-gray-400 text-center py-8"><i class="fas fa-spinner fa-spin text-2xl"></i><p class="mt-2 text-sm">로딩 중...</p></div>
</div>
</div>
<!-- 메인 영역 -->
<div class="flex-1 overflow-y-auto min-w-0">
<!-- 작업장 미선택 안내 -->
<div id="workplaceEmptyState" class="flex items-center justify-center h-full">
<div class="text-center text-gray-400">
<i class="fas fa-industry text-4xl mb-3"></i>
<p class="text-sm">왼쪽 목록에서 공장을 선택하세요</p>
</div>
</div>
<!-- 설비 섹션 -->
<div id="equipmentSection" class="bg-white rounded-xl shadow-sm p-5 hidden">
<div id="eqBackToCategory" class="mb-3 hidden">
<button onclick="backToCategory()" class="flex items-center gap-1.5 text-sm text-slate-500 hover:text-slate-700 px-2 py-1 rounded hover:bg-gray-100 transition-colors">
<i class="fas fa-arrow-left text-xs"></i><span id="eqBackLabel">구역지도로 돌아가기</span>
</button>
</div>
<div class="flex items-center justify-between mb-4 flex-wrap gap-2">
<h2 class="text-base font-semibold text-gray-800"><i class="fas fa-cog text-slate-400 mr-2"></i>설비 관리 - <span id="eqWorkplaceName">-</span></h2>
<div class="flex items-center gap-2 flex-wrap">
<select id="eqStatusFilter" class="input-field px-2 py-1 rounded-lg text-xs" onchange="filterEquipments()">
<option value="">전체 상태</option>
<option value="active">가동중</option>
<option value="maintenance">점검중</option>
<option value="inactive">비활성</option>
</select>
<select id="eqTypeFilter" class="input-field px-2 py-1 rounded-lg text-xs" onchange="filterEquipments()">
<option value="">전체 유형</option>
</select>
<button onclick="openEquipmentModal()" class="px-3 py-1 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-xs font-medium">
<i class="fas fa-plus mr-1"></i>설비 추가
</button>
</div>
</div>
<!-- 설비 배치도 -->
<div class="mb-4 p-3 bg-gray-50 rounded-lg">
<div class="flex items-center justify-between mb-2">
<h3 class="text-sm font-semibold text-gray-700"><i class="fas fa-map-pin text-gray-400 mr-1"></i>설비 배치도</h3>
<div class="flex gap-2">
<label class="px-2 py-1 bg-white border border-gray-200 rounded text-xs cursor-pointer hover:bg-gray-50">
<i class="fas fa-upload mr-1 text-gray-400"></i>이미지 변경
<input type="file" id="wpLayoutImageFile" accept="image/jpeg,image/png,image/gif,image/webp" onchange="uploadWorkplaceLayoutImage()" class="hidden">
</label>
</div>
</div>
<div id="eqMapArea" class="text-center text-gray-400 py-4 text-sm">
<i class="fas fa-image text-2xl mb-1"></i>
<p>배치도 이미지를 업로드하세요</p>
</div>
<div id="eqMapCanvasWrap" class="hidden relative">
<canvas id="eqMapCanvas" style="max-width:100%; border:1px solid #e2e8f0; border-radius:8px; cursor:crosshair;"></canvas>
<p class="text-xs text-gray-400 mt-1">클릭하여 설비 위치 지정 | 설비 아이콘 위에서 드래그하여 이동</p>
</div>
</div>
<!-- 설비 리스트 -->
<div id="equipmentList" class="space-y-2 max-h-[400px] overflow-y-auto">
<p class="text-gray-400 text-center py-4 text-sm">설비가 없습니다.</p>
</div>
</div>
<!-- 구역지도 프리뷰 -->
<div id="zoneMapSection" class="bg-white rounded-xl shadow-sm p-5 hidden">
<div class="flex items-center justify-between mb-4">
<h2 id="zoneMapTitle" class="text-base font-semibold text-gray-800"><i class="fas fa-map text-slate-400 mr-2"></i>구역지도</h2>
<button onclick="openLayoutMapModal()" class="px-4 py-1.5 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium">
<i class="fas fa-cog mr-1"></i>지도 설정
</button>
</div>
<div id="layoutPreviewArea" class="text-center text-gray-400 py-8 text-sm">
<i class="fas fa-image text-3xl mb-2"></i>
<p>레이아웃 이미지가 없습니다. "지도 설정"에서 업로드하세요.</p>
</div>
<div id="layoutPreviewCanvas" class="hidden">
<canvas id="previewCanvas" style="max-width:100%; border:1px solid #e2e8f0; border-radius:8px; cursor:pointer;"></canvas>
</div>
</div>
</div>
</div>
</div>
</main>
<!-- 사용자 편집 모달 -->
<div id="editUserModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl max-w-md w-full p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-900">사용자 정보 수정</h3>
<button onclick="closeEditModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<form id="editUserForm" class="space-y-3">
<input type="hidden" id="editUserId">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">사용자 ID</label>
<input type="text" id="editUsername" class="input-field w-full px-3 py-1.5 rounded-lg text-sm bg-gray-50" readonly>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">이름</label>
<input type="text" id="editFullName" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">부서</label>
<select id="editDepartment" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="">선택</option>
<option value="production">생산</option>
<option value="quality">품질</option>
<option value="purchasing">구매</option>
<option value="design">설계</option>
<option value="sales">영업</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">권한</label>
<select id="editRole" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="user">사용자</option>
<option value="admin">관리자</option>
</select>
</div>
</div>
<div class="flex gap-3 pt-3">
<button type="button" onclick="closeEditModal()" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm">취소</button>
<button type="submit" class="flex-1 px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium"><i class="fas fa-save mr-1"></i>저장</button>
</div>
</form>
</div>
</div>
<!-- 프로젝트 편집 모달 -->
<div id="editProjectModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl max-w-md w-full p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-900">프로젝트 수정</h3>
<button onclick="closeProjectModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<form id="editProjectForm" class="space-y-3">
<input type="hidden" id="editProjectId">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Job No</label>
<input type="text" id="editJobNo" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" required>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">프로젝트명</label>
<input type="text" id="editProjectName" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" required>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">계약일</label>
<input type="date" id="editContractDate" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">납기일</label>
<input type="date" id="editDueDate" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">현장</label>
<input type="text" id="editSite" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">PM</label>
<input type="text" id="editPm" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">상태</label>
<select id="editProjectStatus" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="active">진행중</option>
<option value="completed">완료</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">활성</label>
<select id="editIsActive" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="1">활성</option>
<option value="0">비활성</option>
</select>
</div>
</div>
<div class="flex gap-3 pt-3">
<button type="button" onclick="closeProjectModal()" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm">취소</button>
<button type="submit" class="flex-1 px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium"><i class="fas fa-save mr-1"></i>저장</button>
</div>
</form>
</div>
</div>
<!-- 부서 편집 모달 -->
<div id="editDepartmentModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl max-w-md w-full p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-900">부서 수정</h3>
<button onclick="closeDepartmentModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<form id="editDepartmentForm" class="space-y-3">
<input type="hidden" id="editDeptId">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">부서명</label>
<input type="text" id="editDeptName" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" required>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">상위부서</label>
<select id="editDeptParent" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="">없음 (최상위)</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">설명</label>
<input type="text" id="editDeptDescription" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">표시순서</label>
<input type="number" id="editDeptOrder" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" min="0">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">활성</label>
<select id="editDeptActive" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="1">활성</option>
<option value="0">비활성</option>
</select>
</div>
</div>
<div class="flex gap-3 pt-3">
<button type="button" onclick="closeDepartmentModal()" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm">취소</button>
<button type="submit" class="flex-1 px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium"><i class="fas fa-save mr-1"></i>저장</button>
</div>
</form>
</div>
</div>
<!-- 이슈 카테고리 수정 모달 -->
<div id="editIssueCategoryModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl max-w-md w-full p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-900">카테고리 수정</h3>
<button onclick="closeIssueCategoryModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<form id="editIssueCategoryForm" class="space-y-3">
<input type="hidden" id="editIssueCatId">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">카테고리명</label>
<input type="text" id="editIssueCatName" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" required>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">설명</label>
<input type="text" id="editIssueCatDesc" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">표시순서</label>
<input type="number" id="editIssueCatOrder" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" min="0">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">활성</label>
<select id="editIssueCatActive" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="1">활성</option>
<option value="0">비활성</option>
</select>
</div>
</div>
<div class="flex gap-3 pt-3">
<button type="button" onclick="closeIssueCategoryModal()" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm">취소</button>
<button type="submit" class="flex-1 px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium"><i class="fas fa-save mr-1"></i>저장</button>
</div>
</form>
</div>
</div>
<!-- 이슈 아이템 수정 모달 -->
<div id="editIssueItemModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl max-w-md w-full p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-900">아이템 수정</h3>
<button onclick="closeIssueItemModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<form id="editIssueItemForm" class="space-y-3">
<input type="hidden" id="editIssueItemId">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">카테고리</label>
<select id="editIssueItemCategory" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" required>
<option value="">선택</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">아이템명</label>
<input type="text" id="editIssueItemName" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" required>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">설명</label>
<input type="text" id="editIssueItemDesc" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">심각도</label>
<select id="editIssueItemSeverity" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="low">낮음</option>
<option value="medium">보통</option>
<option value="high">높음</option>
<option value="critical">심각</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">표시순서</label>
<input type="number" id="editIssueItemOrder" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" min="0">
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">활성</label>
<select id="editIssueItemActive" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="1">활성</option>
<option value="0">비활성</option>
</select>
</div>
<div class="flex gap-3 pt-3">
<button type="button" onclick="closeIssueItemModal()" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm">취소</button>
<button type="submit" class="flex-1 px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium"><i class="fas fa-save mr-1"></i>저장</button>
</div>
</form>
</div>
</div>
<!-- 작업장 편집 모달 -->
<div id="editWorkplaceModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl max-w-md w-full p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-900">작업장 수정</h3>
<button onclick="closeWorkplaceModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<form id="editWorkplaceForm" class="space-y-3">
<input type="hidden" id="editWorkplaceId">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">작업장명</label>
<input type="text" id="editWorkplaceName" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" required>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">공장</label>
<select id="editWorkplaceCategory" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="">선택</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">용도</label>
<select id="editWorkplacePurpose" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="">선택</option>
<option value="작업구역">작업구역</option>
<option value="창고">창고</option>
<option value="설비">설비</option>
<option value="휴게시설">휴게시설</option>
</select>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">설명</label>
<input type="text" id="editWorkplaceDesc" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">우선순위</label>
<input type="number" id="editWorkplacePriority" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" min="0">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">활성</label>
<select id="editWorkplaceActive" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="1">활성</option>
<option value="0">비활성</option>
</select>
</div>
</div>
<div class="flex gap-3 pt-3">
<button type="button" onclick="closeWorkplaceModal()" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm">취소</button>
<button type="submit" class="flex-1 px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium"><i class="fas fa-save mr-1"></i>저장</button>
</div>
</form>
</div>
</div>
<!-- 작업장 추가 모달 -->
<div id="addWorkplaceModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl max-w-md w-full p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-900"><i class="fas fa-building text-slate-400 mr-2"></i>작업장 등록</h3>
<button onclick="closeAddWorkplaceModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<form id="addWorkplaceForm" class="space-y-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">작업장명 <span class="text-red-400">*</span></label>
<input type="text" id="newWorkplaceName" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="작업장 이름" required>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">공장</label>
<select id="newWorkplaceCategory" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="">선택</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">용도</label>
<select id="newWorkplacePurpose" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="">선택</option>
<option value="작업구역">작업구역</option>
<option value="창고">창고</option>
<option value="설비">설비</option>
<option value="휴게시설">휴게시설</option>
</select>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">설명</label>
<input type="text" id="newWorkplaceDesc" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="작업장 설명">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">우선순위</label>
<input type="number" id="newWorkplacePriority" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" value="0" min="0">
</div>
<div class="flex gap-3 pt-3">
<button type="button" onclick="closeAddWorkplaceModal()" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm">취소</button>
<button type="submit" class="flex-1 px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium"><i class="fas fa-plus mr-1"></i>추가</button>
</div>
</form>
</div>
</div>
<!-- 구역지도 설정 모달 -->
<div id="layoutMapModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center p-4" style="display:none;">
<div class="bg-white rounded-xl w-full max-w-4xl max-h-[90vh] overflow-y-auto p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-900"><i class="fas fa-map mr-2 text-slate-400"></i>구역지도 설정</h3>
<button onclick="closeLayoutMapModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<!-- 이미지 업로드 -->
<div class="mb-5 p-4 bg-gray-50 rounded-lg">
<h4 class="text-sm font-semibold text-gray-700 mb-3">레이아웃 이미지</h4>
<div id="currentLayoutImage" class="mb-3">
<span class="text-sm text-gray-400">업로드된 이미지가 없습니다</span>
</div>
<div class="flex items-center gap-3">
<input type="file" id="layoutImageFile" accept="image/jpeg,image/png,image/gif,image/webp" onchange="previewLayoutImage(event)" class="text-sm text-gray-500 file:mr-3 file:py-1.5 file:px-3 file:rounded-lg file:border-0 file:text-sm file:bg-slate-100 file:text-slate-700 hover:file:bg-slate-200">
<button onclick="uploadLayoutImage()" class="px-4 py-1.5 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium whitespace-nowrap">
<i class="fas fa-upload mr-1"></i>이미지 업로드
</button>
</div>
</div>
<!-- 캔버스 영역 -->
<div class="mb-5">
<h4 class="text-sm font-semibold text-gray-700 mb-3">영역 그리기 <span class="text-xs text-gray-400 font-normal">(캔버스 위에서 드래그하여 사각형 영역을 그리세요)</span></h4>
<canvas id="regionCanvas" width="800" height="400" style="border:1px solid #e2e8f0; border-radius:8px; cursor:crosshair; max-width:100%;"></canvas>
</div>
<!-- 작업장 선택 + 저장 -->
<div class="mb-5 flex items-center gap-3 flex-wrap">
<select id="regionWorkplaceSelect" class="input-field px-3 py-1.5 rounded-lg text-sm min-w-[200px]">
<option value="">작업장을 선택하세요</option>
</select>
<button onclick="saveRegion()" class="px-4 py-1.5 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700 text-sm font-medium">
<i class="fas fa-save mr-1"></i>영역 저장
</button>
<button onclick="clearCurrentRegion()" class="px-4 py-1.5 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm">
<i class="fas fa-eraser mr-1"></i>초기화
</button>
</div>
<!-- 등록된 영역 목록 -->
<div>
<h4 class="text-sm font-semibold text-gray-700 mb-3">등록된 영역</h4>
<div id="regionList">
<p class="text-sm text-gray-400 text-center py-4">정의된 영역이 없습니다</p>
</div>
</div>
</div>
</div>
<!-- 설비 추가/편집 모달 -->
<div id="equipmentModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl max-w-lg w-full p-6 max-h-[90vh] overflow-y-auto">
<div class="flex items-center justify-between mb-4">
<h3 id="eqModalTitle" class="text-base font-semibold text-gray-900">설비 추가</h3>
<button onclick="closeEquipmentModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<form id="equipmentForm" class="space-y-3">
<input type="hidden" id="eqEditId">
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">관리번호 <span class="text-red-400">*</span></label>
<div class="flex gap-1">
<input type="text" id="eqCode" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="TKP-001" required>
<button type="button" onclick="generateEquipmentCode()" class="px-2 py-1.5 bg-gray-100 hover:bg-gray-200 rounded-lg text-xs text-gray-600 whitespace-nowrap" title="자동생성"><i class="fas fa-magic"></i></button>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">설비명 <span class="text-red-400">*</span></label>
<input type="text" id="eqName" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="설비 이름" required>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">유형</label>
<input type="text" id="eqType" list="eqTypeDatalist" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="유형 선택/입력">
<datalist id="eqTypeDatalist"></datalist>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">상태</label>
<select id="eqStatus" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
<option value="active">가동중</option>
<option value="maintenance">점검중</option>
<option value="inactive">비활성</option>
</select>
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">제조사</label>
<input type="text" id="eqManufacturer" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="제조사">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">모델명</label>
<input type="text" id="eqModel" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="모델명">
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">공급업체</label>
<input type="text" id="eqSupplier" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="공급업체">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">구매가격</label>
<input type="number" id="eqPrice" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="0" min="0">
</div>
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">설치일</label>
<input type="date" id="eqInstallDate" class="input-field w-full px-3 py-1.5 rounded-lg text-sm">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">시리얼번호</label>
<input type="text" id="eqSerial" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="시리얼번호">
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">사양</label>
<input type="text" id="eqSpecs" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="사양 정보">
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">비고</label>
<input type="text" id="eqNotes" class="input-field w-full px-3 py-1.5 rounded-lg text-sm" placeholder="비고">
</div>
<div class="flex gap-3 pt-3">
<button type="button" onclick="closeEquipmentModal()" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg hover:bg-gray-50 text-sm">취소</button>
<button type="submit" class="flex-1 px-4 py-2 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium"><i class="fas fa-save mr-1"></i>저장</button>
</div>
</form>
</div>
</div>
<!-- 설비 상세 모달 -->
<div id="eqDetailModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50 flex items-center justify-center p-4">
<div class="bg-white rounded-xl max-w-2xl w-full max-h-[90vh] overflow-y-auto p-6">
<div class="flex items-center justify-between mb-4">
<h3 class="text-base font-semibold text-gray-900"><i class="fas fa-cog text-slate-400 mr-2"></i><span id="eqDetailTitle">설비 상세</span></h3>
<button onclick="closeEqDetailModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<div id="eqDetailContent"></div>
<!-- 사진 -->
<div class="mt-4 border-t pt-4">
<div class="flex items-center justify-between mb-2">
<h4 class="text-sm font-semibold text-gray-700"><i class="fas fa-camera text-gray-400 mr-1"></i>설비 사진</h4>
<label class="px-2 py-1 bg-gray-100 hover:bg-gray-200 rounded text-xs cursor-pointer">
<i class="fas fa-plus mr-1"></i>사진 추가
<input type="file" id="eqPhotoFile" accept="image/*" onchange="uploadEqPhoto()" class="hidden">
</label>
</div>
<div id="eqPhotoGrid" class="grid grid-cols-4 gap-2">
<p class="text-gray-400 text-xs col-span-4 text-center py-2">사진 없음</p>
</div>
</div>
</div>
</div>
<!-- 사진 확대 모달 -->
<div id="photoViewModal" class="fixed inset-0 bg-black bg-opacity-80 hidden z-[60] flex items-center justify-center p-4 cursor-pointer" onclick="this.classList.add('hidden')">
<img id="photoViewImage" class="max-w-full max-h-[90vh] rounded-lg shadow-2xl">
</div>
<script>
/* ===== Config ===== */
const API_BASE = '/api';
/* ===== Token ===== */
function _cookieGet(n) { const m = document.cookie.match(new RegExp('(?:^|; )' + n + '=([^;]*)')); return m ? decodeURIComponent(m[1]) : null; }
function _cookieRemove(n) { let c = n + '=; path=/; max-age=0'; if (location.hostname.includes('technicalkorea.net')) c += '; domain=.technicalkorea.net'; document.cookie = c; }
function getToken() { return _cookieGet('sso_token') || localStorage.getItem('sso_token') || localStorage.getItem('access_token'); }
function getLoginUrl() {
const h = location.hostname;
if (h.includes('technicalkorea.net')) return location.protocol + '//tkfb.technicalkorea.net/login?redirect=' + encodeURIComponent(location.href);
return location.protocol + '//' + h + ':30000/login?redirect=' + encodeURIComponent(location.href);
}
function decodeToken(t) { try { return JSON.parse(atob(t.split('.')[1].replace(/-/g,'+').replace(/_/g,'/'))); } catch { return null; } }
/* ===== API ===== */
async function api(path, opts = {}) {
const token = getToken();
const res = await fetch(API_BASE + path, { ...opts, headers: { 'Content-Type': 'application/json', 'Authorization': token ? `Bearer ${token}` : '', ...(opts.headers||{}) } });
if (res.status === 401) { location.href = getLoginUrl(); throw new Error('인증 만료'); }
const data = await res.json();
if (!res.ok) throw new Error(data.error || data.detail || '요청 실패');
return data;
}
/* ===== Toast ===== */
function showToast(msg, type = 'success') {
document.querySelector('.toast-message')?.remove();
const el = document.createElement('div');
el.className = `toast-message fixed bottom-4 right-4 px-4 py-3 rounded-lg text-white z-[10000] shadow-lg ${type==='success'?'bg-emerald-500':'bg-red-500'}`;
el.innerHTML = `<i class="fas ${type==='success'?'fa-check-circle':'fa-exclamation-circle'} mr-2"></i>${msg}`;
document.body.appendChild(el);
setTimeout(() => { el.classList.add('opacity-0'); setTimeout(() => el.remove(), 300); }, 3000);
}
/* ===== Helpers ===== */
const DEPT = { production:'생산', quality:'품질', purchasing:'구매', design:'설계', sales:'영업' };
function deptLabel(d) { return DEPT[d] || d || ''; }
/* ===== Tab ===== */
function switchTab(name) {
document.querySelectorAll('[id^="tab-"]').forEach(el => el.classList.add('hidden'));
document.getElementById('tab-' + name)?.classList.remove('hidden');
document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
event.currentTarget.classList.add('active');
// 사이드바 레이아웃 탭에서 main/nav/header 너비 확장
const mainEl = document.querySelector('main');
const navInner = document.getElementById('tabNavInner');
const headerInner = document.getElementById('headerInner');
const wideClass = 'max-w-[1600px]';
const defaultClass = 'max-w-7xl';
if (name === 'workplaces' || name === 'tasks' || name === 'vacations') {
[mainEl, navInner, headerInner].forEach(el => { el.classList.remove(defaultClass); el.classList.add(wideClass); });
} else {
[mainEl, navInner, headerInner].forEach(el => { el.classList.remove(wideClass); el.classList.add(defaultClass); });
}
if (name === 'projects' && !projectsLoaded) loadProjects();
if (name === 'departments' && !departmentsLoaded) loadDepartments();
if (name === 'issueTypes' && !issueTypesLoaded) loadIssueTypes();
if (name === 'workplaces' && !workplacesLoaded) loadWorkplaces();
if (name === 'tasks' && !tasksLoaded) loadTasksTab();
if (name === 'vacations' && !vacationsLoaded) loadVacationsTab();
}
/* ===== Projects CRUD ===== */
let projects = [], projectsLoaded = false;
function formatDate(d) { if (!d) return ''; return d.substring(0, 10); }
function statusBadge(status, isActive) {
if (!isActive || isActive === 0 || isActive === false) return '<span class="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-400">비활성</span>';
if (status === 'completed') return '<span class="px-1.5 py-0.5 rounded text-xs bg-blue-50 text-blue-600">완료</span>';
return '<span class="px-1.5 py-0.5 rounded text-xs bg-emerald-50 text-emerald-600">진행중</span>';
}
async function loadProjects() {
try {
const r = await api('/projects'); projects = r.data || r;
projectsLoaded = true;
displayProjects();
} catch (err) {
document.getElementById('projectList').innerHTML = `<div class="text-red-500 text-center py-6"><i class="fas fa-exclamation-triangle text-xl"></i><p class="text-sm mt-2">${err.message}</p></div>`;
}
}
function displayProjects() {
const c = document.getElementById('projectList');
if (!projects.length) { c.innerHTML = '<p class="text-gray-400 text-center py-4 text-sm">등록된 프로젝트가 없습니다.</p>'; return; }
c.innerHTML = projects.map(p => `
<div class="flex items-center justify-between p-2.5 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-800 truncate"><i class="fas fa-folder mr-1.5 text-gray-400 text-xs"></i>${p.project_name}</div>
<div class="text-xs text-gray-500 flex items-center gap-1.5 mt-0.5 flex-wrap">
<span class="font-mono">${p.job_no}</span>
${p.site?`<span class="px-1.5 py-0.5 rounded bg-amber-50 text-amber-600">${p.site}</span>`:''}
${p.pm?`<span class="px-1.5 py-0.5 rounded bg-slate-50 text-slate-500">${p.pm}</span>`:''}
${statusBadge(p.project_status, p.is_active)}
${p.due_date?`<span class="text-gray-400">${formatDate(p.due_date)}</span>`:''}
</div>
</div>
<div class="flex gap-1 ml-2 flex-shrink-0">
<button onclick="editProject(${p.project_id})" class="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded" title="편집"><i class="fas fa-pen-to-square text-xs"></i></button>
${p.is_active?`<button onclick="deactivateProject(${p.project_id},'${p.project_name.replace(/'/g,"\\'")}')" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded" title="비활성화"><i class="fas fa-ban text-xs"></i></button>`:''}
</div>
</div>`).join('');
}
document.getElementById('addProjectForm').addEventListener('submit', async e => {
e.preventDefault();
try {
await api('/projects', { method: 'POST', body: JSON.stringify({
job_no: document.getElementById('newJobNo').value.trim(),
project_name: document.getElementById('newProjectName').value.trim(),
contract_date: document.getElementById('newContractDate').value || null,
due_date: document.getElementById('newDueDate').value || null,
site: document.getElementById('newSite').value.trim() || null,
pm: document.getElementById('newPm').value.trim() || null
})});
showToast('프로젝트가 추가되었습니다.'); document.getElementById('addProjectForm').reset(); await loadProjects();
} catch(e) { showToast(e.message, 'error'); }
});
function editProject(id) {
const p = projects.find(x => x.project_id === id); if (!p) return;
document.getElementById('editProjectId').value = p.project_id;
document.getElementById('editJobNo').value = p.job_no;
document.getElementById('editProjectName').value = p.project_name;
document.getElementById('editContractDate').value = formatDate(p.contract_date);
document.getElementById('editDueDate').value = formatDate(p.due_date);
document.getElementById('editSite').value = p.site || '';
document.getElementById('editPm').value = p.pm || '';
document.getElementById('editProjectStatus').value = p.project_status || 'active';
document.getElementById('editIsActive').value = p.is_active ? '1' : '0';
document.getElementById('editProjectModal').classList.remove('hidden');
}
function closeProjectModal() { document.getElementById('editProjectModal').classList.add('hidden'); }
document.getElementById('editProjectForm').addEventListener('submit', async e => {
e.preventDefault();
try {
await api(`/projects/${document.getElementById('editProjectId').value}`, { method: 'PUT', body: JSON.stringify({
job_no: document.getElementById('editJobNo').value.trim(),
project_name: document.getElementById('editProjectName').value.trim(),
contract_date: document.getElementById('editContractDate').value || null,
due_date: document.getElementById('editDueDate').value || null,
site: document.getElementById('editSite').value.trim() || null,
pm: document.getElementById('editPm').value.trim() || null,
project_status: document.getElementById('editProjectStatus').value,
is_active: document.getElementById('editIsActive').value === '1'
})});
showToast('수정되었습니다.'); closeProjectModal(); await loadProjects();
} catch(e) { showToast(e.message, 'error'); }
});
async function deactivateProject(id, name) {
if (!confirm(`"${name}" 프로젝트를 비활성화?`)) return;
try { await api(`/projects/${id}`, { method: 'DELETE' }); showToast('프로젝트 비활성화 완료'); await loadProjects(); } catch(e) { showToast(e.message, 'error'); }
}
/* ===== Permission Page Definitions ===== */
const SYSTEM1_PAGES = {
'작업 관리': [
{ key: 's1.dashboard', title: '대시보드', icon: 'fa-chart-line', def: true },
{ key: 's1.work.tbm', title: 'TBM 관리', icon: 'fa-hard-hat', def: true },
{ key: 's1.work.report_create', title: '작업보고서 작성', icon: 'fa-file-pen', def: true },
{ key: 's1.work.analysis', title: '작업 분석', icon: 'fa-magnifying-glass-chart', def: false },
{ key: 's1.work.nonconformity', title: '부적합 현황', icon: 'fa-triangle-exclamation', def: true },
],
'공장 관리': [
{ key: 's1.factory.repair_management', title: '시설설비 관리', icon: 'fa-wrench', def: false },
{ key: 's1.inspection.daily_patrol', title: '일일순회점검', icon: 'fa-clipboard-check', def: false },
{ key: 's1.inspection.checkin', title: '출근 체크', icon: 'fa-fingerprint', def: true },
{ key: 's1.inspection.work_status', title: '근무 현황', icon: 'fa-user-clock', def: false },
],
'안전 관리': [
{ key: 's1.safety.visit_request', title: '출입 신청', icon: 'fa-id-badge', def: true },
{ key: 's1.safety.management', title: '안전 관리', icon: 'fa-fire-extinguisher', def: false },
{ key: 's1.safety.checklist_manage', title: '체크리스트 관리', icon: 'fa-list-check', def: false },
],
'근태 관리': [
{ key: 's1.attendance.my_vacation_info', title: '내 연차 정보', icon: 'fa-umbrella-beach', def: true },
{ key: 's1.attendance.monthly', title: '월간 근태', icon: 'fa-calendar-days', def: true },
{ key: 's1.attendance.vacation_request', title: '휴가 신청', icon: 'fa-paper-plane', def: true },
{ key: 's1.attendance.vacation_management', title: '휴가 관리', icon: 'fa-calendar-check', def: false },
{ key: 's1.attendance.vacation_allocation', title: '휴가 발생 입력', icon: 'fa-calendar-plus', def: false },
{ key: 's1.attendance.annual_overview', title: '연간 휴가 현황', icon: 'fa-chart-pie', def: false },
],
'시스템 관리': [
{ key: 's1.admin.workers', title: '작업자 관리', icon: 'fa-people-group', def: false },
{ key: 's1.admin.projects', title: '프로젝트 관리', icon: 'fa-folder-open', def: false },
{ key: 's1.admin.tasks', title: '작업 관리', icon: 'fa-list-check', def: false },
{ key: 's1.admin.workplaces', title: '작업장 관리', icon: 'fa-warehouse', def: false },
{ key: 's1.admin.equipments', title: '설비 관리', icon: 'fa-gears', def: false },
{ key: 's1.admin.issue_categories', title: '신고 카테고리', icon: 'fa-tags', def: false },
{ key: 's1.admin.attendance_report', title: '출퇴근-보고서 대조', icon: 'fa-scale-balanced', def: false },
]
};
const SYSTEM3_PAGES = {
'메인': [
{ key: 'issues_dashboard', title: '현황판', icon: 'fa-chart-line', def: true },
{ key: 'issues_inbox', title: '수신함', icon: 'fa-inbox', def: true },
{ key: 'issues_management', title: '관리함', icon: 'fa-cog', def: false },
{ key: 'issues_archive', title: '폐기함', icon: 'fa-archive', def: false },
],
'업무': [
{ key: 'daily_work', title: '일일 공수', icon: 'fa-calendar-check', def: false },
{ key: 'projects_manage', title: '프로젝트 관리', icon: 'fa-folder-open', def: false },
],
'보고서': [
{ key: 'reports', title: '보고서', icon: 'fa-chart-bar', def: false },
{ key: 'reports_daily', title: '일일보고서', icon: 'fa-file-excel', def: false },
{ key: 'reports_weekly', title: '주간보고서', icon: 'fa-calendar-week', def: false },
{ key: 'reports_monthly', title: '월간보고서', icon: 'fa-calendar-alt', def: false },
]
};
/* ===== State ===== */
let currentUser = null, users = [], selectedUserId = null, currentPermissions = {};
/* ===== Init ===== */
async function init() {
const token = getToken();
if (!token) { location.href = getLoginUrl(); return; }
const decoded = decodeToken(token);
if (!decoded) { location.href = getLoginUrl(); return; }
currentUser = { id: decoded.user_id||decoded.id, username: decoded.username||decoded.sub, name: decoded.name||decoded.full_name, role: decoded.role||decoded.access_level };
const dn = currentUser.name || currentUser.username;
document.getElementById('headerUserName').textContent = dn;
document.getElementById('headerUserRole').textContent = currentUser.role === 'admin' ? '관리자' : '사용자';
document.getElementById('headerUserAvatar').textContent = dn.charAt(0).toUpperCase();
if (currentUser.role === 'admin') {
document.getElementById('tabNav').classList.remove('hidden');
document.getElementById('adminSection').classList.remove('hidden');
await loadUsers();
} else {
document.getElementById('passwordChangeSection').classList.remove('hidden');
}
setTimeout(() => document.querySelector('.fade-in').classList.add('visible'), 50);
}
/* ===== Users CRUD ===== */
async function loadUsers() {
try {
const r = await api('/users'); users = r.data || r;
displayUsers(); updatePermissionUserSelect();
} catch (err) {
document.getElementById('userList').innerHTML = `<div class="text-red-500 text-center py-6"><i class="fas fa-exclamation-triangle text-xl"></i><p class="text-sm mt-2">${err.message}</p></div>`;
}
}
function displayUsers() {
const c = document.getElementById('userList');
if (!users.length) { c.innerHTML = '<p class="text-gray-400 text-center py-4 text-sm">등록된 사용자가 없습니다.</p>'; return; }
c.innerHTML = users.map(u => `
<div class="flex items-center justify-between p-2.5 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-800 truncate"><i class="fas fa-user mr-1.5 text-gray-400 text-xs"></i>${u.name||u.username}</div>
<div class="text-xs text-gray-500 flex items-center gap-1.5 mt-0.5 flex-wrap">
<span>${u.username}</span>
${u.department?`<span class="px-1.5 py-0.5 rounded bg-green-50 text-green-600">${deptLabel(u.department)}</span>`:''}
<span class="px-1.5 py-0.5 rounded ${u.role==='admin'?'bg-red-50 text-red-600':'bg-slate-50 text-slate-500'}">${u.role==='admin'?'관리자':'사용자'}</span>
${u.is_active===0||u.is_active===false?'<span class="px-1.5 py-0.5 rounded bg-gray-100 text-gray-400">비활성</span>':''}
</div>
</div>
<div class="flex gap-1 ml-2 flex-shrink-0">
<button onclick="editUser(${u.user_id})" class="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded" title="편집"><i class="fas fa-pen-to-square text-xs"></i></button>
<button onclick="resetPassword(${u.user_id},'${u.username}')" class="p-1.5 text-amber-500 hover:text-amber-700 hover:bg-amber-100 rounded" title="비밀번호 초기화"><i class="fas fa-key text-xs"></i></button>
${u.username!=='hyungi'?`<button onclick="deleteUser(${u.user_id},'${u.username}')" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded" title="삭제"><i class="fas fa-trash text-xs"></i></button>`:''}
</div>
</div>`).join('');
}
document.getElementById('addUserForm').addEventListener('submit', async e => {
e.preventDefault();
try {
await api('/users', { method:'POST', body: JSON.stringify({ username: document.getElementById('newUsername').value.trim(), name: document.getElementById('newFullName').value.trim(), password: document.getElementById('newPassword').value, department: document.getElementById('newDepartment').value||null, role: document.getElementById('newRole').value }) });
showToast('사용자가 추가되었습니다.'); document.getElementById('addUserForm').reset(); await loadUsers();
} catch(e) { showToast(e.message,'error'); }
});
function editUser(id) {
const u = users.find(x=>x.user_id===id); if(!u) return;
document.getElementById('editUserId').value=u.user_id; document.getElementById('editUsername').value=u.username;
document.getElementById('editFullName').value=u.name||''; document.getElementById('editDepartment').value=u.department||''; document.getElementById('editRole').value=u.role;
document.getElementById('editUserModal').classList.remove('hidden');
}
function closeEditModal() { document.getElementById('editUserModal').classList.add('hidden'); }
document.getElementById('editUserForm').addEventListener('submit', async e => {
e.preventDefault();
try {
await api(`/users/${document.getElementById('editUserId').value}`, { method:'PUT', body: JSON.stringify({ name: document.getElementById('editFullName').value.trim()||null, department: document.getElementById('editDepartment').value||null, role: document.getElementById('editRole').value }) });
showToast('수정되었습니다.'); closeEditModal(); await loadUsers();
} catch(e) { showToast(e.message,'error'); }
});
async function resetPassword(id, name) {
if (!confirm(`${name}의 비밀번호를 "000000"으로 초기화?`)) return;
try { await api(`/users/${id}/reset-password`,{method:'POST',body:JSON.stringify({new_password:'000000'})}); showToast(`${name} 비밀번호 초기화 완료`); } catch(e) { showToast(e.message,'error'); }
}
async function deleteUser(id, name) {
if (!confirm(`${name}을(를) 비활성화?`)) return;
try { await api(`/users/${id}`,{method:'DELETE'}); showToast('비활성화 완료'); await loadUsers(); } catch(e) { showToast(e.message,'error'); }
}
document.getElementById('changePasswordForm').addEventListener('submit', async e => {
e.preventDefault();
const np = document.getElementById('newPasswordChange').value;
if (np !== document.getElementById('confirmPassword').value) { showToast('비밀번호 불일치','error'); return; }
try {
await api('/users/change-password',{method:'POST',body:JSON.stringify({current_password:document.getElementById('currentPassword').value,new_password:np})});
showToast('비밀번호 변경 완료'); document.getElementById('changePasswordForm').reset();
} catch(e) { showToast(e.message,'error'); }
});
/* ===== Permissions ===== */
function updatePermissionUserSelect() {
const sel = document.getElementById('permissionUserSelect');
sel.innerHTML = '<option value="">사용자 선택</option>';
users.filter(u=>u.role==='user').forEach(u => { const o=document.createElement('option'); o.value=u.user_id; o.textContent=`${u.name||u.username} (${u.username})`; sel.appendChild(o); });
}
document.getElementById('permissionUserSelect').addEventListener('change', async e => {
selectedUserId = e.target.value;
if (selectedUserId) {
await loadUserPermissions(selectedUserId);
renderPermissionGrid();
document.getElementById('permissionPanel').classList.remove('hidden');
document.getElementById('permissionEmpty').classList.add('hidden');
} else {
document.getElementById('permissionPanel').classList.add('hidden');
document.getElementById('permissionEmpty').classList.remove('hidden');
}
});
async function loadUserPermissions(userId) {
// 기본값 세팅
currentPermissions = {};
const allDefs = { ...SYSTEM1_PAGES, ...SYSTEM3_PAGES };
Object.values(allDefs).flat().forEach(p => { currentPermissions[p.key] = p.def; });
try {
const perms = await api(`/users/${userId}/page-permissions`);
(Array.isArray(perms)?perms:[]).forEach(p => { currentPermissions[p.page_name] = !!p.can_access; });
} catch(e) { console.warn('권한 로드 실패:', e); }
}
function renderPermissionGrid() {
renderSystemPerms('s1-perms', SYSTEM1_PAGES, 'blue');
renderSystemPerms('s3-perms', SYSTEM3_PAGES, 'purple');
}
function renderSystemPerms(containerId, pageDef, color) {
const container = document.getElementById(containerId);
let html = '';
Object.entries(pageDef).forEach(([groupName, pages]) => {
const groupId = containerId + '-' + groupName.replace(/\s/g,'');
const allChecked = pages.every(p => currentPermissions[p.key]);
html += `
<div>
<div class="group-header flex items-center justify-between py-2 px-1 rounded" onclick="toggleGroup('${groupId}')">
<div class="flex items-center gap-2">
<i class="fas fa-chevron-down text-xs text-gray-400 transition-transform" id="arrow-${groupId}"></i>
<span class="text-xs font-semibold text-gray-600 uppercase tracking-wide">${groupName}</span>
<span class="text-xs text-gray-400">${pages.length}</span>
</div>
<label class="flex items-center gap-1.5 text-xs text-gray-500 cursor-pointer" onclick="event.stopPropagation()">
<input type="checkbox" ${allChecked?'checked':''} onchange="toggleGroupAll('${groupId}', this.checked)"
class="h-3.5 w-3.5 text-${color}-500 rounded border-gray-300">
<span>전체</span>
</label>
</div>
<div id="${groupId}" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2 mt-1">
${pages.map(p => {
const checked = currentPermissions[p.key] || false;
return `
<label class="perm-item flex items-center gap-2.5 p-2.5 border rounded-lg cursor-pointer ${checked?'checked':'border-gray-200'}" data-group="${groupId}">
<input type="checkbox" id="perm_${p.key}" ${checked?'checked':''} class="h-4 w-4 text-${color}-500 rounded border-gray-300 focus:ring-${color}-400"
onchange="onPermChange(this)">
<i class="fas ${p.icon} text-sm ${checked?`text-${color}-500`:'text-gray-400'}" data-color="${color}"></i>
<span class="text-sm text-gray-700">${p.title}</span>
</label>`;
}).join('')}
</div>
</div>`;
});
container.innerHTML = html;
}
function onPermChange(cb) {
const item = cb.closest('.perm-item');
const icon = item.querySelector('i[data-color]');
const color = icon.dataset.color;
item.classList.toggle('checked', cb.checked);
icon.classList.toggle(`text-${color}-500`, cb.checked);
icon.classList.toggle('text-gray-400', !cb.checked);
// 그룹 전체 체크박스 동기화
const group = item.dataset.group;
const groupCbs = document.querySelectorAll(`[data-group="${group}"] input[type="checkbox"]`);
const allChecked = [...groupCbs].every(c => c.checked);
const groupHeader = document.getElementById(group)?.previousElementSibling;
if (groupHeader) { const gc = groupHeader.querySelector('input[type="checkbox"]'); if(gc) gc.checked = allChecked; }
}
function toggleGroup(groupId) {
const el = document.getElementById(groupId);
const arrow = document.getElementById('arrow-' + groupId);
el.classList.toggle('hidden');
arrow?.classList.toggle('-rotate-90');
}
function toggleGroupAll(groupId, checked) {
document.querySelectorAll(`#${groupId} input[type="checkbox"]`).forEach(cb => {
cb.checked = checked;
onPermChange(cb);
});
}
function toggleSystemAll(prefix, checked) {
const containerId = prefix === 's1' ? 's1-perms' : 's3-perms';
document.querySelectorAll(`#${containerId} input[type="checkbox"]`).forEach(cb => {
cb.checked = checked;
onPermChange(cb);
});
// 그룹 전체 체크박스도 동기화
document.querySelectorAll(`#${containerId} .group-header input[type="checkbox"]`).forEach(cb => cb.checked = checked);
}
// 저장
document.getElementById('savePermissionsBtn').addEventListener('click', async () => {
if (!selectedUserId) return;
const btn = document.getElementById('savePermissionsBtn');
const st = document.getElementById('permissionSaveStatus');
btn.disabled = true; btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>저장 중...';
try {
const allPages = [...Object.values(SYSTEM1_PAGES).flat(), ...Object.values(SYSTEM3_PAGES).flat()];
const permissions = allPages.map(p => {
const cb = document.getElementById('perm_' + p.key);
return { page_name: p.key, can_access: cb ? cb.checked : false };
});
await api('/permissions/bulk-grant', { method:'POST', body: JSON.stringify({ user_id: parseInt(selectedUserId), permissions }) });
st.textContent = '저장 완료'; st.className = 'text-sm text-emerald-600';
showToast('권한이 저장되었습니다.');
setTimeout(() => { st.textContent = ''; }, 3000);
} catch(e) {
st.textContent = e.message; st.className = 'text-sm text-red-500';
showToast('저장 실패: ' + e.message, 'error');
} finally { btn.disabled = false; btn.innerHTML = '<i class="fas fa-save mr-2"></i>권한 저장'; }
});
/* ===== Vacation CRUD ===== */
let vacTypes = [], vacBalances = [], vacationsLoaded = false, vacWorkers = [];
async function loadVacationsTab() {
// 연도 셀렉트 초기화
const sel = document.getElementById('vacYear');
if (sel && !sel.children.length) {
const curYear = new Date().getFullYear();
for (let y = curYear + 1; y >= curYear - 2; y--) {
const o = document.createElement('option');
o.value = y; o.textContent = y + '년';
if (y === curYear) o.selected = true;
sel.appendChild(o);
}
}
await loadVacTypes();
await loadVacWorkers();
await loadVacBalances();
vacationsLoaded = true;
}
async function loadVacTypes() {
try {
const r = await api('/vacations/types?all=true');
vacTypes = r.data || [];
renderVacTypeSidebar();
} catch(e) { console.warn('휴가 유형 로드 실패:', e); }
}
async function loadVacWorkers() {
try {
const r = await api('/workers');
vacWorkers = (r.data || []).filter(w => w.status !== 'inactive');
} catch(e) { console.warn('작업자 로드 실패:', e); }
}
function renderVacTypeSidebar() {
const c = document.getElementById('vacTypeSidebar');
if (!c) return;
if (!vacTypes.length) { c.innerHTML = '<p class="text-gray-400 text-center py-4 text-sm">등록된 유형이 없습니다.</p>'; return; }
c.innerHTML = vacTypes.map(vt => `
<div class="group flex items-center justify-between p-2 rounded-lg ${vt.is_active ? 'bg-gray-50' : 'bg-gray-50 opacity-50'} hover:bg-blue-50 transition-colors">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-800 truncate flex items-center gap-1.5">
${vt.type_name}
${vt.is_system ? '<span class="text-[10px] px-1 py-0.5 rounded bg-blue-50 text-blue-500">시스템</span>' : ''}
${vt.is_special ? '<span class="text-[10px] px-1 py-0.5 rounded bg-purple-50 text-purple-500">특별</span>' : ''}
${!vt.is_active ? '<span class="text-[10px] px-1 py-0.5 rounded bg-gray-100 text-gray-400">비활성</span>' : ''}
</div>
<div class="text-xs text-gray-400 mt-0.5">
${vt.type_code} | 차감 ${vt.deduct_days}일 | 우선순위 ${vt.priority}
</div>
</div>
<div class="flex gap-0.5 ml-1 flex-shrink-0 opacity-0 group-hover:opacity-100 transition-opacity">
<button onclick="editVacType(${vt.id})" class="p-1 text-slate-400 hover:text-slate-600 rounded" title="수정"><i class="fas fa-pen text-[10px]"></i></button>
${!vt.is_system ? `<button onclick="deleteVacType(${vt.id},'${(vt.type_name||'').replace(/'/g,"\\'")}')" class="p-1 text-red-300 hover:text-red-500 rounded" title="비활성화"><i class="fas fa-ban text-[10px]"></i></button>` : ''}
</div>
</div>`).join('');
}
// 유형 모달
function openVacTypeModal(editId) {
document.getElementById('vtEditId').value = '';
document.getElementById('vacTypeForm').reset();
document.getElementById('vtDeductDays').value = '1.0';
document.getElementById('vtPriority').value = '99';
document.getElementById('vacTypeModalTitle').textContent = '휴가 유형 추가';
document.getElementById('vtCode').readOnly = false;
if (editId) {
const vt = vacTypes.find(v => v.id === editId);
if (!vt) return;
document.getElementById('vacTypeModalTitle').textContent = '휴가 유형 수정';
document.getElementById('vtEditId').value = vt.id;
document.getElementById('vtCode').value = vt.type_code;
document.getElementById('vtCode').readOnly = !!vt.is_system;
document.getElementById('vtName').value = vt.type_name;
document.getElementById('vtDeductDays').value = vt.deduct_days;
document.getElementById('vtPriority').value = vt.priority;
document.getElementById('vtDescription').value = vt.description || '';
document.getElementById('vtSpecial').checked = !!vt.is_special;
}
document.getElementById('vacTypeModal').classList.remove('hidden');
}
function closeVacTypeModal() { document.getElementById('vacTypeModal').classList.add('hidden'); }
function editVacType(id) { openVacTypeModal(id); }
document.getElementById('vacTypeForm').addEventListener('submit', async e => {
e.preventDefault();
const editId = document.getElementById('vtEditId').value;
const body = {
type_code: document.getElementById('vtCode').value.trim().toUpperCase(),
type_name: document.getElementById('vtName').value.trim(),
deduct_days: parseFloat(document.getElementById('vtDeductDays').value) || 1.0,
priority: parseInt(document.getElementById('vtPriority').value) || 99,
description: document.getElementById('vtDescription').value.trim() || null,
is_special: document.getElementById('vtSpecial').checked
};
try {
if (editId) {
await api(`/vacations/types/${editId}`, { method: 'PUT', body: JSON.stringify(body) });
showToast('휴가 유형이 수정되었습니다.');
} else {
await api('/vacations/types', { method: 'POST', body: JSON.stringify(body) });
showToast('휴가 유형이 추가되었습니다.');
}
closeVacTypeModal(); await loadVacTypes();
} catch(e) { showToast(e.message, 'error'); }
});
async function deleteVacType(id, name) {
if (!confirm(`"${name}" 유형을 비활성화하시겠습니까?`)) return;
try { await api(`/vacations/types/${id}`, { method: 'DELETE' }); showToast('비활성화되었습니다.'); await loadVacTypes(); }
catch(e) { showToast(e.message, 'error'); }
}
// 연차 배정 테이블
async function loadVacBalances() {
const year = document.getElementById('vacYear')?.value || new Date().getFullYear();
try {
const r = await api(`/vacations/balances/year/${year}`);
vacBalances = r.data || [];
renderVacBalanceTable();
} catch(e) {
document.getElementById('vacBalanceTable').innerHTML = `<div class="text-red-500 text-center py-6"><p class="text-sm">${e.message}</p></div>`;
}
}
function renderVacBalanceTable() {
const c = document.getElementById('vacBalanceTable');
if (!vacBalances.length) {
c.innerHTML = '<div class="text-gray-400 text-center py-8 text-sm"><i class="fas fa-calendar-xmark text-3xl mb-2"></i><p>배정된 연차가 없습니다. "자동 계산" 또는 "개별 배정"을 이용하세요.</p></div>';
return;
}
// 작업자별 그룹핑
const grouped = {};
vacBalances.forEach(b => {
if (!grouped[b.worker_id]) grouped[b.worker_id] = { name: b.worker_name, hire_date: b.hire_date, items: [] };
grouped[b.worker_id].items.push(b);
});
let html = '<table class="w-full text-sm"><thead class="bg-gray-50 sticky top-0"><tr>';
html += '<th class="text-left px-3 py-2 text-xs font-semibold text-gray-500">작업자</th>';
html += '<th class="text-left px-3 py-2 text-xs font-semibold text-gray-500">입사일</th>';
html += '<th class="text-left px-3 py-2 text-xs font-semibold text-gray-500">휴가유형</th>';
html += '<th class="text-center px-3 py-2 text-xs font-semibold text-gray-500">배정</th>';
html += '<th class="text-center px-3 py-2 text-xs font-semibold text-gray-500">사용</th>';
html += '<th class="text-center px-3 py-2 text-xs font-semibold text-gray-500">잔여</th>';
html += '<th class="text-left px-3 py-2 text-xs font-semibold text-gray-500">비고</th>';
html += '<th class="px-3 py-2 text-xs font-semibold text-gray-500 w-16"></th>';
html += '</tr></thead><tbody>';
Object.values(grouped).forEach(g => {
const firstRow = true;
g.items.forEach((b, i) => {
const remaining = parseFloat(b.remaining_days || (b.total_days - b.used_days));
const remClass = remaining <= 0 ? 'text-red-500 font-semibold' : remaining <= 3 ? 'text-amber-500 font-medium' : 'text-emerald-600';
html += `<tr class="border-t border-gray-100 hover:bg-gray-50">`;
if (i === 0) {
html += `<td class="px-3 py-2 font-medium text-gray-800" rowspan="${g.items.length}">${escHtml(g.name)}</td>`;
html += `<td class="px-3 py-2 text-xs text-gray-400" rowspan="${g.items.length}">${g.hire_date ? new Date(g.hire_date).toISOString().substring(0,10) : '-'}</td>`;
}
html += `<td class="px-3 py-2"><span class="px-1.5 py-0.5 rounded text-xs bg-slate-50 text-slate-600">${escHtml(b.type_name)}</span></td>`;
html += `<td class="px-3 py-2 text-center">${b.total_days}</td>`;
html += `<td class="px-3 py-2 text-center">${b.used_days}</td>`;
html += `<td class="px-3 py-2 text-center ${remClass}">${remaining}</td>`;
html += `<td class="px-3 py-2 text-xs text-gray-400 truncate max-w-[150px]" title="${escHtml(b.notes||'')}">${escHtml(b.notes||'')}</td>`;
html += `<td class="px-3 py-2 text-center">
<button onclick="editVacBalance(${b.id})" class="p-1 text-slate-400 hover:text-slate-600" title="수정"><i class="fas fa-pen text-xs"></i></button>
<button onclick="deleteVacBalance(${b.id})" class="p-1 text-red-300 hover:text-red-500" title="삭제"><i class="fas fa-trash-alt text-xs"></i></button>
</td>`;
html += '</tr>';
});
});
html += '</tbody></table>';
c.innerHTML = html;
}
// 자동 계산
async function autoCalcVacation() {
const year = document.getElementById('vacYear')?.value || new Date().getFullYear();
if (!confirm(`${year}년 전체 작업자 연차를 입사일 기준으로 자동 계산합니다.\n기존 배정이 있으면 덮어씁니다. 진행하시겠습니까?`)) return;
try {
const r = await api('/vacations/balances/auto-calculate', { method: 'POST', body: JSON.stringify({ year: parseInt(year) }) });
showToast(`${r.data.count}명 자동 배정 완료`);
await loadVacBalances();
} catch(e) { showToast(e.message, 'error'); }
}
// 개별 배정 모달
function openVacBalanceModal(editId) {
document.getElementById('vbEditId').value = '';
document.getElementById('vacBalanceForm').reset();
document.getElementById('vbTotalDays').value = '0';
document.getElementById('vbUsedDays').value = '0';
document.getElementById('vacBalModalTitle').textContent = '연차 배정';
// 작업자 셀렉트
const wSel = document.getElementById('vbWorker');
wSel.innerHTML = '<option value="">선택</option>';
vacWorkers.forEach(w => { wSel.innerHTML += `<option value="${w.worker_id}">${w.worker_name}</option>`; });
// 유형 셀렉트
const tSel = document.getElementById('vbType');
tSel.innerHTML = '<option value="">선택</option>';
vacTypes.filter(t => t.is_active).forEach(t => { tSel.innerHTML += `<option value="${t.id}">${t.type_name} (${t.type_code})</option>`; });
if (editId) {
const b = vacBalances.find(x => x.id === editId);
if (!b) return;
document.getElementById('vacBalModalTitle').textContent = '배정 수정';
document.getElementById('vbEditId').value = b.id;
wSel.value = b.worker_id;
wSel.disabled = true;
tSel.value = b.vacation_type_id;
tSel.disabled = true;
document.getElementById('vbTotalDays').value = b.total_days;
document.getElementById('vbUsedDays').value = b.used_days;
document.getElementById('vbNotes').value = b.notes || '';
} else {
wSel.disabled = false;
tSel.disabled = false;
}
document.getElementById('vacBalanceModal').classList.remove('hidden');
}
function closeVacBalanceModal() {
document.getElementById('vacBalanceModal').classList.add('hidden');
document.getElementById('vbWorker').disabled = false;
document.getElementById('vbType').disabled = false;
}
function editVacBalance(id) { openVacBalanceModal(id); }
document.getElementById('vacBalanceForm').addEventListener('submit', async e => {
e.preventDefault();
const editId = document.getElementById('vbEditId').value;
try {
if (editId) {
await api(`/vacations/balances/${editId}`, { method: 'PUT', body: JSON.stringify({
total_days: parseFloat(document.getElementById('vbTotalDays').value) || 0,
used_days: parseFloat(document.getElementById('vbUsedDays').value) || 0,
notes: document.getElementById('vbNotes').value.trim() || null
})});
showToast('수정되었습니다.');
} else {
const year = document.getElementById('vacYear')?.value || new Date().getFullYear();
await api('/vacations/balances', { method: 'POST', body: JSON.stringify({
worker_id: parseInt(document.getElementById('vbWorker').value),
vacation_type_id: parseInt(document.getElementById('vbType').value),
year: parseInt(year),
total_days: parseFloat(document.getElementById('vbTotalDays').value) || 0,
used_days: parseFloat(document.getElementById('vbUsedDays').value) || 0,
notes: document.getElementById('vbNotes').value.trim() || null
})});
showToast('배정되었습니다.');
}
closeVacBalanceModal(); await loadVacBalances();
} catch(e) { showToast(e.message, 'error'); }
});
async function deleteVacBalance(id) {
if (!confirm('이 배정을 삭제하시겠습니까?')) return;
try { await api(`/vacations/balances/${id}`, { method: 'DELETE' }); showToast('삭제되었습니다.'); await loadVacBalances(); }
catch(e) { showToast(e.message, 'error'); }
}
/* ===== Tasks CRUD ===== */
let taskWorkTypes = [], allTasks = [], tasksLoaded = false;
let selectedTaskWorkTypeFilter = null;
async function loadTasksTab() {
await loadWorkTypes();
await loadTasks();
tasksLoaded = true;
}
async function loadWorkTypes() {
try {
const r = await api('/tasks/work-types');
taskWorkTypes = r.data || [];
renderWorkTypeSidebar();
populateTaskWorkTypeSelect();
} catch(e) { console.warn('공정 로드 실패:', e); }
}
function renderWorkTypeSidebar() {
const c = document.getElementById('workTypeSidebar');
if (!c) return;
let html = `<div onclick="filterTasksByWorkType(null)" class="flex items-center justify-between p-2 rounded-lg cursor-pointer transition-colors ${selectedTaskWorkTypeFilter === null ? 'bg-blue-50 ring-1 ring-blue-200' : 'hover:bg-gray-50'}">
<span class="text-sm font-medium ${selectedTaskWorkTypeFilter === null ? 'text-blue-700' : 'text-gray-700'}">전체</span>
<span class="text-xs text-gray-400">${allTasks.length}</span>
</div>`;
// 카테고리별 그룹핑
const grouped = {};
taskWorkTypes.forEach(wt => {
const cat = wt.category || '미분류';
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push(wt);
});
Object.keys(grouped).sort().forEach(cat => {
html += `<div class="text-[10px] font-semibold text-gray-400 uppercase tracking-wider mt-3 mb-1 px-2">${cat}</div>`;
grouped[cat].forEach(wt => {
const count = allTasks.filter(t => t.work_type_id === wt.id).length;
const isActive = selectedTaskWorkTypeFilter === wt.id;
html += `<div onclick="filterTasksByWorkType(${wt.id})" class="group flex items-center justify-between p-2 rounded-lg cursor-pointer transition-colors ${isActive ? 'bg-blue-50 ring-1 ring-blue-200' : 'hover:bg-gray-50'}">
<span class="text-sm ${isActive ? 'font-medium text-blue-700' : 'text-gray-700'} truncate">${wt.name}</span>
<div class="flex items-center gap-1">
<span class="text-xs text-gray-400">${count}</span>
<button onclick="event.stopPropagation(); editWorkType(${wt.id})" class="p-0.5 text-gray-300 hover:text-slate-600 opacity-0 group-hover:opacity-100 transition-opacity" title="수정"><i class="fas fa-pen text-[10px]"></i></button>
</div>
</div>`;
});
});
// 미지정 작업 수
const noType = allTasks.filter(t => !t.work_type_id).length;
if (noType > 0) {
html += `<div onclick="filterTasksByWorkType(0)" class="flex items-center justify-between p-2 rounded-lg cursor-pointer transition-colors mt-2 ${selectedTaskWorkTypeFilter === 0 ? 'bg-blue-50 ring-1 ring-blue-200' : 'hover:bg-gray-50'}">
<span class="text-sm text-gray-400 italic">미지정</span>
<span class="text-xs text-gray-400">${noType}</span>
</div>`;
}
c.innerHTML = html;
}
function populateTaskWorkTypeSelect() {
const sel = document.getElementById('taskWorkType');
if (!sel) return;
const val = sel.value;
sel.innerHTML = '<option value="">미지정</option>';
taskWorkTypes.forEach(wt => {
sel.innerHTML += `<option value="${wt.id}">${wt.category ? wt.category + ' > ' : ''}${wt.name}</option>`;
});
sel.value = val;
}
function filterTasksByWorkType(wtId) {
selectedTaskWorkTypeFilter = wtId;
renderWorkTypeSidebar();
displayTasks();
}
async function loadTasks() {
try {
const r = await api('/tasks');
allTasks = r.data || [];
renderWorkTypeSidebar();
displayTasks();
} catch(e) {
document.getElementById('taskList').innerHTML = `<div class="text-red-500 text-center py-6"><i class="fas fa-exclamation-triangle text-xl"></i><p class="text-sm mt-2">${e.message}</p></div>`;
}
}
function displayTasks() {
const c = document.getElementById('taskList');
let filtered = allTasks;
let label = '전체';
if (selectedTaskWorkTypeFilter === 0) {
filtered = allTasks.filter(t => !t.work_type_id);
label = '미지정';
} else if (selectedTaskWorkTypeFilter) {
filtered = allTasks.filter(t => t.work_type_id === selectedTaskWorkTypeFilter);
const wt = taskWorkTypes.find(w => w.id === selectedTaskWorkTypeFilter);
label = wt ? wt.name : '';
}
document.getElementById('taskFilterLabel').textContent = `- ${label}`;
const active = filtered.filter(t => t.is_active).length;
const inactive = filtered.length - active;
document.getElementById('taskStats').textContent = `활성 ${active} / 비활성 ${inactive}`;
if (!filtered.length) { c.innerHTML = '<p class="text-gray-400 text-center py-8 text-sm">등록된 작업이 없습니다.</p>'; return; }
c.innerHTML = filtered.map(t => `
<div class="flex items-center justify-between p-2.5 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-800 truncate">${escHtml(t.task_name)}</div>
<div class="text-xs text-gray-500 flex items-center gap-1.5 mt-0.5 flex-wrap">
${t.work_type_name ? `<span class="px-1.5 py-0.5 rounded bg-slate-50 text-slate-600">${escHtml(t.work_type_name)}</span>` : '<span class="text-gray-300 italic">미지정</span>'}
${t.description ? `<span class="text-gray-400 truncate max-w-[200px]" title="${escHtml(t.description)}">${escHtml(t.description)}</span>` : ''}
${t.is_active ? '<span class="px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-600">활성</span>' : '<span class="px-1.5 py-0.5 rounded bg-gray-100 text-gray-400">비활성</span>'}
</div>
</div>
<div class="flex gap-1 ml-2 flex-shrink-0">
<button onclick="editTask(${t.task_id})" class="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded" title="편집"><i class="fas fa-pen-to-square text-xs"></i></button>
<button onclick="deleteTask(${t.task_id},'${escHtml(t.task_name).replace(/'/g,"\\'")}')" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded" title="삭제"><i class="fas fa-trash-alt text-xs"></i></button>
</div>
</div>`).join('');
}
function escHtml(s) { if (!s) return ''; const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
// 공정 모달
function openWorkTypeModal(editId) {
document.getElementById('wtEditId').value = '';
document.getElementById('workTypeForm').reset();
document.getElementById('workTypeModalTitle').textContent = '공정 추가';
if (editId) {
const wt = taskWorkTypes.find(w => w.id === editId);
if (!wt) return;
document.getElementById('workTypeModalTitle').textContent = '공정 수정';
document.getElementById('wtEditId').value = wt.id;
document.getElementById('wtName').value = wt.name || '';
document.getElementById('wtCategory').value = wt.category || '';
document.getElementById('wtDesc').value = wt.description || '';
}
document.getElementById('workTypeModal').classList.remove('hidden');
}
function closeWorkTypeModal() { document.getElementById('workTypeModal').classList.add('hidden'); }
function editWorkType(id) { openWorkTypeModal(id); }
document.getElementById('workTypeForm').addEventListener('submit', async e => {
e.preventDefault();
const editId = document.getElementById('wtEditId').value;
const body = {
name: document.getElementById('wtName').value.trim(),
category: document.getElementById('wtCategory').value.trim() || null,
description: document.getElementById('wtDesc').value.trim() || null
};
try {
if (editId) {
await api(`/tasks/work-types/${editId}`, { method: 'PUT', body: JSON.stringify(body) });
showToast('공정이 수정되었습니다.');
} else {
await api('/tasks/work-types', { method: 'POST', body: JSON.stringify(body) });
showToast('공정이 추가되었습니다.');
}
closeWorkTypeModal();
await loadWorkTypes();
await loadTasks();
} catch(e) { showToast(e.message, 'error'); }
});
// 작업 모달
function openTaskModal(editId) {
document.getElementById('taskEditId').value = '';
document.getElementById('taskForm').reset();
document.getElementById('taskActive').checked = true;
document.getElementById('taskModalTitle').textContent = '작업 추가';
populateTaskWorkTypeSelect();
// 사이드바 필터 선택된 공정 자동 선택
if (!editId && selectedTaskWorkTypeFilter && selectedTaskWorkTypeFilter !== 0) {
document.getElementById('taskWorkType').value = selectedTaskWorkTypeFilter;
}
if (editId) {
const t = allTasks.find(x => x.task_id === editId);
if (!t) return;
document.getElementById('taskModalTitle').textContent = '작업 수정';
document.getElementById('taskEditId').value = t.task_id;
document.getElementById('taskName').value = t.task_name || '';
document.getElementById('taskWorkType').value = t.work_type_id || '';
document.getElementById('taskDesc').value = t.description || '';
document.getElementById('taskActive').checked = !!t.is_active;
}
document.getElementById('taskModal').classList.remove('hidden');
}
function closeTaskModal() { document.getElementById('taskModal').classList.add('hidden'); }
function editTask(id) { openTaskModal(id); }
document.getElementById('taskForm').addEventListener('submit', async e => {
e.preventDefault();
const editId = document.getElementById('taskEditId').value;
const body = {
task_name: document.getElementById('taskName').value.trim(),
work_type_id: document.getElementById('taskWorkType').value ? parseInt(document.getElementById('taskWorkType').value) : null,
description: document.getElementById('taskDesc').value.trim() || null,
is_active: document.getElementById('taskActive').checked ? 1 : 0
};
try {
if (editId) {
await api(`/tasks/${editId}`, { method: 'PUT', body: JSON.stringify(body) });
showToast('작업이 수정되었습니다.');
} else {
await api('/tasks', { method: 'POST', body: JSON.stringify(body) });
showToast('작업이 추가되었습니다.');
}
closeTaskModal();
await loadTasks();
} catch(e) { showToast(e.message, 'error'); }
});
async function deleteTask(id, name) {
if (!confirm(`"${name}" 작업을 삭제하시겠습니까?`)) return;
try {
await api(`/tasks/${id}`, { method: 'DELETE' });
showToast('작업이 삭제되었습니다.');
await loadTasks();
} catch(e) { showToast(e.message, 'error'); }
}
/* ===== Departments CRUD ===== */
let departments = [], departmentsLoaded = false;
async function loadDepartments() {
try {
const r = await api('/departments'); departments = r.data || r;
departmentsLoaded = true;
populateParentDeptSelects();
displayDepartments();
} catch (err) {
document.getElementById('departmentList').innerHTML = `<div class="text-red-500 text-center py-6"><i class="fas fa-exclamation-triangle text-xl"></i><p class="text-sm mt-2">${err.message}</p></div>`;
}
}
function populateParentDeptSelects() {
['newDeptParent','editDeptParent'].forEach(id => {
const sel = document.getElementById(id); if (!sel) return;
const val = sel.value;
sel.innerHTML = '<option value="">없음 (최상위)</option>';
departments.filter(d => d.is_active !== 0 && d.is_active !== false).forEach(d => {
const o = document.createElement('option'); o.value = d.department_id; o.textContent = d.department_name; sel.appendChild(o);
});
sel.value = val;
});
}
function displayDepartments() {
const c = document.getElementById('departmentList');
if (!departments.length) { c.innerHTML = '<p class="text-gray-400 text-center py-4 text-sm">등록된 부서가 없습니다.</p>'; return; }
c.innerHTML = departments.map(d => `
<div class="flex items-center justify-between p-2.5 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-800 truncate"><i class="fas fa-sitemap mr-1.5 text-gray-400 text-xs"></i>${d.department_name}</div>
<div class="text-xs text-gray-500 flex items-center gap-1.5 mt-0.5 flex-wrap">
${d.parent_name ? `<span class="px-1.5 py-0.5 rounded bg-slate-50 text-slate-500">상위: ${d.parent_name}</span>` : '<span class="px-1.5 py-0.5 rounded bg-indigo-50 text-indigo-500">최상위</span>'}
<span class="text-gray-400">순서: ${d.display_order || 0}</span>
${d.is_active === 0 || d.is_active === false ? '<span class="px-1.5 py-0.5 rounded bg-gray-100 text-gray-400">비활성</span>' : '<span class="px-1.5 py-0.5 rounded bg-emerald-50 text-emerald-600">활성</span>'}
</div>
</div>
<div class="flex gap-1 ml-2 flex-shrink-0">
<button onclick="editDepartment(${d.department_id})" class="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded" title="편집"><i class="fas fa-pen-to-square text-xs"></i></button>
${d.is_active !== 0 && d.is_active !== false ? `<button onclick="deactivateDepartment(${d.department_id},'${(d.department_name||'').replace(/'/g,"\\'")}')" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded" title="비활성화"><i class="fas fa-ban text-xs"></i></button>` : ''}
</div>
</div>`).join('');
}
document.getElementById('addDepartmentForm').addEventListener('submit', async e => {
e.preventDefault();
try {
await api('/departments', { method: 'POST', body: JSON.stringify({
department_name: document.getElementById('newDeptName').value.trim(),
parent_id: document.getElementById('newDeptParent').value ? parseInt(document.getElementById('newDeptParent').value) : null,
description: document.getElementById('newDeptDescription').value.trim() || null,
display_order: parseInt(document.getElementById('newDeptOrder').value) || 0
})});
showToast('부서가 추가되었습니다.'); document.getElementById('addDepartmentForm').reset(); await loadDepartments();
} catch(e) { showToast(e.message, 'error'); }
});
function editDepartment(id) {
const d = departments.find(x => x.department_id === id); if (!d) return;
document.getElementById('editDeptId').value = d.department_id;
document.getElementById('editDeptName').value = d.department_name;
document.getElementById('editDeptDescription').value = d.description || '';
document.getElementById('editDeptOrder').value = d.display_order || 0;
document.getElementById('editDeptActive').value = (d.is_active === 0 || d.is_active === false) ? '0' : '1';
populateParentDeptSelects();
document.getElementById('editDeptParent').value = d.parent_id || '';
document.getElementById('editDepartmentModal').classList.remove('hidden');
}
function closeDepartmentModal() { document.getElementById('editDepartmentModal').classList.add('hidden'); }
document.getElementById('editDepartmentForm').addEventListener('submit', async e => {
e.preventDefault();
try {
await api(`/departments/${document.getElementById('editDeptId').value}`, { method: 'PUT', body: JSON.stringify({
department_name: document.getElementById('editDeptName').value.trim(),
parent_id: document.getElementById('editDeptParent').value ? parseInt(document.getElementById('editDeptParent').value) : null,
description: document.getElementById('editDeptDescription').value.trim() || null,
display_order: parseInt(document.getElementById('editDeptOrder').value) || 0,
is_active: document.getElementById('editDeptActive').value === '1'
})});
showToast('수정되었습니다.'); closeDepartmentModal(); await loadDepartments();
await loadDepartmentsForSelect();
} catch(e) { showToast(e.message, 'error'); }
});
async function deactivateDepartment(id, name) {
if (!confirm(`"${name}" 부서를 비활성화?`)) return;
try { await api(`/departments/${id}`, { method: 'DELETE' }); showToast('부서 비활성화 완료'); await loadDepartments(); } catch(e) { showToast(e.message, 'error'); }
}
/* ===== Issue Types CRUD ===== */
let issueCategories = [], issueItems = [], issueTypesLoaded = false;
let currentIssueType = 'nonconformity';
function severityBadge(severity) {
const colors = { low: 'bg-gray-100 text-gray-600', medium: 'bg-yellow-100 text-yellow-700', high: 'bg-orange-100 text-orange-700', critical: 'bg-red-100 text-red-700' };
const labels = { low: '낮음', medium: '보통', high: '높음', critical: '심각' };
return `<span class="px-1.5 py-0.5 rounded text-xs ${colors[severity]||''}">${labels[severity]||severity}</span>`;
}
async function loadIssueTypes() {
try {
const [catRes, itemRes] = await Promise.all([
api('/work-issues/categories'),
api('/work-issues/items')
]);
issueCategories = catRes.data || catRes;
issueItems = itemRes.data || itemRes;
issueTypesLoaded = true;
populateIssueCategorySelect();
displayIssueCategories();
} catch (err) {
document.getElementById('issueCategoryList').innerHTML = `<div class="text-red-500 text-center py-6"><i class="fas fa-exclamation-triangle text-xl"></i><p class="text-sm mt-2">${err.message}</p></div>`;
}
}
function switchIssueType(type) {
currentIssueType = type;
['nonconformity','safety','facility'].forEach(t => {
const btn = document.getElementById('issueTypeToggle' + t.charAt(0).toUpperCase() + t.slice(1));
if (btn) btn.className = 'px-3 py-1 rounded-md text-xs font-medium ' + (type === t ? 'bg-slate-700 text-white' : 'text-gray-500 hover:bg-gray-200');
});
displayIssueCategories();
}
function populateIssueCategorySelect() {
['newIssueItemCategory', 'editIssueItemCategory'].forEach(id => {
const sel = document.getElementById(id); if (!sel) return;
const val = sel.value;
sel.innerHTML = '<option value="">선택</option>';
issueCategories.filter(c => c.is_active !== 0 && c.is_active !== false).sort((a,b) => (a.display_order||0) - (b.display_order||0)).forEach(c => {
const typeLabel = c.category_type === 'nonconformity' ? '[부적합]' : c.category_type === 'safety' ? '[안전]' : '[시설설비]';
const o = document.createElement('option'); o.value = c.category_id; o.textContent = `${typeLabel} ${c.category_name}`; sel.appendChild(o);
});
sel.value = val;
});
}
function displayIssueCategories() {
const c = document.getElementById('issueCategoryList');
const filtered = issueCategories.filter(cat => cat.category_type === currentIssueType).sort((a,b) => (a.display_order||0) - (b.display_order||0));
if (!filtered.length) { c.innerHTML = '<p class="text-gray-400 text-center py-4 text-sm">등록된 카테고리가 없습니다.</p>'; return; }
c.innerHTML = filtered.map(cat => {
const items = issueItems.filter(it => it.category_id === cat.category_id).sort((a,b) => (a.display_order||0) - (b.display_order||0));
const isInactive = cat.is_active === 0 || cat.is_active === false;
return `
<div class="border rounded-lg ${isInactive ? 'opacity-60' : ''}">
<div class="group-header flex items-center justify-between p-3 rounded-t-lg" onclick="this.nextElementSibling.classList.toggle('hidden'); this.querySelector('.chevron').classList.toggle('fa-chevron-down'); this.querySelector('.chevron').classList.toggle('fa-chevron-right');">
<div class="flex items-center gap-2">
<i class="fas fa-chevron-down chevron text-gray-400 text-xs w-3"></i>
<span class="text-sm font-medium text-gray-800">${cat.category_name}</span>
<span class="px-1.5 py-0.5 rounded text-xs ${items.length > 0 ? 'bg-blue-50 text-blue-600' : 'bg-gray-50 text-gray-400'}">${items.length}개</span>
${isInactive ? '<span class="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-400">비활성</span>' : ''}
</div>
<div class="flex gap-1" onclick="event.stopPropagation()">
<button onclick="editIssueCategory(${cat.category_id})" class="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded" title="편집"><i class="fas fa-pen-to-square text-xs"></i></button>
<button onclick="deleteIssueCategory(${cat.category_id},'${(cat.category_name||'').replace(/'/g,"\\'")}')" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded" title="삭제"><i class="fas fa-trash text-xs"></i></button>
</div>
</div>
<div class="border-t">
${items.length ? items.map(it => {
const itemInactive = it.is_active === 0 || it.is_active === false;
return `
<div class="flex items-center justify-between px-4 py-2 hover:bg-gray-50 ${itemInactive ? 'opacity-60' : ''}">
<div class="flex items-center gap-2 min-w-0">
<span class="text-gray-300 text-xs">├</span>
<span class="text-sm text-gray-700 truncate">${it.item_name}</span>
${severityBadge(it.severity)}
${itemInactive ? '<span class="px-1.5 py-0.5 rounded text-xs bg-gray-100 text-gray-400">비활성</span>' : ''}
</div>
<div class="flex gap-1 flex-shrink-0">
<button onclick="editIssueItem(${it.item_id})" class="p-1 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded" title="편집"><i class="fas fa-pen-to-square text-xs"></i></button>
<button onclick="deleteIssueItem(${it.item_id},'${(it.item_name||'').replace(/'/g,"\\'")}')" class="p-1 text-red-400 hover:text-red-600 hover:bg-red-100 rounded" title="삭제"><i class="fas fa-trash text-xs"></i></button>
</div>
</div>`;
}).join('') : '<p class="text-gray-400 text-center py-3 text-xs">아이템 없음</p>'}
</div>
</div>`;
}).join('');
}
document.getElementById('addIssueCategoryForm').addEventListener('submit', async e => {
e.preventDefault();
const type = document.querySelector('input[name="newIssueCatType"]:checked').value;
try {
await api('/work-issues/categories', { method: 'POST', body: JSON.stringify({
category_name: document.getElementById('newIssueCatName').value.trim(),
category_type: type,
description: document.getElementById('newIssueCatDesc').value.trim() || null,
display_order: parseInt(document.getElementById('newIssueCatOrder').value) || 0
})});
showToast('카테고리가 추가되었습니다.');
document.getElementById('addIssueCategoryForm').reset();
await loadIssueTypes();
} catch(e) { showToast(e.message, 'error'); }
});
document.getElementById('addIssueItemForm').addEventListener('submit', async e => {
e.preventDefault();
try {
await api('/work-issues/items', { method: 'POST', body: JSON.stringify({
category_id: parseInt(document.getElementById('newIssueItemCategory').value),
item_name: document.getElementById('newIssueItemName').value.trim(),
description: document.getElementById('newIssueItemDesc').value.trim() || null,
severity: document.getElementById('newIssueItemSeverity').value,
display_order: parseInt(document.getElementById('newIssueItemOrder').value) || 0
})});
showToast('아이템이 추가되었습니다.');
document.getElementById('addIssueItemForm').reset();
await loadIssueTypes();
} catch(e) { showToast(e.message, 'error'); }
});
function editIssueCategory(id) {
const cat = issueCategories.find(c => c.category_id === id); if (!cat) return;
document.getElementById('editIssueCatId').value = cat.category_id;
document.getElementById('editIssueCatName').value = cat.category_name;
document.getElementById('editIssueCatDesc').value = cat.description || '';
document.getElementById('editIssueCatOrder').value = cat.display_order || 0;
document.getElementById('editIssueCatActive').value = (cat.is_active === 0 || cat.is_active === false) ? '0' : '1';
document.getElementById('editIssueCategoryModal').classList.remove('hidden');
}
function closeIssueCategoryModal() { document.getElementById('editIssueCategoryModal').classList.add('hidden'); }
document.getElementById('editIssueCategoryForm').addEventListener('submit', async e => {
e.preventDefault();
try {
await api(`/work-issues/categories/${document.getElementById('editIssueCatId').value}`, { method: 'PUT', body: JSON.stringify({
category_name: document.getElementById('editIssueCatName').value.trim(),
description: document.getElementById('editIssueCatDesc').value.trim() || null,
display_order: parseInt(document.getElementById('editIssueCatOrder').value) || 0,
is_active: document.getElementById('editIssueCatActive').value === '1'
})});
showToast('카테고리가 수정되었습니다.'); closeIssueCategoryModal(); await loadIssueTypes();
} catch(e) { showToast(e.message, 'error'); }
});
async function deleteIssueCategory(id, name) {
const items = issueItems.filter(it => it.category_id === id);
if (items.length > 0) { showToast(`"${name}" 카테고리에 ${items.length}개 아이템이 있어 삭제할 수 없습니다. 아이템을 먼저 삭제하세요.`, 'error'); return; }
if (!confirm(`"${name}" 카테고리를 삭제하시겠습니까?`)) return;
try { await api(`/work-issues/categories/${id}`, { method: 'DELETE' }); showToast('카테고리가 삭제되었습니다.'); await loadIssueTypes(); } catch(e) { showToast(e.message, 'error'); }
}
function editIssueItem(id) {
const it = issueItems.find(x => x.item_id === id); if (!it) return;
populateIssueCategorySelect();
document.getElementById('editIssueItemId').value = it.item_id;
document.getElementById('editIssueItemCategory').value = it.category_id;
document.getElementById('editIssueItemName').value = it.item_name;
document.getElementById('editIssueItemDesc').value = it.description || '';
document.getElementById('editIssueItemSeverity').value = it.severity || 'medium';
document.getElementById('editIssueItemOrder').value = it.display_order || 0;
document.getElementById('editIssueItemActive').value = (it.is_active === 0 || it.is_active === false) ? '0' : '1';
document.getElementById('editIssueItemModal').classList.remove('hidden');
}
function closeIssueItemModal() { document.getElementById('editIssueItemModal').classList.add('hidden'); }
document.getElementById('editIssueItemForm').addEventListener('submit', async e => {
e.preventDefault();
try {
await api(`/work-issues/items/${document.getElementById('editIssueItemId').value}`, { method: 'PUT', body: JSON.stringify({
category_id: parseInt(document.getElementById('editIssueItemCategory').value),
item_name: document.getElementById('editIssueItemName').value.trim(),
description: document.getElementById('editIssueItemDesc').value.trim() || null,
severity: document.getElementById('editIssueItemSeverity').value,
display_order: parseInt(document.getElementById('editIssueItemOrder').value) || 0,
is_active: document.getElementById('editIssueItemActive').value === '1'
})});
showToast('아이템이 수정되었습니다.'); closeIssueItemModal(); await loadIssueTypes();
} catch(e) { showToast(e.message, 'error'); }
});
async function deleteIssueItem(id, name) {
if (!confirm(`"${name}" 아이템을 삭제하시겠습니까?`)) return;
try { await api(`/work-issues/items/${id}`, { method: 'DELETE' }); showToast('아이템이 삭제되었습니다.'); await loadIssueTypes(); } catch(e) { showToast(e.message, 'error'); }
}
/* ===== Workplaces CRUD ===== */
let workplaces = [], workplacesLoaded = false, workplaceCategories = [];
let selectedWorkplaceId = null, selectedWorkplaceName = '';
let equipments = [], equipmentTypes = [];
let wpNavLevel = 'categories'; // 'categories' | 'workplaces'
let wpNavCategoryId = null;
let wpNavCategoryName = '';
let previewMapRegions = [];
function purposeBadge(p) {
const colors = { '작업구역': 'bg-blue-50 text-blue-600', '창고': 'bg-amber-50 text-amber-600', '설비': 'bg-purple-50 text-purple-600', '휴게시설': 'bg-green-50 text-green-600' };
return p ? `<span class="px-1.5 py-0.5 rounded text-xs ${colors[p] || 'bg-gray-50 text-gray-500'}">${p}</span>` : '';
}
async function loadWorkplaceCategories() {
try {
const r = await api('/workplaces/categories'); workplaceCategories = r.data || r;
populateCategorySelects();
renderSidebar();
} catch(e) { console.warn('카테고리 로드 실패:', e); }
}
function populateCategorySelects() {
['newWorkplaceCategory','editWorkplaceCategory'].forEach(id => {
const sel = document.getElementById(id); if (!sel) return;
const val = sel.value;
sel.innerHTML = '<option value="">선택</option>';
workplaceCategories.forEach(c => { const o = document.createElement('option'); o.value = c.category_id; o.textContent = c.category_name; sel.appendChild(o); });
sel.value = val;
});
}
async function loadWorkplaces() {
await loadWorkplaceCategories();
try {
const r = await api('/workplaces'); workplaces = r.data || r;
workplacesLoaded = true;
renderSidebar();
} catch (err) {
document.getElementById('wpSidebarContent').innerHTML = `<div class="text-red-500 text-center py-6"><i class="fas fa-exclamation-triangle text-xl"></i><p class="text-sm mt-2">${err.message}</p></div>`;
}
}
function renderSidebar() {
const c = document.getElementById('wpSidebarContent');
if (!c) return;
let html = '';
if (wpNavLevel === 'categories') {
// 공장 목록 레벨
html += '<div class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-2">공장 선택</div>';
html += `<button onclick="openAddWorkplaceModal()" class="w-full px-3 py-2 mb-3 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium flex items-center justify-center gap-1.5"><i class="fas fa-plus text-xs"></i>작업장 추가</button>`;
if (!workplaceCategories.length) {
html += '<p class="text-gray-400 text-center py-4 text-sm">등록된 공장이 없습니다.</p>';
} else {
html += '<div class="space-y-1.5 flex-1 overflow-y-auto">';
workplaceCategories.forEach(cat => {
const count = workplaces.filter(w => w.category_id == cat.category_id).length;
html += `<div onclick="drillIntoCategory(${cat.category_id},'${(cat.category_name||'').replace(/'/g,"\\'")}')" class="flex items-center justify-between p-2.5 rounded-lg hover:bg-blue-50 transition-colors cursor-pointer bg-gray-50">
<div class="flex items-center gap-2 min-w-0">
<i class="fas fa-industry text-gray-400 text-sm"></i>
<span class="text-sm font-medium text-gray-800 truncate">${cat.category_name}</span>
</div>
<div class="flex items-center gap-1.5 flex-shrink-0">
<span class="text-xs text-gray-400">${count}</span>
<i class="fas fa-chevron-right text-gray-300 text-xs"></i>
</div>
</div>`;
});
// 미분류 작업장
const uncategorized = workplaces.filter(w => !w.category_id);
if (uncategorized.length) {
html += `<div onclick="drillIntoCategory(0,'미분류')" class="flex items-center justify-between p-2.5 rounded-lg hover:bg-blue-50 transition-colors cursor-pointer bg-gray-50">
<div class="flex items-center gap-2 min-w-0">
<i class="fas fa-folder-open text-gray-300 text-sm"></i>
<span class="text-sm font-medium text-gray-500 truncate">미분류</span>
</div>
<div class="flex items-center gap-1.5 flex-shrink-0">
<span class="text-xs text-gray-400">${uncategorized.length}</span>
<i class="fas fa-chevron-right text-gray-300 text-xs"></i>
</div>
</div>`;
}
html += '</div>';
}
} else {
// 작업장 목록 레벨 (특정 공장 내)
html += `<button onclick="backToCategories()" class="flex items-center gap-1.5 text-sm text-slate-600 hover:text-slate-800 mb-2 px-1 py-1 rounded hover:bg-gray-100 transition-colors"><i class="fas fa-arrow-left text-xs"></i>전체 공장</button>`;
html += `<div class="text-sm font-semibold text-gray-800 mb-2 px-1"><i class="fas fa-industry text-gray-400 mr-1.5"></i>${wpNavCategoryName}</div>`;
html += `<button onclick="openAddWorkplaceModal()" class="w-full px-3 py-2 mb-3 bg-slate-700 text-white rounded-lg hover:bg-slate-800 text-sm font-medium flex items-center justify-center gap-1.5"><i class="fas fa-plus text-xs"></i>작업장 추가</button>`;
const filtered = wpNavCategoryId === 0
? workplaces.filter(w => !w.category_id)
: workplaces.filter(w => w.category_id == wpNavCategoryId);
if (!filtered.length) {
html += '<p class="text-gray-400 text-center py-4 text-sm">등록된 작업장이 없습니다.</p>';
} else {
html += '<div class="space-y-1.5 flex-1 overflow-y-auto">';
filtered.forEach(w => {
html += `<div class="flex items-center justify-between p-2.5 rounded-lg hover:bg-blue-50 transition-colors cursor-pointer ${selectedWorkplaceId === w.workplace_id ? 'bg-blue-50 ring-1 ring-blue-200' : 'bg-gray-50'}" onclick="selectWorkplaceForEquipments(${w.workplace_id},'${(w.workplace_name||'').replace(/'/g,"\\'")}')">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-800 truncate"><i class="fas fa-building mr-1.5 text-gray-400 text-xs"></i>${w.workplace_name}</div>
<div class="text-xs text-gray-500 flex items-center gap-1.5 mt-0.5 flex-wrap">
${purposeBadge(w.workplace_purpose)}
${w.is_active === 0 || w.is_active === false ? '<span class="px-1.5 py-0.5 rounded bg-gray-100 text-gray-400">비활성</span>' : ''}
</div>
</div>
<div class="flex gap-1 ml-2 flex-shrink-0">
<button onclick="event.stopPropagation(); editWorkplace(${w.workplace_id})" class="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded" title="편집"><i class="fas fa-pen-to-square text-xs"></i></button>
${w.is_active !== 0 && w.is_active !== false ? `<button onclick="event.stopPropagation(); deactivateWorkplace(${w.workplace_id},'${(w.workplace_name||'').replace(/'/g,"\\'")}')" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded" title="비활성화"><i class="fas fa-ban text-xs"></i></button>` : ''}
</div>
</div>`;
});
html += '</div>';
}
}
c.innerHTML = html;
}
function drillIntoCategory(categoryId, categoryName) {
wpNavLevel = 'workplaces';
wpNavCategoryId = categoryId;
wpNavCategoryName = categoryName;
selectedWorkplaceId = null;
renderSidebar();
showZoneMapForCategory(categoryId);
}
function backToCategories() {
wpNavLevel = 'categories';
wpNavCategoryId = null;
wpNavCategoryName = '';
selectedWorkplaceId = null;
renderSidebar();
showEmptyState();
}
function showEmptyState() {
document.getElementById('workplaceEmptyState')?.classList.remove('hidden');
document.getElementById('equipmentSection')?.classList.add('hidden');
document.getElementById('zoneMapSection')?.classList.add('hidden');
}
function showZoneMapForCategory(categoryId) {
document.getElementById('workplaceEmptyState')?.classList.add('hidden');
document.getElementById('equipmentSection')?.classList.add('hidden');
document.getElementById('zoneMapSection')?.classList.remove('hidden');
const catName = categoryId === 0 ? '미분류' : (workplaceCategories.find(c => c.category_id == categoryId)?.category_name || '');
document.getElementById('zoneMapTitle').innerHTML = `<i class="fas fa-map text-slate-400 mr-2"></i>${catName} - 구역지도`;
selectedMapCategoryId = categoryId;
if (categoryId === 0) {
document.getElementById('layoutPreviewArea').classList.remove('hidden');
document.getElementById('layoutPreviewCanvas').classList.add('hidden');
document.getElementById('layoutPreviewArea').innerHTML = '<i class="fas fa-info-circle text-2xl mb-2"></i><p>미분류 작업장에는 구역지도가 없습니다.</p>';
return;
}
loadLayoutPreview(categoryId);
}
function backToCategory() {
if (!wpNavCategoryId && wpNavCategoryId !== 0) { backToCategories(); return; }
selectedWorkplaceId = null;
renderSidebar();
showZoneMapForCategory(wpNavCategoryId);
}
function openAddWorkplaceModal() {
populateCategorySelects();
document.getElementById('addWorkplaceForm').reset();
// 공장 드릴다운 상태이면 카테고리 자동 선택
if (wpNavLevel === 'workplaces' && wpNavCategoryId && wpNavCategoryId !== 0) {
document.getElementById('newWorkplaceCategory').value = wpNavCategoryId;
}
document.getElementById('addWorkplaceModal').classList.remove('hidden');
}
function closeAddWorkplaceModal() { document.getElementById('addWorkplaceModal').classList.add('hidden'); }
document.getElementById('addWorkplaceForm').addEventListener('submit', async e => {
e.preventDefault();
try {
await api('/workplaces', { method: 'POST', body: JSON.stringify({
workplace_name: document.getElementById('newWorkplaceName').value.trim(),
category_id: document.getElementById('newWorkplaceCategory').value ? parseInt(document.getElementById('newWorkplaceCategory').value) : null,
workplace_purpose: document.getElementById('newWorkplacePurpose').value || null,
description: document.getElementById('newWorkplaceDesc').value.trim() || null,
display_priority: parseInt(document.getElementById('newWorkplacePriority').value) || 0
})});
showToast('작업장이 추가되었습니다.'); document.getElementById('addWorkplaceForm').reset(); closeAddWorkplaceModal(); await loadWorkplaces();
} catch(e) { showToast(e.message, 'error'); }
});
function editWorkplace(id) {
const w = workplaces.find(x => x.workplace_id === id); if (!w) return;
document.getElementById('editWorkplaceId').value = w.workplace_id;
document.getElementById('editWorkplaceName').value = w.workplace_name;
document.getElementById('editWorkplaceDesc').value = w.description || '';
document.getElementById('editWorkplacePriority').value = w.display_priority || 0;
document.getElementById('editWorkplaceActive').value = (w.is_active === 0 || w.is_active === false) ? '0' : '1';
document.getElementById('editWorkplacePurpose').value = w.workplace_purpose || '';
populateCategorySelects();
document.getElementById('editWorkplaceCategory').value = w.category_id || '';
document.getElementById('editWorkplaceModal').classList.remove('hidden');
}
function closeWorkplaceModal() { document.getElementById('editWorkplaceModal').classList.add('hidden'); }
document.getElementById('editWorkplaceForm').addEventListener('submit', async e => {
e.preventDefault();
try {
await api(`/workplaces/${document.getElementById('editWorkplaceId').value}`, { method: 'PUT', body: JSON.stringify({
workplace_name: document.getElementById('editWorkplaceName').value.trim(),
category_id: document.getElementById('editWorkplaceCategory').value ? parseInt(document.getElementById('editWorkplaceCategory').value) : null,
workplace_purpose: document.getElementById('editWorkplacePurpose').value || null,
description: document.getElementById('editWorkplaceDesc').value.trim() || null,
display_priority: parseInt(document.getElementById('editWorkplacePriority').value) || 0,
is_active: document.getElementById('editWorkplaceActive').value === '1'
})});
showToast('수정되었습니다.'); closeWorkplaceModal(); await loadWorkplaces();
} catch(e) { showToast(e.message, 'error'); }
});
async function deactivateWorkplace(id, name) {
if (!confirm(`"${name}" 작업장을 비활성화?`)) return;
try { await api(`/workplaces/${id}`, { method: 'DELETE' }); showToast('작업장 비활성화 완료'); await loadWorkplaces(); } catch(e) { showToast(e.message, 'error'); }
}
/* ===== Equipment CRUD ===== */
let eqMapImg = null, eqMapCanvas = null, eqMapCtx = null, eqDetailEqId = null;
function eqStatusBadge(status) {
const map = { active:'bg-emerald-50 text-emerald-600', maintenance:'bg-amber-50 text-amber-600', inactive:'bg-gray-100 text-gray-500', external:'bg-blue-50 text-blue-600', repair_external:'bg-blue-50 text-blue-600', repair_needed:'bg-red-50 text-red-600' };
const labels = { active:'가동중', maintenance:'점검중', inactive:'비활성', external:'외부반출', repair_external:'수리외주', repair_needed:'수리필요' };
return `<span class="px-1.5 py-0.5 rounded text-xs ${map[status] || 'bg-gray-100 text-gray-500'}">${labels[status] || status || ''}</span>`;
}
function selectWorkplaceForEquipments(id, name) {
selectedWorkplaceId = id;
selectedWorkplaceName = name;
// 카테고리 레벨에서 직접 호출된 경우, 해당 카테고리로 드릴인
if (wpNavLevel === 'categories') {
const wp = workplaces.find(w => w.workplace_id === id);
if (wp && wp.category_id) {
wpNavLevel = 'workplaces';
wpNavCategoryId = wp.category_id;
wpNavCategoryName = wp.category_name || '';
}
}
renderSidebar();
document.getElementById('workplaceEmptyState')?.classList.add('hidden');
document.getElementById('zoneMapSection')?.classList.add('hidden');
document.getElementById('equipmentSection').classList.remove('hidden');
document.getElementById('eqWorkplaceName').textContent = name;
// 뒤로가기 버튼 표시 (공장 구역지도로 돌아가기)
const backBtn = document.getElementById('eqBackToCategory');
if (backBtn && wpNavCategoryId !== null) {
document.getElementById('eqBackLabel').textContent = `${wpNavCategoryName} 구역지도`;
backBtn.classList.remove('hidden');
} else if (backBtn) {
backBtn.classList.add('hidden');
}
loadEquipments();
loadEquipmentTypes();
loadEqMap();
}
async function loadEquipments() {
try {
const r = await api(`/equipments/workplace/${selectedWorkplaceId}`);
equipments = r.data || [];
displayEquipments();
drawEqMapEquipments();
} catch(e) {
document.getElementById('equipmentList').innerHTML = `<div class="text-red-500 text-center py-4"><p class="text-sm">${e.message}</p></div>`;
}
}
function displayEquipments() {
const statusFilter = document.getElementById('eqStatusFilter').value;
const typeFilter = document.getElementById('eqTypeFilter').value;
let filtered = equipments;
if (statusFilter) filtered = filtered.filter(e => e.status === statusFilter);
if (typeFilter) filtered = filtered.filter(e => e.equipment_type === typeFilter);
const c = document.getElementById('equipmentList');
if (!filtered.length) { c.innerHTML = '<p class="text-gray-400 text-center py-4 text-sm">설비가 없습니다.</p>'; return; }
c.innerHTML = filtered.map(e => `
<div class="flex items-center justify-between p-2.5 bg-gray-50 rounded-lg hover:bg-gray-100 transition-colors cursor-pointer" onclick="openEqDetailModal(${e.equipment_id})">
<div class="flex-1 min-w-0">
<div class="text-sm font-medium text-gray-800 truncate">
<span class="text-xs text-gray-400 font-mono mr-1.5">${e.equipment_code || ''}</span>${e.equipment_name}
${e.is_temporarily_moved ? '<i class="fas fa-arrows-alt text-blue-400 ml-1" title="임시이동중"></i>' : ''}
</div>
<div class="text-xs text-gray-500 flex items-center gap-1.5 mt-0.5 flex-wrap">
${e.equipment_type ? `<span class="px-1.5 py-0.5 rounded bg-slate-50 text-slate-600">${e.equipment_type}</span>` : ''}
${e.manufacturer ? `<span class="text-gray-400">${e.manufacturer}</span>` : ''}
${eqStatusBadge(e.status)}
</div>
</div>
<div class="flex gap-1 ml-2 flex-shrink-0">
<button onclick="event.stopPropagation(); editEquipment(${e.equipment_id})" class="p-1.5 text-slate-500 hover:text-slate-700 hover:bg-slate-200 rounded" title="편집"><i class="fas fa-pen-to-square text-xs"></i></button>
<button onclick="event.stopPropagation(); deleteEquipment(${e.equipment_id},'${(e.equipment_name||'').replace(/'/g,"\\'")}')" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded" title="삭제"><i class="fas fa-trash-alt text-xs"></i></button>
</div>
</div>`).join('');
}
function filterEquipments() { displayEquipments(); }
async function loadEquipmentTypes() {
try {
const r = await api('/equipments/types'); equipmentTypes = r.data || [];
const sel = document.getElementById('eqTypeFilter'); const val = sel.value;
sel.innerHTML = '<option value="">전체 유형</option>';
equipmentTypes.forEach(t => { const o = document.createElement('option'); o.value = t; o.textContent = t; sel.appendChild(o); });
sel.value = val;
const dl = document.getElementById('eqTypeDatalist');
if (dl) { dl.innerHTML = ''; equipmentTypes.forEach(t => { const o = document.createElement('option'); o.value = t; dl.appendChild(o); }); }
} catch(e) { console.warn('설비 유형 로드 실패:', e); }
}
async function openEquipmentModal(editId) {
document.getElementById('eqEditId').value = '';
document.getElementById('equipmentForm').reset();
if (editId) {
document.getElementById('eqModalTitle').textContent = '설비 수정';
const eq = equipments.find(e => e.equipment_id === editId);
if (!eq) return;
document.getElementById('eqEditId').value = eq.equipment_id;
document.getElementById('eqCode').value = eq.equipment_code || '';
document.getElementById('eqName').value = eq.equipment_name || '';
document.getElementById('eqType').value = eq.equipment_type || '';
document.getElementById('eqStatus').value = eq.status || 'active';
document.getElementById('eqManufacturer').value = eq.manufacturer || '';
document.getElementById('eqModel').value = eq.model_name || '';
document.getElementById('eqSupplier').value = eq.supplier || '';
document.getElementById('eqPrice').value = eq.purchase_price || '';
document.getElementById('eqInstallDate').value = eq.installation_date ? eq.installation_date.substring(0, 10) : '';
document.getElementById('eqSerial').value = eq.serial_number || '';
document.getElementById('eqSpecs').value = eq.specifications || '';
document.getElementById('eqNotes').value = eq.notes || '';
} else {
document.getElementById('eqModalTitle').textContent = '설비 추가';
generateEquipmentCode();
}
document.getElementById('equipmentModal').classList.remove('hidden');
}
function closeEquipmentModal() { document.getElementById('equipmentModal').classList.add('hidden'); }
async function generateEquipmentCode() { try { const r = await api('/equipments/next-code?prefix=TKP'); document.getElementById('eqCode').value = r.data || ''; } catch(e) {} }
function editEquipment(id) { openEquipmentModal(id); }
async function deleteEquipment(id, name) {
if (!confirm(`"${name}" 설비를 삭제하시겠습니까?`)) return;
try { await api(`/equipments/${id}`, { method: 'DELETE' }); showToast('설비가 삭제되었습니다.'); await loadEquipments(); } catch(e) { showToast(e.message, 'error'); }
}
document.getElementById('equipmentForm').addEventListener('submit', async e => {
e.preventDefault();
const editId = document.getElementById('eqEditId').value;
const body = {
equipment_code: document.getElementById('eqCode').value.trim(),
equipment_name: document.getElementById('eqName').value.trim(),
equipment_type: document.getElementById('eqType').value.trim() || null,
status: document.getElementById('eqStatus').value,
manufacturer: document.getElementById('eqManufacturer').value.trim() || null,
model_name: document.getElementById('eqModel').value.trim() || null,
supplier: document.getElementById('eqSupplier').value.trim() || null,
purchase_price: document.getElementById('eqPrice').value ? parseFloat(document.getElementById('eqPrice').value) : null,
installation_date: document.getElementById('eqInstallDate').value || null,
serial_number: document.getElementById('eqSerial').value.trim() || null,
specifications: document.getElementById('eqSpecs').value.trim() || null,
notes: document.getElementById('eqNotes').value.trim() || null,
workplace_id: selectedWorkplaceId
};
try {
if (editId) { await api(`/equipments/${editId}`, { method: 'PUT', body: JSON.stringify(body) }); showToast('설비가 수정되었습니다.'); }
else { await api('/equipments', { method: 'POST', body: JSON.stringify(body) }); showToast('설비가 추가되었습니다.'); }
closeEquipmentModal(); await loadEquipments(); loadEquipmentTypes();
} catch(e) { showToast(e.message, 'error'); }
});
/* ===== Equipment Map (설비 배치도) ===== */
function loadEqMap() {
const wp = workplaces.find(w => w.workplace_id === selectedWorkplaceId);
if (!wp || !wp.layout_image) {
document.getElementById('eqMapArea').classList.remove('hidden');
document.getElementById('eqMapCanvasWrap').classList.add('hidden');
return;
}
document.getElementById('eqMapArea').classList.add('hidden');
document.getElementById('eqMapCanvasWrap').classList.remove('hidden');
eqMapCanvas = document.getElementById('eqMapCanvas');
eqMapCtx = eqMapCanvas.getContext('2d');
const imgUrl = wp.layout_image.startsWith('/') ? '/uploads/' + wp.layout_image.replace(/^\/uploads\//, '') : wp.layout_image;
const img = new Image();
img.onload = function() {
const maxW = 780; const scale = img.width > maxW ? maxW / img.width : 1;
eqMapCanvas.width = img.width * scale; eqMapCanvas.height = img.height * scale;
eqMapCtx.drawImage(img, 0, 0, eqMapCanvas.width, eqMapCanvas.height);
eqMapImg = img;
drawEqMapEquipments();
eqMapCanvas.onclick = onEqMapClick;
};
img.src = imgUrl;
}
function drawEqMapEquipments() {
if (!eqMapCanvas || !eqMapCtx || !eqMapImg) return;
eqMapCtx.clearRect(0, 0, eqMapCanvas.width, eqMapCanvas.height);
eqMapCtx.drawImage(eqMapImg, 0, 0, eqMapCanvas.width, eqMapCanvas.height);
equipments.forEach(eq => {
if (eq.map_x_percent == null || eq.map_y_percent == null) return;
const x = (eq.map_x_percent / 100) * eqMapCanvas.width;
const y = (eq.map_y_percent / 100) * eqMapCanvas.height;
const w = ((eq.map_width_percent || 3) / 100) * eqMapCanvas.width;
const h = ((eq.map_height_percent || 3) / 100) * eqMapCanvas.height;
const colors = { active:'#10b981', maintenance:'#f59e0b', inactive:'#94a3b8', external:'#3b82f6', repair_external:'#3b82f6', repair_needed:'#ef4444' };
const color = colors[eq.status] || '#64748b';
eqMapCtx.fillStyle = color + '33'; eqMapCtx.fillRect(x - w/2, y - h/2, w, h);
eqMapCtx.strokeStyle = color; eqMapCtx.lineWidth = 2; eqMapCtx.strokeRect(x - w/2, y - h/2, w, h);
eqMapCtx.fillStyle = color; eqMapCtx.font = 'bold 10px sans-serif'; eqMapCtx.textAlign = 'center';
eqMapCtx.fillText(eq.equipment_code || eq.equipment_name, x, y - h/2 - 3);
eqMapCtx.textAlign = 'start';
});
}
let eqMapPlacingId = null;
function onEqMapClick(e) {
if (!eqMapPlacingId) return;
const r = eqMapCanvas.getBoundingClientRect();
const scaleX = eqMapCanvas.width / r.width; const scaleY = eqMapCanvas.height / r.height;
const px = (e.clientX - r.left) * scaleX; const py = (e.clientY - r.top) * scaleY;
const xPct = (px / eqMapCanvas.width * 100).toFixed(2); const yPct = (py / eqMapCanvas.height * 100).toFixed(2);
saveEqMapPosition(eqMapPlacingId, xPct, yPct);
eqMapPlacingId = null; eqMapCanvas.style.cursor = 'default';
}
async function saveEqMapPosition(eqId, x, y) {
try {
await api(`/equipments/${eqId}/map-position`, { method: 'PATCH', body: JSON.stringify({ map_x_percent: parseFloat(x), map_y_percent: parseFloat(y), map_width_percent: 3, map_height_percent: 3 }) });
showToast('설비 위치가 저장되었습니다.'); await loadEquipments();
} catch(e) { showToast(e.message, 'error'); }
}
function startPlaceEquipment(eqId) {
eqMapPlacingId = eqId; if(eqMapCanvas) eqMapCanvas.style.cursor = 'crosshair';
showToast('배치도에서 위치를 클릭하세요');
}
async function uploadWorkplaceLayoutImage() {
const file = document.getElementById('wpLayoutImageFile').files[0];
if (!file) return;
try {
const fd = new FormData(); fd.append('image', file); const token = getToken();
const res = await fetch(`${API_BASE}/workplaces/${selectedWorkplaceId}/layout-image`, { method: 'POST', headers: { 'Authorization': token ? `Bearer ${token}` : '' }, body: fd });
const result = await res.json(); if (!res.ok) throw new Error(result.error || '업로드 실패');
showToast('배치도 이미지가 업로드되었습니다.');
const wp = workplaces.find(w => w.workplace_id === selectedWorkplaceId);
if (wp) wp.layout_image = result.data.image_path;
loadEqMap();
} catch(e) { showToast(e.message || '업로드 실패', 'error'); }
}
/* ===== Equipment Detail Modal ===== */
async function openEqDetailModal(eqId) {
eqDetailEqId = eqId;
const eq = equipments.find(e => e.equipment_id === eqId);
if (!eq) return;
document.getElementById('eqDetailTitle').textContent = `${eq.equipment_code} - ${eq.equipment_name}`;
document.getElementById('eqReturnBtn').classList.toggle('hidden', !eq.is_temporarily_moved);
const fmt = v => v || '-';
const fmtDate = v => v ? v.substring(0, 10) : '-';
const fmtPrice = v => v ? Number(v).toLocaleString() + '원' : '-';
document.getElementById('eqDetailContent').innerHTML = `
<div class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<div><span class="text-gray-400">유형:</span> ${fmt(eq.equipment_type)}</div>
<div><span class="text-gray-400">상태:</span> ${eqStatusBadge(eq.status)}</div>
<div><span class="text-gray-400">제조사:</span> ${fmt(eq.manufacturer)}</div>
<div><span class="text-gray-400">모델:</span> ${fmt(eq.model_name)}</div>
<div><span class="text-gray-400">공급업체:</span> ${fmt(eq.supplier)}</div>
<div><span class="text-gray-400">구매가격:</span> ${fmtPrice(eq.purchase_price)}</div>
<div><span class="text-gray-400">설치일:</span> ${fmtDate(eq.installation_date)}</div>
<div><span class="text-gray-400">시리얼:</span> ${fmt(eq.serial_number)}</div>
<div class="col-span-2"><span class="text-gray-400">사양:</span> ${fmt(eq.specifications)}</div>
<div class="col-span-2"><span class="text-gray-400">비고:</span> ${fmt(eq.notes)}</div>
</div>
<div class="mt-2"><button onclick="startPlaceEquipment(${eq.equipment_id}); closeEqDetailModal();" class="text-xs text-blue-600 hover:underline"><i class="fas fa-map-pin mr-1"></i>배치도에 위치 지정</button></div>`;
loadEqPhotos(eqId);
document.getElementById('eqDetailModal').classList.remove('hidden');
}
function closeEqDetailModal() { document.getElementById('eqDetailModal').classList.add('hidden'); }
async function loadEqPhotos(eqId) {
const c = document.getElementById('eqPhotoGrid');
try {
const r = await api(`/equipments/${eqId}/photos`); const photos = r.data || [];
if (!photos.length) { c.innerHTML = '<p class="text-gray-400 text-xs col-span-4 text-center py-2">사진 없음</p>'; return; }
c.innerHTML = photos.map(p => {
const fname = (p.photo_path||'').replace(/^\/uploads\//, '');
return `
<div class="relative group cursor-pointer" onclick="document.getElementById('photoViewImage').src='/uploads/${fname}'; document.getElementById('photoViewModal').classList.remove('hidden');">
<img src="/uploads/${fname}" class="w-full h-20 object-cover rounded">
<button onclick="event.stopPropagation(); deleteEqPhoto(${p.photo_id})" class="absolute top-0.5 right-0.5 bg-red-500 text-white rounded-full w-4 h-4 text-[10px] leading-4 text-center opacity-0 group-hover:opacity-100">&times;</button>
</div>`; }).join('');
} catch(e) { c.innerHTML = '<p class="text-red-400 text-xs col-span-4">로드 실패</p>'; }
}
async function uploadEqPhoto() {
const file = document.getElementById('eqPhotoFile').files[0]; if (!file || !eqDetailEqId) return;
try {
const fd = new FormData(); fd.append('photo', file); const token = getToken();
const res = await fetch(`${API_BASE}/equipments/${eqDetailEqId}/photos`, { method: 'POST', headers: { 'Authorization': token ? `Bearer ${token}` : '' }, body: fd });
const result = await res.json(); if (!res.ok) throw new Error(result.error || '업로드 실패');
showToast('사진이 추가되었습니다.'); loadEqPhotos(eqDetailEqId);
} catch(e) { showToast(e.message, 'error'); }
document.getElementById('eqPhotoFile').value = '';
}
async function deleteEqPhoto(photoId) {
if (!confirm('사진을 삭제하시겠습니까?')) return;
try { await api(`/equipments/photos/${photoId}`, { method: 'DELETE' }); showToast('삭제됨'); loadEqPhotos(eqDetailEqId); } catch(e) { showToast(e.message, 'error'); }
}
/* ===== Layout Map (구역지도) ===== */
let layoutMapImage = null;
let mapRegions = [];
let mapCanvas = null;
let mapCtx = null;
let isDrawing = false;
let drawStartX = 0;
let drawStartY = 0;
let currentRect = null;
let selectedMapCategoryId = null;
// 구역지도 프리뷰 캔버스 클릭 → 해당 영역의 작업장으로 드릴다운
document.getElementById('previewCanvas')?.addEventListener('click', function(e) {
if (!previewMapRegions.length) return;
const rect = this.getBoundingClientRect();
const xPct = ((e.clientX - rect.left) / rect.width) * 100;
const yPct = ((e.clientY - rect.top) / rect.height) * 100;
for (const region of previewMapRegions) {
if (xPct >= region.x_start && xPct <= region.x_end && yPct >= region.y_start && yPct <= region.y_end) {
const wp = workplaces.find(w => w.workplace_id === region.workplace_id);
if (wp) {
selectWorkplaceForEquipments(wp.workplace_id, wp.workplace_name);
}
return;
}
}
});
async function loadLayoutPreview(categoryId) {
const cat = workplaceCategories.find(c => c.category_id == categoryId);
if (!cat || !cat.layout_image) {
document.getElementById('layoutPreviewArea').classList.remove('hidden');
document.getElementById('layoutPreviewCanvas').classList.add('hidden');
document.getElementById('layoutPreviewArea').innerHTML = '<i class="fas fa-image text-3xl mb-2"></i><p>레이아웃 이미지가 없습니다. "지도 설정"에서 업로드하세요.</p>';
return;
}
document.getElementById('layoutPreviewArea').classList.add('hidden');
document.getElementById('layoutPreviewCanvas').classList.remove('hidden');
const pCanvas = document.getElementById('previewCanvas');
const pCtx = pCanvas.getContext('2d');
const imgUrl = cat.layout_image.startsWith('http') ? cat.layout_image : '/uploads/' + cat.layout_image.replace(/^\/uploads\//, '');
const img = new Image();
img.onload = async function() {
const maxW = 800;
const scale = img.width > maxW ? maxW / img.width : 1;
pCanvas.width = img.width * scale;
pCanvas.height = img.height * scale;
pCtx.drawImage(img, 0, 0, pCanvas.width, pCanvas.height);
try {
const r = await api(`/workplaces/categories/${categoryId}/map-regions`);
const regions = r.data || [];
previewMapRegions = regions;
regions.forEach(region => {
const x1 = (region.x_start / 100) * pCanvas.width;
const y1 = (region.y_start / 100) * pCanvas.height;
const x2 = (region.x_end / 100) * pCanvas.width;
const y2 = (region.y_end / 100) * pCanvas.height;
pCtx.strokeStyle = '#10b981';
pCtx.lineWidth = 2;
pCtx.strokeRect(x1, y1, x2 - x1, y2 - y1);
pCtx.fillStyle = 'rgba(16, 185, 129, 0.15)';
pCtx.fillRect(x1, y1, x2 - x1, y2 - y1);
pCtx.fillStyle = '#10b981';
pCtx.font = '14px sans-serif';
pCtx.fillText(region.workplace_name || '', x1 + 5, y1 + 20);
});
} catch(e) { console.warn('영역 로드 실패:', e); }
};
img.src = imgUrl;
}
// 구역지도 모달
function openLayoutMapModal() {
if (!selectedMapCategoryId) {
showToast('공장을 먼저 선택해주세요.', 'error');
return;
}
const modal = document.getElementById('layoutMapModal');
mapCanvas = document.getElementById('regionCanvas');
mapCtx = mapCanvas.getContext('2d');
modal.style.display = 'flex';
document.body.style.overflow = 'hidden';
loadLayoutMapData();
updateRegionWorkplaceSelect();
}
function closeLayoutMapModal() {
const modal = document.getElementById('layoutMapModal');
modal.style.display = 'none';
document.body.style.overflow = '';
if (mapCanvas) {
mapCanvas.removeEventListener('mousedown', onCanvasMouseDown);
mapCanvas.removeEventListener('mousemove', onCanvasMouseMove);
mapCanvas.removeEventListener('mouseup', onCanvasMouseUp);
mapCanvas.removeEventListener('mouseleave', onCanvasMouseUp);
}
currentRect = null;
if (selectedMapCategoryId) loadLayoutPreview(selectedMapCategoryId);
}
async function loadLayoutMapData() {
try {
const cat = workplaceCategories.find(c => c.category_id == selectedMapCategoryId);
if (!cat) return;
const imgDiv = document.getElementById('currentLayoutImage');
if (cat.layout_image) {
const imgUrl = cat.layout_image.startsWith('http') ? cat.layout_image : '/uploads/' + cat.layout_image.replace(/^\/uploads\//, '');
imgDiv.innerHTML = `<img src="${imgUrl}" style="max-width:100%;max-height:300px;border-radius:4px;" alt="레이아웃">`;
loadImageToCanvas(imgUrl);
} else {
imgDiv.innerHTML = '<span class="text-sm text-gray-400">업로드된 이미지가 없습니다</span>';
}
const r = await api(`/workplaces/categories/${selectedMapCategoryId}/map-regions`);
mapRegions = r.data || [];
renderRegionList();
} catch(e) {
console.error('레이아웃 데이터 로딩 오류:', e);
}
}
function loadImageToCanvas(imgUrl) {
const img = new Image();
img.onload = function() {
const maxW = 800;
const scale = img.width > maxW ? maxW / img.width : 1;
mapCanvas.width = img.width * scale;
mapCanvas.height = img.height * scale;
mapCtx.drawImage(img, 0, 0, mapCanvas.width, mapCanvas.height);
layoutMapImage = img;
drawExistingRegions();
setupCanvasEvents();
};
img.src = imgUrl;
}
function updateRegionWorkplaceSelect() {
const sel = document.getElementById('regionWorkplaceSelect');
if (!sel) return;
const catWps = workplaces.filter(w => w.category_id == selectedMapCategoryId);
let html = '<option value="">작업장을 선택하세요</option>';
catWps.forEach(wp => {
const hasRegion = mapRegions.some(r => r.workplace_id === wp.workplace_id);
html += `<option value="${wp.workplace_id}">${wp.workplace_name}${hasRegion ? ' (영역 정의됨)' : ''}</option>`;
});
sel.innerHTML = html;
}
function previewLayoutImage(event) {
const file = event.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(e) {
document.getElementById('currentLayoutImage').innerHTML = `
<img src="${e.target.result}" style="max-width:100%;max-height:300px;border-radius:4px;" alt="미리보기">
<p class="text-xs text-gray-400 mt-1">미리보기 (저장하려면 "이미지 업로드" 버튼 클릭)</p>`;
};
reader.readAsDataURL(file);
}
async function uploadLayoutImage() {
const file = document.getElementById('layoutImageFile').files[0];
if (!file) { showToast('이미지를 선택해주세요.', 'error'); return; }
if (!selectedMapCategoryId) { showToast('공장을 먼저 선택해주세요.', 'error'); return; }
try {
const fd = new FormData();
fd.append('image', file);
const token = getToken();
const res = await fetch(`${API_BASE}/workplaces/categories/${selectedMapCategoryId}/layout-image`, {
method: 'POST',
headers: { 'Authorization': token ? `Bearer ${token}` : '' },
body: fd
});
const result = await res.json();
if (!res.ok) throw new Error(result.error || '업로드 실패');
showToast('이미지가 업로드되었습니다.');
const imgUrl = '/uploads/' + result.data.image_path.replace(/^\/uploads\//, '');
document.getElementById('currentLayoutImage').innerHTML = `<img src="${imgUrl}" style="max-width:100%;max-height:300px;border-radius:4px;" alt="레이아웃">`;
loadImageToCanvas(imgUrl);
// 카테고리 데이터 갱신
await loadWorkplaceCategories();
} catch(e) {
showToast(e.message || '업로드 실패', 'error');
}
}
// 캔버스 드로잉
function setupCanvasEvents() {
mapCanvas.removeEventListener('mousedown', onCanvasMouseDown);
mapCanvas.removeEventListener('mousemove', onCanvasMouseMove);
mapCanvas.removeEventListener('mouseup', onCanvasMouseUp);
mapCanvas.removeEventListener('mouseleave', onCanvasMouseUp);
mapCanvas.addEventListener('mousedown', onCanvasMouseDown);
mapCanvas.addEventListener('mousemove', onCanvasMouseMove);
mapCanvas.addEventListener('mouseup', onCanvasMouseUp);
mapCanvas.addEventListener('mouseleave', onCanvasMouseUp);
}
function onCanvasMouseDown(e) {
const r = mapCanvas.getBoundingClientRect();
const scaleX = mapCanvas.width / r.width;
const scaleY = mapCanvas.height / r.height;
drawStartX = (e.clientX - r.left) * scaleX;
drawStartY = (e.clientY - r.top) * scaleY;
isDrawing = true;
}
function onCanvasMouseMove(e) {
if (!isDrawing) return;
const r = mapCanvas.getBoundingClientRect();
const scaleX = mapCanvas.width / r.width;
const scaleY = mapCanvas.height / r.height;
const curX = (e.clientX - r.left) * scaleX;
const curY = (e.clientY - r.top) * scaleY;
mapCtx.clearRect(0, 0, mapCanvas.width, mapCanvas.height);
if (layoutMapImage) mapCtx.drawImage(layoutMapImage, 0, 0, mapCanvas.width, mapCanvas.height);
drawExistingRegions();
const w = curX - drawStartX;
const h = curY - drawStartY;
mapCtx.strokeStyle = '#3b82f6';
mapCtx.lineWidth = 3;
mapCtx.strokeRect(drawStartX, drawStartY, w, h);
mapCtx.fillStyle = 'rgba(59, 130, 246, 0.2)';
mapCtx.fillRect(drawStartX, drawStartY, w, h);
currentRect = { startX: drawStartX, startY: drawStartY, endX: curX, endY: curY };
}
function onCanvasMouseUp() { isDrawing = false; }
function drawExistingRegions() {
mapRegions.forEach(region => {
const x1 = (region.x_start / 100) * mapCanvas.width;
const y1 = (region.y_start / 100) * mapCanvas.height;
const x2 = (region.x_end / 100) * mapCanvas.width;
const y2 = (region.y_end / 100) * mapCanvas.height;
mapCtx.strokeStyle = '#10b981';
mapCtx.lineWidth = 2;
mapCtx.strokeRect(x1, y1, x2 - x1, y2 - y1);
mapCtx.fillStyle = 'rgba(16, 185, 129, 0.15)';
mapCtx.fillRect(x1, y1, x2 - x1, y2 - y1);
mapCtx.fillStyle = '#10b981';
mapCtx.font = '14px sans-serif';
mapCtx.fillText(region.workplace_name || '', x1 + 5, y1 + 20);
});
}
function clearCurrentRegion() {
currentRect = null;
mapCtx.clearRect(0, 0, mapCanvas.width, mapCanvas.height);
if (layoutMapImage) mapCtx.drawImage(layoutMapImage, 0, 0, mapCanvas.width, mapCanvas.height);
drawExistingRegions();
}
async function saveRegion() {
const wpId = document.getElementById('regionWorkplaceSelect').value;
if (!wpId) { showToast('작업장을 선택해주세요.', 'error'); return; }
if (!currentRect) { showToast('영역을 그려주세요.', 'error'); return; }
try {
const xStart = (Math.min(currentRect.startX, currentRect.endX) / mapCanvas.width * 100).toFixed(2);
const yStart = (Math.min(currentRect.startY, currentRect.endY) / mapCanvas.height * 100).toFixed(2);
const xEnd = (Math.max(currentRect.startX, currentRect.endX) / mapCanvas.width * 100).toFixed(2);
const yEnd = (Math.max(currentRect.startY, currentRect.endY) / mapCanvas.height * 100).toFixed(2);
const existing = mapRegions.find(r => r.workplace_id == wpId);
const body = { workplace_id: parseInt(wpId), category_id: selectedMapCategoryId, x_start: xStart, y_start: yStart, x_end: xEnd, y_end: yEnd, shape: 'rect' };
if (existing) {
await api(`/workplaces/map-regions/${existing.region_id}`, { method: 'PUT', body: JSON.stringify(body) });
} else {
await api('/workplaces/map-regions', { method: 'POST', body: JSON.stringify(body) });
}
showToast('영역이 저장되었습니다.');
await loadLayoutMapData();
updateRegionWorkplaceSelect();
clearCurrentRegion();
document.getElementById('regionWorkplaceSelect').value = '';
} catch(e) { showToast(e.message || '저장 실패', 'error'); }
}
function renderRegionList() {
const div = document.getElementById('regionList');
if (!mapRegions.length) { div.innerHTML = '<p class="text-sm text-gray-400 text-center py-4">정의된 영역이 없습니다</p>'; return; }
div.innerHTML = '<div class="space-y-2">' + mapRegions.map(r => `
<div class="flex items-center justify-between p-2.5 bg-gray-50 rounded-lg">
<div>
<span class="text-sm font-semibold text-gray-800">${r.workplace_name || ''}</span>
<span class="text-xs text-gray-400 ml-2">(${Number(r.x_start).toFixed(1)}%, ${Number(r.y_start).toFixed(1)}%) ~ (${Number(r.x_end).toFixed(1)}%, ${Number(r.y_end).toFixed(1)}%)</span>
</div>
<button onclick="deleteRegion(${r.region_id})" class="p-1.5 text-red-400 hover:text-red-600 hover:bg-red-100 rounded text-xs"><i class="fas fa-trash-alt"></i> 삭제</button>
</div>`).join('') + '</div>';
}
async function deleteRegion(regionId) {
if (!confirm('이 영역을 삭제하시겠습니까?')) return;
try {
await api(`/workplaces/map-regions/${regionId}`, { method: 'DELETE' });
showToast('영역이 삭제되었습니다.');
await loadLayoutMapData();
updateRegionWorkplaceSelect();
} catch(e) { showToast(e.message || '삭제 실패', 'error'); }
}
/* ===== Logout ===== */
function doLogout() {
if (!confirm('로그아웃?')) return;
_cookieRemove('sso_token'); localStorage.removeItem('sso_token'); localStorage.removeItem('access_token'); localStorage.removeItem('currentUser');
location.href = getLoginUrl();
}
/* ===== Boot ===== */
init();
</script>
</body>
</html>