Files
tk-factory-services/system1-factory/web/pages/work/schedule.html
Hyungi Ahn 1cfd4da8ba feat(pwa): Network-First 서비스 워커 — 홈화면 앱 자동 갱신
- sw.js: Network-First 캐시 전략 (GET + same-origin + res.ok만 캐시)
- tkfb-core.js: SW 등록 + 업데이트 감지 시 자동 새로고침
  (최초 설치 시 토스트 방지: controller 체크)
- manifest.json: start_url → dashboard-new.html
- nginx: sw.js, manifest.json no-cache 헤더
- 배포 시 sw.js의 APP_VERSION만 변경하면 전 사용자 자동 갱신

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-01 15:36:36 +09:00

413 lines
24 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 공장관리</title>
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="/static/css/tkfb.css?v=2026040103">
<style>
/* Gantt container */
.gantt-wrapper { position: relative; overflow: auto; border: 1px solid #e2e8f0; border-radius: 0.5rem; background: #fff; }
.gantt-container { position: relative; min-width: 100%; }
/* Left column sticky */
.gantt-label { position: sticky; left: 0; z-index: 20; background: #fff; border-right: 2px solid #e2e8f0; min-width: 250px; max-width: 250px; }
.gantt-header .gantt-label { background: #f1f5f9; z-index: 25; }
/* Row styles */
.gantt-row { display: flex; border-bottom: 1px solid #f1f5f9; min-height: 32px; align-items: stretch; }
.gantt-row:hover { background: #fafbfc; }
.gantt-row.project-row { background: #f8fafc; font-weight: 600; }
.gantt-row.project-row .gantt-label { background: #f8fafc; }
.gantt-row.phase-row .gantt-label { padding-left: 1.25rem; color: #6b7280; font-size: 0.8rem; }
.gantt-row.task-row .gantt-label { padding-left: 2.25rem; font-size: 0.8rem; }
.gantt-row.milestone-row .gantt-label { padding-left: 1.25rem; font-size: 0.8rem; color: #7c3aed; }
.gantt-row.nc-row .gantt-label { padding-left: 1.25rem; font-size: 0.8rem; color: #dc2626; }
/* Header */
.gantt-header { display: flex; border-bottom: 2px solid #e2e8f0; background: #f1f5f9; position: sticky; top: 0; z-index: 22; }
.gantt-month-header { display: flex; border-bottom: 1px solid #e2e8f0; background: #f8fafc; position: sticky; top: 0; z-index: 22; }
/* Timeline cells */
.gantt-timeline { display: flex; flex: 1; position: relative; }
.gantt-day { flex: 0 0 var(--day-width); border-right: 1px solid #f1f5f9; display: flex; align-items: center; justify-content: center; font-size: 0.65rem; color: #9ca3af; }
.gantt-day.weekend { background: #fafafa; }
.gantt-day.month-label { font-weight: 600; color: #475569; font-size: 0.75rem; justify-content: flex-start; padding-left: 4px; border-right: 1px solid #cbd5e1; }
/* Bars */
.gantt-bar { position: absolute; height: 20px; top: 6px; border-radius: 3px; cursor: pointer; transition: opacity 0.15s; min-width: 4px; z-index: 5; }
.gantt-bar:hover { opacity: 0.85; filter: brightness(1.1); }
.gantt-bar-progress { height: 100%; border-radius: 3px; opacity: 0.4; }
.gantt-bar-label { position: absolute; left: 4px; top: 1px; font-size: 0.65rem; color: #fff; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: calc(100% - 8px); line-height: 18px; }
/* Today marker */
.today-marker { position: absolute; top: 0; bottom: 0; width: 2px; background: #ef4444; z-index: 10; pointer-events: none; }
/* Milestone diamond */
.milestone-marker { position: absolute; top: 6px; width: 14px; height: 14px; background: #7c3aed; transform: rotate(45deg); z-index: 5; cursor: pointer; border: 1px solid #6d28d9; }
.milestone-marker:hover { filter: brightness(1.2); }
/* NC badge */
.nc-badge { display: inline-flex; align-items: center; justify-content: center; background: #fef2f2; color: #dc2626; border-radius: 9999px; padding: 0 0.5rem; font-size: 0.7rem; font-weight: 600; height: 20px; cursor: pointer; position: absolute; top: 6px; z-index: 5; }
/* Collapse toggle */
.collapse-toggle { cursor: pointer; user-select: none; }
.collapse-toggle .arrow { display: inline-block; transition: transform 0.2s; font-size: 0.6rem; margin-right: 4px; }
.collapse-toggle.collapsed .arrow { transform: rotate(-90deg); }
/* Zoom controls */
.zoom-btn.active { background: #ea580c; color: #fff; }
/* Label content */
.label-content { display: flex; align-items: center; height: 100%; padding: 0 0.75rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
</style>
</head>
<body class="bg-gray-50">
<header class="bg-orange-700 text-white sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between items-center h-14">
<div class="flex items-center gap-3">
<button id="mobileMenuBtn" class="lg:hidden text-orange-200 hover:text-white">
<i class="fas fa-bars text-xl"></i>
</button>
<i class="fas fa-industry text-xl text-orange-200"></i>
<h1 class="text-lg font-semibold">TK 공장관리</h1>
</div>
<div class="flex items-center gap-4">
<span id="headerUserName" class="text-sm hidden sm:block">-</span>
<div id="headerUserAvatar" class="w-8 h-8 bg-orange-600 rounded-full flex items-center justify-center text-sm font-bold">-</div>
<button onclick="doLogout()" class="text-orange-200 hover:text-white" title="로그아웃">
<i class="fas fa-sign-out-alt"></i>
</button>
</div>
</div>
</div>
</header>
<div id="mobileOverlay" class="hidden fixed inset-0 bg-black/50 z-30 lg:hidden"></div>
<div class="max-w-full mx-auto px-4 sm:px-6 lg:px-8 py-4 fade-in">
<div class="flex gap-6">
<nav id="sideNav" class="hidden lg:flex flex-col gap-1 w-52 flex-shrink-0 pt-2 fixed lg:static z-40 bg-white lg:bg-transparent p-4 lg:p-0 rounded-lg lg:rounded-none shadow-lg lg:shadow-none top-14 left-0 bottom-0 overflow-y-auto"></nav>
<div class="flex-1 min-w-0">
<div class="mb-4">
<h2 class="text-xl font-bold text-gray-800">공정표</h2>
<p class="text-sm text-gray-500 mt-0.5">프로젝트별 공정 일정을 Gantt 차트로 관리합니다</p>
</div>
<!-- 툴바 -->
<div class="bg-white rounded-xl shadow-sm p-4 mb-4 flex flex-wrap items-center gap-3">
<div class="flex items-center gap-2">
<label class="text-sm font-medium text-gray-600">연도:</label>
<select id="yearSelect" class="input-field rounded-lg px-3 py-1.5 text-sm w-24"></select>
</div>
<div class="flex items-center gap-1">
<button class="zoom-btn px-3 py-1.5 rounded-lg text-sm border" data-zoom="month">월간</button>
<button class="zoom-btn px-3 py-1.5 rounded-lg text-sm border active" data-zoom="quarter">분기</button>
<button class="zoom-btn px-3 py-1.5 rounded-lg text-sm border" data-zoom="year">연간</button>
</div>
<div class="flex items-center gap-2 ml-auto">
<button id="btnGenTemplate" class="hidden bg-indigo-600 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-indigo-700" onclick="openTemplateModal()">
<i class="fas fa-wand-magic-sparkles mr-1"></i>표준공정 생성
</button>
<button id="btnAddEntry" class="hidden bg-orange-600 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-orange-700">
<i class="fas fa-plus mr-1"></i>항목 추가
</button>
<button id="btnBatchAdd" class="hidden bg-blue-600 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-blue-700">
<i class="fas fa-layer-group mr-1"></i>일괄 생성
</button>
<button id="btnAddMilestone" class="hidden bg-purple-600 text-white px-3 py-1.5 rounded-lg text-sm hover:bg-purple-700">
<i class="fas fa-diamond mr-1"></i>마일스톤
</button>
</div>
</div>
<!-- Gantt Chart -->
<div class="gantt-wrapper" id="ganttWrapper" style="max-height: calc(100vh - 220px);">
<div class="gantt-container" id="ganttContainer">
<!-- Rendered by JS -->
</div>
</div>
</div>
</div>
</div>
<!-- 항목 추가/수정 모달 -->
<div id="entryModal" class="modal-overlay hidden">
<div class="modal-content p-6">
<div class="flex justify-between items-center mb-4">
<h3 id="entryModalTitle" class="text-lg font-bold">공정표 항목 추가</h3>
<button onclick="closeEntryModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times text-xl"></i></button>
</div>
<form id="entryForm" onsubmit="return false;">
<input type="hidden" id="entryId">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">프로젝트 *</label>
<select id="entryProject" class="input-field w-full rounded-lg px-3 py-2 text-sm" required></select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">공정 단계 *</label>
<select id="entryPhase" class="input-field w-full rounded-lg px-3 py-2 text-sm" required></select>
</div>
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">작업명 *</label>
<div class="flex gap-2">
<select id="entryTemplate" class="input-field flex-1 rounded-lg px-3 py-2 text-sm">
<option value="">직접 입력</option>
</select>
<input type="text" id="entryTaskName" class="input-field flex-1 rounded-lg px-3 py-2 text-sm" placeholder="작업명 입력">
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">시작일 *</label>
<input type="date" id="entryStartDate" class="input-field w-full rounded-lg px-3 py-2 text-sm" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">종료일 *</label>
<input type="date" id="entryEndDate" class="input-field w-full rounded-lg px-3 py-2 text-sm" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">담당자</label>
<input type="text" id="entryAssignee" class="input-field w-full rounded-lg px-3 py-2 text-sm" placeholder="담당자">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">진행률 (%)</label>
<input type="number" id="entryProgress" class="input-field w-full rounded-lg px-3 py-2 text-sm" min="0" max="100" value="0">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">상태</label>
<select id="entryStatus" class="input-field w-full rounded-lg px-3 py-2 text-sm">
<option value="planned">계획</option>
<option value="in_progress">진행중</option>
<option value="completed">완료</option>
<option value="delayed">지연</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">선행작업 (의존관계)</label>
<select id="entryDependencies" class="input-field w-full rounded-lg px-3 py-2 text-sm" multiple style="min-height: 60px;"></select>
</div>
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">메모</label>
<textarea id="entryNotes" class="input-field w-full rounded-lg px-3 py-2 text-sm" rows="2"></textarea>
</div>
</div>
<div class="flex justify-end gap-2 mt-4">
<button type="button" onclick="closeEntryModal()" class="px-4 py-2 text-sm border rounded-lg hover:bg-gray-50">취소</button>
<button type="button" onclick="saveEntry()" class="px-4 py-2 text-sm bg-orange-600 text-white rounded-lg hover:bg-orange-700">저장</button>
</div>
</form>
</div>
</div>
<!-- 일괄 생성 모달 -->
<div id="batchModal" class="modal-overlay hidden">
<div class="modal-content p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-bold">템플릿 기반 일괄 생성</h3>
<button onclick="closeBatchModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times text-xl"></i></button>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4 mb-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">프로젝트 *</label>
<select id="batchProject" class="input-field w-full rounded-lg px-3 py-2 text-sm"></select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">공정 단계 *</label>
<select id="batchPhase" class="input-field w-full rounded-lg px-3 py-2 text-sm" onchange="loadBatchTemplates()"></select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">기준 시작일 *</label>
<input type="date" id="batchStartDate" class="input-field w-full rounded-lg px-3 py-2 text-sm" onchange="recalcBatchDates()">
</div>
</div>
<div id="batchTemplateList" class="space-y-2 mb-4 max-h-60 overflow-y-auto">
<!-- 템플릿 목록 동적 생성 -->
</div>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeBatchModal()" class="px-4 py-2 text-sm border rounded-lg hover:bg-gray-50">취소</button>
<button type="button" onclick="saveBatchEntries()" class="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700">일괄 생성</button>
</div>
</div>
</div>
<!-- 마일스톤 모달 -->
<div id="milestoneModal" class="modal-overlay hidden">
<div class="modal-content p-6">
<div class="flex justify-between items-center mb-4">
<h3 id="milestoneModalTitle" class="text-lg font-bold">마일스톤 추가</h3>
<button onclick="closeMilestoneModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times text-xl"></i></button>
</div>
<form id="milestoneForm" onsubmit="return false;">
<input type="hidden" id="milestoneId">
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">프로젝트 *</label>
<select id="milestoneProject" class="input-field w-full rounded-lg px-3 py-2 text-sm" required></select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">마일스톤명 *</label>
<input type="text" id="milestoneName" class="input-field w-full rounded-lg px-3 py-2 text-sm" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">날짜 *</label>
<input type="date" id="milestoneDate" class="input-field w-full rounded-lg px-3 py-2 text-sm" required>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">유형</label>
<select id="milestoneType" class="input-field w-full rounded-lg px-3 py-2 text-sm">
<option value="deadline">납기</option>
<option value="review">검토</option>
<option value="inspection">검사</option>
<option value="delivery">출하</option>
<option value="meeting">회의</option>
<option value="other">기타</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">상태</label>
<select id="milestoneStatus" class="input-field w-full rounded-lg px-3 py-2 text-sm">
<option value="upcoming">예정</option>
<option value="completed">완료</option>
<option value="missed">미달성</option>
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">연결 작업</label>
<select id="milestoneEntry" class="input-field w-full rounded-lg px-3 py-2 text-sm">
<option value="">없음</option>
</select>
</div>
<div class="sm:col-span-2">
<label class="block text-sm font-medium text-gray-700 mb-1">메모</label>
<textarea id="milestoneNotes" class="input-field w-full rounded-lg px-3 py-2 text-sm" rows="2"></textarea>
</div>
</div>
<div class="flex justify-end gap-2 mt-4">
<button type="button" onclick="closeMilestoneModal()" class="px-4 py-2 text-sm border rounded-lg hover:bg-gray-50">취소</button>
<button type="button" onclick="saveMilestone()" class="px-4 py-2 text-sm bg-purple-600 text-white rounded-lg hover:bg-purple-700">저장</button>
</div>
</form>
</div>
</div>
<!-- 부적합 팝업 -->
<div id="ncPopup" class="modal-overlay hidden">
<div class="modal-content p-6" style="max-width: 600px;">
<div class="flex justify-between items-center mb-4">
<h3 id="ncPopupTitle" class="text-lg font-bold">부적합 현황</h3>
<button onclick="document.getElementById('ncPopup').classList.add('hidden')" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times text-xl"></i></button>
</div>
<div id="ncPopupContent" class="space-y-2 max-h-80 overflow-y-auto"></div>
</div>
</div>
<!-- 바 상세 팝업 -->
<div id="barDetailPopup" class="modal-overlay hidden">
<div class="modal-content p-6" style="max-width: 500px;">
<div class="flex justify-between items-center mb-4">
<h3 id="barDetailTitle" class="text-lg font-bold">작업 상세</h3>
<button onclick="document.getElementById('barDetailPopup').classList.add('hidden')" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times text-xl"></i></button>
</div>
<div id="barDetailContent"></div>
<div id="barDetailActions" class="flex justify-end gap-2 mt-4"></div>
</div>
</div>
<!-- 표준공정 생성 모달 -->
<div id="templateModal" class="modal-overlay hidden">
<div class="modal-content p-6" style="max-width: 400px;">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-bold"><i class="fas fa-wand-magic-sparkles text-indigo-500 mr-2"></i>표준공정 생성</h3>
<button onclick="closeTemplateModal()" class="text-gray-400 hover:text-gray-600"><i class="fas fa-times"></i></button>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">프로젝트</label>
<select id="tmplProjectSelect" class="input-field w-full rounded-lg px-3 py-2 text-sm"></select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 mb-1">제품유형</label>
<select id="tmplProductType" class="input-field w-full rounded-lg px-3 py-2 text-sm">
<option value="">선택</option>
</select>
</div>
<p class="text-xs text-gray-500"><i class="fas fa-info-circle mr-1"></i>선택한 제품유형의 표준공정이 자동으로 생성됩니다</p>
</div>
<div class="flex justify-end gap-2 mt-5">
<button onclick="closeTemplateModal()" class="px-4 py-2 border rounded-lg text-sm text-gray-600 hover:bg-gray-50">취소</button>
<button onclick="generateTemplate()" class="px-4 py-2 bg-indigo-600 text-white rounded-lg text-sm hover:bg-indigo-700">
<i class="fas fa-wand-magic-sparkles mr-1"></i>생성
</button>
</div>
</div>
</div>
<script src="/static/js/tkfb-core.js?v=2026040105"></script>
<script src="/js/schedule.js?v=2026031701"></script>
<script>
// 표준공정 생성 모달
async function openTemplateModal() {
const modal = document.getElementById('templateModal');
const projSel = document.getElementById('tmplProjectSelect');
const typeSel = document.getElementById('tmplProductType');
// 프로젝트 목록 로드
try {
const r = await apiFetch('/api/schedule/product-types');
typeSel.innerHTML = '<option value="">선택</option>';
(r.data || []).forEach(pt => {
typeSel.innerHTML += `<option value="${pt.code}">${pt.code} - ${escapeHtml(pt.name)}</option>`;
});
} catch(e) { console.warn('제품유형 로드 실패:', e); }
// 프로젝트 목록 (기존 gantt에서 사용 중인 projects 변수 활용)
if (typeof allProjects !== 'undefined' && allProjects.length) {
projSel.innerHTML = allProjects.map(p =>
`<option value="${p.project_id}">${escapeHtml(p.job_no)} - ${escapeHtml(p.project_name)}</option>`
).join('');
} else {
try {
const r = await apiFetch('/api/projects/active');
const projs = r.data || [];
projSel.innerHTML = projs.map(p =>
`<option value="${p.project_id}">${escapeHtml(p.job_no)} - ${escapeHtml(p.project_name)}</option>`
).join('');
} catch(e) { projSel.innerHTML = '<option>프로젝트 로드 실패</option>'; }
}
modal.classList.remove('hidden');
}
function closeTemplateModal() {
document.getElementById('templateModal').classList.add('hidden');
}
async function generateTemplate() {
const projectId = document.getElementById('tmplProjectSelect').value;
const productTypeCode = document.getElementById('tmplProductType').value;
if (!projectId || !productTypeCode) {
showToast('프로젝트와 제품유형을 선택해주세요', 'error');
return;
}
try {
const r = await apiFetch('/api/schedule/generate-from-template', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ project_id: parseInt(projectId), product_type_code: productTypeCode })
});
showToast(r.message || `${r.data.created}개 표준공정이 생성되었습니다`);
closeTemplateModal();
if (typeof loadGanttData === 'function') loadGanttData();
} catch(e) {
showToast(e.message || '표준공정 생성 실패', 'error');
}
}
</script>
</body>
</html>